feat(ui): restore Logo countdown and gated skip
This commit is contained in:
@@ -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 覆盖。
|
||||
|
||||
+29
-11
@@ -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();
|
||||
|
||||
+21
-6
@@ -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();
|
||||
|
||||
+115
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user