0.3.1-0-pre.3
This commit is contained in:
54
crates/apps/game-snake-poc-tauri/frontend/ts/assets.ts
Normal file
54
crates/apps/game-snake-poc-tauri/frontend/ts/assets.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// file: crates/apps/game-snake-poc-tauri/frontend/ts/assets.ts
|
||||
// version: 1
|
||||
|
||||
export interface SnakeTauriAssets {
|
||||
engineGeneration: number;
|
||||
game: string;
|
||||
gameKind: string;
|
||||
}
|
||||
|
||||
interface CommonRuntimeAsset {
|
||||
schema: number;
|
||||
scope: string;
|
||||
engine_generation: number;
|
||||
}
|
||||
|
||||
interface GameRuntimeAsset {
|
||||
schema: number;
|
||||
game: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(relativePath: string): Promise<T> {
|
||||
const response = await fetch(new URL(relativePath, document.baseURI));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Asset runtime indisponible (${response.status}) : ${relativePath}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function validateCommonRuntime(asset: CommonRuntimeAsset): void {
|
||||
if (asset.schema !== 1 || asset.scope !== "common" || asset.engine_generation !== 1) {
|
||||
throw new Error("Asset common://data/runtime.json invalide pour le POC Tauri Snake.");
|
||||
}
|
||||
}
|
||||
|
||||
function validateGameRuntime(asset: GameRuntimeAsset): void {
|
||||
if (asset.schema !== 1 || asset.game !== "game-snake-poc" || asset.kind !== "snake") {
|
||||
throw new Error("Asset game://data/game.json invalide pour le POC Tauri Snake.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSnakeTauriAssets(): Promise<SnakeTauriAssets> {
|
||||
const [commonRuntime, gameRuntime] = await Promise.all([
|
||||
fetchJson<CommonRuntimeAsset>("./common/data/runtime.json"),
|
||||
fetchJson<GameRuntimeAsset>("./game/data/game.json"),
|
||||
]);
|
||||
validateCommonRuntime(commonRuntime);
|
||||
validateGameRuntime(gameRuntime);
|
||||
return {
|
||||
engineGeneration: commonRuntime.engine_generation,
|
||||
game: gameRuntime.game,
|
||||
gameKind: gameRuntime.kind,
|
||||
};
|
||||
}
|
||||
147
crates/apps/game-snake-poc-tauri/frontend/ts/game.ts
Normal file
147
crates/apps/game-snake-poc-tauri/frontend/ts/game.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// file: crates/apps/game-snake-poc-tauri/frontend/ts/game.ts
|
||||
// version: 1
|
||||
|
||||
import init, { SnakeWasmGame } from "@snake-wasm";
|
||||
import type { RuntimeDescriptor } from "./bridge";
|
||||
import { frontendInfo } from "./logging";
|
||||
import { TauriRuntimeProvenance, type SnakeInputSource } from "./provenance";
|
||||
|
||||
const FRAME_MILLIS = 16;
|
||||
const MAX_FRAME_ELAPSED_MILLIS = 250;
|
||||
|
||||
export type SnakeDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
export interface SnakeTauriSession {
|
||||
queueDirection(direction: SnakeDirection, source: SnakeInputSource): void;
|
||||
renderNow(): void;
|
||||
refreshDeviceClass(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function color(red: number, green: number, blue: number): string {
|
||||
return `rgb(${red} ${green} ${blue})`;
|
||||
}
|
||||
|
||||
function resizeCanvas(canvas: HTMLCanvasElement): void {
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const width = Math.max(1, Math.floor(canvas.clientWidth * ratio));
|
||||
const height = Math.max(1, Math.floor(canvas.clientHeight * ratio));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
function render(
|
||||
game: SnakeWasmGame,
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
score: HTMLOutputElement,
|
||||
length: HTMLOutputElement,
|
||||
): void {
|
||||
resizeCanvas(canvas);
|
||||
context.fillStyle = color(game.background_red(), game.background_green(), game.background_blue());
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const count = game.rectangle_count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
context.fillStyle = color(game.rectangle_red(index), game.rectangle_green(index), game.rectangle_blue(index));
|
||||
context.fillRect(
|
||||
game.rectangle_x(index) * canvas.width,
|
||||
game.rectangle_y(index) * canvas.height,
|
||||
game.rectangle_width(index) * canvas.width,
|
||||
game.rectangle_height(index) * canvas.height,
|
||||
);
|
||||
}
|
||||
score.value = `Score : ${game.score()}`;
|
||||
length.value = `Longueur : ${game.length()}`;
|
||||
}
|
||||
|
||||
function queueDirection(game: SnakeWasmGame, direction: SnakeDirection): void {
|
||||
switch (direction) {
|
||||
case "left":
|
||||
game.left();
|
||||
return;
|
||||
case "right":
|
||||
game.right();
|
||||
return;
|
||||
case "up":
|
||||
game.up();
|
||||
return;
|
||||
case "down":
|
||||
game.down();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startGame(
|
||||
canvas: HTMLCanvasElement,
|
||||
score: HTMLOutputElement,
|
||||
length: HTMLOutputElement,
|
||||
provenanceOutput: HTMLOutputElement,
|
||||
descriptor: RuntimeDescriptor,
|
||||
): Promise<SnakeTauriSession> {
|
||||
await init();
|
||||
frontendInfo("Snake Tauri WASM initialized");
|
||||
const context = canvas.getContext("2d");
|
||||
if (context === null) {
|
||||
throw new Error("Le contexte Canvas 2D est indisponible.");
|
||||
}
|
||||
const renderingContext: CanvasRenderingContext2D = context;
|
||||
const game = new SnakeWasmGame();
|
||||
const provenance = new TauriRuntimeProvenance(game, provenanceOutput, descriptor);
|
||||
let previous = performance.now();
|
||||
let accumulator = 0;
|
||||
let animationFrame: number | null = null;
|
||||
let disposed = false;
|
||||
|
||||
function renderNow(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
render(game, canvas, renderingContext, score, length);
|
||||
}
|
||||
|
||||
function frame(now: number): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const elapsed = Math.min(Math.max(0, now - previous), MAX_FRAME_ELAPSED_MILLIS);
|
||||
previous = now;
|
||||
accumulator += elapsed;
|
||||
while (accumulator >= FRAME_MILLIS) {
|
||||
game.tick();
|
||||
accumulator -= FRAME_MILLIS;
|
||||
}
|
||||
renderNow();
|
||||
animationFrame = window.requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
renderNow();
|
||||
animationFrame = window.requestAnimationFrame(frame);
|
||||
return {
|
||||
queueDirection(direction: SnakeDirection, source: SnakeInputSource): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
provenance.recordInput(source);
|
||||
queueDirection(game, direction);
|
||||
},
|
||||
renderNow,
|
||||
refreshDeviceClass(): void {
|
||||
if (!disposed) {
|
||||
provenance.refreshDeviceClass();
|
||||
}
|
||||
},
|
||||
dispose(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
if (animationFrame !== null) {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
animationFrame = null;
|
||||
}
|
||||
game.free();
|
||||
},
|
||||
};
|
||||
}
|
||||
57
crates/apps/game-snake-poc-tauri/frontend/ts/input.ts
Normal file
57
crates/apps/game-snake-poc-tauri/frontend/ts/input.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// file: crates/apps/game-snake-poc-tauri/frontend/ts/input.ts
|
||||
// version: 1
|
||||
|
||||
import type { SnakeDirection, SnakeTauriSession } from "./game";
|
||||
|
||||
const KEY_DIRECTIONS: Readonly<Record<string, SnakeDirection>> = {
|
||||
arrowleft: "left",
|
||||
a: "left",
|
||||
q: "left",
|
||||
arrowright: "right",
|
||||
d: "right",
|
||||
arrowup: "up",
|
||||
w: "up",
|
||||
z: "up",
|
||||
arrowdown: "down",
|
||||
s: "down",
|
||||
};
|
||||
|
||||
function buttonDirection(button: HTMLButtonElement): SnakeDirection | null {
|
||||
const direction = button.dataset.direction;
|
||||
if (direction === "left" || direction === "right" || direction === "up" || direction === "down") {
|
||||
return direction;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function bindInputs(session: SnakeTauriSession, buttons: readonly HTMLButtonElement[]): void {
|
||||
window.addEventListener("keydown", event => {
|
||||
if (event.repeat) {
|
||||
return;
|
||||
}
|
||||
const direction = KEY_DIRECTIONS[event.key.toLowerCase()];
|
||||
if (direction === undefined) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
session.queueDirection(direction, "keyboard-mouse");
|
||||
});
|
||||
|
||||
for (const button of buttons) {
|
||||
const direction = buttonDirection(button);
|
||||
if (direction === null) {
|
||||
continue;
|
||||
}
|
||||
button.addEventListener("pointerdown", event => {
|
||||
event.preventDefault();
|
||||
const source = event.pointerType === "touch" ? "touch" : "keyboard-mouse";
|
||||
session.queueDirection(direction, source);
|
||||
});
|
||||
button.addEventListener("click", event => {
|
||||
if (event.detail !== 0) {
|
||||
return;
|
||||
}
|
||||
session.queueDirection(direction, "keyboard-mouse");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
// file: crates/apps/game-snake-poc-tauri/frontend/ts/main.ts
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
import init, { SnakeWasmGame } from "@snake-wasm";
|
||||
import "../css/main.css";
|
||||
import { loadSnakeTauriAssets } from "./assets";
|
||||
import { getRuntimeDescriptor, reportFrontendReady } from "./bridge";
|
||||
import { startGame } from "./game";
|
||||
import { bindInputs } from "./input";
|
||||
import { frontendError, frontendInfo, initializeFrontendTracing } from "./logging";
|
||||
|
||||
function requiredElement<T extends HTMLElement>(identifier: string): T {
|
||||
@@ -18,39 +20,48 @@ async function bootstrap(): Promise<void> {
|
||||
await initializeFrontendTracing();
|
||||
frontendInfo("Snake Tauri frontend bootstrap started");
|
||||
const descriptor = await getRuntimeDescriptor();
|
||||
await init();
|
||||
const game = new SnakeWasmGame();
|
||||
const configured = game.configure_runtime_provenance(
|
||||
descriptor.platformFamily,
|
||||
descriptor.runtimeHost,
|
||||
descriptor.deviceClass,
|
||||
descriptor.inputProfile,
|
||||
);
|
||||
if (!configured) {
|
||||
game.free();
|
||||
throw new Error("La provenance Tauri Android n'a pas été acceptée par l'adapter Snake WASM.");
|
||||
}
|
||||
const status = requiredElement<HTMLParagraphElement>("runtime-status");
|
||||
const canvas = requiredElement<HTMLCanvasElement>("game");
|
||||
const score = requiredElement<HTMLOutputElement>("score");
|
||||
const length = requiredElement<HTMLOutputElement>("length");
|
||||
const provenance = requiredElement<HTMLOutputElement>("runtime-provenance");
|
||||
status.textContent = "Runtime minimal initialisé. Le Canvas et les contrôles arrivent dans 0-pre.3.";
|
||||
provenance.value = [
|
||||
game.provenance_platform_family(),
|
||||
game.provenance_runtime_host(),
|
||||
game.provenance_execution_model(),
|
||||
game.provenance_device_class(),
|
||||
game.provenance_input_profile(),
|
||||
].join(" / ");
|
||||
window.addEventListener("pagehide", () => game.free(), { once: true });
|
||||
const assets = requiredElement<HTMLOutputElement>("asset-status");
|
||||
const status = requiredElement<HTMLSpanElement>("runtime-status");
|
||||
const error = requiredElement<HTMLParagraphElement>("startup-error");
|
||||
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-direction]"));
|
||||
|
||||
status.textContent = "Chargement assets…";
|
||||
const assetMetadata = await loadSnakeTauriAssets();
|
||||
assets.value = `${assetMetadata.gameKind} / engine-v${assetMetadata.engineGeneration}`;
|
||||
frontendInfo(`Snake Tauri assets ready: ${assetMetadata.game} / ${assets.value}`);
|
||||
|
||||
status.textContent = "Chargement WASM…";
|
||||
const session = await startGame(canvas, score, length, provenance, descriptor);
|
||||
bindInputs(session, buttons);
|
||||
window.addEventListener("resize", () => {
|
||||
session.refreshDeviceClass();
|
||||
session.renderNow();
|
||||
});
|
||||
window.addEventListener("pagehide", () => session.dispose(), { once: true });
|
||||
|
||||
document.body.dataset.runtimeState = "ready";
|
||||
status.textContent = "Prêt";
|
||||
error.hidden = true;
|
||||
canvas.focus();
|
||||
await reportFrontendReady();
|
||||
frontendInfo(`Snake Tauri frontend ready: ${provenance.value}`);
|
||||
}
|
||||
|
||||
void bootstrap().catch(reason => {
|
||||
const message = reason instanceof Error ? reason.message : String(reason);
|
||||
const message = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
|
||||
document.body.dataset.runtimeState = "error";
|
||||
const status = document.getElementById("runtime-status");
|
||||
const error = document.getElementById("startup-error");
|
||||
if (status !== null) {
|
||||
status.textContent = `Échec d'initialisation : ${message}`;
|
||||
status.textContent = "Erreur";
|
||||
}
|
||||
if (error !== null) {
|
||||
error.textContent = `Impossible de démarrer Snake : ${message}`;
|
||||
error.hidden = false;
|
||||
}
|
||||
frontendError(`Snake Tauri frontend bootstrap failed: ${message}`);
|
||||
});
|
||||
|
||||
97
crates/apps/game-snake-poc-tauri/frontend/ts/provenance.ts
Normal file
97
crates/apps/game-snake-poc-tauri/frontend/ts/provenance.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// file: crates/apps/game-snake-poc-tauri/frontend/ts/provenance.ts
|
||||
// version: 1
|
||||
|
||||
import type { SnakeWasmGame } from "@snake-wasm";
|
||||
import type { RuntimeDescriptor } from "./bridge";
|
||||
import { frontendInfo } from "./logging";
|
||||
|
||||
export type SnakeInputSource = "keyboard-mouse" | "touch";
|
||||
|
||||
type DeviceClass = "desktop" | "phone" | "tablet" | "unknown";
|
||||
type InputProfile = "keyboard-mouse" | "mixed" | "touch" | "unknown";
|
||||
|
||||
function detectedDeviceClass(): DeviceClass {
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
const finePointer = window.matchMedia("(pointer: fine)").matches;
|
||||
if (!coarsePointer || finePointer) {
|
||||
return "desktop";
|
||||
}
|
||||
const shortEdge = Math.min(window.screen.width, window.screen.height);
|
||||
if (!Number.isFinite(shortEdge) || shortEdge <= 0) {
|
||||
return "unknown";
|
||||
}
|
||||
return shortEdge < 768 ? "phone" : "tablet";
|
||||
}
|
||||
|
||||
function inputProfile(keyboardMouseObserved: boolean, touchObserved: boolean): InputProfile {
|
||||
if (keyboardMouseObserved && touchObserved) {
|
||||
return "mixed";
|
||||
}
|
||||
if (keyboardMouseObserved) {
|
||||
return "keyboard-mouse";
|
||||
}
|
||||
if (touchObserved) {
|
||||
return "touch";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function displayLabel(game: SnakeWasmGame): string {
|
||||
return [
|
||||
game.provenance_platform_family(),
|
||||
game.provenance_runtime_host(),
|
||||
game.provenance_execution_model(),
|
||||
game.provenance_device_class(),
|
||||
game.provenance_input_profile(),
|
||||
].join(" / ");
|
||||
}
|
||||
|
||||
export class TauriRuntimeProvenance {
|
||||
private keyboardMouseObserved = false;
|
||||
private touchObserved = false;
|
||||
private deviceClass: DeviceClass = detectedDeviceClass();
|
||||
|
||||
public constructor(
|
||||
private readonly game: SnakeWasmGame,
|
||||
private readonly output: HTMLOutputElement,
|
||||
private readonly descriptor: RuntimeDescriptor,
|
||||
) {
|
||||
if (descriptor.executionModel !== "wasm") {
|
||||
throw new Error(`Modèle d'exécution Tauri inattendu : ${descriptor.executionModel}`);
|
||||
}
|
||||
this.synchronize();
|
||||
}
|
||||
|
||||
public recordInput(source: SnakeInputSource): void {
|
||||
if (source === "touch") {
|
||||
if (this.touchObserved) {
|
||||
return;
|
||||
}
|
||||
this.touchObserved = true;
|
||||
} else {
|
||||
if (this.keyboardMouseObserved) {
|
||||
return;
|
||||
}
|
||||
this.keyboardMouseObserved = true;
|
||||
}
|
||||
this.synchronize();
|
||||
}
|
||||
|
||||
public refreshDeviceClass(): void {
|
||||
const nextDeviceClass = detectedDeviceClass();
|
||||
if (nextDeviceClass === this.deviceClass) {
|
||||
return;
|
||||
}
|
||||
this.deviceClass = nextDeviceClass;
|
||||
this.synchronize();
|
||||
}
|
||||
|
||||
private synchronize(): void {
|
||||
const profile = inputProfile(this.keyboardMouseObserved, this.touchObserved);
|
||||
if (!this.game.configure_runtime_provenance(this.descriptor.platformFamily, this.descriptor.runtimeHost, this.deviceClass, profile)) {
|
||||
throw new Error("La provenance Tauri Android n'a pas pu être configurée dans le bridge WASM.");
|
||||
}
|
||||
this.output.value = displayLabel(this.game);
|
||||
frontendInfo(`Snake Tauri provenance: ${this.output.value}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user