Files
ftcandClaude Opus 5 7fbe4c324d Игра запускается и играется: три рантайм-бага и адаптивная вёрстка
Проверено вживую в Chrome: лобби -> матч -> бот ходит -> результат ->
статистика в лобби. Полный матч отыгран, оба исхода видел.

Баги, которые собирались и типизировались, но падали только в рантайме:

1. Canvas не менял размер под экран. Своего cc.Widget у него не было,
   а alignCanvasWithScreen правит только камеру. Canvas оставался
   1280x720, и все растянутые по нему дети — тоже.
2. [...map.values()] превращается сборкой в [].concat(map.values()),
   а concat не разворачивает итератор, а кладёт его одним элементом.
   Отсюда "view.cancelAnimations is not a function" на первой же уборке
   карт. Теперь Array.from.
3. Рубашка козыря рисовалась поверх колоды, а не под ней.

Вёрстка переделана под требование UI.md (4:3 .. 21:9):
- fitWidth даёт постоянную ширину, но не высоту: на 4:3 видно только
  ±720 юнитов. Кнопка "Leave" на y=840 и низ руки на -860 были за
  экраном именно там, где это важнее всего
- рука и панель действий считаются от нижней границы экрана,
  "Leave" прибит виджетом к левому верхнему углу
- стол — один ряд до шести карт вместо двух рядов по три: экономит
  ~215 юнитов высоты, которых на 4:3 нет. Ряд центрируется по тем
  картам, что реально на столе
- затемнение под окном результата рисуется непрозрачным спрайтом:
  тонировать и без того полупрозрачный спрайт почти ничего не даёт

Инструменты:
- npm run serve — отдаёт сборку и роут /_aspects: игра в трёх
  фиксированных iframe-ах 4:3 / 16:9 / 21:9. Движок подгоняет себя
  под окно, поэтому iframe — единственный простой способ увидеть
  вёрстку в форме, которой окно не имеет
- npm run check:spreads — ловит спред неитерируемого в массив
- npm run check — чистота core, спреды, сцены, типы, тесты

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:41:47 +03:00

12 KiB
Raw Permalink Blame History

Technical structure

How this project is laid out, what each layer is allowed to do, and how to work on it. Companion to ARCHITECTURE.md, which states the intent; this file states the implementation.

  • Engine: Cocos Creator 3.8.8, TypeScript.
  • Targets: Web (mobile browser), Android, iOS. Portrait only.
  • Editor automation: the cocos-code-mode extension — see CODE_MODE_SETUP.md.

Layering

Four layers, with dependencies pointing strictly downward. A layer may import from the layers below it and never from the ones above.

  ui/        Cocos components: scenes, prefabs, views, input, animation
   ↓
  app/       bootstrap, service locator, scene transitions
   ↓
  modules/   engine-aware services: savegame, (later) audio, analytics
   ↓
  core/      pure rules and bots — no `cc` import anywhere

The rule, enforced by npm run check:core-purity: nothing under assets/scripts/core/ may import from cc, and under assets/scripts/modules/ only the adapters explicitly listed in that script may. Everything else is plain TypeScript, runnable under Node, which is what makes the rules and the save format testable without the engine.

Folder map

assets/
  scenes/
    loader.scene              module init and art preload, then → lobby
    lobby.scene               Canvas > Background, Safe > LobbyRoot | GameRoot
  prefabs/
    card.prefab               one playing card, face or back
  resources/
    cards/                    36 faces + back + cards.pac, loaded by path
  art/
    ui/                       table surface, nine-slice panels and buttons
  scripts/
    core/                     PURE TS — no engine imports
      cards/                  Suit, Rank, Card, Deck
      rules/                  GameState, actions, events, the reducer
      bot/                    PlayerView projection, Bot contract, BasicBot
      match/                  Match (stateful shell), runMatch (headless driver)
      util/                   seeded RNG, shuffle
    modules/
      save/                   SaveManager, ISaveSection, StatsSection,
                              IStorage + MemoryStorage, CocosStorage (engine)
    app/                      Bootstrap, ServiceLocator, SceneRouter
    ui/
      MainFlow.ts             owns both screens and moves between them
      lobby/                  LobbyScreen: statistics and the New Game button
      game/                   GameScreen, CardView, CardLibrary
      common/                 Theme (layout, palette, timings), Strings
tests/
  core/                       vitest specs against assets/scripts/core
  support/                    position fixtures and the state invariant checker
  tools/                      measuring tools, not part of the suite
art-src/cards/                vector sources for the card faces (LGPL, kept)
docs/                         specs, this file, plan, decisions
local/                        scratch art storage; contents get consumed
scripts/                      build and check tools run through npm
extensions/cocos-code-mode/   editor tool server

The rules core

A deterministic state machine. The only entry point is a pure reducer:

apply(state: GameState, action: Action): { state: GameState; events: GameEvent[] }
  • GameState is plain data: hands, deck, trump card, table slots, discard, phase, whose turn it is, and the RNG state. It is serialisable, which makes every test a fixture and every bug reproducible from a seed.
  • Action is what a participant wants to do — Attack, Defend, Take, Pass. Legality is decided by the core, never by the caller; legalActions() exists so the UI can grey out impossible taps, not as the source of truth.
  • GameEvent is what happened — CardDealt, CardPlayed, CardBeaten, TableTaken, TableDiscarded, TurnPassed, PlayerOut, GameOver. The view layer animates the event stream; it never inspects GameState directly for anything it is not allowed to see.
  • Randomness comes from a seeded PRNG carried inside the state. Same seed plus same action sequence always reproduces the same match.

Bots

A bot is decide(view: PlayerView) => Action. PlayerView is a projection of GameState containing only legitimately visible information — own hand, table, trump, opponents' hand sizes, deck and discard counts — plus the list of legal actions. BasicBot implements the policy in GAMEPLAY.md.

Match wraps the reducer with the current state and an event subscription; the game screen and the headless runMatch driver both hold one. As a reference point, 5000 BasicBot self-play matches split roughly 43/43/14 between the two seats and draws, with the opening attacker losing about 37% of the time — npm run stats:bot reprints these.

Savegame

Per ARCHITECTURE.md: SaveManager keeps a registry of ISaveSection implementations. save() enumerates them and builds { [section.key]: section.save() }; load() parses and dispatches each payload back to the matching section. A missing or unparsable section is skipped with a warning rather than failing the whole load, so a bad release cannot brick a save.

Keys are explicit string constants, not class names — release builds minify.

The document written is { version, sections: { <key>: <payload> } }. Storage sits behind IStorage, so all of the save logic is testable headlessly; CocosStorage is the only file in the module that imports the engine, and it falls back to an in-memory store when the device refuses to persist (private browsing, a full quota) rather than taking the game down.

The lobby scene

Both screens live in one scene, per ARCHITECTURE.md, so starting a match is a change of visible root rather than a scene load:

Canvas  (1080×1920, Canvas + MainFlow)
  Camera            orthographic, UI_2D only, clears to table green
  Background        table_bg, widget-stretched
  Safe              cc.SafeArea — everything tappable is inside it
    LobbyRoot       LobbyScreen: title, statistics panel, New Game
    GameRoot        GameScreen + CardLibrary (inactive until a match starts)
      Deck / OpponentLabel / Message / CardLayer / ActionBar / QuitButton
      ResultOverlay dimmer, result panel, Back

GameScreen renders from the current GameState rather than replaying the event stream — see docs/DECISIONS.md. Card nodes are pooled and stay bound to their Card while it is visible, so movement is a tween to a new position.

Buttons are @property(Button) references subscribed in onLoad; the scene's clickEvents lists are deliberately empty.

Conventions

  • TypeScript, 4-space indent, PascalCase for types, camelCase for members, _private for backing fields (matching Cocos's own style).
  • One exported class per file, file named after it.
  • Cocos components live only in ui/; they are thin and hold no rules logic.
  • No any in core/. strict is off in the Cocos tsconfig for engine-side convenience, but the test harness type-checks core/ in strict mode.

Workflows

Tests

npm test

Runs vitest over tests/core against assets/scripts/core. No editor required. The soak specs play several thousand complete matches and re-check every state invariant after every single action, so a rule regression surfaces at the move that caused it.

npm run typecheck         # strict tsc over core/ and the tests
npm run check:core-purity # fails if core/ ever imports the engine
npm run stats:bot         # BasicBot self-play distribution, not a test

Driving the editor

Cocos Creator must be running with this project open. See CODE_MODE_SETUP.md; the port changes on every editor restart, so always read ~/.utcp_config.json.

Scripts and other text assets are written to disk directly and picked up by the editor's asset watcher. Scenes and prefabs are authored through the tools — never by hand-editing the .scene/.prefab JSON.

Art

npm run build:art

Regenerates every sprite in assets/art from source. Card faces are rasterised from the vectors in art-src/cards; the card back, table and UI chrome are authored as SVG inside scripts/build-art.mjs and rasterised from there, so a colour or radius is changed in one readable place rather than in a binary.

Fixed numbers the layout depends on:

Card sprite 220 × 320 px
Nine-slice border 40 px on 120 × 120 tiles
Table background 540 × 960, drawn at 2×

Card sprite frames have trimming switched off, because layout maths assumes every card rect is exactly 220 × 320. Card faces are named <rank>_of_<suit>, matching Card.assetName — that string is the only contract between the rules core and the art.

Licence: the faces are LGPL-3.0 and attribution is mandatory. See assets/resources/cards/ATTRIBUTION.md, which also explains what would have to change to drop the dependency.

Builds

A headless build, which is what CI would run:

"C:/ProgramData/cocos/editors/Creator/3.8.8/CocosCreator.exe"   --project "C:/Work/fool-cardgame"   --build "platform=web-mobile;debug=true;startScene=<loader.scene uuid>"

It writes to build/web-mobile in about 40 seconds. Two caveats: it starts a second editor instance, so it competes with a running editor for temp/logs/project.log and can fail with a duplicate-class error — close the editor, or build from its Build panel. And startScene must be passed, or the build has no launch scene and stops on the splash.

Per-platform texture settings

ART.md asks for ASTC on mobile and PNG on the web. Compression is set per image (or per folder) in the Inspector's texture-compress section, not in code, so it has to be applied once in the editor and is then stored in the .meta files:

Target Setting
web-mobile PNG, as now
android ASTC 6×6 for the card atlas, ASTC 4×4 for the UI sprites
ios the same ASTC settings; the format is supported on every device the game targets

The card atlas is the only texture where this matters — 2048×2048 uncompressed is 16 MB of video memory, and ASTC 6×6 brings that to roughly 1.2 MB. Do it before the first device build.

Looking at it

npm run serve

Serves build/web-mobile on http://localhost:8123, plus one route of its own:

  • /_aspects — the game in fixed-size iframes at 4:3, 16:9 and 21:9 portrait, side by side. The engine resizes itself to fill whatever window it is in, so an iframe is the only straightforward way to see the layout at a shape the desktop window is not.
  • /_aspects?only=16:9&h=690 — one viewport, which boots much faster than three.

The layout has been checked this way at all three ratios, and a full match played through in the browser: deal, attack, defend, take, discard, redraw, result overlay, and the statistics written and read back in the lobby.

Three traps this project has already fallen into

All three build and type-check cleanly, look right in the editor, and fail only at runtime. Each now has a guard; npm run check runs all of them.

  1. Node layers. nodeCreate puts nodes on DEFAULT; a Canvas camera renders UI_2D only, so the build shows the camera's clear colour and nothing else. The scene view renders every layer, so the editor looks fine.
  2. The Canvas needs its own Widget. Without one the Canvas keeps the size it was authored at and every widget-stretched child inherits it. alignCanvasWithScreen does not do this — it only adjusts the camera.
  3. Array spread of a non-array. The build turns [...x] into [].concat(x), which appends an iterator rather than expanding it. Use Array.from.