@@ -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 [email protected] \
|
||||
--preload-file [email protected] \
|
||||
--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}/"
|
||||
@@ -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/<你的學號>/<commit前8碼>/`
|
||||
4. 用瀏覽器打開那個連結就能玩。
|
||||
|
||||
## 注意
|
||||
|
||||
- 編譯有 **2 分鐘** 上限、限制記憶體/CPU,別寫無窮迴圈。
|
||||
- 編譯環境**連不到外網**,不要在程式裡嘗試下載東西。
|
||||
@@ -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 <fstream> // 讀地圖檔
|
||||
#include <string>
|
||||
|
||||
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 };
|
||||
|
||||
// 曼哈頓距離(自己寫,避免引入 <cstdlib> 的 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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user