iT邦幫忙

2024 iThome 鐵人賽

DAY 19
1

今且有言於此,不知其與是類乎?其與是不類乎?

-- <莊子>

這個 patch,時值正要開賽的八月底(8/25,是 gen6、gen7 世代的時期),當時覺得太納悶了,怎麼 train 都沒有可靠的結果的感覺,於是,我需要深入檢視已訓練的模型是怎麼應對它面對的場景。雖然這一套 SGF 規格是我設計,目前的狀態也頗適合,但是閱讀起來還是很慢。所以,我需要有工具來輔助我閱讀棋譜。

今天就開始分享這些嘗試的內容。儘管這個專案的各個部份都有大量與 ChartGPT(4o)互動的結果,但是除了文字描述之外,並沒有特別揭露我使用的命令和它的回應。這個部份牽涉到我非常不熟悉的網頁技術,也如同先前與部份同事、友人提過的,我會特別展開這些諮詢它所獲得的內容。所以這個部份不但是跨出 Rust(系統與伺服器)+ Python(代理人與蒙地卡羅樹訓練框架)+ Bash(土法煉鋼手動兜起 AlphZero 的各個模組)組合的一步,也是在歷史上的的這個瞬間,一個十數年未接觸 javascript、只在最近幾年摸過一點點 Django 的我可以如何這樣來建構網頁的範例。

直接的需求是閱讀棋譜,我的想像很簡單。就是我喜歡 2048 的配色,所以視覺上會請它參考該遊戲網頁的風格,雖然我不確定它能否做到。然後是日後,我希望可以直接沿用大部份的架構做成可以遊玩的介面,所以也會請它安插滑鼠點擊轉換為座標的功能;之後就是疫途遊戲的細部描述,因為會需要載入棋譜,而相關功能我已經寫在遊戲系統中,所以我希望可以使用 Rust 做後端,再設法傳遞資料給前端。

所以來分享我下的命令。文法很隨意,請包涵:

Is there a simple way to craft a web just like the famous 2048(https://play2048.co/), both in style and in implementation?

You are a veteran web developer, and you will guide me through the development, with the following rough specs:

0. Rule of thumb: As simple as possible.
1. Frontend in simple html+javascript+css, unless applying any framework would make it simpler.
1.1 Two grids in the view. One is 6x6 grid, and the other is 5x5 grid.
1.2 The first main interaction with the page is mouse click event, and the other is scroll event.
1.3 For a mouse click event: clicking any region in the 6x6 grid will result in the effect that the javascript part can get the coordinates from (0,0) to (5,5); clicking any region in the 5x5 grid, (-2, -2) to (2,2).
1.4 For a scroll event, the javascript part can get up/down.
2. Backend in Rust. 
2.1 It will load a custom format file, representing the status of the grids.
2.2 It will transform/embed the information somehow into the javascript (or javascript template), so that those runtime interaction will be purely process in browser.
2.2 It will host the server.

Please provide me the code and how to run steps. Please also expect some questions will follow up.

它生給我的回應,撇除掉那些寒暄,首先它介紹專案架構

2048-like-web/
├── static/
│   ├── index.html
│   ├── style.css
│   └── app.js
├── src/
│   └── main.rs
└── Cargo.toml

然後是 Rust 需要的相依性菜單 Cargo.toml

[package]
name = "2048-like-web"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4.0"

Rust 伺服器本體

use actix_files::NamedFile;
use actix_web::{get, App, HttpServer, Responder};
use std::path::PathBuf;

#[get("/")]
async fn index() -> impl Responder {
    NamedFile::open("static/index.html")
}

#[get("/static/{filename:.*}")]
async fn static_files(path: PathBuf) -> impl Responder {
    NamedFile::open(format!("static/{}", path.display()))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .service(index)
            .service(static_files)
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

再來是前端實作,首頁的部份,

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>2048-Like Web</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="grid-container">
        <div id="grid-6x6" class="grid"></div>
        <div id="grid-5x5" class="grid"></div>
    </div>

    <script src="app.js"></script>
</body>
</html>

定義風格的 css 檔案

body {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    margin: 0;
    font-family: Arial, sans-serif;
}

.grid-container {
    display: flex;
    gap: 20px;
}

.grid {
    display: grid;
    gap: 2px;
    background-color: #bbada0;
    padding: 10px;
}

#grid-6x6 {
    grid-template-columns: repeat(6, 50px);
    grid-template-rows: repeat(6, 50px);
}

#grid-5x5 {
    grid-template-columns: repeat(5, 50px);
    grid-template-rows: repeat(5, 50px);
}

.grid div {
    background-color: #eee4da;
    width: 50px;
    height: 50px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 18px;
    font-weight: bold;
}

核心運作邏輯的 app.js

document.addEventListener("DOMContentLoaded", () => {
    const grid6x6 = document.getElementById("grid-6x6");
    const grid5x5 = document.getElementById("grid-5x5");

    // Create the 6x6 grid
    for (let row = 0; row < 6; row++) {
        for (let col = 0; col < 6; col++) {
            const cell = document.createElement("div");
            cell.dataset.row = row;
            cell.dataset.col = col;
            cell.addEventListener("click", () => handleClick6x6(row, col));
            grid6x6.appendChild(cell);
        }
    }

    // Create the 5x5 grid
    for (let row = -2; row < 3; row++) {
        for (let col = -2; col < 3; col++) {
            const cell = document.createElement("div");
            cell.dataset.row = row;
            cell.dataset.col = col;
            cell.addEventListener("click", () => handleClick5x5(row, col));
            grid5x5.appendChild(cell);
        }
    }

    // Handle click on the 6x6 grid
    function handleClick6x6(row, col) {
        console.log(`6x6 Grid clicked at (${row}, ${col})`);
    }

    // Handle click on the 5x5 grid
    function handleClick5x5(row, col) {
        console.log(`5x5 Grid clicked at (${row}, ${col})`);
    }

    // Handle scroll events
    document.addEventListener("wheel", (event) => {
        if (event.deltaY < 0) {
            console.log("Scrolled up");
        } else {
            console.log("Scrolled down");
        }
    });
});

後續

以上有零星的問題要解決,像是相依性不足(只要稍微嚴厲地附上錯誤訊息,它就會給出一個好一點的 Cargo.toml,但其實應該可以在一開始的 spec 附加思維鏈,讓它自己知道要把程式或專案寫好)、專案架構和路徑問題之類。但整體來說是可用的。後續也做了一些互動與修正,小型的像是我發現滑鼠滾輪太敏感,與它提及之後,加入了 throttle 功能,限制觸發頻率,這個現在已經移除了,因為我覺得還是 PAgeUp/PageDown 按著比較好用。還有一個小實驗,本來是希望之後遊玩的時候有類似反白當前棋盤格的功能,所以跟它詢問了點擊之後變色特定格子的方法,但這個後來覺得還是先別花力氣在上面。

多了一個語言要處理,會讓我回想起小時候聽到 3.5' 磁碟機還有數據機撥電話的時候的那種嘎嘎嘎的聲音,思緒變得非常緩慢,秒針卻動得飛快。我需要幫助的是,疫途的設置階段和每一步區分開來,正如目前這個專案的實作取向一般。前者是一開始需要固定在盤面上的,後者則是這個讀譜程式可以來回滾動檢視的。於是我,

Thank you very much.

Alright, I admit, I am really that that (編註:not that into... typo) into web things. I will try to describe the problem and let you do the job for me.

Actually this is a record viewer for a chess varient(編註:要解釋疫途,太累了,不如說是西洋棋變體), where many different stages involved.

I use sgf format to record the game, and the game logic is done in Rust. That's why I have a library for that. Anyway, Let's start with the setup face. This varient is interesting that the black/white backgrounds are no longer regular and they do affect the game play. Each game can start with a different pattern, and their record is like

;AW[db][ef][fe][ab][cc][ea][ed][dc][ca][ad][fc][df][ba][ce][be][bf][de]
;AB[bd][af][fd][ff][cd][cb][fb][dd][ac][eb][bc][ee][aa][ae][da][cf][fa][ec][bb]

Where AW attribute denotes the grid should be white and the AB should be black. I believe I can do things like

1. Rust part: before we start the Http server, we can load the sgf and finish parsing the full game record.
2. store the game record into a more easy to digest format as a dynamic asset.
3. The javascript should color the grids according to the setup information
4. Whe the browser loads the page, we can now use the scroll event to undo/redo the effect of the setup information, in the granularity of single coordinates.

Any comments on my thought?

它先是稱讚我的構想很不錯,然後給了 Rust 和 javascript 兩邊的程式碼,

use actix_files as fs;
use actix_web::{web, App, HttpServer, Responder};
use serde::Serialize;

#[derive(Serialize)]
struct GameState {
    white_positions: Vec<String>,
    black_positions: Vec<String>,
}

async fn game_state() -> impl Responder {
    let state = GameState {
        white_positions: vec!["db", "ef", "fe", /*...*/].into_iter().map(String::from).collect(),
        black_positions: vec!["bd", "af", "fd", /*...*/].into_iter().map(String::from).collect(),
    };
    web::Json(state)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/game_state", web::get().to(game_state))
            .service(fs::Files::new("/", "static").index_file("index.html"))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

javascript 部份則有分設置處理和來回滾動的處理,前者:

document.addEventListener("DOMContentLoaded", () => {
    fetch('/game_state')
        .then(response => response.json())
        .then(data => {
            setupGrid(data.white_positions, data.black_positions);
        });

    function setupGrid(whitePositions, blackPositions) {
        whitePositions.forEach(pos => {
            const [col, row] = convertPosition(pos);
            const cell = document.querySelector(`#grid-6x6 div[data-row="${row}"][data-col="${col}"]`);
            if (cell) cell.style.backgroundColor = 'white';
        });

        blackPositions.forEach(pos => {
            const [col, row] = convertPosition(pos);
            const cell = document.querySelector(`#grid-6x6 div[data-row="${row}"][data-col="${col}"]`);
            if (cell) cell.style.backgroundColor = 'black';
        });
    }

    function convertPosition(pos) {
        // Convert SGF-style coordinates to grid indices
        const col = pos.charCodeAt(0) - 'a'.charCodeAt(0);
        const row = pos.charCodeAt(1) - 'a'.charCodeAt(0);
        return [col, row];
    }
});

後者:

let currentStep = 0;

function updateGridOnScroll(direction) {
    // direction is either "up" or "down"
    if (direction === "up" && currentStep > 0) {
        currentStep--;
    } else if (direction === "down" && currentStep < totalSteps) {
        currentStep++;
    }
    applySetupStep(currentStep);
}

function applySetupStep(step) {
    // Logic to update the grid based on the current step
}

document.addEventListener("wheel", (event) => {
    if (event.deltaY < 0) {
        updateGridOnScroll("up");
    } else {
        updateGridOnScroll("down");
    }
});

不得不說助益甚大,以趕時間的 side project 來講。如果不趕時間的話,我認為把這些知識內化還是比較長遠,作為技術人也更紮實。所謂長遠的意思是,現在就算我能用 10 分鐘取得不錯的程式碼片段、然後兩三個小時整合成我要的樣子,日後只要需求稍有不同,我還是得一樣花這些時間;然而要是這些知識更內化(當然,這是數十、數百小時的努力),或許程式碼信手打來不會差太多,但是後續的整合(與既有的其他業務邏輯、其他程式模組結合所需的所有工作),都會更順利。

總之,這些是 ChatGPT 吐給我的模板,使用瀏覽器的 console 簡單看過,相當可靠。視覺上也可以接受。明天再繼續展示後來如何將這些材料拼湊成 record_viewer

目前狀況

如昨晚最新的 commit 描述的,目前正在進行第 21 世代的自我對局收集。回顧第 20 代的資訊,昨天已經提及 Random 對 Random 分佈的效果可能逐漸遺失。除了這一點比較明顯之外,其餘的數據判讀都讓人難免困惑。

像是 Play 13.244.9 兩個版本,到底誰比較強呢?單看兩者對決,應該是 3.24 比較強吧,

3.24 vs. 4.9
Doctor's winrate: 36.00%
Plague's steps to win: 20.12 +/- 10.55
Doctor's steps to win: 31.22 +/- 14.22

4.9 vs. 3.24
Doctor's winrate: 42.00%
Plague's steps to win: 22.48 +/- 10.28
Doctor's steps to win: 29.86 +/- 9.56

前手是疫病方,後手是醫療方。

3.24 在擔任疫病方時,醫療方的勝率低;反之則高。而且這個勝率相差甚多。再者,3.24 擔任醫療方時,令疫病方的平均獲勝步數增加了,在標準差並未明顯波動的情況;又,它在醫療方的獲勝步數的平均手數更短,分佈更集中些。

只是如果這套說得通,那為什麼兩造各自作為疫病方對決隨機猴子,勝率上沒有明顯差異,甚至分佈上看來,3.24 並未比較好?它得花更長手數勝利,它的隨機猴子對手更加快勝。

4.9 vs. random
Doctor's winrate: 38.00%
Plague's steps to win: 21.74 +/- 10.37
Doctor's steps to win: 33.26 +/- 10.96

3.24 vs. random
Doctor's winrate: 39.00%
Plague's steps to win: 23.59 +/- 12.83
Doctor's steps to win: 30.26 +/- 10.55

是因為還不夠大數嗎?但要怎麼樣才能算是足夠大數呢?一般比賽也不可能讓兩個人連續對戰這麼多次的。深切的感受到自己的統計力的極限。當然這裡有一個先驗的常態分佈假設,也許那並不為真?

持續自我對局中。


上一篇
殘局譜生成器
下一篇
互動元素的導入 2/2
系列文
DeltaPathogen:國產雙人不對稱抽象棋「疫途」之桌遊 AI 實戰27
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言