0.3.0-0-pre.5

This commit is contained in:
2026-09-20 13:58:13 +02:00
parent 86b8af3b61
commit 7f0635ec9d
27 changed files with 827 additions and 39 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: Web/game-snake-poc/frontend/main.html -->
<!-- version: 3 -->
<!-- version: 4 -->
<!DOCTYPE html>
<html lang="fr">
@@ -81,6 +81,16 @@
<i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
</button>
</div>
<dl class="runtime-details small border-top pt-3 mt-3 mb-0">
<div class="d-flex justify-content-between gap-3">
<dt class="text-body-secondary fw-normal">Provenance</dt>
<dd class="mb-2 text-end"><output id="runtime-provenance">web / browser / wasm / unknown / unknown</output></dd>
</div>
<div class="d-flex justify-content-between gap-3">
<dt class="text-body-secondary fw-normal">Assets</dt>
<dd class="mb-0 text-end"><output id="asset-status">chargement…</output></dd>
</div>
</dl>
<div class="alert alert-info small mt-3 mb-0" role="note">
Les entrées ne font pas avancer la simulation : elles sont consommées au prochain <code>tick()</code> WASM.
</div>

View File

@@ -1,5 +1,5 @@
// file: Web/game-snake-poc/frontend/sass/_app.scss
// version: 3
// version: 4
$app-header-height: 72px;
$app-footer-height: 42px;
@@ -101,6 +101,11 @@ body {
min-width: 0;
}
.runtime-details dd {
min-width: 0;
overflow-wrap: anywhere;
}
.direction-pad {
display: grid;
grid-template-columns: repeat(3, minmax(64px, 88px));

View File

@@ -0,0 +1,54 @@
// file: Web/game-snake-poc/frontend/ts/assets.ts
// version: 1
export interface SnakeWebAssets {
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 Web 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 Web Snake.");
}
}
export async function loadSnakeWebAssets(): Promise<SnakeWebAssets> {
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,
};
}

View File

@@ -1,7 +1,9 @@
// file: Web/game-snake-poc/frontend/ts/game.ts
// version: 2
// version: 3
import init, { SnakeWasmGame } from "@snake-wasm";
import { webDebug, webInfo } from "./logging";
import { BrowserRuntimeProvenance, type SnakeInputSource } from "./provenance";
const FRAME_MILLIS = 16;
const MAX_FRAME_ELAPSED_MILLIS = 250;
@@ -9,7 +11,12 @@ const MAX_FRAME_ELAPSED_MILLIS = 250;
export type SnakeDirection = "left" | "right" | "up" | "down";
export interface SnakeWebSession {
queueDirection(direction: SnakeDirection): void;
queueDirection(direction: SnakeDirection, source: SnakeInputSource): void;
pause(): void;
resume(): void;
renderNow(): void;
refreshDeviceClass(): void;
dispose(): void;
}
function color(red: number, green: number, blue: number): string {
@@ -71,18 +78,42 @@ export async function startGame(
canvas: HTMLCanvasElement,
score: HTMLOutputElement,
length: HTMLOutputElement,
provenanceOutput: HTMLOutputElement,
): Promise<SnakeWebSession> {
await init();
webInfo("snake-web-runtime", "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 BrowserRuntimeProvenance(game, provenanceOutput);
let previous = performance.now();
let accumulator = 0;
let animationFrame: number | null = null;
let running = false;
let disposed = false;
function renderNow(): void {
if (disposed) {
return;
}
render(game, canvas, renderingContext, score, length);
}
function schedule(): void {
if (!running || disposed || animationFrame !== null) {
return;
}
animationFrame = window.requestAnimationFrame(frame);
}
function frame(now: number): void {
animationFrame = null;
if (!running || disposed) {
return;
}
const elapsed = Math.min(Math.max(0, now - previous), MAX_FRAME_ELAPSED_MILLIS);
previous = now;
accumulator += elapsed;
@@ -90,15 +121,59 @@ export async function startGame(
game.tick();
accumulator -= FRAME_MILLIS;
}
render(game, canvas, renderingContext, score, length);
window.requestAnimationFrame(frame);
renderNow();
schedule();
}
render(game, canvas, renderingContext, score, length);
window.requestAnimationFrame(frame);
function pause(): void {
if (!running || disposed) {
return;
}
running = false;
accumulator = 0;
if (animationFrame !== null) {
window.cancelAnimationFrame(animationFrame);
animationFrame = null;
}
webDebug("snake-web-runtime", "runtime_paused");
}
function resume(): void {
if (running || disposed) {
return;
}
previous = performance.now();
accumulator = 0;
running = true;
schedule();
webDebug("snake-web-runtime", "runtime_resumed");
}
renderNow();
resume();
return {
queueDirection(direction: SnakeDirection): void {
queueDirection(direction: SnakeDirection, source: SnakeInputSource): void {
if (disposed) {
return;
}
provenance.recordInput(source);
queueDirection(game, direction);
},
pause,
resume,
renderNow,
refreshDeviceClass(): void {
if (!disposed) {
provenance.refreshDeviceClass();
}
},
dispose(): void {
if (disposed) {
return;
}
pause();
disposed = true;
game.free();
},
};
}

View File

@@ -1,7 +1,8 @@
// file: Web/game-snake-poc/frontend/ts/input.ts
// version: 1
// version: 2
import type { SnakeDirection, SnakeWebSession } from "./game";
import { webDebug } from "./logging";
const KEY_DIRECTIONS: Readonly<Record<string, SnakeDirection>> = {
arrowleft: "left",
@@ -34,7 +35,8 @@ export function bindInputs(session: SnakeWebSession, buttons: readonly HTMLButto
return;
}
event.preventDefault();
session.queueDirection(direction);
session.queueDirection(direction, "keyboard-mouse");
webDebug("snake-web-input", "direction", { direction, source: "keyboard-mouse" });
});
for (const button of buttons) {
@@ -44,13 +46,16 @@ export function bindInputs(session: SnakeWebSession, buttons: readonly HTMLButto
}
button.addEventListener("pointerdown", event => {
event.preventDefault();
session.queueDirection(direction);
const source = event.pointerType === "touch" ? "touch" : "keyboard-mouse";
session.queueDirection(direction, source);
webDebug("snake-web-input", "direction", { direction, source });
});
button.addEventListener("click", event => {
if (event.detail !== 0) {
return;
}
session.queueDirection(direction);
session.queueDirection(direction, "keyboard-mouse");
webDebug("snake-web-input", "direction", { direction, source: "keyboard-mouse" });
});
}
}

View File

@@ -0,0 +1,58 @@
// file: Web/game-snake-poc/frontend/ts/lifecycle.ts
// version: 1
import type { SnakeWebSession } from "./game";
import { webDebug, webInfo } from "./logging";
export interface SnakeLifecycleBinding {
dispose(): void;
}
export function bindBrowserLifecycle(session: SnakeWebSession, stage: HTMLElement): SnakeLifecycleBinding {
const resizeObserver = new ResizeObserver(() => {
session.refreshDeviceClass();
session.renderNow();
});
function synchronizeVisibility(): void {
if (document.visibilityState === "hidden") {
session.pause();
webDebug("snake-web-lifecycle", "visibility_hidden");
return;
}
session.resume();
session.renderNow();
webDebug("snake-web-lifecycle", "visibility_visible");
}
function pageHide(event: PageTransitionEvent): void {
if (event.persisted) {
session.pause();
} else {
session.dispose();
}
webInfo("snake-web-lifecycle", "page_hide", { persisted: event.persisted });
}
function pageShow(event: PageTransitionEvent): void {
session.resume();
session.renderNow();
webInfo("snake-web-lifecycle", "page_show", { persisted: event.persisted });
}
resizeObserver.observe(stage);
document.addEventListener("visibilitychange", synchronizeVisibility);
window.addEventListener("pagehide", pageHide);
window.addEventListener("pageshow", pageShow);
synchronizeVisibility();
return {
dispose(): void {
resizeObserver.disconnect();
document.removeEventListener("visibilitychange", synchronizeVisibility);
window.removeEventListener("pagehide", pageHide);
window.removeEventListener("pageshow", pageShow);
session.dispose();
},
};
}

View File

@@ -0,0 +1,45 @@
// file: Web/game-snake-poc/frontend/ts/logging.ts
// version: 1
export type WebLogLevel = "debug" | "info" | "warn" | "error";
export type WebLogFields = Readonly<Record<string, boolean | number | string | null>>;
type ConsoleMethod = (...items: unknown[]) => void;
function consoleMethod(level: WebLogLevel): ConsoleMethod {
if (level === "debug") {
return console.debug.bind(console);
}
if (level === "info") {
return console.info.bind(console);
}
if (level === "warn") {
return console.warn.bind(console);
}
return console.error.bind(console);
}
function emit(level: WebLogLevel, target: string, action: string, fields: WebLogFields = {}): void {
const event = {
target,
action,
...fields,
};
consoleMethod(level)(`[games.sasedev][${target}] ${action}`, event);
}
export function webDebug(target: string, action: string, fields: WebLogFields = {}): void {
emit("debug", target, action, fields);
}
export function webInfo(target: string, action: string, fields: WebLogFields = {}): void {
emit("info", target, action, fields);
}
export function webWarn(target: string, action: string, fields: WebLogFields = {}): void {
emit("warn", target, action, fields);
}
export function webError(target: string, action: string, fields: WebLogFields = {}): void {
emit("error", target, action, fields);
}

View File

@@ -1,10 +1,13 @@
// file: Web/game-snake-poc/frontend/ts/main.ts
// version: 2
// version: 3
import ResizeObserver from "resize-observer-polyfill";
import "simplebar";
import { loadSnakeWebAssets } from "./assets";
import { startGame } from "./game";
import { bindInputs } from "./input";
import { bindBrowserLifecycle } from "./lifecycle";
import { webError, webInfo } from "./logging";
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
@@ -18,25 +21,39 @@ function requireElement<T extends Element>(selector: string): T {
async function main(): Promise<void> {
const canvas = requireElement<HTMLCanvasElement>("#game");
const stage = requireElement<HTMLElement>(".game-stage");
const score = requireElement<HTMLOutputElement>("#score");
const length = requireElement<HTMLOutputElement>("#length");
const provenance = requireElement<HTMLOutputElement>("#runtime-provenance");
const assets = requireElement<HTMLOutputElement>("#asset-status");
const status = requireElement<HTMLSpanElement>("#runtime-status");
const error = requireElement<HTMLParagraphElement>("#startup-error");
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-direction]"));
status.textContent = "Chargement assets…";
const assetMetadata = await loadSnakeWebAssets();
assets.value = `${assetMetadata.gameKind} / engine-v${assetMetadata.engineGeneration}`;
webInfo("snake-web-assets", "runtime_assets_loaded", {
engineGeneration: assetMetadata.engineGeneration,
game: assetMetadata.game,
gameKind: assetMetadata.gameKind,
});
status.textContent = "Chargement WASM…";
const session = await startGame(canvas, score, length);
const session = await startGame(canvas, score, length, provenance);
bindInputs(session, buttons);
bindBrowserLifecycle(session, stage);
status.className = "badge text-bg-success";
status.textContent = "Prêt";
error.hidden = true;
canvas.focus();
webInfo("snake-web-main", "runtime_ready");
}
void main().catch(caughtError => {
const status = document.querySelector<HTMLSpanElement>("#runtime-status");
const error = document.querySelector<HTMLParagraphElement>("#startup-error");
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
const message = caughtError instanceof Error ? caughtError.stack ?? caughtError.message : String(caughtError);
if (status !== null) {
status.className = "badge text-bg-danger";
status.textContent = "Erreur";
@@ -45,4 +62,5 @@ void main().catch(caughtError => {
error.textContent = `Impossible de démarrer Snake : ${message}`;
error.hidden = false;
}
webError("snake-web-main", "runtime_start_failed", { message });
});

View File

@@ -0,0 +1,98 @@
// file: Web/game-snake-poc/frontend/ts/provenance.ts
// version: 1
import type { SnakeWasmGame } from "@snake-wasm";
import { webInfo } 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 BrowserRuntimeProvenance {
private keyboardMouseObserved = false;
private touchObserved = false;
private deviceClass: DeviceClass = detectedDeviceClass();
public constructor(
private readonly game: SnakeWasmGame,
private readonly output: HTMLOutputElement,
) {
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.deviceClass, profile)) {
throw new Error("La provenance navigateur n'a pas pu être configurée dans le bridge WASM.");
}
this.output.value = displayLabel(this.game);
webInfo("snake-web-provenance", "runtime_provenance", {
deviceClass: this.game.provenance_device_class(),
executionModel: this.game.provenance_execution_model(),
inputProfile: this.game.provenance_input_profile(),
platformFamily: this.game.provenance_platform_family(),
runtimeHost: this.game.provenance_runtime_host(),
});
}
}

View File

@@ -19,6 +19,7 @@
"@types/node": "^26.1",
"sass-embedded": "^1.102",
"typescript": "^7.0",
"vite": "^8.2"
"vite": "^8.2",
"vite-plugin-static-copy": "^4.1"
}
}

View File

@@ -1,10 +1,11 @@
// file: Web/game-snake-poc/vite.config.ts
// version: 1
// version: 2
import { NodePackageImporter } from "sass-embedded";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { defineConfig, normalizePath } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
const appRoot = fileURLToPath(new URL(".", import.meta.url));
const repositoryRoot = normalizePath(resolve(appRoot, "../.."));
@@ -14,8 +15,18 @@ const wasmRoot = normalizePath(resolve(externalBuildRoot, "wasm"));
const wasmModule = normalizePath(resolve(wasmRoot, "game_snake_poc_wasm.js"));
const frontendDist = normalizePath(resolve(externalBuildRoot, "dist"));
const viteCacheDir = normalizePath(resolve(externalBuildRoot, "vite-cache"));
const commonRuntimeAsset = normalizePath(resolve(repositoryRoot, "assets/common/data/runtime.json"));
const gameRuntimeAsset = normalizePath(resolve(repositoryRoot, "assets/game-snake-poc/data/game.json"));
export default defineConfig({
plugins: [
viteStaticCopy({
targets: [
{ src: commonRuntimeAsset, dest: "common/data" },
{ src: gameRuntimeAsset, dest: "game/data" },
],
}),
],
base: "./",
cacheDir: viteCacheDir,
clearScreen: false,
@@ -74,7 +85,7 @@ export default defineConfig({
port: 1434,
strictPort: true,
fs: {
allow: [appRoot, externalBuildRoot],
allow: [appRoot, repositoryRoot, externalBuildRoot],
},
},
preview: {