feat: add local platform login Cocos scene and view
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"ver": "1.1.50",
|
||||
"importer": "scene",
|
||||
"imported": true,
|
||||
"uuid": "67e69aac-9982-4e78-a44c-1d9c42f016ae",
|
||||
"files": [
|
||||
".json"
|
||||
],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "6072a7f3-9095-4e71-9dbd-c952d89a64a7",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { _decorator, Component, Label, Node, Prefab, Sprite, UITransform, instantiate, isValid, sys } from 'cc';
|
||||
import { DEBUG } from 'cc/env';
|
||||
import { HttpConfigFetcher } from '../../framework/config/remote-config-fetcher.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, 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')
|
||||
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 spinner: Node | null = null;
|
||||
private loginInstance: Node | null = null;
|
||||
private stopped = false;
|
||||
private firstDrawAt: number | null = null;
|
||||
private version: number | 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');
|
||||
this.spinner = requireNode(loadingRoot, 'Layer614_Loading/group-40/载入动画');
|
||||
this.validateSprites(loadingRoot);
|
||||
if (this.spinner.getComponent(UITransform) === null || this.spinner.getComponent(Sprite) === null) {
|
||||
throw new Error('Loading spinner requires UITransform and Sprite');
|
||||
}
|
||||
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: { mode: 'debug', 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(),
|
||||
loadResources: () => this.loadResources(),
|
||||
waitForMinimumDisplay: async () => {
|
||||
await firstDraw;
|
||||
const drawnAt = this.required(this.firstDrawAt, 'loading first draw');
|
||||
await this.delay(Math.max(0, 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),
|
||||
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 {
|
||||
if (event.kind === 'startup-config') this.version = (event.data as RuntimeConfig).identity.version;
|
||||
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 prefab = this.required(this.loginPrefab, 'loginPrefab');
|
||||
const loading = this.required(this.loadingPrefab, 'loadingPrefab');
|
||||
if (!isValid(prefab) || !isValid(prefab.data) || !isValid(loading) || !isValid(loading.data)) {
|
||||
throw new Error('Local login serialized 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);
|
||||
});
|
||||
}
|
||||
|
||||
update(deltaSeconds: number): void {
|
||||
if (!this.stopped && this.spinner !== null && this.spinner.activeInHierarchy) this.spinner.angle -= deltaSeconds * 180;
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
this.stopped = true;
|
||||
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, spinnerAngle: this.spinner?.angle });
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2597060c-ad16-46f1-89ed-42bfaec74ad9",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "aea065db-0dfa-4da8-a2cb-f1fe0165d910",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2c6ce55f-b5a3-4503-bc65-ca12c5537c8f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2bbf94d8-6aa1-4b5f-a94c-c3f12205f46c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { Director, Label, Node, Sprite, UITransform, 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 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); };
|
||||
} };
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5af2497c-15f2-4493-be9b-a6391d25b878",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "96a339e3-f954-4e96-b07f-9fe124d457f0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user