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;