From 6d79d12c6f4a363a2b054249c208505801bbc6a0 Mon Sep 17 00:00:00 2001 From: AaronWu-train Date: Sat, 1 Aug 2026 12:10:02 +0800 Subject: [PATCH] Initial Grid++ game --- .gitea/workflows/build.yml | 43 +++++++ README.md | 27 +++++ main.cpp | 241 +++++++++++++++++++++++++++++++++++++ map.txt | 22 ++++ 4 files changed, 333 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 README.md create mode 100644 main.cpp create mode 100644 map.txt diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..94b4f65 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,43 @@ +# ============================================================================= +# build.yml —— 學生 push 後,Gitea Actions 自動把 main.cpp 編成 WASM 網頁 +# 並發佈到公開的 builds 區。學生通常不必改這個檔。 +# ============================================================================= +name: build-wasm +on: [push] + +jobs: + build: + runs-on: gridpp # 對應 runner 的 label(act_runner-config.yaml) + container: + image: gridpp-builder:latest # 內含 emcc + raylib-web + Grid++ 引擎 + # 安全:限制記憶體/CPU/行程數,搭配 timeout 防無窮迴圈或挖礦。 + # 外連已由 runner 把 job 容器掛在 internal 網擋掉(連得到 gitea、連不到外網)。 + # 掛 gridpp_builds 是為了最後一步把產物寫進 builds 區。 + options: --memory=512m --cpus=1 --pids-limit=256 -v gridpp_builds:/srv/builds + timeout-minutes: 2 # 防無窮迴圈/挖礦 + + steps: + - name: 取得程式碼 + uses: actions/checkout@v4 + + - name: 產生素材 assets.db + run: python3 "$GRIDPP/create_assets.py" + + - name: 編成 WASM 網頁 + run: | + mkdir -p site + emcc main.cpp -std=c++17 \ + -I"$GRIDPP/include" -I"$RAYLIB_WEB/include" -L"$RAYLIB_WEB/lib" -lraylib \ + -s USE_GLFW=3 -s ASYNCIFY -s ALLOW_MEMORY_GROWTH=1 \ + --preload-file assets.db@assets.db \ + --preload-file map.txt@map.txt \ + --shell-file "$GRIDPP/shell.html" \ + -o site/index.html + + - name: 發佈到 builds 區 + run: | + SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) # commit 前 8 碼 + DEST="/srv/builds/${GITHUB_ACTOR}/${SHORT}" + mkdir -p "$DEST" + cp site/index.html site/index.js site/index.wasm site/index.data "$DEST/" + echo "✅ 公開連結:http://<本機IP>:8080/${GITHUB_ACTOR}/${SHORT}/" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d45687 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Grid++ 學生範本 repo + +把這個資料夾的內容當作你的遊戲 repo 起點。你**只需要改 `main.cpp` 和 `map.txt`**。 + +## 檔案 + +| 檔案 | 說明 | 要不要改 | +|---|---|---| +| `main.cpp` | 你的遊戲主程式(用 Grid++ 引擎) | ✅ 這是你寫遊戲的地方 | +| `map.txt` | 地圖(第一行是「列數 欄數」,接著是地圖) | ✅ 可改 | +| `.gitea/workflows/build.yml` | 自動編譯設定 | ❌ 通常不用動 | + +> 引擎標頭(`GridPlusPlus.h` 等)、素材產生器(`create_assets.py`)、網頁外殼都已內建在 +> 編譯映像裡,你的 repo 不必自帶。 + +## 怎麼用 + +1. 在本機用 raylib native 開發、測試你的遊戲。 +2. `git push` 到老師的 Gitea。 +3. push 後會自動編譯成 WASM 網頁。成功後到 Actions 的 log 最後一行, + 會看到你的公開連結:`http://<本機IP>:8080/<你的學號>//` +4. 用瀏覽器打開那個連結就能玩。 + +## 注意 + +- 編譯有 **2 分鐘** 上限、限制記憶體/CPU,別寫無窮迴圈。 +- 編譯環境**連不到外網**,不要在程式裡嘗試下載東西。 diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..4c2d316 --- /dev/null +++ b/main.cpp @@ -0,0 +1,241 @@ +// ============================================================================= +// examples/pacman —— Grid++ 範例:Pac-Man 小遊戲 +// 逐步拆解見文件 docs/tutorial/pacman.md。 +// +// 流程:開始畫面 →(按 Start)遊戲中 → 獲勝 / 失敗 →(按 Restart)重來一局。 +// 玩法:方向鍵移動(持續朝目前方向走,按方向鍵且該方向不是牆才轉向), +// 吃光所有豆子獲勝,被任何鬼魂抓到失敗。 +// +// map.txt 格式:第一行「列數 欄數」,之後 rows*cols 個整數 +// (1=牆 0=通道豆子 2=玩家 3=鬼魂)。 +// +// 編譯(在本資料夾內,raylib 已安裝): +// g++ main.cpp -I../.. -o game -lraylib -lopengl32 -lgdi32 -lwinmm +// 執行前確保本資料夾有 map.txt 與 assets.db +// (assets.db 用根目錄的 create_assets.py 產生:`python ../../create_assets.py`)。 +// ============================================================================= +#include "GridMaze.h" // 用 -I../.. 指到專案根;會自動帶進核心 GridPlusPlus.h +#include "GridUI.h" // Label / Button 等 UI 元件 +#include // 讀地圖檔 +#include + +using namespace std; + +// 本範例除了 string 外不使用 STL 容器:地圖邊讀邊處理,物件直接 spawn。 + +// 遊戲階段:0=開始畫面, 1=遊戲中, 2=獲勝, 3=失敗 +int g_state = 0; +int g_pelletsLeft = 0; // 還剩幾顆豆子 +bool g_paused = false; // 是否暫停(由暫停按鈕切換) +GridMaze* g_maze = nullptr; // 讓角色查詢牆壁 +GridObject* g_player = nullptr; // 讓鬼魂知道玩家在哪 +GridEngine* g_game = nullptr; // 讓「重新開始」按鈕能重建世界 + +// 四個方向,對應 setDirection 的 0~3:右、上、左、下 +static const int DX[4] = { 1, 0, -1, 0 }; +static const int DY[4] = { 0, -1, 0, 1 }; + +// 曼哈頓距離(自己寫,避免引入 的 abs) +static int manhattan(int ax, int ay, int bx, int by) { + int dx = ax - bx, dy = ay - by; + if (dx < 0) dx = -dx; + if (dy < 0) dy = -dy; + return dx + dy; +} + +// 豆子:被小精靈吃到就消失 +class Pellet : public GridObject { +public: + Pellet(int x, int y) : GridObject("pellet", x, y) { setTag("pellet"); } + + void onCollide(GridObject* other) override { + if (!eaten && other->getTag() == "pacman") { + eaten = true; + if (--g_pelletsLeft <= 0) g_state = 2; // 吃完 → 獲勝 + } + } + void render(GridEngine* engine) override { + if (!eaten) GridObject::render(engine); + } +private: + bool eaten = false; +}; + +// 鬼魂:貪心追逐(挑離玩家最近、且不回頭的方向)或隨機。各自有不同顏色(tint)。 +class Ghost : public GridObject { +public: + Ghost(int x, int y, Color color, bool isRandom) + : GridObject("ghost", x, y), random(isRandom) { + setTag("ghost"); + setTint(color); // 同一張白色素材染成不同顏色 + } + + void onUpdate() override { + if (g_state != 1 || g_paused || !g_player) return; + if (++timer < 12) return; // 移動速度(比玩家稍慢) + timer = 0; + + int options[4], nopt = 0; // 非牆、且不回頭 + int any[4], nany = 0; // 非牆(死路時才用,允許回頭) + for (int d = 0; d < 4; d++) { + if (g_maze->isWall(getX() + DX[d], getY() + DY[d])) continue; + any[nany++] = d; + if (d == (dir ^ 2)) continue; // d 與目前方向相反 → 回頭,先跳過 + options[nopt++] = d; + } + int* pool = nopt ? options : any; + int n = nopt ? nopt : nany; + if (n == 0) return; + + int pick = pool[0]; + if (random) { + pick = pool[GetRandomValue(0, n - 1)]; // 隨機鬼 + } else { // 貪心鬼:挑離玩家曼哈頓距離最小的 + int best = 1 << 30; + for (int k = 0; k < n; k++) { + int d = pool[k]; + int dist = manhattan(getX() + DX[d], getY() + DY[d], + g_player->getX(), g_player->getY()); + if (dist < best) { best = dist; pick = d; } + } + } + dir = pick; + move(DX[dir], DY[dir]); + } +private: + bool random; + int timer = 0; + int dir = 0; +}; + +// 小精靈(玩家):持續朝目前方向走;按方向鍵且該方向不是牆才轉向。 +class Pacman : public GridObject { +public: + Pacman(int x, int y) : GridObject("pacman", x, y) { setTag("pacman"); } + + void onUpdate() override { + if (g_state != 1 || g_paused) return; + if (IsKeyDown(KEY_RIGHT)) want = 0; // 每幀記下想要的方向(緩衝) + if (IsKeyDown(KEY_UP)) want = 1; + if (IsKeyDown(KEY_LEFT)) want = 2; + if (IsKeyDown(KEY_DOWN)) want = 3; + + if (++timer < 8) return; // 移動速度 + timer = 0; + + if (want >= 0 && !g_maze->isWall(getX() + DX[want], getY() + DY[want])) + dir = want; // 想要的方向合法 → 轉過去 + if (dir >= 0 && !g_maze->isWall(getX() + DX[dir], getY() + DY[dir])) { + move(DX[dir], DY[dir]); + setDirection(dir); // 旋轉素材朝向移動方向 + } + } + void onCollide(GridObject* other) override { + if (other->getTag() == "ghost") g_state = 3; // 被鬼抓到 → 失敗 + } +private: + int timer = 0; + int dir = -1; // -1 = 還沒開始移動 + int want = -1; +}; + +// 依 map.txt 佈置一局:清掉上一局 → 重建迷宮、豆子、角色。開始與重新開始都呼叫它。 +void buildLevel(GridEngine& game) { + game.clearObjects(); + g_pelletsLeft = 0; + g_paused = false; + + ifstream f("map.txt"); + int rows, cols; + f >> rows >> cols; + + GridMaze* maze = new GridMaze(cols, rows); + // 自動拼接:給 6 種基本牆形狀,其餘方向引擎會旋轉素材湊出來。 + maze->setWallTiles("wall_iso", "wall_end", "wall_straight", + "wall_corner", "wall_tee", "wall_cross"); + game.spawn(maze); // 先放迷宮(畫最底層) + g_maze = maze; + + Color colors[4] = { RED, PINK, SKYBLUE, ORANGE }; // 四隻鬼的顏色 + int gi = 0; + Pacman* player = nullptr; + for (int y = 0; y < rows; y++) + for (int x = 0; x < cols; x++) { + int t; + f >> t; + if (t == 1) maze->setWall(x, y, true); + else if (t == 0) { game.spawn(new Pellet(x, y)); g_pelletsLeft++; } + else if (t == 2) player = new Pacman(x, y); + else if (t == 3) { game.spawn(new Ghost(x, y, colors[gi % 4], gi == 3)); gi++; } + } + if (player) { g_player = player; game.spawn(player); } // 玩家最後生成(畫最上層) +} + +// 分數與各畫面的訊息(UI 覆蓋層,跳脫網格、用像素座標) +class ScoreUI : public UIElement { +public: + void draw() override { + if (g_state == 1) { // 遊戲中:左上角分數 + DrawText(TextFormat("Pellets: %d", g_pelletsLeft), 8, 8, 20, YELLOW); + if (g_paused) bigText("PAUSED", ORANGE); + return; + } + // 非遊戲中:壓暗背景,畫大字(按鈕由各自的類別畫在這之上) + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.6f)); + if (g_state == 0) bigText("PAC-MAN", YELLOW); + else if (g_state == 2) bigText("YOU WIN!", GREEN); + else if (g_state == 3) bigText("GAME OVER", RED); + } +private: + static void bigText(const char* msg, Color col) { + int fs = 40, tw = MeasureText(msg, fs); + DrawText(msg, GetScreenWidth() / 2 - tw / 2, GetScreenHeight() / 2 - 70, fs, col); + } +}; + +// 各按鈕只在「對的階段」顯示與作用:覆寫 onClick(行為)與 onUpdate/draw(階段判斷)。 +class StartButton : public Button { +public: + StartButton(int x, int y, int w, int h) : Button("Start", x, y, w, h) {} + void onClick() override { g_state = 1; } // 開始遊戲 + void onUpdate() override { if (g_state == 0) Button::onUpdate(); } + void draw() override { if (g_state == 0) Button::draw(); } +}; + +class RestartButton : public Button { +public: + RestartButton(int x, int y, int w, int h) : Button("Restart", x, y, w, h) {} + void onClick() override { buildLevel(*g_game); g_state = 1; } // 重來一局 + void onUpdate() override { if (g_state == 2 || g_state == 3) Button::onUpdate(); } + void draw() override { if (g_state == 2 || g_state == 3) Button::draw(); } +}; + +class PauseButton : public Button { +public: + PauseButton(int x, int y, int w, int h) : Button("Pause", x, y, w, h) {} + void onClick() override { g_paused = !g_paused; } + void onUpdate() override { if (g_state == 1) Button::onUpdate(); } + void draw() override { if (g_state == 1) Button::draw(); } +}; + +int main() { + int rows, cols; + { ifstream f("map.txt"); f >> rows >> cols; } // 先讀尺寸決定視窗大小 + + GridEngine game(cols, rows, 32); + g_game = &game; + game.loadAssets("assets.db"); + + buildLevel(game); // 先建好一局 + g_state = 0; // 停在開始畫面 + + // UI 覆蓋層(ScoreUI 先加,按鈕畫在它之上) + int bw = 120, bh = 40, bx = cols * 32 / 2 - bw / 2, by = rows * 32 / 2; + game.addUI(new ScoreUI()); + game.addUI(new StartButton(bx, by, bw, bh)); + game.addUI(new RestartButton(bx, by, bw, bh)); + game.addUI(new PauseButton(cols * 32 - 88, 6, 82, 24)); + + game.run(); + return 0; +} diff --git a/map.txt b/map.txt new file mode 100644 index 0000000..7689c08 --- /dev/null +++ b/map.txt @@ -0,0 +1,22 @@ +21 19 +1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 1 1 1 0 1 1 1 0 1 1 1 0 1 1 1 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 1 1 1 0 1 0 1 0 1 0 1 0 1 1 1 0 1 +1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 1 1 1 0 1 1 1 0 1 1 1 0 1 1 1 0 1 +1 0 0 0 1 0 1 1 1 0 1 1 1 0 1 0 0 0 1 +1 0 1 1 1 0 1 3 3 0 3 3 1 0 1 1 1 0 1 +1 0 0 0 1 0 1 1 1 1 1 1 1 0 1 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 1 1 1 0 1 1 1 0 1 1 1 0 1 1 1 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 1 1 1 0 1 0 1 0 1 0 1 0 1 1 1 0 1 +1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 1 1 1 0 1 1 1 2 1 1 1 0 1 1 1 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1