Trang này trả lời ba câu hỏi: (a) code chia thành những lớp (layer) nào và lớp nào được gọi lớp nào; (b) vì sao thứ tự thẻ
<script> trong index.html lại như vậy; (c) từ máy bạn tới trình duyệt của người chơi, code đi qua những đâu.
Sơ đồ phụ thuộc giữa các namespace (ai gọi API của ai) đã có ở ARCHITECTURE-DIAGRAMS mục 1. Ở đây ta nhìn theo kênh giao tiếp và luật hướng phụ thuộc.
Mục lục:
- Component diagram: các lớp và bốn kênh giao tiếp
- Luật hướng phụ thuộc (ma trận)
- Thứ tự nạp
<script>và phụ thuộc lúc LOAD - Deployment: từ máy bạn tới tên miền
- Pipeline CI chi tiết
- Dây chuyền ảnh: Wikimedia Commons → assets/photos
1. Component diagram: các lớp và bốn kênh giao tiếp #
flowchart TB
subgraph L4["Tính năng (feature)"]
direction LR
ui["ui/<br/>OG.Menus · OG.Shop"]
story["story/<br/>OG.Story · Learn · Guide · Photos"]
world["world/<br/>OG.World · OG.Quests"]
social["social/<br/>OG.Social"]
bosses["bosses/<br/>OG.Bosses"]
audio["audio/<br/>OG.Audio"]
end
subgraph L3["Gameplay"]
game["game/<br/>OG.Game · Level · Player · Enemies<br/>Collectibles · Particles · Progress<br/>Difficulty · BossScene"]
end
subgraph L2["Đồ hoạ + dữ liệu"]
direction LR
gfx["gfx/<br/>OG.Sprites · OG.Decor"]
data["data/<br/>OG.Data"]
end
subgraph L1["Lõi engine"]
core["core/<br/>OG.util · Events · Save · Input · Camera<br/>Physics · DOM · Scenes · Loop<br/>Scenery · Journeys"]
end
journeys[["journeys/<id>/ (dữ liệu)<br/>theme · content · extras · journey"]]
photos[["assets/photos/<id>/credits.js (dữ liệu)"]]
bus(("OG.Events<br/>event bus"))
L4 -->|"① gọi trực tiếp API công khai"| L3
L4 -->|"①"| L2
L4 -->|"①"| L1
L3 -->|"①"| L2
L3 -->|"①"| L1
L2 -->|"①"| L1
L3 -.->|"② OG.util.call (tuỳ chọn)"| L4
L1 -.->|"② main.js: util.call(ns, 'init')<br/>theo FEATURE_INIT_ORDER"| L4
L3 ==>|"③ emit"| bus
L4 ==>|"③ emit / on"| bus
bus ==>|"③ on"| audio
journeys -->|"④ register lúc LOAD"| L1
journeys -->|"④"| L2
journeys -->|"④ registerType"| L3
journeys -->|"④ Bosses.register"| bosses
photos -->|"④ Photos.register"| storyBốn kênh giao tiếp:
| Kênh | Hình | Dùng khi | Ví dụ thật |
|---|---|---|---|
| ① Gọi trực tiếp | mũi tên liền | lớp trên cần dịch vụ của lớp dưới (luôn có mặt) | OG.Physics.moveBody trong src/game/player.js |
| ② Gọi tuỳ chọn | nét đứt | lớp dưới cần "báo" lớp trên mà không được phụ thuộc cứng | OG.util.call('Difficulty', 'softFail', ...) trong src/game/game.js loseLife |
| ③ Event bus | mũi tên đậm | thông báo "đã xảy ra chuyện gì", không cần biết ai nghe | emit('player:jump') → Audio, Sprites nghe (src/core/events.js) |
| ④ Đăng ký lúc LOAD | mũi tên liền từ khối dữ liệu | dữ liệu / phần mở rộng cắm vào registry | OG.Journeys.register trong src/journeys/home/journey.js |
Vì sao bốn kênh: mỗi kênh giải một bài toán khác nhau về coupling (độ dính giữa module). ① rõ ràng nhất nhưng dính chặt, nên chỉ cho đi
xuống. ② cho phép xoá hẳn một module tính năng (xoá thẻ <script>) mà game vẫn chạy. ③ cho nhiều người nghe một sự việc mà nơi phát không
phải sửa (thêm âm thanh mới = thêm listener trong audio.js). ④ là inversion of control (đảo ngược điều khiển): engine không có danh sách
hành trình, hành trình tự đăng ký.
Đánh đổi: ② và ③ khó lần theo hơn (không "Go to definition" được) — vì vậy có bảng OG.EV và sơ đồ luồng event ở tổng quan.
2. Luật hướng phụ thuộc (ma trận) #
Hàng = lớp gọi, cột = lớp bị gọi. ✅ được gọi trực tiếp · 🔸 chỉ qua OG.util.call / event · ⛔ không được.
| gọi ↓ / bị gọi → | core | data | gfx | game | feature | journeys |
|---|---|---|---|---|---|---|
| core | ✅ | ⛔ (trừ journeys.js đọc lúc chạy) |
⛔ | ⛔ (trừ main.js gọi Progress.init) |
🔸 | ⛔ |
| data | ✅ | ✅ | ⛔ | ⛔ | ⛔ | ⛔ (OG.Data.getLevel tra OG.Journeys, ở core) |
| gfx | ✅ | ✅ | ✅ | đọc trạng thái lúc chạy (OG.Level.state, OG.Game.run) |
⛔ | ⛔ |
| game | ✅ | ✅ | ✅ | ✅ | 🔸 (Bosses, Menus, Shop, Quests, Social, Story) |
⛔ |
| feature | ✅ | ✅ | ✅ | ✅ | ✅ đọc/gọi API công khai, không ghi đè namespace khác | ⛔ |
| journeys | ✅ (đăng ký) | ✅ (registerCharacter) |
✅ (Decor.define) |
✅ (Enemies.registerType) |
✅ (Bosses.register) |
⛔ (hành trình không gọi nhau) |
Các luật này đến từ docs/ARCHITECTURE.md mục 1 và 8 ("mọi lời gọi từ core hoặc game vào module tính năng đi qua
OG.util.call") và bảng phụ thuộc đo bằng grep ở ARCHITECTURE-DIAGRAMS mục 1.4.
Ngoại lệ có chủ đích: src/core/journeys.js đọc OG.Bosses, OG.Progress lúc chạy để kiểm tra dữ liệu.
Vì sao: hướng phụ thuộc một chiều = sửa UI không làm vỡ vật lý; core/ có thể mang sang game khác. Đây là ý tưởng layered architecture
(kiến trúc phân tầng) và dependency rule: phụ thuộc chỉ chỉ vào trong / xuống dưới.
3. Thứ tự nạp <script> và phụ thuộc lúc LOAD #
Mũi tên ở đây chỉ vẽ phụ thuộc lúc LOAD (file đọc OG.X ngay khi được nạp) — loại phụ thuộc bắt buộc thứ tự. Phụ thuộc lúc chạy không vẽ.
flowchart TB
subgraph G1["1 · Core"]
ns["namespace.js<br/>tạo window.OG, OG.util"] --> ev["events.js"] --> sv["save.js · input.js · camera.js<br/>physics.js · dom.js · scenes.js · loop.js"]
sv --> scr["scenery-registry.js"] --> jr["journeys.js"]
end
subgraph G2["2 · Data"]
chars["characters.js"] --> lvls["levels.js"]
end
subgraph G3["3 · Gfx"]
spr["sprites.js"] --> skins["skins.js<br/>registerSkin lúc load"] --> scen["scenery.js"] --> dec["decor.js<br/>registerDecor/Layer"] --> decp["decor-places.js · decor-home.js<br/>OG.Decor.define"]
end
subgraph G45["4–5 · Audio + Game"]
aud["audio.js → music.js"]
gm["particles · progress · difficulty · game · player<br/>enemies · collectibles · level · boss-scene<br/>→ default-scenes.js (scene mặc định)"]
end
subgraph G6["6 · Bosses"]
bs["bosses.js (kit, factories)"] --> b3["joey.js · marky.js · deedee.js<br/>Bosses.register"] --> trio["trio.js<br/>dùng factories.*"]
end
subgraph G79["7–9 · World, UI, Social"]
wl["extraLevels.js (registerLevel) → world.js → journey-map.js → quests.js"]
uis["menus.js → hud.js → shop.js"]
soc["social.js → leaderboard.js (cần OG.Social)"]
end
subgraph G10["10 · Story"]
st["story.js → learn.js → photos.js"] --> cr["assets/photos/*/credits.js<br/>OG.Photos.register"] --> gd["guide.js → scenes.js<br/>Scenes.register lúc load"]
end
subgraph G11["11 · Journeys (×4)"]
th["theme.js<br/>Scenery.registerTheme"] --> ct["content.js<br/>Journeys.register"] --> ex["extras.js<br/>Enemies.registerType..."] --> jj["journey.js<br/>Journeys.register (gộp)"]
end
main["12 · main.js — LUÔN CUỐI<br/>boot() khi DOM sẵn sàng"]
G1 --> G2 --> G3 --> G45 --> G6 --> G79 --> G10 --> G11 --> main
chars -.->|"OG.Data.registerSkin"| skins
scr -.->|"OG.Scenery"| dec
lvls -.->|"OG.Data.registerLevel"| wlCách đọc: khối là nhóm trong index.html (đánh số giống docs/ARCHITECTURE.md mục 2), mũi tên đậm giữa
các khối là thứ tự nạp; mũi tên đứt là phụ thuộc lúc LOAD vượt nhóm đáng chú ý.
Lý do của từng ràng buộc:
namespace.jsđầu tiên: tạowindow.OGvàOG.utilmà mọi file khác dùng ngay dòngvar OG = window.OG;.scenery-registry.js,journeys.jsthuộc core (không phải gfx/story) để dữ liệu hành trình đăng ký được mà không cần file vẽ nào.skins.jssaucharacters.jsvì gọiOG.Data.registerSkinlúc nạp;decor*.jssauscenery-registry.jsvì gọiregisterDecor/registerLayer.default-scenes.jscuối nhóm game: đăng ký scene mặc định để module tính năng nạp sau ghi đè tronginit().trio.jssaujoey/marky/deedee.jsvì dùngOG.Bosses.factories.*lúc nạp (thiếu factory nào thì trận chỉ có các tên còn lại).credits.js(ảnh) sauphotos.js: smoke test kiểm tra đúng điều này (photoCheckstrong tests/smoke.js).scenes.jscủa story gắnOG.Story.drawComicPanellúc nạp nên saustory.js; nó đăng ký bản DOM dự phòng củajourneyMap, còn bản canvas được src/world/journey-map.js đăng ký trongOG.World.init(lúc chạy, nên thắng).- Mỗi hành trình:
theme → content → extras → journey.journey.jscuối vì tham chiếu chủ đề, thẻ, câu đố theo tên (đượcvalidatekiểm lúc boot). main.jscuối cùng: mọiOG.Xđã có;boot()mới bắt đầu đọcOG.Save.datavà gọiinit().
Vì sao phải lo thứ tự: không có ES modules thì trình duyệt không tự sắp xếp phụ thuộc như import; thứ tự thẻ <script> là đồ thị phụ thuộc.
Quy tắc giảm rủi ro: lúc LOAD chỉ đăng ký, mọi việc khác để lúc CHẠY (trong init() hoặc hàm), nên phần lớn phụ thuộc không quan tâm thứ tự.
4. Deployment: từ máy bạn tới tên miền #
flowchart LR
subgraph DEV["Máy người phát triển"]
direction TB
src["index.html, src/, assets/,<br/>docs/, CNAME, robots.txt,<br/>sitemap.xml, llms.txt, site.webmanifest"]
run1["Mở thẳng index.html<br/>(file://)"]
run2["python -m http.server 8080<br/>hoặc npx serve ."]
smoke1["node tests/smoke.js"]
src --> run1 & run2 & smoke1
end
subgraph GH["GitHub"]
repo[("repo, nhánh master")]
subgraph ACT["GitHub Actions: Deploy GitHub Pages"]
build["job build<br/>smoke test → đóng gói _site"]
deploy["job deploy<br/>actions/deploy-pages@v4"]
build --> deploy
end
pages["GitHub Pages<br/>môi trường github-pages"]
end
dns["DNS tên miền<br/>oggy-adventure.tiennhm.io.vn<br/>(file CNAME)"]
user["Trình duyệt người chơi<br/>HTTPS, localStorage riêng theo tên miền"]
src -->|"git push master<br/>(hoặc chạy tay workflow_dispatch)"| repo
repo --> build
deploy --> pages
pages --> dns --> userCách đọc: chỉ có file tĩnh, không có máy chủ ứng dụng hay cơ sở dữ liệu. Bên trái là ba cách chạy ở máy bạn
(xem README.md phần cách chạy): mở thẳng file:// (game cố ý hỗ trợ), hoặc server tĩnh (tránh trình duyệt chặn localStorage qua file://).
Push lên master kích hoạt workflow .github/workflows/pages.yml. File CNAME chứa tên miền riêng; GitHub Pages phục vụ
site dưới tên miền đó.
Vì sao: game thuần client nên GitHub Pages (miễn phí, có HTTPS) là đủ. HTTPS còn cần cho navigator.clipboard và navigator.share
(chỉ có trong secure context). Dữ liệu người chơi nằm trong localStorage của trình duyệt theo từng tên miền — đổi tên miền thì người chơi
"mất" save (dữ liệu vẫn ở tên miền cũ), nên tên miền cũng là một phần hợp đồng dữ liệu.
5. Pipeline CI chi tiết #
flowchart TB
trig(["push lên master<br/>hoặc workflow_dispatch"]) --> conc["concurrency: group pages,<br/>cancel-in-progress: true"]
conc --> b1
subgraph BUILD["job build (ubuntu-latest)"]
b1["actions/checkout@v5"] --> b2["actions/setup-node@v5<br/>node-version 22"]
b2 --> b3{"node tests/smoke.js<br/>XANH?"}
b3 -->|"không"| stop(["dừng: KHÔNG deploy"])
b3 -->|"có"| b4["Đóng gói site:<br/>mkdir _site<br/>cp -r index.html src docs assets CNAME .nojekyll<br/>robots.txt sitemap.xml llms.txt site.webmanifest _site/<br/>rm -f _site/assets/photos/*/_entries.json"]
b4 --> b5["actions/configure-pages@v5"] --> b6["actions/upload-pages-artifact@v3<br/>path: _site"]
end
subgraph DEPLOY["job deploy (needs: build)"]
d1["actions/deploy-pages@v4<br/>environment: github-pages"]
end
b6 --> d1
d1 --> live(["site mới trên GitHub Pages"])Cách đọc: đúng từng bước trong .github/workflows/pages.yml. Quyền tối thiểu: contents: read, pages: write, id-token: write.
Vì sao:
- Smoke test làm cổng chặn: tests/smoke.js nạp mọi script bằng
vmvới DOM giả (tests/fake-dom.js) và chơi hết game; đỏ thì không deploy. Không có build step nên đây là "bước biên dịch" duy nhất bắt lỗi. - Đóng gói có chọn lọc (danh sách trắng thư mục):
tests/,legacy/,tools/không lên mạng;_entries.json(file tạm của công cụ ảnh) bị xoá. File public mới (vd một trang HTML ở gốc) phải thêm vào lệnhcp, nếu không sẽ không được deploy. .nojekyll: tắt Jekyll của GitHub Pages để phục vụ nguyên file (Jekyll bỏ qua thư mục/file bắt đầu bằng_).concurrency+cancel-in-progress: push liên tiếp chỉ deploy bản mới nhất.
6. Dây chuyền ảnh: Wikimedia Commons → assets/photos #
flowchart LR
author["Người soạn nội dung"] -->|"search 'từ khoá'"| tool["tools/photos/fetch_commons.py<br/>(Python 3 + Pillow, chạy TAY)"]
tool <-->|"API Commons: User-Agent,<br/>nghỉ ~1 giây, lùi lại khi 429"| commons[("Wikimedia Commons")]
manual[("Unsplash / Pexels / NASA<br/>(lệnh url)")] --> tool
content["src/journeys/<id>/content.js<br/>(danh sách cardId hợp lệ)"] -->|"card_ids"| tool
tool -->|"get: tải, thu nhỏ cạnh dài ≤ 800 px,<br/>WebP quality 78, kiểm giấy phép"| webp[("assets/photos/<id>/<cardId>.webp")]
tool -->|"get / url / remove"| entries[("_entries.json<br/>(tạm, .gitignore)")]
entries -->|"write <id>"| credits["assets/photos/<id>/credits.js<br/>OG.Photos.register(...)<br/>JSON giữa BEGIN/END-ENTRIES"]
credits --> game["Game: OG.Photos (nạp lười)"]
credits --> creditsPage["docs/credits.html<br/>(trang ghi công)"]
credits --> smoke["tests/smoke.js photoChecks<br/>giấy phép, cardId, thứ tự nạp"]Cách đọc: công cụ chạy trên máy người soạn, không nằm trong game và không được deploy. Lệnh search, get, url, write, list, remove
mô tả ở đầu tools/photos/fetch_commons.py. Đầu ra là ảnh WebP + credits.js; game và trang
docs/credits.html đều đọc credits.js qua OG.Photos.register (src/story/photos.js).
Vì sao:
- Ảnh nằm trong repo, không hotlink (nhúng thẳng link ảnh Commons): chạy được offline/
file://, không phụ thuộc máy chủ người khác, kích thước đã tối ưu, và ghi công được kiểm soát (ADR-09). - Sinh code từ dữ liệu (
credits.js): vẫn là JSON hợp lệ giữa hai dấu mốc để công cụ và smoke test đọc lại bằngjson.loads/JSON.parse. - Một danh sách trắng giấy phép dùng ở ba nơi (công cụ,
OG.Photos.licenseOk, smoke test) → ảnh sai giấy phép bị chặn ở cả lúc soạn, lúc chạy và lúc CI.