feat: migrate platform login scenes and native login flows

This commit is contained in:
2026-09-06 09:06:00 +08:00
parent f2a948f059
commit 695d3be25c
140 changed files with 7599 additions and 4397 deletions
@@ -1,237 +0,0 @@
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 { 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, CocosLogoCountdownView, createLogoCountdown, createAssetLoadTask, createCocosFrameScheduler, positionDiagnosticStopButtonBelow, requireNode } from './local-login-view.ts';
const { ccclass, property } = _decorator;
/** Explicit DEBUG browser scene entry. All business state belongs to its host Runtime. */
@ccclass('LocalPlatformLogin')
export class LocalPlatformLogin extends Component {
@property(Prefab) loadingPrefab: Prefab | null = null;
@property(Prefab) loginPrefab: Prefab | null = null;
@property(Node) loadingRoot: Node | null = null;
@property(Node) loginRoot: Node | null = null;
@property(Node) resultRoot: Node | null = null;
@property(Node) statusOverlay: Node | null = null;
@property(Label) statusLabel: Label | null = null;
@property(Label) resultLabel: Label | null = null;
@property(Node) stopButton: Node | null = null;
private host: LocalLoginHost | null = null;
private view: CocosLocalLoginView | null = null;
private resourceProgress: ProgressBar | null = null;
private loginInstance: Node | null = null;
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;
private readonly stopTouch = () => this.stop();
async start(): Promise<void> {
// This guard precedes reading browser storage, preparing account or creating socket.
if (!DEBUG || sys.isNative) throw new Error('LocalPlatformLogin requires DEBUG browser preview');
try {
const loadingRoot = this.required(this.loadingRoot, 'loadingRoot');
const loginRoot = this.required(this.loginRoot, 'loginRoot');
const stopButton = this.required(this.stopButton, 'stopButton');
this.view = new CocosLocalLoginView({ loadingRoot, loginRoot,
resultRoot: this.required(this.resultRoot, 'resultRoot'),
statusOverlay: this.required(this.statusOverlay, 'statusOverlay'),
statusLabel: this.required(this.statusLabel, 'statusLabel'),
resultLabel: this.required(this.resultLabel, 'resultLabel') });
this.required(this.loadingPrefab, 'loadingPrefab');
this.required(this.loginPrefab, 'loginPrefab');
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');
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');
const logoButton = requireNode(logo, 'group-1/Spirit3103');
positionDiagnosticStopButtonBelow(stopButton, logoButton);
this.countdownView = new CocosLogoCountdownView(logoButton, 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');
const firstDraw = this.nextDraw().then(() => {
this.firstDrawAt = performance.now();
this.record({ kind: 'loading-first-draw', data: null });
});
// A configuration failure can cancel this frame before Runtime consumes its gate.
void firstDraw.catch(() => {});
const view = this.view;
const browser = window as unknown as NativeHost & { returnCitySN?: unknown };
this.host = createLocalLoginHost({
configOptions: { hostKind: 'h5', search: window.location.search,
win: browser, fetcher: new HttpConfigFetcher() },
storage: window.localStorage, random: Math.random, now: Date.now,
sampleHost: () => ({ location: null,
...('returnCitySN' in browser ? { returnCitySN: browser.returnCitySN } : {}) }),
view: {
render: model => { this.pageModel = model; this.record({ kind: 'page-model', data: model }); view.render(model); },
activate: page => { view.activate(page); this.record({ kind: 'page-active', data: page }); },
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 Promise.all([firstDraw, this.required(this.countdown, 'Logo countdown').promise]);
const drawnAt = this.required(this.firstDrawAt, 'loading first draw');
this.record({ kind: 'minimum-display-ready', data: performance.now() - drawnAt });
},
createSocket: url => new WebSocket(url), record: event => this.record(event),
onBusy: busy => view.setBusy(busy),
onFailure: error => this.fail(error),
});
await this.host.start();
} catch (error) { this.fail(error); }
}
private required<T>(value: T | null, name: string): T {
if (value === null || value === undefined) throw new Error(`LocalPlatformLogin missing ${name}`);
return value;
}
private record(event: LocalLoginEvidence): void {
this.evidence.push(Object.freeze({ ...event, at: performance.now() }));
}
private validateSprites(root: Node): void {
for (const sprite of root.getComponentsInChildren(Sprite)) {
if (!sprite.node.activeInHierarchy) continue;
const frame = sprite.spriteFrame;
if (frame === null || !isValid(frame) || frame.texture === null || !isValid(frame.texture)) {
throw new Error(`Invalid SpriteFrame/texture dependency: ${sprite.node.name}`);
}
}
}
private async loadResources(): Promise<void> {
if (this.stopped) throw new Error('Local login resources cancelled');
const serializedPrefab = this.required(this.loginPrefab, 'loginPrefab');
const loading = this.required(this.loadingPrefab, 'loadingPrefab');
if (!isValid(serializedPrefab) || !isValid(serializedPrefab.data) || !isValid(loading) || !isValid(loading.data)) {
throw new Error('Local login serialized Prefab dependency is invalid');
}
const progress = this.required(this.resourceProgress, 'resource ProgressBar');
const task = createAssetLoadTask<Prefab>((onProgress, onComplete) => {
assetManager.loadAny(serializedPrefab.uuid, onProgress, (error, asset) => onComplete(error, asset as Prefab));
}, value => { progress.progress = value; });
this.pending.add(task.cancel);
let prefab: Prefab;
try { prefab = await task.promise; }
finally { this.pending.delete(task.cancel); }
if (this.stopped) throw new Error('Local login resources cancelled');
if (!isValid(prefab) || !isValid(prefab.data)) throw new Error('Loaded login Prefab dependency is invalid');
const instance = instantiate(prefab);
this.loginInstance = instance;
instance.parent = this.required(this.loginRoot, 'loginRoot');
const view = this.required(this.view, 'view');
view.bindLogin(instance, () => {
const host = this.required(this.host, 'host');
void host.login().catch(error => this.fail(error));
});
view.setVersion(this.required(this.version, 'resolved identity.version'));
// Validate active prefab content before the root is exposed as the login page.
for (const sprite of instance.getComponentsInChildren(Sprite)) {
if (!sprite.node.active) continue;
const frame = sprite.spriteFrame;
if (frame === null || !isValid(frame) || frame.texture === null || !isValid(frame.texture)) {
throw new Error(`Invalid login SpriteFrame/texture dependency: ${sprite.node.name}`);
}
}
await this.nextDraw();
this.record({ kind: 'resources-ready', data: { login: prefab.uuid, loading: loading.uuid } });
}
private nextDraw(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.stopped) { reject(new Error('Local login frame cancelled')); return; }
const cancelFrame = createCocosFrameScheduler().request(() => {
this.pending.delete(cancel); resolve();
});
const cancel = () => { cancelFrame(); this.pending.delete(cancel); reject(new Error('Local login frame cancelled')); };
this.pending.add(cancel);
});
}
private delay(milliseconds: number): Promise<void> {
return new Promise((resolve, reject) => {
if (this.stopped) { reject(new Error('Local login timer cancelled')); return; }
const timer = window.setTimeout(() => { this.pending.delete(cancel); resolve(); }, milliseconds);
const cancel = () => { window.clearTimeout(timer); this.pending.delete(cancel); reject(new Error('Local login timer cancelled')); };
this.pending.add(cancel);
});
}
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();
this.host?.stop();
}
private fail(error: unknown): void {
if (this.stopped) return;
this.record({ kind: 'component-failure', data: error });
try { this.release(); }
catch (cleanup) {
this.record({ kind: 'cleanup-failure', data: cleanup });
this.view?.showFailure({ error, cleanup });
return;
}
this.view?.showFailure(error);
if (this.view === null && this.statusLabel !== null) this.statusLabel.string = `验证失败:${String(error)}`;
}
stop(): void {
if (this.stopped) return;
this.view?.showStopped();
try { this.release(); }
catch (error) { this.view?.showFailure(error); }
this.record({ kind: 'stopped', data: null });
}
onDestroy(): void {
this.stop();
if (this.loginInstance !== null && isValid(this.loginInstance)) this.loginInstance.destroy();
}
/** Local memory only: callers must redact raw account/device IDs before sharing. */
getDiagnostics() {
return Object.freeze({ events: Object.freeze([...this.evidence]), snapshot: this.host?.getSnapshot(),
pageModel: this.pageModel, stopped: this.stopped, pendingCount: this.pending.size,
firstDrawAt: this.firstDrawAt, resourceProgress: this.resourceProgress?.progress });
}
}
@@ -1,225 +0,0 @@
import { resolveLocalStartup } from '../../framework/config/local-startup.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';
import type { PlatformState } from '../../framework/platform/stores/platform-types.ts';
import { PlatformUiController } from '../../framework/presentation/platform-ui-controller.ts';
import type { PlatformViewPort, FrameScheduler } from '../../framework/presentation/ui-contracts.ts';
import { createDiagnosticGameEntry } from './local-login-game-entry.ts';
import { createLocalLoginWire } from './local-login-wire.ts';
import { LocalLoginTransport, type EvidenceSink } from './local-login-transport.ts';
export interface LocalLoginHostOptions {
configOptions: ResolveRuntimeConfigOptions;
storage: LocalStoragePort;
random: () => number;
now: () => number;
sampleHost: () => LocalHostSnapshot;
view: PlatformViewPort;
scheduler: FrameScheduler;
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;
onFailure: (error: unknown) => void;
}
export interface LocalLoginHost {
start(): Promise<void>;
login(): Promise<void>;
stop(): void;
getSnapshot(): { state: PlatformState | null; ready: boolean; busy: boolean };
}
class LocalLoginHostFault extends Error {
readonly errors: readonly unknown[];
constructor(errors: readonly unknown[]) {
super('Local login host failed with additional observer or cleanup errors');
this.name = 'LocalLoginHostFault';
this.errors = Object.freeze([...errors]);
}
}
function collected(errors: readonly unknown[]): unknown {
return errors.length === 1 ? errors[0] : new LocalLoginHostFault(errors);
}
/** Development composition only; Runtime remains the sole business state owner. */
export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLoginHost {
let runtime: PlatformRuntime | null = null;
let controller: PlatformUiController | null = null;
let sources: LocalLoginSources | null = null;
let startup: Promise<void> | null = null;
let loginOperation: Promise<void> | null = null;
let terminal = false;
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');
}
function setBusy(value: boolean): void {
if (busy === value) return;
busy = value;
options.onBusy(value);
}
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); }
try { runtime?.stop(); } catch (error) { errors.push(error); }
cleanupErrors = null;
}
function fail(error: unknown): void {
if (cleanupErrors !== null) {
if (!cleanupErrors.includes(error)) cleanupErrors.push(error);
return;
}
if (terminal) return;
const errors = [error];
shutdown(errors);
options.onFailure(collected(errors));
}
function wireFault(error: unknown): void {
if (serverFailure === null) { fail(error); return; }
// The Wire scope marker follows the existing denial/kick handler. Keep the
// original server payload, and retain any independent Wire cleanup errors.
const extras = error instanceof Error && 'errors' in error
? (error as Error & { errors: readonly unknown[] }).errors.slice(1) : [];
fail(collected([serverFailure.data, ...extras]));
}
function transportFault(error: unknown): void {
fail(serverFailure === null ? error : collected([serverFailure.data, error]));
}
async function readyTask(task: () => Promise<void>): Promise<void> {
try { requireActive(); await task(); requireActive(); }
catch (error) { fail(error); throw error; }
}
function start(): Promise<void> {
if (startup !== null) return startup;
if (terminal) return Promise.reject(new Error('Local login host is stopped or terminal'));
startup = Promise.resolve().then(async () => {
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();
const entry = createDiagnosticGameEntry(context.config.identity.gameid, options.record);
sources = createLocalLoginSources({ context, storage: options.storage, random: options.random,
now: options.now, sampleHost: options.sampleHost,
isSessionActive: () => busy || runtime?.state.app.phase === 'logged-in' });
controller = new PlatformUiController({
render: model => {
if (terminal) return;
try { options.view.render(model); }
catch (error) { fail(error); throw error; }
},
activate: page => {
if (terminal) return;
try {
options.view.activate(page);
if (!terminal && page === 'lobby') setBusy(false);
} catch (error) { fail(error); throw error; }
},
showOverlay: model => {
if (terminal) return;
if (model.kind === 'denial') serverFailure = { data: model.outcome.data };
if (model.kind === 'kicked') serverFailure = { data: model.data };
try { options.view.showOverlay(model); }
catch (error) {
if (model.kind === 'fatal') fail(collected([model.error, error]));
else transportFault(error);
throw error;
}
if (model.kind === 'fatal') fail(model.error);
},
}, {
request: callback => {
try {
const cancel = options.scheduler.request(callback);
return () => {
try { cancel(); }
catch (error) { fail(error); throw error; }
};
} catch (error) { fail(error); throw error; }
},
}, fail);
runtime = new PlatformRuntime({ gameEntry: entry,
resolveRuntimeConfig: async () => context.config,
createWireClient: () => createLocalLoginWire({ context, record: options.record, onFault: wireFault,
transportFactory: () => new LocalLoginTransport({ context, createSocket: options.createSocket,
record: options.record, onFault: transportFault }) }),
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();
requireActive();
}).catch(error => { fail(error); throw error; });
return startup;
}
function login(): Promise<void> {
if (terminal) return Promise.reject(new Error('Local login host is stopped or terminal'));
if (busy && loginOperation !== null) return loginOperation;
if (runtime === null || controller === null || sources === null || !runtime.ready) {
return Promise.reject(new Error('Local login host is not ready for login'));
}
const activeSources = sources;
const activeController = controller;
loginOperation = Promise.resolve().then(async () => {
requireActive();
const account = await activeSources.prepareLogin();
requireActive();
activeController.login(account);
}).catch(error => { fail(error); throw error; });
try { setBusy(true); } catch (error) { fail(error); }
return loginOperation;
}
return {
start, login,
stop() {
if (terminal) return;
const errors: unknown[] = [];
shutdown(errors);
if (errors.length > 0) {
const error = collected(errors);
options.onFailure(error);
throw error;
}
},
getSnapshot: () => ({ state: runtime === null ? null : runtime.state,
ready: !terminal && runtime !== null && runtime.ready, busy }),
};
}
@@ -1,138 +0,0 @@
import {
assertLocalConnectionTarget,
type LocalStartupContext,
} from '../../framework/config/local-startup.ts';
import type { Transport } from '../../framework/net/transport.ts';
export type LocalLoginEvidence = { kind: string; data: unknown };
export type EvidenceSink = (event: LocalLoginEvidence) => void;
interface LocalLoginTransportOptions {
context: LocalStartupContext;
createSocket: (url: string) => WebSocket;
onFault: (error: unknown) => void;
record: EvidenceSink;
}
/** ES2020 aggregate: retain originals before notifying a terminal observer once. */
class LocalLoginTransportFault extends Error {
readonly errors: readonly unknown[];
constructor(errors: readonly unknown[]) {
super('Local login transport failed with an additional cleanup error');
this.name = 'LocalLoginTransportFault';
this.errors = Object.freeze([...errors]);
}
}
/** Local-only socket boundary; raw evidence precedes protocol decoding. */
export class LocalLoginTransport implements Transport {
private socket: WebSocket | null = null;
private generation = 0;
private openCallback?: () => void;
private messageCallback?: (frame: string) => void;
private closeCallback?: () => void;
constructor(private readonly options: LocalLoginTransportOptions) {}
connect(url: string): void {
try {
assertLocalConnectionTarget(this.options.context, url);
} catch (error) {
this.options.onFault(error);
throw error;
}
this.close();
let socket: WebSocket;
try {
socket = this.options.createSocket(url);
} catch (error) {
this.options.onFault(error);
throw error;
}
this.socket = socket;
const generation = this.generation;
socket.onopen = () => {
if (this.socket === socket) this.openCallback?.();
};
socket.onmessage = (event) => {
if (this.socket !== socket) return;
if (typeof event.data !== 'string') {
this.stopWithFault(new TypeError('Local login WebSocket requires text frames'));
return;
}
try {
this.options.record({ kind: 'transport-receive', data: event.data });
} catch (error) {
throw this.stopWithFault(error);
}
if (this.socket === socket) this.messageCallback?.(event.data);
};
socket.onclose = () => {
if (this.socket !== socket) return;
this.socket = null;
this.closeCallback?.();
};
socket.onerror = (event) => {
if (this.socket !== socket) return;
try {
this.options.record({ kind: 'transport-error', data: event });
} catch (error) {
throw this.stopWithFault(error);
}
if (this.socket !== socket || this.generation !== generation) return;
// Invalidate before close: browsers may dispatch close after error.
this.socket = null;
try {
socket.close();
} catch (error) {
this.options.onFault(error);
throw error;
}
if (this.generation === generation) this.closeCallback?.();
};
}
send(frame: string): void {
const socket = this.socket;
if (!socket || socket.readyState !== 1) {
throw new Error('Local login WebSocket must be OPEN before send');
}
try {
socket.send(frame);
this.options.record({ kind: 'transport-send', data: frame });
} catch (error) {
throw this.stopWithFault(error);
}
}
close(): void {
this.generation++;
const socket = this.socket;
this.socket = null;
if (!socket) return;
try {
socket.close();
} catch (error) {
this.options.onFault(error);
throw error;
}
}
onOpen(callback: () => void): void { this.openCallback = callback; }
onMessage(callback: (frame: string) => void): void { this.messageCallback = callback; }
onClose(callback: () => void): void { this.closeCallback = callback; }
private stopWithFault(error: unknown): unknown {
const socket = this.socket;
this.socket = null;
const errors: unknown[] = [error];
try {
socket?.close();
} catch (cleanupError) {
errors.push(cleanupError);
}
const fault = errors.length === 1 ? error : new LocalLoginTransportFault(errors);
this.options.onFault(fault);
return fault;
}
}
@@ -1,317 +0,0 @@
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 {
loadingRoot: Node; loginRoot: Node; resultRoot: Node;
statusOverlay: Node; statusLabel: Label; resultLabel: Label;
}
export interface LogoCountdownState { readonly visible: boolean; readonly text: string; }
const DIAGNOSTIC_STOP_GAP = 12;
/** Keeps the scene diagnostic control clear of the native Logo click region. */
export function positionDiagnosticStopButtonBelow(stopButton: Node, logoButton: Node): void {
const stopTransform = stopButton.getComponent(UITransform);
const logoTransform = logoButton.getComponent(UITransform);
const stopParentTransform = stopButton.parent?.getComponent(UITransform);
if (!stopTransform || !logoTransform || !stopParentTransform) {
throw new Error('Diagnostic stop layout requires native UITransform bindings');
}
const widget = stopButton.getComponent(Widget);
if (widget) {
widget.updateAlignment();
widget.enabled = false;
}
const logoBottom = logoTransform.convertToWorldSpaceAR(
new Vec3((0.5 - logoTransform.anchorX) * logoTransform.width, -logoTransform.anchorY * logoTransform.height, 0),
);
const bottomInStopParent = stopParentTransform.convertToNodeSpaceAR(logoBottom);
const current = stopButton.position;
stopButton.setPosition(new Vec3(current.x,
bottomInStopParent.y - DIAGNOSTIC_STOP_GAP - (1 - stopTransform.anchorY) * stopTransform.height,
current.z));
}
/** 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;
}
type AssetLoadStarter<T> = (
onProgress: (completed: number, total: number) => void,
onComplete: (error: unknown, asset?: T) => void,
) => void;
/** Owns loader callback lifetime so stop/failure cannot revive the login flow. */
export function createAssetLoadTask<T>(start: AssetLoadStarter<T>, setProgress: (progress: number) => void): AssetLoadTask<T> {
let settled = false;
let resolveTask!: (asset: T) => void;
let rejectTask!: (error: unknown) => void;
const promise = new Promise<T>((resolve, reject) => { resolveTask = resolve; rejectTask = reject; });
const reject = (error: unknown) => {
if (settled) return;
settled = true;
rejectTask(error);
};
try {
setProgress(0);
start((completed, total) => {
if (settled) return;
if (!Number.isInteger(completed) || !Number.isInteger(total) || completed < 0 || total <= 0 || completed > total) {
reject(new Error(`Invalid asset load progress: completed=${completed}, total=${total}`));
return;
}
try { setProgress(completed / total); }
catch (error) { reject(error); }
}, (error, asset) => {
if (settled) return;
if (error !== null && error !== undefined) { reject(error); return; }
if (asset === null || asset === undefined) { reject(new Error('Asset loader completed without an asset')); return; }
settled = true;
resolveTask(asset);
});
} catch (error) { reject(error); }
return { promise, cancel() { reject(new Error('Asset load cancelled')); } };
}
export function requireNode(root: Node, path: string): Node {
const node = root.getChildByPath(path);
if (node === null) throw new Error(`Local login binding missing node: ${path}`);
return node;
}
function describeFailure(error: unknown): string {
if (error instanceof Error) {
const details = 'errors' in error ? (error as Error & { errors: unknown[] }).errors : [];
return [error.message, ...details.map(describeFailure)].join('\n');
}
if (typeof error === 'string') return error;
return JSON.stringify(error, (_key, value: unknown) => value instanceof Error ? describeFailure(value) : value);
}
/** Projection of Controller models; owns only Cocos bindings and input state. */
export class CocosLocalLoginView implements PlatformViewPort {
private visitor: Node | null = null;
private version: Label | null = null;
private mask: Node | null = null;
private touch: (() => void) | null = null;
private canLogin = false;
private busy = false;
private terminal = false;
private page: PageId = 'loading';
constructor(private readonly refs: ViewReferences) {
for (const [name, value] of Object.entries(refs)) {
if (value === null || value === undefined) throw new Error(`Local login binding missing ${name}`);
}
}
bindLogin(instance: Node, onLogin: () => void): void {
if (this.visitor !== null || this.terminal) throw new Error('Local login view cannot bind twice or after disposal');
const visitor = requireNode(instance, 'group-2/游客登录');
if (visitor.getComponent(Sprite) === null) throw new Error('游客登录 requires Sprite');
if (visitor.getComponent(UITransform) === null) throw new Error('游客登录 requires UITransform');
const version = requireNode(instance, 'group-2/版本显示').getComponent(Label);
if (version === null) throw new Error('版本显示 requires Label');
const mask = requireNode(instance, 'group-2/登录中遮罩');
const hidden = ['微信登录', '手机登录', 'qq登录', '快速登录', '隐藏快速登录-1', '隐藏快速登录-2',
'登录头像背景框', '登录中头像', '登录中昵称', '登录中动画', '登录中头像框']
.map(name => requireNode(instance, `group-2/${name}`));
hidden.forEach(node => { node.active = false; });
this.visitor = visitor; this.version = version; this.mask = mask;
this.touch = () => {
if (!this.terminal && !this.busy && this.canLogin && this.page === 'login') onLogin();
};
visitor.on(Node.EventType.TOUCH_END, this.touch);
mask.active = this.busy;
}
setVersion(version: number): void {
if (this.version === null) throw new Error('Login instance must be bound before version display');
this.version.string = String(version);
}
render(model: PageModel): void {
if (this.terminal) return;
switch (model.page) {
case 'loading': this.refs.statusLabel.string = '正在加载资源和连接本地服务…'; break;
case 'login': this.canLogin = model.canLogin; break;
case 'lobby':
this.refs.resultLabel.string = `登录成功\n玩家 ID:${model.self.playerid}\n昵称:${model.self.nickname}\n豆豆:${model.self.bean}`;
break;
case 'room': throw new Error('Local login view cannot present room');
}
}
activate(page: PageId): void {
if (this.terminal) return;
if (page === 'room') throw new Error('Local login view cannot activate room');
this.page = page;
this.refs.loadingRoot.active = page === 'loading';
this.refs.loginRoot.active = page === 'login';
this.refs.resultRoot.active = page === 'lobby';
this.refs.statusOverlay.active = page === 'loading' || this.busy;
}
setBusy(busy: boolean): void {
if (this.terminal) return;
this.busy = busy;
if (this.mask !== null) this.mask.active = busy;
this.refs.statusOverlay.active = busy || this.page === 'loading';
if (busy) this.refs.statusLabel.string = '正在登录,请稍候…';
}
showOverlay(model: OverlayModel): void {
if (this.terminal) return;
switch (model.kind) {
case 'none': this.refs.statusOverlay.active = this.busy || this.page === 'loading'; break;
case 'reconnect':
this.refs.statusOverlay.active = true;
this.refs.statusLabel.string = '连接中断,正在重连…'; break;
case 'denial': this.showFailure(model.outcome.data); break;
case 'kicked': this.showFailure(model.data); break;
case 'fatal': this.showFailure(model.error); break;
}
}
showFailure(error: unknown): void {
this.dispose();
this.refs.statusOverlay.active = true;
this.refs.statusLabel.string = `验证失败:${describeFailure(error)}`;
}
showStopped(): void {
if (this.terminal) return;
this.dispose();
this.refs.statusOverlay.active = true;
this.refs.statusLabel.string = '已停止本次验证';
}
dispose(): void {
this.terminal = true; this.canLogin = false; this.busy = false;
if (this.visitor !== null && this.touch !== null) this.visitor.off(Node.EventType.TOUCH_END, this.touch);
this.touch = null;
if (this.mask !== null) this.mask.active = false;
}
}
export function createCocosFrameScheduler(): FrameScheduler {
return { request(callback) {
const onDraw = () => { director.off(Director.EVENT_AFTER_DRAW, onDraw); callback(); };
director.on(Director.EVENT_AFTER_DRAW, onDraw);
return () => { director.off(Director.EVENT_AFTER_DRAW, onDraw); };
} };
}
@@ -1,9 +0,0 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5af2497c-15f2-4493-be9b-a6391d25b878",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,120 +0,0 @@
import type { LocalStartupContext } from '../../framework/config/local-startup.ts';
import type { Transport } from '../../framework/net/transport.ts';
import { WireClient, type WireEvent } from '../../framework/net/wire-client.ts';
import type { PlatformWireClient } from '../../framework/platform/runtime.ts';
import { parseLoginResponse } from '../../framework/protocol/contracts/index.ts';
import type { EvidenceSink } from './local-login-transport.ts';
interface LocalLoginWireOptions {
context: LocalStartupContext;
transportFactory: () => Transport;
onFault: (error: unknown) => void;
record: EvidenceSink;
}
/** ES2020-compatible aggregate retaining original errors, including non-Error values. */
class LocalLoginWireFault extends Error {
readonly errors: readonly unknown[];
constructor(errors: readonly unknown[]) {
super('Local login Wire failed with additional cleanup or evidence errors');
this.name = 'LocalLoginWireFault';
this.errors = Object.freeze([...errors]);
}
}
export function createLocalLoginWire(options: LocalLoginWireOptions): PlatformWireClient {
const wire = new WireClient({ servers: options.context.config.servers, transportFactory: options.transportFactory });
const listeners = new Set<(event: WireEvent) => void>();
let stopped = false;
let faulted = false;
let terminalDelivery = false;
function stop(): void {
if (stopped) return;
stopped = true;
unsubscribe();
listeners.clear();
wire.stop();
}
function fail(error: unknown, operation?: string, event?: WireEvent): unknown {
if (faulted) return error;
faulted = true;
const errors = [error];
try { stop(); } catch (cleanupError) { errors.push(cleanupError); }
if (operation !== undefined) {
try { options.record({ kind: 'scope-violation', data: { operation, event } }); }
catch (recordError) { errors.push(recordError); }
}
const fault = errors.length === 1 ? error : new LocalLoginWireFault(errors);
options.onFault(fault);
return fault;
}
function scopeError(operation: string): Error {
return new Error(`Local platform login scope forbids ${operation}`);
}
function assertActive(): void {
if (stopped || faulted || terminalDelivery) throw new Error('Local login Wire is stopped or terminal');
}
function deliver(event: WireEvent): void {
for (const listener of [...listeners]) {
if (stopped || faulted) break;
listener(event);
}
}
const unsubscribe = wire.subscribe(event => {
if (stopped || faulted || terminalDelivery) return;
try {
options.record({ kind: 'wire-event', data: event });
if (stopped || faulted) return;
if (event.type !== 'message') { deliver(event); return; }
const { route, rpc, data } = event.message;
if (rpc === 'connect_agentserver' || rpc === 'connect_roomserver' || (route !== 'agent' && route !== 'platform')) {
fail(scopeError(`${route}/${rpc}`), `${route}/${rpc}`, event);
return;
}
let terminalOperation: string | undefined;
if (route === 'agent' && rpc === 'player_login') {
const parsed = parseLoginResponse(data);
if (parsed.state === 0 && parsed.room !== null) {
fail(scopeError('login room recovery'), 'login room recovery', event);
return;
}
if (parsed.state !== 0) terminalOperation = 'login rejected';
}
if (route === 'agent' && rpc === 'kick_server') terminalOperation = 'kick_server';
if (terminalOperation !== undefined) {
// Existing runtime failure display runs first, but cannot initiate more traffic.
terminalDelivery = true;
try { deliver(event); }
catch (error) { fail(error); return; }
fail(scopeError(terminalOperation), terminalOperation, event);
return;
}
deliver(event);
} catch (error) { fail(error); }
});
return {
subscribe(listener) {
if (stopped || faulted) return () => {};
listeners.add(listener);
return () => { listeners.delete(listener); };
},
start() { assertActive(); wire.start(); },
send(envelope) {
assertActive();
if (envelope.route !== 'agent' || envelope.rpc !== 'player_login') {
throw fail(scopeError(`outbound ${envelope.route}/${envelope.rpc}`), `outbound ${envelope.route}/${envelope.rpc}`);
}
wire.send(envelope);
},
switchServer() { assertActive(); throw fail(scopeError('switchServer'), 'switchServer'); },
reconnectCurrent() { assertActive(); wire.reconnectCurrent(); },
stop,
};
}
@@ -1,9 +0,0 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "96a339e3-f954-4e96-b07f-9fe124d457f0",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -2,7 +2,7 @@
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "6072a7f3-9095-4e71-9dbd-c952d89a64a7",
"uuid": "2d0af0de-962c-48db-a658-3f0396048806",
"files": [],
"subMetas": {},
"userData": {}
@@ -0,0 +1,9 @@
import { _decorator, Component, SpriteFrame } from 'cc';
const {ccclass,property}=_decorator;
/** Serialized frame references keep both original phone-panel modes in the build. */
@ccclass('PhoneLoginAssets')
export class PhoneLoginAssets extends Component {
@property(SpriteFrame) loginTitle:SpriteFrame|null=null;
@property(SpriteFrame) codeWaiting:SpriteFrame|null=null;
}
@@ -2,7 +2,7 @@
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2bbf94d8-6aa1-4b5f-a94c-c3f12205f46c",
"uuid": "1f765b40-c144-4ae2-b1b0-7b6ba99fe17a",
"files": [],
"subMetas": {},
"userData": {}
@@ -0,0 +1,217 @@
import { _decorator, assetManager, Component, Director, Label, Node, Prefab, ProgressBar, SceneAsset,
UITransform, Widget, director, instantiate, isValid } from 'cc';
import { resolveRuntimeConfig, type RuntimeConfig } from '../../framework/config/runtime-config.ts';
import { CocosLocalConfigFetcher } from './local-config-fetcher.ts';
import { HttpConfigFetcher } from '../../framework/config/remote-config-fetcher.ts';
import { resolveLaunchWaitTime } from '../../framework/config/remote-config.ts';
import { resolveLoginPolicy, type LoginPolicy } from '../../framework/presentation/login-policy.ts';
import { LoginAccounts, type LoginNativeSettings } from '../../framework/adapters/native/login-accounts.ts';
import { WebViewJavascriptBridgeAdapter, type WebViewJavascriptBridgeEnvironment } from '../../framework/adapters/native/webview-javascript-bridge.ts';
import { PlatformRuntime } from '../../framework/platform/runtime.ts';
import { WireClient } from '../../framework/net/wire-client.ts';
import { CocosWebSocketTransport } from '../../framework/net/cocos-transport.ts';
import type { DeviceLoginMode, LoginAccountIdentity, LoginDeviceSnapshot } from '../../framework/protocol/contracts/login-contract.ts';
import { createLoginStageGameEntry } from './login-stage-game-entry.ts';
import { CocosLogoCountdownView, createLogoCountdown } from './logo-countdown.ts';
import { requireNode } from './node-bindings.ts';
import { SceneFlow } from './scene-flow.ts';
import { LoginPanel } from './login-panel.ts';
const {ccclass,property}=_decorator;
function required<T>(value:T|null|undefined,name:string):T{if(value==null)throw Error(`PlatformStartup missing ${name}`);return value;}
/** One persistent owner for config/socket/account; scenes own their own visual trees. */
@ccclass('PlatformStartup')
export class PlatformStartup extends Component {
@property(SceneAsset) loadingScene:SceneAsset|null=null;
@property(SceneAsset) loginScene:SceneAsset|null=null;
@property(Prefab) loadingPrefab:Prefab|null=null;
@property(Prefab) loginPrefab:Prefab|null=null;
@property(Prefab) protocolPrefab:Prefab|null=null;
@property(Prefab) phonePrefab:Prefab|null=null;
private runtime:PlatformRuntime|null=null;
private config:RuntimeConfig|null=null;
private policy:LoginPolicy|null=null;
private accounts:LoginAccounts|null=null;
private panel:LoginPanel|null=null;
private bridge:WebViewJavascriptBridgeAdapter|null=null;
private restoreShareLogin:(()=>void)|null=null;
private restoreLocation:(()=>void)|null=null;
private startLocation:(()=>void)|null=null;
private location:unknown=null;
private loginResponse:unknown=null;
private unsubscribe:(()=>void)|null=null;
private countdown:ReturnType<typeof createLogoCountdown>|null=null;
private countdownView:CocosLogoCountdownView|null=null;
private progress:ProgressBar|null=null;
private status:Label|null=null;
private device:LoginDeviceSnapshot|null=null;
private busy=false;
private terminal=false;
private readonly events:Array<{kind:string;data:unknown}>=[];
private readonly flow=new SceneFlow(name=>new Promise<void>((resolve,reject)=>{
if(!director.loadScene(name,error=>error?reject(error):resolve()))reject(Error(`Cannot load scene ${name}`));
}));
/** 常驻服务按加载场景、配置与桥接、连接、登录场景顺序启动;场景仅拥有自己的 UI。 */
async start():Promise<void>{
try{
required(this.loadingScene,'loadingScene');required(this.loginScene,'loginScene');
required(this.loadingPrefab,'loadingPrefab');required(this.loginPrefab,'loginPrefab');
required(this.protocolPrefab,'protocolPrefab');required(this.phonePrefab,'phonePrefab');
director.addPersistRootNode(this.node);
await this.flow.enter(this.loadingScene!.name,()=>this.bindLoading());
if(this.terminal)return;
const injected=window as unknown as {settings?:LoginNativeSettings & {startlocation(type:number):void};app_version?:number|string;app_appversion?:number|string};
const bridgeHost=injected.app_version!==undefined&&Number(injected.app_version)!==0;
const native=bridgeHost?{
getcompareCode:()=>Number(injected.app_appversion),
// uAgent_3 passes the original argument, not its unused local tp variable.
accreditlogin:(provider:number)=>required(this.bridge,'native bridge').call('accreditlogin',provider===2?undefined:provider,()=>{}),
}:injected.settings;
this.config=await resolveRuntimeConfig({hostKind:bridgeHost?'uAgent_3':native?'native-settings':'h5',win:window as any,
search:window.location.search,fetcher:new HttpConfigFetcher(),localFetcher:new CocosLocalConfigFetcher()});
if(this.terminal)return;
this.policy=resolveLoginPolicy(this.config.remoteConfig);
const loginConfig={source:this.config.source,gameserver:this.config.gameserver,identity:this.config.identity,
showVisitor:this.policy.showVisitor,deviceLogin:this.policy.deviceLogin,qqLogin:this.policy.qqLogin};
this.events.push({kind:'login-config',data:loginConfig});
if(this.config.isDebugger)console.info('[PlatformStartup] login-config',JSON.stringify(loginConfig));
this.accounts=new LoginAccounts({storage:window.localStorage,settings:native,random:Math.random,now:Date.now,
gameid:this.config.identity.gameid,agentid:this.config.identity.agentid,policy:this.policy,
submit:(account,mode)=>this.login(account,mode),tip:text=>this.tip(text)});
const previous=(window as any).sharelogin;
const callback=(openid:unknown,headimgurl:unknown,nickname:unknown,sex:unknown,city:unknown,Province:unknown,unionid:unknown)=>{
if(!this.terminal)this.acceptNative({openid,headimgurl,nickname,sex,city,Province,unionid},'global');
};
(window as any).sharelogin=callback;
this.restoreShareLogin=()=>{if((window as any).sharelogin===callback){if(previous===undefined)delete (window as any).sharelogin;else (window as any).sharelogin=previous;}};
const oldLocation=(window as any).getlocationinfo;
const locationCallback=(data:unknown)=>{
if(this.terminal)return;
try{
const value=typeof data==='string'?JSON.parse(data):data;
if(value===null||typeof value!=='object'||Array.isArray(value))throw Error('Invalid getlocationinfo payload');
this.location=value;
}catch(error){this.tip(error instanceof Error?error.message:String(error));}
};
(window as any).getlocationinfo=locationCallback;
this.restoreLocation=()=>{if((window as any).getlocationinfo===locationCallback){if(oldLocation===undefined)delete (window as any).getlocationinfo;else (window as any).getlocationinfo=oldLocation;}};
if(native){
this.bridge=new WebViewJavascriptBridgeAdapter(window as unknown as WebViewJavascriptBridgeEnvironment);
this.bridge.register('sharelogin',data=>this.acceptNative(data,'bridge'));
this.bridge.register('getlocationinfo',locationCallback);
this.startLocation=()=>{if(bridgeHost)required(this.bridge,'native bridge').call('startlocation',2,()=>{});
else required(injected.settings,'settings').startlocation(2);};
}
required(this.countdown,'countdown').start(resolveLaunchWaitTime(this.config.remoteConfig));
this.runtime=new PlatformRuntime({gameEntry:createLoginStageGameEntry(this.config.identity.gameid,event=>this.events.push(event)),
resolveRuntimeConfig:async()=>required(this.config,'config'),
createWireClient:config=>{
const wire=new WireClient({servers:config.servers,transportFactory:()=>new CocosWebSocketTransport()});
wire.subscribe(event=>{this.events.push({kind:'wire',data:event.type});
if(event.type==='message'&&event.message.route==='agent'&&event.message.rpc==='player_login')this.loginResponse=event.message.data;
});return wire;
},
scene:{showLoading:()=>{},showLogin:()=>{void this.showLogin().catch(error=>this.fail(error));},
showLobby:()=>{try{required(this.accounts,'accounts').loginSucceeded(required(this.loginResponse,'login response'),required(this.device,'prepared login device').deviceLogin);
this.busy=false;this.panel?.setBusy(false);this.panel?.close();this.tip('登录成功');}catch(error){this.fail(error);}},
showRoom:()=>this.fail(Error('当前入口尚未接入子游戏场景')),
showReconnect:()=>this.tip('连接中断,正在重连…'),
showKicked:data=>this.fail(Error(`账号退出:${JSON.stringify(data)}`)),
showServerDenial:outcome=>{this.busy=false;this.panel?.setBusy(false);this.tip(JSON.stringify(outcome.data));},
showFatal:error=>this.fail(error)},
loadResources:()=>this.loadLoginResources(),
waitForMinimumDisplay:()=>required(this.countdown,'countdown').promise,
getLoginDeviceSnapshot:()=>required(this.device,'prepared login device'),
});
this.unsubscribe=this.runtime.subscribeState(state=>this.countdown?.socketReady(state.app.phase==='connected'||state.app.phase==='logged-in'),error=>this.fail(error));
await this.runtime.start();
}catch(error){this.fail(error);}
}
private canvas():Node{return required(director.getScene(),'scene').getChildByName('Canvas') ?? (()=>{throw Error('Scene requires Canvas');})();}
private createStatus():void{
const canvas=this.canvas();const node=new Node('Status');node.parent=canvas;node.layer=canvas.layer;
node.setPosition(0,315,0);node.addComponent(UITransform).setContentSize(1200,60);
this.status=node.addComponent(Label);this.status.string='';this.status.fontSize=24;this.status.lineHeight=28;
}
private async bindLoading():Promise<void>{
const logo=instantiate(required(this.loadingPrefab,'loadingPrefab'));logo.parent=this.canvas();logo.setPosition(0,0,0);
this.createStatus();requireNode(logo,'group-32').active=false;
this.progress=required(requireNode(logo,'group-1/Spirit254').getComponent(ProgressBar),'Logo ProgressBar');
const labels=logo.getComponentsInChildren(Label).filter(label=>label.node.name==='Spirit3104');
if(labels.length!==1)throw Error('Logo requires one Spirit3104');
this.countdownView=new CocosLogoCountdownView(requireNode(logo,'group-1/Spirit3103'),labels[0].node,()=>this.countdown?.skip());
this.countdown=createLogoCountdown({now:()=>performance.now(),schedule:(callback,ms)=>{const id=window.setTimeout(callback,ms);return ()=>window.clearTimeout(id);},
render:state=>this.countdownView?.render(state)});
await new Promise<void>(resolve=>director.once(Director.EVENT_AFTER_DRAW,resolve));
this.events.push({kind:'scene',data:'Loading'});
}
private async loadLoginResources():Promise<void>{
const prefab=required(this.loginPrefab,'loginPrefab');
await new Promise<void>((resolve,reject)=>assetManager.loadAny(prefab.uuid,(done,total)=>{
if(!this.terminal&&this.progress&&total>0)this.progress.progress=done/total;
},error=>error?reject(error):resolve()));
if(this.terminal)throw Error('Startup stopped');
this.countdown?.resourcesReady();required(this.progress,'progress').node.active=false;
}
/** 完成真实场景切换后再绑定按钮和尝试缓存登录;弹窗挂在独立 PopupRoot。 */
private async showLogin():Promise<void>{
this.countdownView?.dispose();this.countdownView=null;
await this.flow.enter(required(this.loginScene,'loginScene').name,()=>{
const root=instantiate(required(this.loginPrefab,'loginPrefab'));root.parent=this.canvas();root.setPosition(0,0,0);
const popup=new Node('PopupRoot');popup.parent=this.canvas();popup.layer=root.layer;popup.addComponent(UITransform).setContentSize(1600,720);
this.createStatus();this.panel=new LoginPanel(root,popup,required(this.protocolPrefab,'protocolPrefab'),required(this.phonePrefab,'phonePrefab'),
required(this.accounts,'accounts'),required(this.policy,'policy'),message=>this.tip(message),
()=>!this.busy&&!this.terminal&&this.runtime?.state.app.phase==='connected');
const version=required(requireNode(root,'group-2/版本显示').getComponent(Label),'version Label');
// Beta 展示格式使用当前渠道版本号;只改变显示,不改变登录协议中的数值版本。
version.string=`v${required(this.config,'config').identity.version}.0.0 Beta`;
// Label 自动尺寸更新后重新计算左上角 Widget,避免长版本文字向屏幕外扩展。
required(version.node.getComponent(UITransform),'version UITransform').setAnchorPoint(0,1);
version.updateRenderData(true);
const versionWidget=required(version.node.getComponent(Widget),'version Widget');
versionWidget.target=this.canvas();
versionWidget.updateAlignment();
this.events.push({kind:'scene',data:'Login'});
// Legacy location acquisition is independent of login. Keep the source null
// until a real callback arrives; report host failure without stopping the socket.
try{this.startLocation?.();}catch(error){this.tip(`定位接口调用失败:${error instanceof Error?error.message:String(error)}`);}
if(this.accounts!.cached())this.accounts!.autoLogin();
});
}
private acceptNative(data:unknown,channel:'global'|'bridge'):void{
if(this.terminal)return;
try{required(this.accounts,'accounts').acceptNative(data,channel);}catch(error){this.tip(error instanceof Error?error.message:String(error));}
}
/** 所有登录最终收口:再次校验协议与就绪状态,保存本次模式供登录响应使用。 */
private login(account:LoginAccountIdentity,mode:DeviceLoginMode):void{
const runtime=required(this.runtime,'runtime');
if(this.busy||this.terminal)return;
if(!required(this.accounts,'accounts').requireAgreement())return;
if(!runtime.ready)throw Error('登录资源或连接尚未就绪');
const storage=window.localStorage;
let machineid=storage.getItem('tsgame_machineId_');
if(machineid===null){machineid=`${Date.now()}-${1000+Math.floor(Math.random()*8999)}`;storage.setItem('tsgame_machineId_',machineid);}
const host=window as unknown as {returnCitySN?:{ip:string;province:string;city:string}};
const city=host.returnCitySN;
if(city!==undefined&&(!city||typeof city.ip!=='string'||typeof city.province!=='string'||typeof city.city!=='string'))throw Error('Invalid returnCitySN');
this.device={location:this.location,machineid,machineroom:'',deviceLogin:mode,loginPlayerId:{enabled:false},...(city?{ip:city.ip}:{})};
this.busy=true;this.panel?.setBusy(true);
try{runtime.login(city?{...account,province:city.province,city:city.city}:account);}
catch(error){this.busy=false;this.panel?.setBusy(false);throw error;}
}
private tip(text:string):void{if(this.status&&isValid(this.status))this.status.string=text;}
private fail(error:unknown):void{
this.tip(error instanceof Error?error.message:String(error));console.error('[PlatformStartup]',error);
this.release();
}
/** 幂等释放计时器、订阅和宿主回调,阻止销毁后的迟到消息重新激活流程。 */
private release():void{
if(this.terminal)return;this.terminal=true;this.flow.dispose();this.panel?.dispose();
this.countdown?.cancel();this.countdownView?.dispose();this.unsubscribe?.();this.bridge?.dispose();
this.restoreShareLogin?.();this.restoreLocation?.();this.runtime?.stop();
}
onDestroy():void{this.release();}
getDiagnostics(){return {events:[...this.events],ready:this.runtime?.ready,busy:this.busy,terminal:this.terminal};}
}
@@ -2,7 +2,7 @@
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "aea065db-0dfa-4da8-a2cb-f1fe0165d910",
"uuid": "6dba829e-bbd7-4da8-bd48-2f214eeccc93",
"files": [],
"subMetas": {},
"userData": {}
@@ -0,0 +1,29 @@
import { resources, TextAsset } from 'cc';
import { DEBUG } from 'cc/env';
import { ConfigFetchError, ConfigParseError, type ConfigFetcher } from '../../framework/config/remote-config.ts';
/**
* 开发专用配置读取器。使用 Cocos 资源数据库读取项目 txt,支持预览和调试构建。
* 路径由 profiles.ts 唯一指定;读取失败绝不改用远程,以免测试了错误的数据。
*/
export class CocosLocalConfigFetcher implements ConfigFetcher {
async fetch(resourcePath: string): Promise<unknown> {
// 除启动配置的 debug 限制外,发布构建也在引擎边界禁止加载开发配置。
if (!DEBUG) throw new ConfigFetchError('本地配置仅允许开发预览或调试构建');
const asset = await new Promise<TextAsset>((resolve, reject) => {
resources.load(resourcePath, TextAsset, (error, result) => {
if (error) reject(new ConfigFetchError(`本地配置读取失败: ${resourcePath}.txt: ${error.message}`));
else if (!result) reject(new ConfigFetchError(`本地配置资源为空: ${resourcePath}.txt`));
else resolve(result);
});
});
try {
return JSON.parse(asset.text) as unknown;
} catch {
throw new ConfigParseError(`本地配置 JSON 解析失败: ${resourcePath}.txt`);
} finally {
// 内容已转为普通数据;下次启动重新加载,避免沿用旧 TextAsset 缓存。
resources.release(resourcePath, TextAsset);
}
}
}
@@ -2,7 +2,7 @@
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2597060c-ad16-46f1-89ed-42bfaec74ad9",
"uuid": "97325d47-c809-4a38-9e1a-520ee2a3d863",
"files": [],
"subMetas": {},
"userData": {}
@@ -0,0 +1,166 @@
import { Animation, assetManager, BlockInputEvents, Color, EditBox, ImageAsset, Label, Node, Prefab, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform, Widget, instantiate, isValid } from 'cc';
import type { LoginAccounts } from '../../framework/adapters/native/login-accounts.ts';
import { loginButtons, type LoginPolicy } from '../../framework/presentation/login-policy.ts';
import { requireNode } from './node-bindings.ts';
import { PhoneLoginAssets } from './PhoneLoginAssets.ts';
/** 登录场景节点与弹窗的生命周期管理;协议状态和账户行为由 LoginAccounts 唯一持有。 */
export class LoginPanel {
private readonly releases:Array<()=>void>=[];
private modal:Node|null=null;
private modalReleases:Array<()=>void>=[];
private disposed=false;
private readonly loadingAnimation:Animation;
constructor(readonly root:Node,private readonly popupRoot:Node,
private readonly protocol:Prefab,private readonly phone:Prefab,
private readonly accounts:LoginAccounts,policy:LoginPolicy,
private readonly tip:(message:string)=>void,private readonly canAct:()=>boolean){
const group=requireNode(root,'group-2');
const animation=requireNode(group,'Logining/登录中动画').getComponent(Animation);
if(!animation||!animation.defaultClip)throw Error('登录中动画 requires Animation and default clip');
this.loadingAnimation=animation;
const state=loginButtons(policy,accounts.versionState,accounts.cached()!==null);
const names={wechat:'微信登录',visitor:'游客登录',phone:'手机登录',quick:'快速登录',qq:'qq登录'};
for(const key of Object.keys(names) as Array<keyof typeof names>)requireNode(group,names[key]).active=state[key];
// JumpWxAuth uses legacy 1280-wide coordinates; prefab coordinates are centered.
const left=(name:string,x:number,width?:number,height?:number)=>{
const node=requireNode(group,name),ui=node.getComponent(UITransform);if(!ui)throw Error(`${name} requires UITransform`);
const widget=node.getComponent(Widget);if(widget)widget.enabled=false;
if(width!==undefined&&height!==undefined)ui.setContentSize(width,height);
node.setPosition(x-640+ui.width*ui.anchorX,node.position.y,node.position.z);
};
if(policy.deviceLogin)left('手机登录',490);
if(accounts.versionState===1){requireNode(group,'渠道logo').active=false;left('微信登录',113,320,98);left('游客登录',488);}
else left('微信登录',415);
// Both switches may be enabled by the remote hierarchy. Keep both actions
// visible, with the user-requested side-by-side layout instead of overlap.
if(state.visitor&&state.phone){
for(const [name,x] of [['游客登录',-210],['手机登录',210]] as const){
const node=requireNode(group,name),widget=node.getComponent(Widget);if(widget)widget.enabled=false;
node.setPosition(x,node.position.y,node.position.z);
}
}
// 登录中展示节点统一位于 prefab 的 Logining 分组,路径必须与资源一致。
const logining=requireNode(group,'Logining');
for(const name of ['登录头像背景框','登录中头像','登录中昵称','登录中动画','登录中头像框'])requireNode(logining,name).active=state.account;
this.setBusy(state.account);
const cached=accounts.cached();if(cached){const label=requireNode(logining,'登录中昵称').getComponent(Label);if(!label)throw Error('登录中昵称 requires Label');label.string=cached.nickname;}
if(cached?.avatar){
const sprite=requireNode(logining,'登录中头像').getComponent(Sprite);if(!sprite)throw Error('登录中头像 requires Sprite');
assetManager.loadRemote<ImageAsset>(cached.avatar,{ext:'.png'},(error,image)=>{
if(this.disposed||!isValid(sprite))return;
if(error){this.tip(`登录头像加载失败:${error.message}`);return;}
const texture=new Texture2D();texture.image=image;const frame=new SpriteFrame();frame.texture=texture;sprite.spriteFrame=frame;
this.releases.push(()=>{if(isValid(sprite))sprite.spriteFrame=null;frame.destroy();texture.destroy();});
});
}
const check=requireNode(group,'协议确认/check');check.active=accounts.agreed;
this.bind(requireNode(group,'协议确认'),()=>{accounts.setAgreement(!accounts.agreed);check.active=accounts.agreed;});
this.bind(requireNode(group,'协议内容'),()=>this.openProtocol());
this.bind(requireNode(group,'微信登录'),()=>accounts.authorize('wechat'));
this.bind(requireNode(group,'qq登录'),()=>accounts.authorize('qq'));
this.bind(requireNode(group,'游客登录'),()=>accounts.visitor());
this.bind(requireNode(group,'快速登录'),()=>accounts.device());
this.bind(requireNode(group,'手机登录'),()=>this.openPhone());
this.bind(requireNode(group,'隐藏快速登录-1'),()=>accounts.hiddenFirst());
this.bind(requireNode(group,'隐藏快速登录-2'),()=>accounts.hiddenSecond());
}
private bind(node:Node,action:()=>void,modal=false){
const listener=()=>{if(this.disposed||!this.canAct())return;try{action();}catch(error){this.tip(error instanceof Error?error.message:String(error));}};
node.on(Node.EventType.TOUCH_END,listener);
(modal?this.modalReleases:this.releases).push(()=>node.off(Node.EventType.TOUCH_END,listener));
}
private open(prefab:Prefab):Node|null{
if(this.modal!==null)return null;
const modal=instantiate(prefab);modal.parent=this.popupRoot;modal.setPosition(0,0,0);modal.active=true;
this.modal=modal;return modal;
}
/** 协议弹窗独立实例化,每次打开回到顶部,关闭时解除监听并销毁实例。 */
openProtocol():void{
const modal=this.open(this.protocol);if(!modal)return;
const scroll=requireNode(modal,'group-37/协议滚动').getComponent(ScrollView);
if(!scroll)throw Error('协议滚动 requires ScrollView');scroll.scrollToTop(0);
for(const name of ['Spirit253','协议遮罩'])this.bind(requireNode(modal,`group-37/${name}`),()=>this.close(),true);
}
/** 沿用原工程输入与验证码倒计时规则;弹窗不另存协议勾选状态。 */
openPhone():void{
if(!this.accounts.requireAgreement())return;
const modal=this.open(this.phone);if(!modal)return;
const group=requireNode(modal,'group-106');
requireNode(modal,'group-108').active=false;
const frames=modal.getComponent(PhoneLoginAssets);
if(!frames?.loginTitle||!frames.codeWaiting)throw Error('PhoneLoginAssets requires loginTitle/codeWaiting');
const title=requireNode(group,'Spirit3252').getComponent(Sprite);if(!title)throw Error('Spirit3252 requires Sprite');title.spriteFrame=frames.loginTitle;
const saved=this.accounts.phonePanelState();
const input=(name:string,textName:string,placeholderName:string)=>{
const node=requireNode(group,name),labelNode=requireNode(group,textName);
const placeholder=requireNode(group,placeholderName);
const glyph=labelNode.getComponent(UITransform);if(!glyph)throw Error(`${textName} requires UITransform`);
// Existing art remains the input background; native EditBox owns text entry.
const ui=node.getComponent(UITransform);if(!ui)throw Error(`${name} requires UITransform`);
const text=new Node('InputText');text.parent=node;text.layer=node.layer;
const textUi=text.addComponent(UITransform);textUi.setAnchorPoint(0,0.5);textUi.setContentSize(ui.width-85,ui.height);
text.setPosition(-ui.width/2+65,0,0);
const inputLabel=text.addComponent(Label);inputLabel.string='';inputLabel.fontSize=glyph.height;
inputLabel.horizontalAlign=Label.HorizontalAlign.LEFT;inputLabel.overflow=Label.Overflow.CLAMP;inputLabel.color=Color.BLACK;
const hint=new Node('Placeholder');hint.parent=node;hint.layer=node.layer;
hint.addComponent(UITransform).setContentSize(ui.width-20,ui.height);
const hintLabel=hint.addComponent(Label);hintLabel.string='';hintLabel.fontSize=glyph.height;
// Configure before onLoad so EditBox does not create orphan default labels.
labelNode.active=false;node.active=false;
const edit=node.addComponent(EditBox);edit.textLabel=inputLabel;edit.placeholderLabel=hintLabel;
edit.string='';edit.placeholder='';
edit.inputMode=EditBox.InputMode.NUMERIC;edit.maxLength=18;
const changed=()=>{edit.string=edit.string.replace(/\D/g,'');placeholder.active=edit.string.length===0;};
node.on(EditBox.EventType.TEXT_CHANGED,changed);this.modalReleases.push(()=>node.off(EditBox.EventType.TEXT_CHANGED,changed));
changed();node.active=true;
const alignText=()=>{
textUi.setAnchorPoint(0,1);textUi.setContentSize(ui.width-85,ui.height);
text.setPosition(-ui.width/2+65,ui.height/2,0);inputLabel.verticalAlign=Label.VerticalAlign.CENTER;
};
alignText();node.on(Node.EventType.SIZE_CHANGED,alignText);
this.modalReleases.push(()=>node.off(Node.EventType.SIZE_CHANGED,alignText));return edit;
};
const phone=input('Spirit3243','Spirit3247','Spirit3249');
const code=input('Spirit3244','Spirit3248','Spirit3250');
if(saved?.phoneNumber){phone.string=saved.phoneNumber;requireNode(group,'Spirit3249').active=false;}
const codeButton=requireNode(group,'Spirit3245'),codeSprite=codeButton.getComponent(Sprite);
if(!codeSprite||!codeSprite.spriteFrame)throw Error('Spirit3245 requires SpriteFrame');
const idleFrame=codeSprite.spriteFrame;
const timerNode=requireNode(group,'Spirit3246'),timerSprite=timerNode.getComponent(Sprite);
if(!timerSprite)throw Error('Spirit3246 requires Sprite');timerSprite.enabled=false;
const timerWidget=timerNode.getComponent(Widget);if(timerWidget)timerWidget.enabled=false;
timerNode.setPosition(codeButton.position);timerNode.getComponent(UITransform)!.setContentSize(148,28);
const timerText=new Node('CountdownText');timerText.parent=timerNode;timerText.layer=timerNode.layer;
timerText.addComponent(UITransform).setContentSize(148,28);
const timerLabel=timerText.addComponent(Label);timerLabel.fontSize=28;timerLabel.color=Color.BLACK;
let restTime=saved===null?0:saved.restTime;
let waiting=restTime>0;
const renderTimer=()=>{timerNode.active=waiting;timerLabel.string=`${restTime}秒`;codeSprite.spriteFrame=waiting?frames.codeWaiting:idleFrame;};
renderTimer();
if(waiting){const timer=window.setInterval(()=>{restTime--;if(restTime<0){waiting=false;window.clearInterval(timer);}renderTimer();},1000);
this.modalReleases.push(()=>window.clearInterval(timer));}
this.bind(codeButton,()=>{if(restTime<=0&&phone.string)this.tip('请通过相关渠道获取');},true);
this.bind(requireNode(group,'Spirit3253'),()=>{if(!this.accounts.requireAgreement())return;
if(!phone.string||!code.string){this.tip('手机号与验证码均不能为空!');return;}
this.accounts.phone(phone.string,code.string);this.close();},true);
for(const name of ['Spirit3251','Spirit3241'])this.bind(requireNode(group,name),()=>this.close(),true);
const background=requireNode(group,'Spirit3242');if(!background.getComponent(BlockInputEvents))background.addComponent(BlockInputEvents);
}
/** 帧切换由 prefab 的 AnimationClip 驱动;登录状态只负责显隐及播放生命周期。 */
setBusy(busy:boolean):void{
const group=requireNode(this.root,'group-2/Logining');
group.active=busy;
for(const name of ['登录中遮罩','登录头像背景框','登录中头像','登录中昵称','登录中动画','登录中头像框'])requireNode(group,name).active=busy;
if(busy){
const clip=this.loadingAnimation.defaultClip!;
if(!this.loadingAnimation.getState(clip.name)?.isPlaying)this.loadingAnimation.play(clip.name);
}else this.loadingAnimation.stop();
}
/** 清理弹窗事件和验证码计时器,避免反复打开时累积回调。 */
close():void{
this.modalReleases.splice(0).forEach(release=>release());
if(this.modal&&isValid(this.modal))this.modal.destroy();this.modal=null;
}
dispose():void{this.setBusy(false);this.disposed=true;this.close();this.releases.splice(0).forEach(release=>release());}
}
@@ -2,7 +2,7 @@
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2c6ce55f-b5a3-4503-bc65-ca12c5537c8f",
"uuid": "e79a22bd-cbb6-41d0-a2ca-6ffb7e340f38",
"files": [],
"subMetas": {},
"userData": {}
@@ -1,8 +1,12 @@
import type { GameEntry } from '../../framework/sdk/contracts/game-entry.ts';
import type { EvidenceSink } from './local-login-transport.ts';
/** 登录阶段的诊断事件,仅供入口记录生命周期和越界操作。 */
export type LoginEntryEvent = { kind: string; data: unknown };
type EvidenceSink = (event: LoginEntryEvent) => void;
/** Satisfies entry validation while making every actual game operation a scope error. */
export function createDiagnosticGameEntry(gameId: string | number, record: EvidenceSink): GameEntry {
/** 登录阶段尚未接入子游戏:满足运行时入口契约,但任何游戏操作均显式拒绝。
* 接入真实子游戏时应替换此入口,不能把占位实现当作大厅或房间支持。
*/
export function createLoginStageGameEntry(gameId: string | number, record: EvidenceSink): GameEntry {
let nextInstanceId = 0;
function violation(operation: string, instanceId?: number): never {
record({ kind: 'scope-violation', data: { operation, instanceId } });
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "57db0201-3b1b-482e-b783-f19e5e9a47a7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,116 @@
import { Label, Node, UITransform, Vec3, Widget } from 'cc';
/** Logo 展示状态;倒计时只控制最短展示时间,资源和连接就绪由运行时独立管理。 */
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')); },
};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1baca79a-2862-41de-b01e-616eb90baf04",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,8 @@
import { Node } from 'cc';
/** 按迁移资源的固定路径取节点;绑定缺失直接报错,避免把资源错误掩盖成按钮无响应。 */
export function requireNode(root: Node, path: string): Node {
const node = root.getChildByPath(path);
if (node === null) throw new Error(`Platform login binding missing node: ${path}`);
return node;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "a7ceb35e-66c2-4ecf-ba76-3e8ee1aac9a0",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,18 @@
/** Serializes scene lifetime; services are owned by the persistent startup component. */
export class SceneFlow {
private pending:Promise<void>=Promise.resolve();
private disposed=false;
constructor(private readonly load:(name:string)=>Promise<void>){}
/** 串行加载后绑定新场景;等待期间已销毁则禁止继续绑定。 */
enter(name:string,bind:()=>void|Promise<void>):Promise<void>{
const next=this.pending.then(async()=>{
if(this.disposed)throw Error('SceneFlow disposed');
await this.load(name);
if(this.disposed)throw Error('SceneFlow disposed');
await bind();
});
this.pending=next;
return next;
}
dispose():void{this.disposed=true;}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5fb154af-f704-44d3-9786-640db8ac3051",
"files": [],
"subMetas": {},
"userData": {}
}