feat(presentation): wire platform scenes and UI commands
This commit is contained in:
@@ -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();
|
||||
|
||||
+211
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user