M5-M7: сцены, лобби, экран игры и цикл матча

Обе сцены собраны через Code Mode, не руками.

- loader: поднимает модули, прогревает арт карт, уходит в lobby
- lobby: обе UI-ветки в одной сцене (по ARCHITECTURE.md), матч — это
  смена видимого корня, а не загрузка сцены
- GameScreen рисует текущее состояние и доводит узлы твинами, а не
  проигрывает поток событий: узел карты живёт вместе со своей картой,
  поэтому "шестёрка ушла из руки на стол" не требует отдельного случая
- один тап = ход целиком; логика вынесена в core/rules/intent.ts
  и покрыта тестами, в том числе проверкой, что она никогда не предложит
  ход, который правила отвергнут
- арт карт переехал в assets/resources: .pac — это инструкция упаковки
  на сборке, а не рантайм-ассет, и без ссылок на кадры в сборку
  не попадало ничего. Теперь все 37 спрайтов в одной текстуре 2048x2048
- клики кнопок подписываются в коде, а не через clickEvents сцены
- строки интерфейса собраны в одном файле

Сборка web-mobile проходит чисто, все ссылки в сцене проверены.
Вживую не запускалось: браузер в этом окружении не поднимает
главный цикл движка. Подробности — в docs/TECHNICAL.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ftc
2026-09-13 11:30:28 +03:00
co-authored by Claude Opus 5
parent 2056673946
commit 4f3a26dc45
111 changed files with 8168 additions and 37 deletions
+171
View File
@@ -0,0 +1,171 @@
[
{
"__type__": "cc.Prefab",
"_name": "card",
"_objFlags": 0,
"__editorExtras__": {},
"_native": "",
"data": {
"__id__": 1
},
"optimizationPolicy": 0,
"persistent": false
},
{
"__type__": "cc.Node",
"_name": "card",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": null,
"_children": [],
"_active": true,
"_components": [
{
"__id__": 2
},
{
"__id__": 4
},
{
"__id__": 6
}
],
"_prefab": {
"__id__": 8
},
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": ""
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 1
},
"_enabled": true,
"__prefab": {
"__id__": 3
},
"_contentSize": {
"__type__": "cc.Size",
"width": 220,
"height": 320
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "5332t7MZRMZ7fwhsKNiE8i"
},
{
"__type__": "cc.Sprite",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 1
},
"_enabled": true,
"__prefab": {
"__id__": 5
},
"_customMaterial": null,
"_srcBlendFactor": 2,
"_dstBlendFactor": 4,
"_color": {
"__type__": "cc.Color",
"r": 255,
"g": 255,
"b": 255,
"a": 255
},
"_spriteFrame": {
"__uuid__": "610347f5-8934-4a68-8e68-70269c912a7a@f9941",
"__expectedType__": "cc.SpriteFrame"
},
"_type": 0,
"_fillType": 0,
"_sizeMode": 0,
"_fillCenter": {
"__type__": "cc.Vec2",
"x": 0,
"y": 0
},
"_fillStart": 0,
"_fillRange": 0,
"_isTrimmedMode": true,
"_useGrayscale": false,
"_atlas": null,
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "9bawLq5CJJCLV+Gk6r0mbF"
},
{
"__type__": "da016ez+jBIsZXjka0QzaXY",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 1
},
"_enabled": true,
"__prefab": {
"__id__": 7
},
"sprite": {
"__id__": 4
},
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "85phkdNBxMuaolENti1wG5"
},
{
"__type__": "cc.PrefabInfo",
"root": {
"__id__": 1
},
"asset": {
"__id__": 0
},
"fileId": "f4cMBOjypJJZjyWxfFGIq6",
"instance": null,
"targetOverrides": null
}
]
+13
View File
@@ -0,0 +1,13 @@
{
"ver": "1.1.50",
"importer": "prefab",
"imported": true,
"uuid": "1ad36ebe-345c-4936-be65-c838f51b4eb4",
"files": [
".json"
],
"subMetas": {},
"userData": {
"syncNodeName": "card"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "a300e972-9887-4462-8c4d-47fe8ce9c106",
"files": [],
"subMetas": {},
"userData": {
"isBundle": true,
"bundleConfigID": "default",
"bundleName": "resources",
"priority": 8
}
}

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

@@ -3,7 +3,9 @@
"importer": "auto-atlas", "importer": "auto-atlas",
"imported": true, "imported": true,
"uuid": "914afa5f-3f77-4d7d-835e-530e298c6816", "uuid": "914afa5f-3f77-4d7d-835e-530e298c6816",
"files": [], "files": [
".json"
],
"subMetas": {}, "subMetas": {},
"userData": { "userData": {
"maxWidth": 2048, "maxWidth": 2048,

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+881
View File
@@ -0,0 +1,881 @@
[
{
"__type__": "cc.SceneAsset",
"_name": "loader",
"_objFlags": 0,
"__editorExtras__": {},
"_native": "",
"scene": {
"__id__": 1
}
},
{
"__type__": "cc.Scene",
"_name": "loader",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": null,
"_children": [
{
"__id__": 2
}
],
"_active": true,
"_components": [],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"autoReleaseAssets": false,
"_globals": {
"__id__": 19
},
"_id": "1aee5910-40c3-4b99-bc8d-e477cf0e25ed"
},
{
"__type__": "cc.Node",
"_name": "Canvas",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 1
},
"_children": [
{
"__id__": 3
},
{
"__id__": 6
},
{
"__id__": 10
},
{
"__id__": 13
}
],
"_active": true,
"_components": [
{
"__id__": 16
},
{
"__id__": 17
},
{
"__id__": 18
}
],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 640,
"y": 360,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": "b1zlEjeP1PhKkvGqJs9BIU"
},
{
"__type__": "cc.Node",
"_name": "Camera",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 2
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 4
},
{
"__id__": 5
}
],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 1000
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": "2a7WNH7jhEM6VozsNzw40D"
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 3
},
"_enabled": true,
"__prefab": null,
"_contentSize": {
"__type__": "cc.Size",
"width": 100,
"height": 100
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": "eazUhZVnFKVo+ewS5T/BhE"
},
{
"__type__": "cc.Camera",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 3
},
"_enabled": true,
"__prefab": null,
"_projection": 0,
"_priority": 1073741824,
"_fov": 45,
"_fovAxis": 0,
"_orthoHeight": 440.25052192066806,
"_near": 1,
"_far": 2000,
"_color": {
"__type__": "cc.Color",
"r": 8,
"g": 44,
"b": 26,
"a": 255
},
"_depth": 1,
"_stencil": 0,
"_clearFlags": 7,
"_rect": {
"__type__": "cc.Rect",
"x": 0,
"y": 0,
"width": 1,
"height": 1
},
"_aperture": 19,
"_shutter": 7,
"_iso": 0,
"_screenScale": 1,
"_visibility": 33554432,
"_targetTexture": null,
"_postProcess": null,
"_usePostProcess": false,
"_cameraType": -1,
"_trackingType": 0,
"_id": "8b1/mYGuxP9KcC4z0D4cNh"
},
{
"__type__": "cc.Node",
"_name": "Background",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 2
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 7
},
{
"__id__": 8
},
{
"__id__": 9
}
],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": "71vPMl7sZIb5K5fhEuSsMZ"
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 6
},
"_enabled": true,
"__prefab": null,
"_contentSize": {
"__type__": "cc.Size",
"width": 1280,
"height": 720
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": "b13TjyVF1EFoHFL0w6g3gL"
},
{
"__type__": "cc.Sprite",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 6
},
"_enabled": true,
"__prefab": null,
"_customMaterial": null,
"_srcBlendFactor": 2,
"_dstBlendFactor": 4,
"_color": {
"__type__": "cc.Color",
"r": 255,
"g": 255,
"b": 255,
"a": 255
},
"_spriteFrame": {
"__uuid__": "d5553c08-e820-46af-a832-40d807724dfb@f9941",
"__expectedType__": "cc.SpriteFrame"
},
"_type": 0,
"_fillType": 0,
"_sizeMode": 0,
"_fillCenter": {
"__type__": "cc.Vec2",
"x": 0,
"y": 0
},
"_fillStart": 0,
"_fillRange": 0,
"_isTrimmedMode": true,
"_useGrayscale": false,
"_atlas": null,
"_id": "1eZC1dDqZKBKdiIGlCVQdh"
},
{
"__type__": "cc.Widget",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 6
},
"_enabled": true,
"__prefab": null,
"_alignFlags": 45,
"_target": null,
"_left": 0,
"_right": 0,
"_top": 0,
"_bottom": 0,
"_horizontalCenter": 0,
"_verticalCenter": 0,
"_isAbsLeft": true,
"_isAbsRight": true,
"_isAbsTop": true,
"_isAbsBottom": true,
"_isAbsHorizontalCenter": true,
"_isAbsVerticalCenter": true,
"_originalWidth": 540,
"_originalHeight": 960,
"_alignMode": 1,
"_lockFlags": 0,
"_id": "6fkN296q1Ef5voPy2qYK6X"
},
{
"__type__": "cc.Node",
"_name": "Title",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 2
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 11
},
{
"__id__": 12
}
],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 60,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": "e2HX8VID5CHqTyzON7WJb5"
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 10
},
"_enabled": true,
"__prefab": null,
"_contentSize": {
"__type__": "cc.Size",
"width": 900,
"height": 160
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": "d2e/FNyWJMm4yQCG2+A4LP"
},
{
"__type__": "cc.Label",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 10
},
"_enabled": true,
"__prefab": null,
"_customMaterial": null,
"_srcBlendFactor": 2,
"_dstBlendFactor": 4,
"_color": {
"__type__": "cc.Color",
"r": 244,
"g": 194,
"b": 69,
"a": 255
},
"_string": "Fool",
"_horizontalAlign": 1,
"_verticalAlign": 1,
"_actualFontSize": 127,
"_fontSize": 130,
"_fontFamily": "Arial",
"_lineHeight": 163,
"_overflow": 2,
"_enableWrapText": false,
"_font": null,
"_isSystemFontUsed": true,
"_spacingX": 0,
"_isItalic": false,
"_isBold": true,
"_isUnderline": false,
"_underlineHeight": 2,
"_cacheMode": 0,
"_enableOutline": false,
"_outlineColor": {
"__type__": "cc.Color",
"r": 0,
"g": 0,
"b": 0,
"a": 255
},
"_outlineWidth": 2,
"_enableShadow": false,
"_shadowColor": {
"__type__": "cc.Color",
"r": 0,
"g": 0,
"b": 0,
"a": 255
},
"_shadowOffset": {
"__type__": "cc.Vec2",
"x": 2,
"y": 2
},
"_shadowBlur": 2,
"_id": "93TKfIU91MsYoPl+MbLJLU"
},
{
"__type__": "cc.Node",
"_name": "Hint",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 2
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 14
},
{
"__id__": 15
}
],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": -60,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": "edGCK/J5pJMo3QjOFjHMU8"
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 13
},
"_enabled": true,
"__prefab": null,
"_contentSize": {
"__type__": "cc.Size",
"width": 900,
"height": 60
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": "0amqxP4mdHTJm3HJPkzYdS"
},
{
"__type__": "cc.Label",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 13
},
"_enabled": true,
"__prefab": null,
"_customMaterial": null,
"_srcBlendFactor": 2,
"_dstBlendFactor": 4,
"_color": {
"__type__": "cc.Color",
"r": 174,
"g": 196,
"b": 182,
"a": 255
},
"_string": "loading",
"_horizontalAlign": 1,
"_verticalAlign": 1,
"_actualFontSize": 36,
"_fontSize": 36,
"_fontFamily": "Arial",
"_lineHeight": 45,
"_overflow": 2,
"_enableWrapText": false,
"_font": null,
"_isSystemFontUsed": true,
"_spacingX": 0,
"_isItalic": false,
"_isBold": false,
"_isUnderline": false,
"_underlineHeight": 2,
"_cacheMode": 0,
"_enableOutline": false,
"_outlineColor": {
"__type__": "cc.Color",
"r": 0,
"g": 0,
"b": 0,
"a": 255
},
"_outlineWidth": 2,
"_enableShadow": false,
"_shadowColor": {
"__type__": "cc.Color",
"r": 0,
"g": 0,
"b": 0,
"a": 255
},
"_shadowOffset": {
"__type__": "cc.Vec2",
"x": 2,
"y": 2
},
"_shadowBlur": 2,
"_id": "be14O3yOdDardPtTt2bDrD"
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 2
},
"_enabled": true,
"__prefab": null,
"_contentSize": {
"__type__": "cc.Size",
"width": 1280,
"height": 720
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": "9429lRu9BL6rByObLUXHia"
},
{
"__type__": "cc.Canvas",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 2
},
"_enabled": true,
"__prefab": null,
"_cameraComponent": {
"__id__": 5
},
"_alignCanvasWithScreen": true,
"_id": "7crolsyK9Cs5GFmQZmc9QW"
},
{
"__type__": "c91ddke4YBE9JjRtYzU2wFp",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 2
},
"_enabled": true,
"__prefab": null,
"nextScene": "lobby",
"_id": "dcSHYP80NEeZirk4SzzDBT"
},
{
"__type__": "cc.SceneGlobals",
"ambient": {
"__id__": 20
},
"shadows": {
"__id__": 21
},
"_skybox": {
"__id__": 22
},
"fog": {
"__id__": 23
},
"octree": {
"__id__": 24
},
"skin": {
"__id__": 25
},
"lightProbeInfo": {
"__id__": 26
},
"postSettings": {
"__id__": 27
},
"bakedWithStationaryMainLight": false,
"bakedWithHighpLightmap": false
},
{
"__type__": "cc.AmbientInfo",
"_skyColorHDR": {
"__type__": "cc.Vec4",
"x": 0.2,
"y": 0.5,
"z": 0.8,
"w": 0.520833125
},
"_skyColor": {
"__type__": "cc.Vec4",
"x": 0.2,
"y": 0.5,
"z": 0.8,
"w": 0.520833125
},
"_skyIllumHDR": 20000,
"_skyIllum": 20000,
"_groundAlbedoHDR": {
"__type__": "cc.Vec4",
"x": 0.2,
"y": 0.2,
"z": 0.2,
"w": 1
},
"_groundAlbedo": {
"__type__": "cc.Vec4",
"x": 0.2,
"y": 0.2,
"z": 0.2,
"w": 1
},
"_skyColorLDR": {
"__type__": "cc.Vec4",
"x": 0.452588,
"y": 0.607642,
"z": 0.755699,
"w": 0
},
"_skyIllumLDR": 0.8,
"_groundAlbedoLDR": {
"__type__": "cc.Vec4",
"x": 0.618555,
"y": 0.577848,
"z": 0.544564,
"w": 0
}
},
{
"__type__": "cc.ShadowsInfo",
"_enabled": false,
"_type": 0,
"_normal": {
"__type__": "cc.Vec3",
"x": 0,
"y": 1,
"z": 0
},
"_distance": 0,
"_planeBias": 1,
"_shadowColor": {
"__type__": "cc.Color",
"r": 76,
"g": 76,
"b": 76,
"a": 255
},
"_maxReceived": 4,
"_size": {
"__type__": "cc.Vec2",
"x": 1024,
"y": 1024
}
},
{
"__type__": "cc.SkyboxInfo",
"_envLightingType": 0,
"_envmapHDR": {
"__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0",
"__expectedType__": "cc.TextureCube"
},
"_envmap": {
"__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0",
"__expectedType__": "cc.TextureCube"
},
"_envmapLDR": {
"__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0",
"__expectedType__": "cc.TextureCube"
},
"_diffuseMapHDR": null,
"_diffuseMapLDR": null,
"_enabled": false,
"_useHDR": true,
"_editableMaterial": null,
"_reflectionHDR": null,
"_reflectionLDR": null,
"_rotationAngle": 0
},
{
"__type__": "cc.FogInfo",
"_type": 0,
"_fogColor": {
"__type__": "cc.Color",
"r": 200,
"g": 200,
"b": 200,
"a": 255
},
"_enabled": false,
"_fogDensity": 0.3,
"_fogStart": 0.5,
"_fogEnd": 300,
"_fogAtten": 5,
"_fogTop": 1.5,
"_fogRange": 1.2,
"_accurate": false
},
{
"__type__": "cc.OctreeInfo",
"_enabled": false,
"_minPos": {
"__type__": "cc.Vec3",
"x": -1024,
"y": -1024,
"z": -1024
},
"_maxPos": {
"__type__": "cc.Vec3",
"x": 1024,
"y": 1024,
"z": 1024
},
"_depth": 8
},
{
"__type__": "cc.SkinInfo",
"_enabled": true,
"_blurRadius": 0.01,
"_sssIntensity": 3
},
{
"__type__": "cc.LightProbeInfo",
"_giScale": 1,
"_giSamples": 1024,
"_bounces": 2,
"_reduceRinging": 0,
"_showProbe": true,
"_showWireframe": true,
"_showConvex": false,
"_data": null,
"_lightProbeSphereVolume": 1
},
{
"__type__": "cc.PostSettingsInfo",
"_toneMappingType": 0
}
]
+1
View File
@@ -0,0 +1 @@
{"ver":"1.1.50","importer":"scene","imported":true,"uuid":"1aee5910-40c3-4b99-bc8d-e477cf0e25ed","files":[".json"],"subMetas":{},"userData":{}}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"ver":"1.1.50","importer":"scene","imported":true,"uuid":"220f80f8-6216-4ec2-b63b-72365bcbfde9","files":[".json"],"subMetas":{},"userData":{}}
+56
View File
@@ -0,0 +1,56 @@
import { CocosStorage } from '../modules/save/CocosStorage';
import { SaveManager } from '../modules/save/SaveManager';
import { StatsSection } from '../modules/save/StatsSection';
/**
* The application's services, created once by the loader scene.
*
* Cocos components are built by the engine, so there is nowhere to inject
* dependencies through a constructor; a small explicit locator is the honest
* alternative to every component reaching for its own singleton. Everything it
* holds is engine-independent apart from the storage adapter, so a test can
* build one over `MemoryStorage` and drive the whole thing headlessly.
*/
export class App {
private static _instance: App | null = null;
readonly save: SaveManager;
readonly stats: StatsSection;
private constructor(save: SaveManager, stats: StatsSection) {
this.save = save;
this.stats = stats;
}
/** Builds the services and loads saved progress. Called once, from `Bootstrap`. */
static create(): App {
if (App._instance) {
return App._instance;
}
const stats = new StatsSection();
const save = new SaveManager(new CocosStorage());
save.register(stats);
save.load();
App._instance = new App(save, stats);
return App._instance;
}
static get instance(): App {
if (!App._instance) {
throw new Error('App.create() has not run — is the loader scene the start scene?');
}
return App._instance;
}
static get exists(): boolean {
return App._instance !== null;
}
/** Records the result of a match and writes it out immediately. */
recordMatch(outcome: 'win' | 'loss' | 'draw'): void {
this.stats.recordResult(outcome);
this.save.save();
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "982035ad-66a6-428b-bba3-4fe31ed7a095",
"files": [],
"subMetas": {},
"userData": {}
}
+42
View File
@@ -0,0 +1,42 @@
import { _decorator, Component, director, game, resources, SpriteFrame } from 'cc';
import { App } from './App';
import { CARDS_DIR } from '../ui/game/CardLibrary';
const { ccclass, property } = _decorator;
/**
* The loader scene, per ARCHITECTURE.md: bring the modules up, then hand over
* to the lobby. Everything that must exist before any gameplay runs belongs
* here, and nowhere else.
*/
@ccclass('Bootstrap')
export class Bootstrap extends Component {
@property({ tooltip: 'Scene to open once the modules are ready.' })
nextScene = 'lobby';
start(): void {
// A phone that throttles to 30 fps makes the card animations stutter;
// asking for 60 up front is cheap and the engine falls back on its own.
game.frameRate = 60;
App.create();
// Warm the card art here rather than on the first deal: thirty-seven
// sprites are the only sizeable load in the game, and a loader scene is
// exactly where a player expects to wait for it.
resources.loadDir(CARDS_DIR, SpriteFrame, (error) => {
if (error) {
console.error('[bootstrap] could not preload the card art', error);
}
this._openLobby();
});
}
private _openLobby(): void {
director.loadScene(this.nextScene, (error) => {
if (error) {
console.error(`[bootstrap] could not open '${this.nextScene}'`, error);
}
});
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c91dd91e-e180-44f4-98d1-b58cd4db0169",
"files": [],
"subMetas": {},
"userData": {}
}
+35
View File
@@ -0,0 +1,35 @@
import { Card } from '../cards/Card';
import { Action, Actions } from './Action';
import { GameState, Phase, PlayerId } from './GameState';
import { beats, currentActor, legalActions, unbeatenSlots } from './DurakRules';
/**
* What tapping a card means for a player right now, or null if that card cannot
* be played.
*
* This lives in the core rather than in the game screen so it can be tested
* without the engine: it is the one piece of interface logic that can silently
* turn a legal move into a rejected tap, and it is far too easy to get wrong by
* eye. The screen just calls it and submits whatever comes back.
*
* One tap is the whole interaction. When defending, the attack to beat is worked
* out here instead of being chosen separately — the serialised rules leave only
* one unanswered attack at a time, and where several somehow exist the lowest
* slot this card can beat is taken.
*/
export function intentFor(state: GameState, player: PlayerId, card: Card): Action | null {
if (currentActor(state) !== player) {
return null;
}
if (state.phase === Phase.Defence) {
const slot = unbeatenSlots(state)
.find((i) => beats(card, state.table[i].attack, state.trumpSuit));
return slot === undefined ? null : Actions.defend(player, card, slot);
}
const wanted = Actions.attack(player, card);
const legal = legalActions(state, player)
.some((a) => a.type === wanted.type && (a as { card?: Card }).card === card);
return legal ? wanted : null;
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b1aa773a-2d2c-43f5-980a-d6e2ffb829fd",
"files": [],
"subMetas": {},
"userData": {}
}
+71
View File
@@ -0,0 +1,71 @@
import { _decorator, Component, Node } from 'cc';
import { App } from '../app/App';
import { LobbyScreen } from './lobby/LobbyScreen';
import { GameScreen, MatchOutcome } from './game/GameScreen';
const { ccclass, property } = _decorator;
/**
* Owns the two screens the lobby scene contains and moves between them.
*
* ARCHITECTURE.md puts the main game UI in the lobby scene, so a match is a
* change of visible screen rather than a scene load: no reload cost between
* games, and no way for a half-finished match to leak into a new one as long
* as `GameScreen.teardown` is honest about clearing up.
*/
@ccclass('MainFlow')
export class MainFlow extends Component {
@property(LobbyScreen)
lobby: LobbyScreen | null = null;
@property(GameScreen)
game: GameScreen | null = null;
@property(Node)
lobbyRoot: Node | null = null;
@property(Node)
gameRoot: Node | null = null;
start(): void {
if (this.lobby) {
this.lobby.onStartRequested = () => this.startMatch();
}
if (this.game) {
this.game.onFinished = (outcome) => this.finishMatch(outcome);
this.game.onQuitRequested = () => this.abandonMatch();
}
this.showLobby();
}
private showLobby(): void {
setActive(this.gameRoot, false);
setActive(this.lobbyRoot, true);
this.lobby?.refresh();
}
private startMatch(): void {
setActive(this.lobbyRoot, false);
setActive(this.gameRoot, true);
this.game?.startNewMatch();
}
private finishMatch(outcome: MatchOutcome): void {
if (App.exists) {
App.instance.recordMatch(outcome);
}
this.game?.teardown();
this.showLobby();
}
/** Leaving early still ends the match — and it counts as a loss. */
private abandonMatch(): void {
this.finishMatch('loss');
}
}
function setActive(node: Node | null, active: boolean): void {
if (node) {
node.active = active;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0b757fc9-4bb2-4007-9b13-b618d0655b6e",
"files": [],
"subMetas": {},
"userData": {}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Every piece of text the player reads, in one place.
*
* Localisation is out of scope for iteration 1 (see docs/PLAN.md), but keeping
* the strings together means adding a language later is a change to this file
* and nothing else — rather than a hunt through scenes and components.
*/
export const Strings = {
appTitle: 'Fool',
appSubtitle: 'a game of durak',
newGame: 'New Game',
quit: 'Leave',
playAgain: 'Play Again',
backToLobby: 'Back',
statsTitle: 'Your record',
statsPlayed: 'Played',
statsWon: 'Won',
statsLost: 'Lost',
statsStreak: 'Streak',
take: 'Take',
done: 'Done',
yourMove: 'Your move',
yourDefence: 'Beat it, or take',
addMore: 'Add cards, or say done',
opponentThinking: 'Opponent is thinking',
opponentTook: 'Opponent takes the cards',
youTook: 'You take the cards',
trumpIs: 'Trump',
deckLeft: 'Deck',
opponentHand: 'Opponent',
wonTitle: 'You win',
lostTitle: 'You are the fool',
drawTitle: 'A draw',
wonBody: 'Your opponent was left holding the cards.',
lostBody: 'You were left holding the cards.',
drawBody: 'You both ran out at the same time.',
cannotPlayThat: 'You cannot play that card',
} as const;
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "672ddd76-ded7-498e-97b1-df2d7d30f662",
"files": [],
"subMetas": {},
"userData": {}
}
+81
View File
@@ -0,0 +1,81 @@
import { Color } from 'cc';
/**
* Shared visual constants. Anything a layout or a colour is tuned by lives here
* rather than being typed into each scene, so a change lands everywhere at once
* and the scene files stay free of magic numbers.
*
* Lengths are in design units — the screen is always exactly 1080 wide, see
* docs/DECISIONS.md.
*/
export const Layout = {
designWidth: 1080,
designHeight: 1920,
/** Card sprite size, matching what scripts/build-art.mjs produces. */
cardWidth: 220,
cardHeight: 320,
/** Cards on the table are drawn smaller so six pairs fit comfortably. */
tableCardScale: 0.62,
/** The opponent's hand is smaller again — it is only ever card backs. */
opponentCardScale: 0.5,
/** Horizontal step between neighbouring cards in the player's hand. */
handStep: 128,
/** Never let a hand grow wider than this; the step shrinks instead. */
handMaxWidth: 940,
/** How far the tapped card lifts out of the hand. */
handLift: 48,
/** Offset of a beating card from the card it beats. */
beatOffset: { x: 26, y: -34 },
/** Comfortable minimum for anything tappable, per docs/UI.md. */
minTapSize: 120,
screenMargin: 40,
/**
* Where things sit on the table, in canvas coordinates (origin at the
* centre, y up). Vertical positions stay well inside the 1080×1920 design
* area so that a 4:3 screen — which crops the top and bottom — still shows
* every one of them. See docs/DECISIONS.md.
*/
region: {
opponentHandY: 600,
opponentLabelY: 770,
deck: { x: -390, y: 150 },
trump: { x: -300, y: 120 },
discard: { x: 430, y: 150 },
/** Centre of the first table row; the second row sits a row-height below. */
tableTopY: -20,
tableRowStep: 235,
tableColumnStep: 180,
messageY: 330,
actionBarY: -430,
playerHandY: -700,
},
} as const;
export const Palette = {
gold: new Color(244, 194, 69, 255),
cream: new Color(247, 240, 222, 255),
dim: new Color(174, 196, 182, 255),
good: new Color(126, 217, 140, 255),
bad: new Color(232, 116, 104, 255),
shade: new Color(0, 0, 0, 170),
} as const;
/** How long the standard card movements take, in seconds. */
export const Timing = {
deal: 0.28,
dealStagger: 0.06,
play: 0.22,
collect: 0.3,
flip: 0.16,
/** Pause before the bot acts, so its moves can be followed. */
botThinking: 0.55,
/** Pause after a turn resolves, before the next one starts. */
turnBreak: 0.4,
} as const;
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "3ba498f9-9dcb-48dc-bb1d-1de46602c43a",
"files": [],
"subMetas": {},
"userData": {}
}
+88
View File
@@ -0,0 +1,88 @@
import { _decorator, Component, resources, SpriteFrame } from 'cc';
import { Card } from '../../core/cards/Card';
const { ccclass, property } = _decorator;
/** Folder under `assets/resources` holding the 36 faces and the back. */
export const CARDS_DIR = 'cards';
const BACK_NAME = 'card_back';
/**
* Turns a `Card` into the sprite frame that draws it.
*
* The frames are loaded as a folder out of the `resources` bundle rather than
* being referenced one by one in the scene: thirty-seven inspector slots would
* be miserable to maintain, and an auto-atlas (`.pac`) is only a build-time
* packing instruction — it is not something that can be loaded and asked for a
* frame by name at runtime. The `.pac` beside the images still does its job,
* packing all thirty-seven into one texture so the table costs one draw call.
*
* The only contract between the rules and the art is `Card.assetName`.
*/
@ccclass('CardLibrary')
export class CardLibrary extends Component {
@property({ tooltip: 'Folder under assets/resources holding the card sprites.' })
directory = CARDS_DIR;
private readonly _frames = new Map<string, SpriteFrame>();
private readonly _missingReported = new Set<string>();
private _ready = false;
private _waiting: (() => void)[] = [];
onLoad(): void {
this.load();
}
get ready(): boolean {
return this._ready;
}
/**
* Loads the folder. Bootstrap preloads the same folder before the lobby
* opens, so in practice this resolves from cache within a frame.
*/
load(): void {
if (this._ready) {
return;
}
resources.loadDir(this.directory, SpriteFrame, (error, frames) => {
if (error) {
console.error(`[cards] could not load '${this.directory}'`, error);
return;
}
for (const frame of frames) {
this._frames.set(frame.name, frame);
}
this._ready = true;
const waiting = this._waiting;
this._waiting = [];
for (const resolve of waiting) resolve();
});
}
/** Runs `done` once the frames are available — immediately if they already are. */
whenReady(done: () => void): void {
if (this._ready) {
done();
} else {
this._waiting.push(done);
}
}
frameFor(card: Card): SpriteFrame | null {
return this._frame(card.assetName);
}
get backFrame(): SpriteFrame | null {
return this._frame(BACK_NAME);
}
private _frame(name: string): SpriteFrame | null {
const frame = this._frames.get(name) ?? null;
if (!frame && this._ready && !this._missingReported.has(name)) {
this._missingReported.add(name);
console.error(`[cards] '${name}' is missing — run 'npm run build:art'`);
}
return frame;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4e2c42cf-866c-4ba0-aa4f-78c256d28a0f",
"files": [],
"subMetas": {},
"userData": {}
}

Some files were not shown because too many files have changed in this diff Show More