diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts index 8e7e5b7..3e44ffa 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts @@ -1,4 +1,5 @@ import type { ChannelIdentity } from './identity.ts'; +import { DEFAULT_LAUNCH_WAIT_TIME_MS } from './sources/defaults.ts'; /** One in-memory view over the JSON root received by legacy getConfig_Succ. */ export interface RemoteConfig { @@ -10,6 +11,21 @@ export interface ConfigFetcher { fetch(gameserver: string, cacheBust?: () => string): Promise; } +/** Legacy initial value -> hall_config merge -> scoped top-level override. */ +export function resolveLaunchWaitTime(remote: RemoteConfig): number { + let value: unknown = DEFAULT_LAUNCH_WAIT_TIME_MS; + const hall = remote.getValue('hall_config'); + if (typeof hall === 'object' && hall !== null && Object.prototype.hasOwnProperty.call(hall, 'launchWaitTime')) { + value = (hall as Record).launchWaitTime; + } + const override = remote.getValue('launchWaitTime'); + if (override !== null && override !== undefined) value = override; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error('Invalid launchWaitTime: expected finite nonnegative milliseconds'); + } + return value; +} + export class ConfigFetchError extends Error {} export class ConfigParseError extends Error {} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/sources/defaults.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/sources/defaults.ts index f92d7f3..2dcf69e 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/sources/defaults.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/sources/defaults.ts @@ -1,5 +1,8 @@ import type { ChannelIdentity } from '../identity.ts'; +/** Legacy GameData launchWaitTime (04_Data.js): timer units are milliseconds. */ +export const DEFAULT_LAUNCH_WAIT_TIME_MS = 10; + /** * 构建期默认渠道身份(旧 version.js 等价物)。 * gameid 每子游戏固定写死;agentid/channelid/marketid/version 为默认,运行期由 query/native/profile 覆盖。 diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/LocalPlatformLogin.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/LocalPlatformLogin.ts index fb47160..31d926b 100644 --- a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/LocalPlatformLogin.ts +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/LocalPlatformLogin.ts @@ -1,15 +1,14 @@ import { _decorator, assetManager, Component, Label, Node, Prefab, ProgressBar, Sprite, instantiate, isValid, sys } from 'cc'; import { DEBUG } from 'cc/env'; import { HttpConfigFetcher } from '../../framework/config/remote-config-fetcher.ts'; +import { resolveLaunchWaitTime } from '../../framework/config/remote-config.ts'; import type { NativeHost } from '../../framework/config/sources/native-settings.ts'; -import type { RuntimeConfig } from '../../framework/config/runtime-config.ts'; import type { PageModel } from '../../framework/presentation/ui-contracts.ts'; import { createLocalLoginHost, type LocalLoginHost } from './local-login-host.ts'; import type { LocalLoginEvidence } from './local-login-transport.ts'; -import { CocosLocalLoginView, createAssetLoadTask, createCocosFrameScheduler, requireNode } from './local-login-view.ts'; +import { CocosLocalLoginView, CocosLogoCountdownView, createLogoCountdown, createAssetLoadTask, createCocosFrameScheduler, requireNode } from './local-login-view.ts'; const { ccclass, property } = _decorator; -const MINIMUM_LOADING_MS = 500; /** Explicit DEBUG browser scene entry. All business state belongs to its host Runtime. */ @ccclass('LocalPlatformLogin') @@ -31,6 +30,8 @@ export class LocalPlatformLogin extends Component { private stopped = false; private firstDrawAt: number | null = null; private version: number | null = null; + private countdown: ReturnType | null = null; + private countdownView: CocosLogoCountdownView | null = null; private readonly pending = new Set<() => void>(); private readonly evidence: (LocalLoginEvidence & { at: number })[] = []; private pageModel: PageModel | null = null; @@ -53,7 +54,18 @@ export class LocalPlatformLogin extends Component { const logo = requireNode(loadingRoot, 'Layer1_Logo'); this.resourceProgress = requireNode(logo, 'group-1/Spirit254').getComponent(ProgressBar); if (this.resourceProgress === null) throw new Error('Layer1_Logo/group-1/Spirit254 requires ProgressBar'); - for (const path of ['group-1/Spirit3103', 'group-1/Spirit3104', 'group-32']) requireNode(logo, path).active = false; + requireNode(logo, 'group-32').active = false; + const countdownLabels = logo.getComponentsInChildren(Label).filter(label => label.node.name === 'Spirit3104'); + if (countdownLabels.length !== 1) throw new Error('Logo requires exactly one Spirit3104 Label'); + this.countdownView = new CocosLogoCountdownView(requireNode(logo, 'group-1/Spirit3103'), countdownLabels[0].node, + () => this.countdown?.skip()); + this.countdown = createLogoCountdown({ now: () => performance.now(), + schedule: (callback, ms) => { + const timer = window.setTimeout(() => { + try { callback(); } catch (error) { this.fail(error); } + }, ms); + return () => window.clearTimeout(timer); + }, render: state => this.countdownView?.render(state) }); this.validateSprites(loadingRoot); stopButton.on(Node.EventType.TOUCH_END, this.stopTouch); this.view.activate('loading'); @@ -77,15 +89,20 @@ export class LocalPlatformLogin extends Component { showOverlay: model => view.showOverlay(model), }, scheduler: createCocosFrameScheduler(), + onConfigResolved: config => { + this.version = config.identity.version; + this.required(this.countdown, 'Logo countdown').start(resolveLaunchWaitTime(config.remoteConfig)); + }, + onResourcesReady: () => { + this.countdown?.resourcesReady(); + this.required(this.resourceProgress, 'resource ProgressBar').node.active = false; + }, + onSocketReady: ready => this.countdown?.socketReady(ready), + onStopped: () => { this.countdown?.cancel(); this.countdownView?.dispose(); }, loadResources: () => this.loadResources(), waitForMinimumDisplay: async () => { - await firstDraw; + await Promise.all([firstDraw, this.required(this.countdown, 'Logo countdown').promise]); const drawnAt = this.required(this.firstDrawAt, 'loading first draw'); - let remaining = MINIMUM_LOADING_MS - (performance.now() - drawnAt); - while (remaining > 0) { - await this.delay(remaining); - remaining = MINIMUM_LOADING_MS - (performance.now() - drawnAt); - } this.record({ kind: 'minimum-display-ready', data: performance.now() - drawnAt }); }, createSocket: url => new WebSocket(url), record: event => this.record(event), @@ -102,7 +119,6 @@ export class LocalPlatformLogin extends Component { } private record(event: LocalLoginEvidence): void { - if (event.kind === 'startup-config') this.version = (event.data as RuntimeConfig).identity.version; this.evidence.push(Object.freeze({ ...event, at: performance.now() })); } @@ -176,6 +192,8 @@ export class LocalPlatformLogin extends Component { private release(): void { this.stopped = true; + this.countdown?.cancel(); + this.countdownView?.dispose(); for (const cancel of [...this.pending]) cancel(); this.stopButton?.off(Node.EventType.TOUCH_END, this.stopTouch); this.view?.dispose(); diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts index 7ed15b0..68cda18 100644 --- a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts @@ -1,5 +1,5 @@ import { resolveLocalStartup } from '../../framework/config/local-startup.ts'; -import type { ResolveRuntimeConfigOptions } from '../../framework/config/runtime-config.ts'; +import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../framework/config/runtime-config.ts'; import { createLocalLoginSources, type LocalHostSnapshot, type LocalLoginSources } from '../../framework/adapters/local/login-sources.ts'; import type { LocalStoragePort } from '../../framework/adapters/local/local-storage.ts'; import { PlatformRuntime } from '../../framework/platform/runtime.ts'; @@ -18,8 +18,12 @@ export interface LocalLoginHostOptions { sampleHost: () => LocalHostSnapshot; view: PlatformViewPort; scheduler: FrameScheduler; - loadResources: () => Promise; - waitForMinimumDisplay: () => Promise; + loadResources: (config: RuntimeConfig) => Promise; + waitForMinimumDisplay: (config: RuntimeConfig) => Promise; + onConfigResolved?: (config: RuntimeConfig) => void; + onResourcesReady?: () => void; + onSocketReady?: (ready: boolean) => void; + onStopped?: () => void; createSocket: (url: string) => WebSocket; record: EvidenceSink; onBusy: (busy: boolean) => void; @@ -57,6 +61,7 @@ export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLogin let busy = false; let cleanupErrors: unknown[] | null = null; let serverFailure: { data: unknown } | null = null; + let unsubscribeState: (() => void) | null = null; function requireActive(): void { if (terminal) throw new Error('Local login host is stopped or terminal'); @@ -71,6 +76,8 @@ export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLogin function shutdown(errors: unknown[]): void { terminal = true; cleanupErrors = errors; + try { options.onStopped?.(); } catch (error) { errors.push(error); } + try { unsubscribeState?.(); unsubscribeState = null; } catch (error) { errors.push(error); } // Disable input before releasing either owner; dispose preserves drawn output. try { setBusy(false); } catch (error) { errors.push(error); } try { controller?.dispose(); } catch (error) { errors.push(error); } @@ -103,7 +110,7 @@ export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLogin } async function readyTask(task: () => Promise): Promise { - try { requireActive(); await task(); } + try { requireActive(); await task(); requireActive(); } catch (error) { fail(error); throw error; } } @@ -114,6 +121,8 @@ export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLogin requireActive(); const context = await resolveLocalStartup(options.configOptions); requireActive(); + options.onConfigResolved?.(context.config); + requireActive(); const { mode, isDebugger, identity, servers, source, gameserver } = context.config; options.record({ kind: 'startup-config', data: { mode, isDebugger, identity, servers, source, gameserver } }); requireActive(); @@ -162,10 +171,16 @@ export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLogin createWireClient: () => createLocalLoginWire({ context, record: options.record, onFault: wireFault, transportFactory: () => new LocalLoginTransport({ context, createSocket: options.createSocket, record: options.record, onFault: transportFault }) }), - scene: controller, loadResources: () => readyTask(options.loadResources), - waitForMinimumDisplay: () => readyTask(options.waitForMinimumDisplay), + scene: controller, loadResources: () => readyTask(async () => { + await options.loadResources(context.config); + requireActive(); options.onResourcesReady?.(); + }), + waitForMinimumDisplay: () => readyTask(() => options.waitForMinimumDisplay(context.config)), getLoginDeviceSnapshot: sources.getLoginDeviceSnapshot, }); + unsubscribeState = runtime.subscribeState(state => { + if (!terminal) options.onSocketReady?.(state.app.phase === 'connected' || state.app.phase === 'logged-in'); + }, fail); controller.connect(runtime); requireActive(); await runtime.start(); diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-view.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-view.ts index d1484f3..527d856 100644 --- a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-view.ts +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-view.ts @@ -1,4 +1,4 @@ -import { Director, Label, Node, Sprite, UITransform, director } from 'cc'; +import { Director, Label, Node, Sprite, UITransform, Vec3, Widget, director } from 'cc'; import type { FrameScheduler, OverlayModel, PageId, PageModel, PlatformViewPort } from '../../framework/presentation/ui-contracts.ts'; interface ViewReferences { @@ -6,6 +6,120 @@ interface ViewReferences { statusOverlay: Node; statusLabel: Label; resultLabel: Label; } +export interface LogoCountdownState { readonly visible: boolean; readonly text: string; } + +/** Native input and measured label layout, independent of serialized label parentage. */ +export class CocosLogoCountdownView { + private readonly label: Label; + private readonly buttonTransform: UITransform; + private readonly textTransform: UITransform; + private readonly parentTransform: UITransform; + private disposed = false; + + constructor(private readonly button: Node, private readonly text: Node, private readonly onSkip: () => void) { + const label = text.getComponent(Label), buttonTransform = button.getComponent(UITransform); + const textTransform = text.getComponent(UITransform), parentTransform = text.parent?.getComponent(UITransform); + if (!label || !buttonTransform || !textTransform || !parentTransform) { + throw new Error('Logo countdown requires Label and native UITransform bindings'); + } + this.label = label; this.buttonTransform = buttonTransform; + this.textTransform = textTransform; this.parentTransform = parentTransform; + // The label follows the actual button center, so its old absolute Widget must not fight layout. + const widget = text.getComponent(Widget); if (widget) widget.enabled = false; + this.label.overflow = Label.Overflow.NONE; + button.active = false; text.active = false; + button.on(Node.EventType.TOUCH_END, onSkip); + } + + render(state: LogoCountdownState): void { + if (this.disposed) return; + this.button.active = state.visible; this.text.active = state.visible; + this.label.string = state.text; + if (!state.visible) return; + this.button.getComponent(Widget)?.updateAlignment(); + this.label.updateRenderData(true); + const button = this.buttonTransform, text = this.textTransform; + const center = button.convertToWorldSpaceAR(new Vec3((0.5 - button.anchorX) * button.width, (0.5 - button.anchorY) * button.height, 0)); + const position = this.parentTransform.convertToNodeSpaceAR(center); + position.x += (text.anchorX - 0.5) * text.width; + position.y += (text.anchorY - 0.5) * text.height; + this.text.setPosition(position); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.button.off(Node.EventType.TOUCH_END, this.onSkip); + this.button.active = false; this.text.active = false; + } +} + +/** Display gate only. Runtime still independently owns resource/config/socket gates. */ +export function createLogoCountdown(options: { + now(): number; + schedule(callback: () => void, milliseconds: number): () => void; + render(state: LogoCountdownState): void; +}) { + let started = false, finished = false, resources = false, socket = false; + let remaining = 0, deadline = 0; + let cancelDeadline: (() => void) | null = null, cancelTick: (() => void) | null = null; + let resolve!: () => void, reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + // Cancellation may precede the host attaching the display gate. + void promise.catch(() => {}); + const render = () => options.render({ visible: started && !finished && remaining > 0, + text: `倒计时${Math.ceil(Math.max(0, remaining) / 1000)}s${resources ? ' 跳过' : ''}` }); + const finish = (error?: unknown) => { + if (finished) return; + finished = true; + const failures: unknown[] = error === undefined ? [] : [error]; + for (const cancel of [cancelDeadline, cancelTick]) { + try { cancel?.(); } catch (failure) { failures.push(failure); } + } + cancelDeadline = null; cancelTick = null; + try { render(); } catch (failure) { failures.push(failure); } + if (failures.length > 0) reject(failures[0]); else resolve(); + }; + const guard = (operation: () => void) => { + if (finished) return; + try { operation(); } catch (error) { finish(error); } + }; + const checkDeadline = () => { + cancelDeadline = null; + if (finished) return; + const wait = deadline - options.now(); + if (wait <= 0) { finish(); return; } + guard(() => { cancelDeadline = options.schedule(checkDeadline, wait); }); + }; + const tick = () => { + cancelTick = null; + if (finished) return; + guard(() => { + remaining -= 1000; + render(); + if (remaining > 1000) cancelTick = options.schedule(tick, 1000); + }); + }; + render(); + return { + promise, + start(milliseconds: number) { + if (finished) return; + if (started) throw new Error('Logo countdown already started'); + if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new Error('Invalid launchWaitTime'); + started = true; remaining = milliseconds; deadline = options.now() + milliseconds; + guard(() => { + render(); checkDeadline(); + if (!finished && remaining > 1000) cancelTick = options.schedule(tick, 1000); + }); + }, + resourcesReady() { guard(() => { resources = true; render(); }); }, + socketReady(value: boolean) { if (!finished) socket = value; }, + skip() { if (!finished && started && resources && socket) finish(); }, + cancel() { finish(new Error('Logo countdown cancelled')); }, + }; +} + export interface AssetLoadTask { readonly promise: Promise; cancel(): void; diff --git a/cocoscreator_projects/framework-tests/config/launch-wait-time.test.ts b/cocoscreator_projects/framework-tests/config/launch-wait-time.test.ts new file mode 100644 index 0000000..96ea427 --- /dev/null +++ b/cocoscreator_projects/framework-tests/config/launch-wait-time.test.ts @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as config from '../../YouleNexus/assets/framework/config/remote-config.ts'; +import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts'; + +test('launchWaitTime follows legacy default, hall own field and truthy hierarchy precedence', () => { + const resolve = (config as any).resolveLaunchWaitTime; + assert.equal(typeof resolve, 'function'); + for (const [raw, expected] of [ + [{}, 10], [{ hall_config: { launchWaitTime: 0 } }, 0], + [{ hall_config: { launchWaitTime: 4000 }, launchWaitTime: 0 }, 4000], + [{ hall_config: { launchWaitTime: 4000 }, launchWaitTime: 2500 }, 2500], + [{ launchWaitTime: 2500, agentlist: [{ agentid: BUILD_IDENTITY.agentid, launchWaitTime: 3200 }] }, 3200], + ] as const) assert.equal(resolve(config.createRemoteConfig(raw, BUILD_IDENTITY)), expected); + for (const value of [-1, Infinity, NaN, '5000', false, null, undefined]) { + assert.throws(() => resolve(config.createRemoteConfig({ hall_config: { launchWaitTime: value } }, BUILD_IDENTITY)), /launchWaitTime/); + } +}); diff --git a/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts index 5e43abf..5c55540 100644 --- a/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts +++ b/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import type { PageModel, OverlayModel, PageId } from '../../YouleNexus/assets/framework/presentation/ui-contracts.ts'; -import type { ResolveRuntimeConfigOptions } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; function deferred() { let resolve!: () => void; @@ -31,7 +31,9 @@ class Socket { } async function setup(overrides: { config?: Partial; render?: (model: PageModel) => void; cancelFrame?: () => void; showOverlay?: (model: OverlayModel) => void; - record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void } = {}) { + record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void; + onConfigResolved?: (config: RuntimeConfig) => void; onResourcesReady?: () => void; + onSocketReady?: (ready: boolean) => void; onStopped?: () => void } = {}) { const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-host.ts') .catch(error => assert.fail(`Local login host implementation must exist: ${String(error)}`)); const resources = deferred(), minimum = deferred(); @@ -40,6 +42,8 @@ async function setup(overrides: { config?: Partial; const values = new Map(), frames = new Set<() => void>(); let fetches = 0; const host = api.createLocalLoginHost({ + onConfigResolved: overrides.onConfigResolved, onResourcesReady: overrides.onResourcesReady, + onSocketReady: overrides.onSocketReady, onStopped: overrides.onStopped, configOptions: { hostKind: 'h5', win: {}, search: '?profile=local&version=731', fetcher: { async fetch() { fetches++; return { player_server_tcp: 'remote.example:3088' }; } }, ...overrides.config }, storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } }, @@ -57,6 +61,57 @@ async function setup(overrides: { config?: Partial; return { host, resources, minimum, sockets, pages, overlays, activations, failures, busy, evidence, values, frames, draw, ready, fetches: () => fetches }; } +test('host exposes resolved config and independent socket/resources readiness before display, and stops late events', async () => { + const configs: RuntimeConfig[] = [], socket: boolean[] = []; let resources = 0, stopped = 0; + const h = await setup({ onConfigResolved: config => configs.push(config), onResourcesReady: () => resources++, + onSocketReady: ready => socket.push(ready), onStopped: () => stopped++ }); + const starting = h.host.start(); await flush(); + assert.equal(configs.length, 1); assert.equal(configs[0].identity.version, 731); + assert.equal(configs[0].remoteConfig.getValue('player_server_tcp'), 'remote.example:3088'); + h.sockets[0].open(); await flush(); assert.equal(socket[socket.length - 1], true); + assert.equal(h.host.getSnapshot().ready, false); + h.resources.resolve(); await flush(); assert.equal(resources, 1); + assert.equal(h.host.getSnapshot().ready, false); + h.minimum.resolve(); await starting; h.host.stop(); assert.equal(stopped, 1); + const events = socket.length; h.sockets[0].open(); await flush(); assert.equal(socket.length, events); +}); + +test('host stop cancels presentation and ignores a resource completion still pending', async () => { + let resources = 0, stopped = 0; + const h = await setup({ onResourcesReady: () => resources++, onStopped: () => stopped++ }); + const starting = h.host.start(); const rejection = assert.rejects(starting, /stop|terminal/); + await flush(); h.host.stop(); h.resources.resolve(); h.minimum.resolve(); + await rejection; assert.equal(resources, 0); assert.equal(stopped, 1); +}); + +for (const order of [ + ['resources', 'socket', 'minimum'], ['resources', 'minimum', 'socket'], + ['socket', 'resources', 'minimum'], ['socket', 'minimum', 'resources'], + ['minimum', 'resources', 'socket'], ['minimum', 'socket', 'resources'], +] as const) test(`all startup gates remain independent in ${order.join(' -> ')} order`, async t => { + const h = await setup(); t.after(() => h.host.stop()); + const starting = h.host.start(); await flush(); + for (let index = 0; index < order.length; index++) { + const gate = order[index]; + if (gate === 'socket') h.sockets[0].open(); else h[gate].resolve(); + await flush(); + assert.equal(h.host.getSnapshot().ready, index === order.length - 1); + assert.equal(h.activations.includes('login'), index === order.length - 1); + } + await starting; h.draw(); assert.equal(h.activations.includes('login'), true); +}); + +test('pending config cannot be bypassed by previously completed resource/display tasks', async t => { + const config = deferred(); let resolved = 0; + const h = await setup({ config: { fetcher: { async fetch() { await config.promise; return {}; } } }, + onConfigResolved: () => resolved++ }); + t.after(() => h.host.stop()); const starting = h.host.start(); + h.resources.resolve(); h.minimum.resolve(); await flush(); + assert.equal(resolved, 0); assert.equal(h.sockets.length, 0); assert.equal(h.host.getSnapshot().ready, false); + config.resolve(); await flush(); assert.equal(resolved, 1); assert.equal(h.host.getSnapshot().ready, false); + h.sockets[0].open(); await starting; h.draw(); assert.equal(h.host.getSnapshot().ready, true); +}); + for (const missing of ['resources', 'minimum', 'socket'] as const) test(`login waits for ${missing}, and start composes only once`, async t => { const h = await setup(); t.after(() => h.host.stop()); const starting = h.host.start(); const repeated = h.host.start(); diff --git a/cocoscreator_projects/framework-tests/local-platform-login/view.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/view.test.ts index 97ea59f..680f208 100644 --- a/cocoscreator_projects/framework-tests/local-platform-login/view.test.ts +++ b/cocoscreator_projects/framework-tests/local-platform-login/view.test.ts @@ -4,18 +4,35 @@ import { readFileSync, existsSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import ts from 'typescript'; import { EventEmitter } from 'node:events'; +import { resolveLaunchWaitTime } from '../../YouleNexus/assets/framework/config/remote-config.ts'; class Sprite {} -class UITransform {} +class Vec3 { constructor(public x = 0, public y = 0, public z = 0) {} } +class Widget { enabled = true; updateAlignment() {} } +class UITransform { + width = 100; height = 30; anchorX = 0.5; anchorY = 0.5; + convertToWorldSpaceAR(value: Vec3) { return new Vec3(value.x + 250, value.y + 70, value.z); } + convertToNodeSpaceAR(value: Vec3) { return new Vec3(value.x - 20, value.y - 10, value.z); } +} class ProgressBar { progress = 0; } -class Label { string = ''; } +class Label { + static Overflow = { NONE: 0 }; overflow = 1; + string = ''; updateRenderData() {} +} class Node extends EventEmitter { static EventType = { TOUCH_END: 'touch-end' }; active = true; children: Node[] = []; + parent: Node | null = null; + position = new Vec3(); + setPosition(position: Vec3) { this.position = position; } components = new Map(); constructor(public name = '') { super(); } getComponent(type: unknown) { return this.components.get(type) ?? null; } + getComponentsInChildren(type: unknown): any[] { + const own = this.getComponent(type); + return [...(own ? [own] : []), ...this.children.flatMap(child => child.getComponentsInChildren(type))]; + } getChildByPath(path: string): Node | null { const [head, ...tail] = path.split('/'); const child = this.children.find(node => node.name === head); @@ -23,6 +40,79 @@ class Node extends EventEmitter { } } const director = new EventEmitter(); +function countdownFixture() { + const { createLogoCountdown } = load(); + assert.equal(typeof createLogoCountdown, 'function'); + let now = 0; let id = 0; + const timers = new Map void }>(); + const states: { visible: boolean; text: string }[] = []; + const gate = createLogoCountdown({ now: () => now, + schedule: (callback: () => void, ms: number) => { + const key = ++id; timers.set(key, { at: now + ms, callback }); return () => timers.delete(key); + }, render: (state: { visible: boolean; text: string }) => states.push(state) }); + const advance = (ms: number) => { + const end = now + ms; + while (true) { + const next = [...timers].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) break; + now = next[1].at; timers.delete(next[0]); next[1].callback(); + } + now = end; + }; + return { gate, timers, states, advance, last: () => states[states.length - 1]! }; +} + +test('Logo countdown ticks legacy milliseconds and skip requires config, resource and socket readiness', async () => { + const f = countdownFixture(); let complete = false; void f.gate.promise.then(() => { complete = true; }); + assert.equal(f.last().visible, false); + f.gate.skip(); f.gate.start(2500); + assert.equal(f.last().text, '倒计时3s'); + f.advance(1000); assert.equal(f.last().text, '倒计时2s'); + f.gate.resourcesReady(); assert.equal(f.last().text, '倒计时2s 跳过'); + f.gate.skip(); await Promise.resolve(); assert.equal(complete, false); + f.gate.socketReady(true); await Promise.resolve(); assert.equal(complete, false, 'premature click must not queue'); + f.gate.skip(); await f.gate.promise; + assert.equal(f.last().visible, false); assert.equal(f.timers.size, 0); +}); + +test('Logo exact deadline is independent from resources and socket; terminal cancellation ignores late events', async () => { + const f = countdownFixture(); f.gate.start(10); f.advance(9); + assert.equal(f.last().visible, true); f.advance(1); await f.gate.promise; + assert.equal(f.last().visible, false); assert.equal(f.timers.size, 0); + const cancelled = countdownFixture(); cancelled.gate.start(5000); + const rejection = assert.rejects(cancelled.gate.promise, /cancelled/); + cancelled.gate.cancel(); await rejection; + const count = cancelled.states.length; + cancelled.gate.start(9999); cancelled.gate.resourcesReady(); cancelled.gate.socketReady(true); cancelled.gate.skip(); cancelled.advance(10000); + assert.equal(cancelled.states.length, count); assert.equal(cancelled.timers.size, 0); +}); + +test('Logo render failure at completion rejects the gate and clears all timers', async () => { + const { createLogoCountdown } = load(); let now = 0; let failRender = false; + const callbacks = new Set<() => void>(); + const gate = createLogoCountdown({ now: () => now, + schedule: (callback: () => void) => { callbacks.add(callback); return () => callbacks.delete(callback); }, + render: () => { if (failRender) throw new Error('label rendering failed'); } }); + gate.start(2500); failRender = true; now = 2500; + const rejection = assert.rejects(gate.promise, /label rendering failed/); + assert.doesNotThrow(() => { const callback = [...callbacks][0]; callbacks.delete(callback); callback(); }); + await rejection; assert.equal(callbacks.size, 0); +}); + +test('Logo scheduling and tick render failures settle pending display promises', async () => { + const { createLogoCountdown } = load(); + const failed = createLogoCountdown({ now: () => 0, schedule: () => { throw new Error('timer failed'); }, render() {} }); + const scheduling = assert.rejects(failed.promise, /timer failed/); + assert.doesNotThrow(() => failed.start(2500)); await scheduling; + let failRender = false; const callbacks = new Set<() => void>(); + const gate = createLogoCountdown({ now: () => 0, + schedule: (callback: () => void) => { callbacks.add(callback); return () => callbacks.delete(callback); }, + render: () => { if (failRender) throw new Error('tick rendering failed'); } }); + gate.start(2500); failRender = true; + const rendering = assert.rejects(gate.promise, /tick rendering failed/); + assert.doesNotThrow(() => { const callback = [...callbacks][1]; callbacks.delete(callback); callback(); }); + await rendering; assert.equal(callbacks.size, 0); +}); const source = new URL('../../YouleNexus/assets/scripts/local-platform-login/local-login-view.ts', import.meta.url); function load() { assert.ok(existsSync(source), 'Cocos local login view implementation must exist'); @@ -31,10 +121,26 @@ function load() { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText, { exports, require: (name: string) => { assert.equal(name, 'cc'); - return { Node, Sprite, UITransform, Label, director, Director: { EVENT_AFTER_DRAW: 'draw' } }; + return { Node, Sprite, UITransform, Label, Widget, Vec3, director, Director: { EVENT_AFTER_DRAW: 'draw' } }; }, Error }); return exports as any; } + +test('Logo native binding measures label around button center and releases its touch listener', () => { + const { CocosLogoCountdownView } = load(); assert.equal(typeof CocosLogoCountdownView, 'function'); + const button = new Node('Spirit3103'), text = new Node('Spirit3104'), parent = new Node('group'); + text.parent = parent; + for (const node of [button, text, parent]) node.components.set(UITransform, new UITransform()); + const label = new Label(); text.components.set(Label, label); + const widget = new Widget(); text.components.set(Widget, widget); + let clicks = 0; const view = new CocosLogoCountdownView(button, text, () => clicks++); + assert.equal(button.active, false); assert.equal(text.active, false); + view.render({ visible: true, text: '倒计时3s 跳过' }); + assert.equal(label.string, '倒计时3s 跳过'); assert.equal(label.overflow, Label.Overflow.NONE); + assert.equal(widget.enabled, false); assert.equal(text.position.x, 230); assert.equal(text.position.y, 60); + button.emit('touch-end'); assert.equal(clicks, 1); + view.dispose(); assert.equal(button.listenerCount('touch-end'), 0); assert.equal(button.active, false); +}); function fixture() { const refs = { loadingRoot: new Node(), loginRoot: new Node(), resultRoot: new Node(), statusOverlay: new Node(), statusLabel: new Label(), resultLabel: new Label() }; @@ -155,7 +261,8 @@ test('component rejects non-debug/native before creating host or writing browser _decorator: { ccclass: decorator, property: () => () => {} } }; if (name.endsWith('local-login-host.ts')) return { createLocalLoginHost: () => { hosts++; } }; if (name.endsWith('local-login-view.ts')) return load(); - if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} }; + if (name.endsWith('remote-config.ts')) return { resolveLaunchWaitTime }; + if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} }; throw new Error(`Unexpected dependency ${name}`); }, Error }); const component = new exports.LocalPlatformLogin(); @@ -175,6 +282,7 @@ test('component stop cancels pending draw and timer work', async () => { _decorator: { ccclass: () => (target: unknown) => target, property: () => () => {} } }; if (name.endsWith('local-login-host.ts')) return {}; if (name.endsWith('local-login-view.ts')) return load(); + if (name.endsWith('remote-config.ts')) return { resolveLaunchWaitTime }; if (name.endsWith('remote-config-fetcher.ts')) return {}; throw new Error(`Unexpected dependency ${name}`); }, Error, performance: { now: () => 123 }, window: { @@ -203,6 +311,7 @@ test('minimum display ignores an early timer wake and cancellation clears its re hostOptions = options; return { start: async () => {}, stop() {}, getSnapshot: () => ({}) }; } }; if (name.endsWith('local-login-view.ts')) return load(); + if (name.endsWith('remote-config.ts')) return { resolveLaunchWaitTime }; if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} }; throw new Error(`Unexpected dependency ${name}`); }, Error, performance: { now: () => clock }, window: { @@ -210,15 +319,18 @@ test('minimum display ignores an early timer wake and cancellation clears its re setTimeout: (callback: () => void) => { timers.set(++timerId, callback); return timerId; }, clearTimeout: (id: number) => timers.delete(id), } }); - async function started() { + async function started(emitDraw = true) { const component = new exports.LocalPlatformLogin(); const f = fixture(); Object.assign(component, f.refs, { stopButton: new Node(), loadingPrefab: {}, loginPrefab: {} }); const logo = new Node('Layer1_Logo'); const group = new Node('group-1'); const bar = new Node('Spirit254'); bar.components.set(ProgressBar, new ProgressBar()); - group.children.push(bar, new Node('Spirit3103'), new Node('Spirit3104')); + const button = new Node('Spirit3103'), text = new Node('Spirit3104'); + for (const node of [button, text, group]) node.components.set(UITransform, new UITransform()); + const label = new Label() as Label & { node: Node }; label.node = text; text.components.set(Label, label); text.parent = group; + group.children.push(bar, button, text); logo.children.push(group, new Node('group-32')); component.loadingRoot.children.push(logo); component.loadingRoot.getComponentsInChildren = () => []; - await component.start(); director.emit('draw'); await Promise.resolve(); + await component.start(); if (emitDraw) director.emit('draw'); await Promise.resolve(); return component; } const drain = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }; @@ -227,6 +339,7 @@ test('minimum display ignores an early timer wake and cancellation clears its re timers.delete(id); callback(); } const component = await started(); let completed = false; + hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'launchWaitTime' ? 500 : null } }); const minimum = hostOptions.waitForMinimumDisplay().then(() => { completed = true; }); await drain(); wake(599.8); await drain(); assert.equal(completed, false, '499.8 ms elapsed must not open minimum display gate'); @@ -236,9 +349,16 @@ test('minimum display ignores an early timer wake and cancellation clears its re component.stop(); clock = 1000; const cancelled = await started(); + hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'launchWaitTime' ? 500 : null } }); const cancelledMinimum = hostOptions.waitForMinimumDisplay(); const rejection = assert.rejects(cancelledMinimum, /cancelled/); await drain(); wake(1499.8); await drain(); assert.equal(timers.size, 1); cancelled.stop(); await rejection; assert.equal(timers.size, 0); assert.equal(cancelled.getDiagnostics().pendingCount, 0); + + const undrawn = await started(false); let zeroComplete = false; + hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'hall_config' ? { launchWaitTime: 0 } : null } }); + const zeroMinimum = hostOptions.waitForMinimumDisplay().then(() => { zeroComplete = true; }); + await drain(); assert.equal(zeroComplete, false, 'zero display deadline cannot bypass first native frame'); + director.emit('draw'); await zeroMinimum; assert.equal(zeroComplete, true); undrawn.stop(); }); diff --git a/docs/superpowers/reports/2026-09-06-logo-countdown-acceptance.md b/docs/superpowers/reports/2026-09-06-logo-countdown-acceptance.md new file mode 100644 index 0000000..8c72d72 --- /dev/null +++ b/docs/superpowers/reports/2026-09-06-logo-countdown-acceptance.md @@ -0,0 +1,29 @@ +# Logo 倒计时与跳过验收 + +日期:2026-09-06。基线09e80e5,主checkout分支codex/local-platform-login。用户批准恢复Spirit3103/Spirit3104的原工程行为。 + +## 原工程依据与实现边界 + +- 04_Data.js:181:hallConfig.launchWaitTime初始值10。12_Logic.js:1790起:先合并hall_config,再以get_paravalue逐项覆盖。来源默认仅在配置层定义,消费UI不兜底。 +- 12_Logic.js:1469起:配置成功后设置Logo计时、显示倒计时并初始化连接。gameabc.min.js:3370设置ontime,1876起按当前毫秒时间差触发;1319以Date.getTime取时间。10是毫秒,不擅自扩大为10秒。 +- 11_GameUI.js:8821:显示3103/3104,文字为倒计时ceil(remaining/1000)s,资源100%时追加两个空格和跳过;每1000ms递减,到期隐藏两者并清理定时器。7937的adaptPositionSpid只做文字水平居中,可用Cocos布局表达。 +- 11_GameUI.js:2182:点击3103必须同时满足firstConnect_Succ、getJSONState及LoadCount==100,才清理计时并进入登录。自动切换同样不能忽略配置/资源/连接状态。 +- save/Layer00001.xml:210:原文字字号14、白色;保留当前节点字号/外观。 + +现代实现使用一次性的启动就绪门表达这些条件,替换旧引擎重复绘制计时轮询,不复刻配置回调顺序造成的偶然提前跳转或轮询延迟。保留Logo原生资源进度条;只接倒计时与跳过,不扩展广告图替换、房间、子游戏或其他RPC。 + +## 验证记录 + +- 生产代码完成,独立规格/质量审查通过。TDD新增配置、协调器、宿主回调、原生绑定、异常路径均有RED→GREEN记录;最终聚焦50/50。全量显式枚举TS636/636,指定架构49/49;framework tsc、现代Creator项目严格类型检查、导入边界和diff-check通过。未运行聚合测试/资源生成器。 +- 配置解析保持默认10毫秒→hall_config自有字段→scoped单项覆盖。UI从明确的config回调获取来源;不从diagnostics取业务数据。倒计时使用精确截止时间,firstDraw保持独立门。结束/失败/停止均清理计时与点击,当前连接状态由Runtime订阅给出;早点击不排队。 +- 五个既有TS经MCP刷新,编辑器主checkout/currentLocalPlatformLogin,无资源写入。创建独立sandbox Chromium窗口12(fp_ad50e68505262f18),未点击登录。 +- 实际无参数网页fp_ce914709f5aedc1e:ready=true、stopped=false、pending=0、资源progress=1。3104父节点为Spirit3103,文字倒计时1s但控件已隐藏。firstDraw1078.7ms、展示门结束1239.5ms、资源就绪1248ms、登录页1249.4ms;配置默认短等待未绕过资源门。无component-failure。 +- 长倒计时视觉夹具尝试仅在独立窗口内装饰HttpConfigFetcher返回数据并重载同一场景,未写生产配置,未记录成功结果。该MCP调用没有返回,后续只读编辑器查询也挂起;已停止工具等待。HTTP8765/health仍返回ok,不能据此认定编辑器调用可用。已请求用户关闭独立验证窗口/恢复Funplay扩展。 +- 待恢复MCP后完成:长倒计时文字逐秒变化、实际点击跳过与到期隐藏的网页验收;恢复临时fetcher装饰(若安装成功)并停止/关闭独立窗口。此项未完成,不以50项模拟测试冒充网页验收。 + +## 用户资源改动保护 + +实施期间只读检查发现Logo.prefab新增未提交差异:Spirit3104已移为Spirit3103子节点,局部位置为0。该差异不是本批工具产生,单独记录SHA256并保留,不随代码提交。代码应唯一定位目标Label并按实际父节点布局,避免绑定旧的同级路径;支持这一现有用户布局不需要修改序列化资源。 + + +整体独立代码审查通过,无可操作问题;结论附带上述网页长倒计时验收未完成的限定。原有50个未跟踪文件与外部Logo.prefab快照SHA256均不变,两项历史meta删除保留。