diff --git a/assets/prefabs/card.prefab b/assets/prefabs/card.prefab index 05daf08..2044048 100644 --- a/assets/prefabs/card.prefab +++ b/assets/prefabs/card.prefab @@ -53,7 +53,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, diff --git a/assets/scenes/loader.scene b/assets/scenes/loader.scene index a133320..3872a70 100644 --- a/assets/scenes/loader.scene +++ b/assets/scenes/loader.scene @@ -111,7 +111,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -159,7 +159,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -278,7 +278,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -417,7 +417,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -549,7 +549,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -691,6 +691,9 @@ "_enabled": true, "__prefab": null, "nextScene": "lobby", + "statusLabel": { + "__id__": 15 + }, "_id": "dcSHYP80NEeZirk4SzzDBT" }, { diff --git a/assets/scenes/lobby.scene b/assets/scenes/lobby.scene index da07db5..2bf2675 100644 --- a/assets/scenes/lobby.scene +++ b/assets/scenes/lobby.scene @@ -108,7 +108,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -156,7 +156,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -275,7 +275,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -424,7 +424,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -488,7 +488,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -536,7 +536,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -668,7 +668,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -828,7 +828,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -876,7 +876,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -1008,7 +1008,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -1140,7 +1140,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -1272,7 +1272,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -1404,7 +1404,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -1536,7 +1536,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -1668,7 +1668,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -1800,7 +1800,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -1932,7 +1932,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -2132,7 +2132,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -2180,7 +2180,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -2545,7 +2545,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -2594,7 +2594,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -2642,7 +2642,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -2796,7 +2796,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -2928,7 +2928,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -3060,7 +3060,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -3164,7 +3164,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -3219,7 +3219,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -3267,7 +3267,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -3529,7 +3529,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -3577,7 +3577,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -3861,7 +3861,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -3909,7 +3909,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -4171,7 +4171,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -4225,7 +4225,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -4424,7 +4424,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -4472,7 +4472,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -4604,7 +4604,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -4743,7 +4743,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, @@ -4791,7 +4791,7 @@ "z": 1 }, "_mobility": 0, - "_layer": 1073741824, + "_layer": 33554432, "_euler": { "__type__": "cc.Vec3", "x": 0, diff --git a/assets/scripts/app/Bootstrap.ts b/assets/scripts/app/Bootstrap.ts index f0a98c1..2be3859 100644 --- a/assets/scripts/app/Bootstrap.ts +++ b/assets/scripts/app/Bootstrap.ts @@ -1,42 +1,81 @@ -import { _decorator, Component, director, game, resources, SpriteFrame } from 'cc'; +import { _decorator, Component, director, game, Label, resources, SpriteFrame } from 'cc'; import { App } from './App'; import { CARDS_DIR } from '../ui/game/CardLibrary'; const { ccclass, property } = _decorator; +/** If the art has not arrived by then, open the lobby anyway rather than hang. */ +const PRELOAD_TIMEOUT = 10; + /** * 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. + * + * Every step reports itself to the on-screen label and to the console, because + * a loader that stalls silently is the hardest kind of bug to diagnose from a + * bug report — "it shows a green screen" should never again be all we know. */ @ccclass('Bootstrap') export class Bootstrap extends Component { @property({ tooltip: 'Scene to open once the modules are ready.' }) nextScene = 'lobby'; + @property({ type: Label, tooltip: 'Shows what the loader is doing.' }) + statusLabel: Label | null = null; + + private _handedOver = false; + 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; + this._report('starting modules'); 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) => { + this._report('loading cards'); + resources.loadDir(CARDS_DIR, SpriteFrame, (error, frames) => { if (error) { console.error('[bootstrap] could not preload the card art', error); + } else { + console.log(`[bootstrap] ${frames.length} card sprites ready`); } this._openLobby(); }); + + // A failed bundle can leave that callback unfired. The lobby does not + // need the art to show, so never let the loader be a dead end. + this.scheduleOnce(() => { + if (!this._handedOver) { + console.warn('[bootstrap] card art did not arrive in time, opening the lobby anyway'); + this._openLobby(); + } + }, PRELOAD_TIMEOUT); } private _openLobby(): void { + if (this._handedOver) { + return; + } + this._handedOver = true; + + this._report('opening lobby'); director.loadScene(this.nextScene, (error) => { if (error) { console.error(`[bootstrap] could not open '${this.nextScene}'`, error); + this._report(`could not open ${this.nextScene}`); } }); } + + private _report(what: string): void { + console.log(`[bootstrap] ${what}`); + if (this.statusLabel) { + this.statusLabel.string = what; + } + } } diff --git a/docs/CODE_MODE_SETUP.md b/docs/CODE_MODE_SETUP.md index 588da0b..bd60417 100644 --- a/docs/CODE_MODE_SETUP.md +++ b/docs/CODE_MODE_SETUP.md @@ -120,3 +120,26 @@ CocosEditor.inspectorSetSettingsProperties({ values: [1080, true], }); ``` + +## Known issue: `nodeCreate` puts nodes on the wrong layer for UI + +A node created with `nodeCreate` lands on the **DEFAULT** layer +(`1073741824`). A Canvas camera is normally set to render **UI_2D** +(`33554432`) only, so every node authored through the tools is invisible at +runtime — the camera draws its clear colour and nothing else, which looks like +the game has frozen on a blank coloured screen. + +The editor's own "create node" menu assigns UI_2D, so this only bites +automation, and nothing in the editor viewport reveals it: the scene-view camera +renders every layer, so the scene looks perfectly fine while the build does not. + +**Always set the layer after creating a UI node:** + +```js +const UI_2D = 33554432; +CocosEditor.inspectorSetInstanceProperties({ + reference: newNode, propertyPaths: ['layer'], values: [UI_2D], +}); +``` + +To check a whole scene, walk `nodeGetTree` and assert every node's `layer`. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 49f266c..513277d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -159,3 +159,15 @@ rules leave only one unanswered attack at a time. Putting it in the core means the one piece of interface logic that can silently reject a legal move is covered by headless tests, including a property test that it never proposes an action the rules would refuse. + +## 2026-09-13 · Presentation · The loader reports every step it takes + +**Decision.** `Bootstrap` writes each stage to the on-screen label and to the +console, and falls through to the lobby after ten seconds even if the art +preload never calls back. + +**Why.** The first build ran but showed nothing but a green screen, because +every node authored through the editor tools had been left on the DEFAULT layer +while the camera only renders UI_2D (see CODE_MODE_SETUP.md). A blank screen +carries no information about which step failed, and a loader that can hang +forever on an asset the lobby does not even need is a dead end for no reason. diff --git a/package.json b/package.json index f8ede53..5cd7a07 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "typecheck": "tsc -p tsconfig.test.json", "check:core-purity": "node scripts/check-core-purity.mjs", "stats:bot": "vitest run --config vitest.stats.mts", - "build:art": "node scripts/build-art.mjs" + "build:art": "node scripts/build-art.mjs", + "check:scenes": "node scripts/check-scenes.mjs", + "check": "npm run check:core-purity && npm run check:scenes && npm run typecheck && npm test" } } diff --git a/scripts/check-scenes.mjs b/scripts/check-scenes.mjs new file mode 100644 index 0000000..a9d00a6 --- /dev/null +++ b/scripts/check-scenes.mjs @@ -0,0 +1,104 @@ +// Checks the scenes and prefabs for the mistakes that only show up at runtime, +// and that the editor viewport actively hides from you. +// +// npm run check:scenes +// +// The one that cost a whole debugging round: a node created through the editor +// tool server lands on the DEFAULT layer, but a Canvas camera renders UI_2D +// only. The scene view draws every layer, so it looks perfect in the editor and +// the build shows nothing but the camera's clear colour. +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const UI_2D = 33554432; +const ROOTS = ['assets/scenes', 'assets/prefabs']; + +const problems = []; + +function collect(dir, out = []) { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) collect(path, out); + else if (path.endsWith('.scene') || path.endsWith('.prefab')) out.push(path); + } + return out; +} + +/** Scene files are a flat array of objects that reference each other by index. */ +function load(path) { + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + problems.push(`${path}: could not be parsed — ${error.message}`); + return []; + } +} + +function checkLayers(path, objects) { + const wrong = objects + .filter((o) => o && o.__type__ === 'cc.Node' && o._layer !== UI_2D) + .map((o) => `${o._name ?? '?'} (layer ${o._layer})`); + + if (wrong.length > 0) { + problems.push( + `${path}: ${wrong.length} node(s) are not on the UI_2D layer, so the ` + + `Canvas camera will not draw them: ${wrong.slice(0, 6).join(', ')}` + + `${wrong.length > 6 ? ', …' : ''}`, + ); + } +} + +function checkCanvas(path, objects) { + const canvases = objects.filter((o) => o && o.__type__ === 'cc.Canvas'); + if (path.endsWith('.scene') && canvases.length === 0) { + problems.push(`${path}: no cc.Canvas, so nothing 2D will render`); + } + for (const canvas of canvases) { + if (!canvas._cameraComponent) { + problems.push(`${path}: a Canvas has no camera assigned`); + } + } +} + +function checkCameras(path, objects) { + for (const camera of objects.filter((o) => o && o.__type__ === 'cc.Camera')) { + if (typeof camera._visibility === 'number' && (camera._visibility & UI_2D) === 0) { + problems.push(`${path}: a Camera cannot see the UI_2D layer (visibility ${camera._visibility})`); + } + } +} + +/** Any script property left holding an empty uuid is an unwired inspector slot. */ +function checkReferences(path, objects) { + for (const object of objects) { + if (!object || typeof object !== 'object') continue; + const type = object.__type__; + // Engine types legitimately leave optional slots empty; our own scripts + // are the ones whose every reference is deliberate. + if (typeof type !== 'string' || type.startsWith('cc.')) continue; + + for (const [key, value] of Object.entries(object)) { + if (value && typeof value === 'object' && value.__uuid__ === '') { + problems.push(`${path}: ${type}.${key} is an empty reference`); + } + } + } +} + +for (const root of ROOTS) { + for (const path of collect(root)) { + const objects = load(path); + checkLayers(path, objects); + checkCanvas(path, objects); + checkCameras(path, objects); + checkReferences(path, objects); + } +} + +if (problems.length > 0) { + console.error(`Scene problems:\n - ${problems.join('\n - ')}`); + process.exit(1); +} + +console.log('scenes OK');