feat(ui): restore Logo countdown and gated skip

This commit is contained in:
2026-09-06 00:32:37 +08:00
parent 09e80e5052
commit 84fa739ddd
9 changed files with 415 additions and 27 deletions
@@ -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<unknown>;
}
/** 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<string, unknown>).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 {}
@@ -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 覆盖。
@@ -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<typeof createLogoCountdown> | 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();
@@ -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<void>;
waitForMinimumDisplay: () => Promise<void>;
loadResources: (config: RuntimeConfig) => Promise<void>;
waitForMinimumDisplay: (config: RuntimeConfig) => Promise<void>;
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<void>): Promise<void> {
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();
@@ -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<void>((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<T> {
readonly promise: Promise<T>;
cancel(): void;
@@ -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/);
}
});
@@ -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<ResolveRuntimeConfigOptions>; 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<ResolveRuntimeConfigOptions>;
const values = new Map<string, string>(), 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<ResolveRuntimeConfigOptions>;
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();
@@ -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<unknown, unknown>();
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<number, { at: number; callback: () => 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,6 +261,7 @@ 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.ts')) return { resolveLaunchWaitTime };
if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} };
throw new Error(`Unexpected dependency ${name}`);
}, Error });
@@ -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();
});
@@ -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删除保留。