feat(presentation): wire platform scenes and UI commands

This commit is contained in:
2026-09-05 12:47:15 +08:00
parent 8b7efa7de0
commit d29a659541
4 changed files with 860 additions and 16 deletions
@@ -11,7 +11,7 @@ class FrameRendererFault extends Error {
}
export class FrameRenderer {
private pending: (() => void) | null = null;
private pending: { cancel: (() => void) | null } | null = null;
private generation = 0;
private disposed = false;
@@ -25,28 +25,45 @@ export class FrameRenderer {
if (this.disposed || this.pending !== null) return;
const generation = this.generation;
this.pending = this.scheduler.request(() => {
if (this.disposed || generation !== this.generation) return;
this.pending = null;
this.generation += 1;
try {
this.render();
} catch (error) {
let fault: unknown = error;
// Reserve ownership before registration, which may reenter flush or dispose.
const pending: { cancel: (() => void) | null } = { cancel: null };
this.pending = pending;
let cancel: () => void;
try {
cancel = this.scheduler.request(() => {
if (this.disposed || generation !== this.generation) return;
this.pending = null;
this.generation += 1;
try {
this.dispose();
} catch (cancelError) {
fault = new FrameRendererFault([error, cancelError]);
this.render();
} catch (error) {
let fault: unknown = error;
try {
this.dispose();
} catch (cancelError) {
fault = new FrameRendererFault([error, cancelError]);
}
this.onFault(fault);
}
this.onFault(fault);
});
} catch (error) {
if (this.pending === pending) {
this.pending = null;
this.generation += 1;
}
});
throw error;
}
if (this.disposed || generation !== this.generation) {
cancel();
return;
}
pending.cancel = cancel;
}
public flush(): void {
if (this.disposed) return;
const cancel = this.pending;
const cancel = this.pending === null ? null : this.pending.cancel;
this.pending = null;
this.generation += 1;
const generation = this.generation;
@@ -59,7 +76,7 @@ export class FrameRenderer {
if (this.disposed) return;
this.disposed = true;
const cancel = this.pending;
const cancel = this.pending === null ? null : this.pending.cancel;
this.pending = null;
this.generation += 1;
if (cancel !== null) cancel();
@@ -0,0 +1,211 @@
import type { LoginAccountIdentity } from '../protocol/contracts/index.ts';
import type { JoinRoomCommand } from '../platform/commands.ts';
import type { ScenePort } from '../platform/scene-port.ts';
import type { ServerDenial } from '../platform/runtime-session.ts';
import type { PlatformState } from '../platform/stores/platform-types.ts';
import type { FrameScheduler, OverlayModel, PageId, PlatformViewPort, UiRuntime } from './ui-contracts.ts';
import { selectPageModel } from './page-model.ts';
import { FrameRenderer } from './frame-renderer.ts';
class PlatformUiFault extends Error {
readonly errors: readonly unknown[];
constructor(errors: readonly unknown[]) {
super('PlatformUiController operation and cleanup failed');
this.name = 'PlatformUiFault';
this.errors = Object.freeze([...errors]);
}
}
function collectedFault(errors: readonly unknown[]): unknown {
return errors.length === 1 ? errors[0] : new PlatformUiFault(errors);
}
function throwCollected(errors: readonly unknown[]): void {
if (errors.length > 0) throw collectedFault(errors);
}
export class PlatformUiController implements ScenePort {
private runtime: UiRuntime | null = null;
private disposed = false;
private terminal = false;
private faultReported = false;
private transition = 0;
private overlay: OverlayModel = { kind: 'none' };
private page: PageId | null = null;
private unsubscribe: (() => void) | null = null;
private readonly renderer: FrameRenderer;
constructor(
private readonly view: PlatformViewPort,
scheduler: FrameScheduler,
private readonly onFault: (error: unknown) => void,
) {
this.renderer = new FrameRenderer(scheduler, () => this.renderCurrent(), error => this.handleFault(error));
}
connect(runtime: UiRuntime): void {
if (this.disposed) throw new Error('PlatformUiController is disposed');
if (this.runtime !== null) throw new Error('PlatformUiController already connected');
this.runtime = runtime;
try {
this.unsubscribe = runtime.subscribeState(state => this.stateChanged(state), error => this.handleFault(error));
this.requireTransition(this.transition);
this.stateChanged(runtime.state);
this.requireTransition(this.transition);
} catch (error) {
this.terminal = true;
throw collectedFault([error, ...this.stopUpdates()]);
}
}
showLoading(): void { this.showPage('loading'); }
showLogin(): void { this.showPage('login'); }
showLobby(): void { this.showPage('lobby'); }
showRoom(): void { this.showPage('room'); }
showReconnect(): void {
if (this.stopped) return;
this.requireRuntime();
this.setOverlay({ kind: 'reconnect' });
}
showServerDenial(outcome: ServerDenial): void {
if (this.stopped) return;
this.requireRuntime();
this.setOverlay({ kind: 'denial', outcome });
}
showKicked(data: unknown): void {
if (this.stopped) return;
this.showTerminal({ kind: 'kicked', data });
}
showFatal(error: Error): void {
if (this.disposed || this.overlay.kind === 'fatal') return;
this.showTerminal({ kind: 'fatal', error });
}
dismissDenial(): void {
this.requireRuntime();
if (this.overlay.kind === 'denial') this.setOverlay({ kind: 'none' });
}
login(account: LoginAccountIdentity): void {
const runtime = this.requireRuntime();
if (this.page !== 'login') throw new Error('login page is not open');
if (!runtime.ready) throw new Error('Runtime is not ready for login');
runtime.login(account);
}
joinRoom(command: JoinRoomCommand): void {
const runtime = this.requireRuntime();
if (this.page !== 'lobby') throw new Error('joinRoom requires the lobby page');
runtime.joinRoom(command);
}
prepare(): void {
const runtime = this.requireRuntime();
if (this.page !== 'room') throw new Error('prepare requires the room page');
const model = selectPageModel('room', runtime.state, runtime.ready);
if (model.page !== 'room' || !model.canPrepare) throw new Error('Cannot prepare');
runtime.prepare();
}
exitRoom(): void {
const runtime = this.requireRuntime();
if (this.page !== 'room') throw new Error('exit requires the room page');
const model = selectPageModel('room', runtime.state, runtime.ready);
if (model.page !== 'room' || !model.canExit) throw new Error('Cannot exit');
runtime.exitRoom();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
throwCollected(this.stopUpdates());
}
private stopUpdates(): unknown[] {
const errors: unknown[] = [];
const unsubscribe = this.unsubscribe;
this.unsubscribe = null;
try { this.renderer.dispose(); } catch (error) { errors.push(error); }
if (unsubscribe !== null) {
try { unsubscribe(); } catch (error) { errors.push(error); }
}
return errors;
}
private showPage(page: PageId): void {
if (this.stopped) return;
this.requireRuntime();
const transition = ++this.transition;
this.page = page;
this.renderer.flush();
this.requireTransition(transition);
this.view.activate(page);
this.requireTransition(transition);
if (this.overlay.kind !== 'none') {
this.setOverlay({ kind: 'none' });
this.requireTransition(transition);
}
}
private renderCurrent(): void {
if (this.stopped || this.page === null) return;
const runtime = this.requireRuntime();
this.view.render(selectPageModel(this.page, runtime.state, runtime.ready));
}
private requireRuntime(): UiRuntime {
if (this.disposed) throw new Error('PlatformUiController is disposed');
if (this.terminal) throw new Error('PlatformUiController is terminal');
if (this.runtime === null) throw new Error('PlatformUiController is not connected');
return this.runtime;
}
private get stopped(): boolean { return this.disposed || this.terminal; }
private requireTransition(transition: number): void {
if (this.stopped || transition !== this.transition) {
throw new Error('PlatformUiController lifecycle interrupted page transition');
}
}
private setOverlay(model: OverlayModel): void {
this.overlay = model;
this.view.showOverlay(model);
}
private showTerminal(model: Extract<OverlayModel, { kind: 'fatal' | 'kicked' }>): void {
this.terminal = true;
this.overlay = model;
const errors = this.stopUpdates();
// Cleanup can synchronously dispose the Presenter or promote kick to fatal.
if (!this.disposed && this.overlay === model) {
try { this.view.showOverlay(model); } catch (error) { errors.push(error); }
}
throwCollected(errors);
}
private stateChanged(state: PlatformState): void {
if (this.stopped) return;
if (this.overlay.kind === 'reconnect'
&& (state.app.phase === 'connected' || state.app.phase === 'logged-in')) {
this.setOverlay({ kind: 'none' });
if (this.stopped) return;
}
if (this.page !== null) this.renderer.request();
}
private handleFault(error: unknown): void {
if (this.faultReported) return;
this.faultReported = true;
this.terminal = true;
const fault = collectedFault([error, ...this.stopUpdates()]);
try { this.onFault(fault); } catch (callbackError) {
throw collectedFault([fault, callbackError]);
}
}
}