- scripts/build-art.mjs: все спрайты игры собираются из исходников, а не кладутся руками. Лица карт растеризуются из векторов, рубашка/стол/панели/кнопки нарисованы SVG-ом прямо в скрипте - 36 лиц 220x320 + рубашка, автоатлас 2048x2048, обрезка выключена (раскладка рассчитывает на точный размер карты) - девятипатчевые плитки 120x120 с рамкой 40 - фон стола 540x960, рисуется в 2x — градиенту детализация не нужна, а памяти под текстуру уходит вчетверо меньше - исходные векторы перенесены из local/ в art-src/cards - ATTRIBUTION.md: LGPL-3.0, атрибуция обязательна; там же написано, что менять, если такая лицензия не подходит для релиза Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.9 KiB
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-modeextension — 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, then → lobby
lobby.scene main menu; hosts the match UI
prefabs/
card.prefab one playing card, face or back
...
art/
cards/ 36 faces + back, packed into cards.pac (2048²)
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/
lobby/ lobby screen controller
game/ match screen: hand, table, deck, animations
common/ reusable widgets, safe-area root
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[] }
GameStateis 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.Actionis 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.GameEventis what happened —CardDealt,CardPlayed,CardBeaten,TableTaken,TableDiscarded,TurnPassed,PlayerOut,GameOver. The view layer animates the event stream; it never inspectsGameStatedirectly 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.
Conventions
- TypeScript, 4-space indent,
PascalCasefor types,camelCasefor members,_privatefor 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
anyincore/.strictis off in the Cocos tsconfig for engine-side convenience, but the test harness type-checkscore/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/art/cards/ATTRIBUTION.md, which
also explains what would have to change to drop the dependency.
Builds
Web: web-mobile template. Mobile: ASTC textures per ART.md, PNG for
web.