0.3.1-0-pre.3

This commit is contained in:
2026-09-21 01:13:37 +02:00
parent e4b657d0d3
commit 4f677dbc77
19 changed files with 1001 additions and 98 deletions

View File

@@ -1,5 +1,5 @@
/* file: crates/apps/game-snake-poc-tauri/frontend/css/main.css */
/* version: 1 */
/* version: 2 */
:root {
font-family: system-ui, sans-serif;
@@ -14,45 +14,222 @@
html,
body {
width: 100%;
min-width: 320px;
min-height: 100%;
margin: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: max(1.5rem, env(safe-area-inset-top)) max(1.5rem, env(safe-area-inset-right)) max(1.5rem, env(safe-area-inset-bottom))
max(1.5rem, env(safe-area-inset-left));
padding: max(0.75rem, env(safe-area-inset-top)) max(0.75rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom))
max(0.75rem, env(safe-area-inset-left));
background: #101418;
overscroll-behavior: none;
}
.bootstrap-panel {
width: min(100%, 34rem);
padding: 1.5rem;
border: 1px solid currentColor;
.app-header,
.app-footer,
.game-panel,
.control-panel {
border: 1px solid #50606b;
border-radius: 0.75rem;
background: #161c21;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
}
.eyebrow {
margin: 0 0 0.5rem;
opacity: 0.7;
margin: 0 0 0.15rem;
color: #9fb0ba;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin: 0 0 1rem;
font-size: clamp(1.6rem, 7vw, 2.4rem);
margin-bottom: 0;
font-size: clamp(1.25rem, 6vw, 1.85rem);
}
#runtime-status {
margin: 0 0 0.75rem;
h2 {
margin-bottom: 0.35rem;
font-size: 1.1rem;
}
#runtime-provenance {
.status-badge,
.game-summary output {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: #29343c;
color: #f4f7f8;
font-size: 0.85rem;
white-space: nowrap;
}
body[data-runtime-state="ready"] .status-badge {
background: #1d5f3d;
}
body[data-runtime-state="error"] .status-badge {
background: #7a2630;
}
.app-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
margin-block: 0.75rem;
}
.game-panel,
.control-panel {
min-width: 0;
padding: 0.75rem;
}
.game-summary {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.game-stage {
width: min(100%, 21rem);
max-height: 58vh;
margin-inline: auto;
aspect-ratio: 3 / 5;
overflow: hidden;
border: 1px solid #6d7f8a;
border-radius: 0.45rem;
background: #000;
}
#game {
display: block;
overflow-wrap: anywhere;
font-family: ui-monospace, monospace;
width: 100%;
height: 100%;
touch-action: none;
outline: none;
}
body[data-runtime-state="error"] #runtime-status {
font-weight: 700;
#game:focus-visible {
box-shadow: inset 0 0 0 3px #8fbfff;
}
.control-panel > div:first-child p {
margin-bottom: 0.75rem;
color: #b9c4ca;
font-size: 0.9rem;
}
.direction-pad {
display: grid;
grid-template-columns: repeat(3, minmax(3.75rem, 5rem));
grid-template-rows: repeat(2, minmax(3.3rem, 4.2rem));
gap: 0.45rem;
justify-content: center;
margin-inline: auto;
user-select: none;
}
.direction-pad button {
border: 1px solid #7892a1;
border-radius: 0.6rem;
background: #202b32;
color: #f4f7f8;
font-size: 1.45rem;
touch-action: manipulation;
}
.direction-pad button:active {
background: #355365;
}
.direction-up {
grid-column: 2;
grid-row: 1;
}
.direction-left {
grid-column: 1;
grid-row: 2;
}
.direction-down {
grid-column: 2;
grid-row: 2;
}
.direction-right {
grid-column: 3;
grid-row: 2;
}
.runtime-details {
margin: 0.9rem 0 0;
padding-top: 0.8rem;
border-top: 1px solid #3a474f;
font-size: 0.78rem;
}
.runtime-details div {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.75rem;
margin-top: 0.45rem;
}
.runtime-details dt {
color: #a9b6bd;
}
.runtime-details dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
text-align: right;
}
.startup-error {
margin: 0.9rem 0 0;
padding: 0.65rem;
border: 1px solid #b34b57;
border-radius: 0.5rem;
background: #51232a;
}
.app-footer {
padding: 0.55rem 0.75rem;
color: #a9b6bd;
font-size: 0.75rem;
text-align: center;
}
@media (min-width: 760px) {
.app-main {
grid-template-columns: minmax(18rem, 1fr) minmax(17rem, 22rem);
align-items: start;
}
.game-stage {
width: min(100%, 24rem);
max-height: 70vh;
}
}

View File

@@ -1,17 +1,59 @@
<!-- file: crates/apps/game-snake-poc-tauri/frontend/main.html -->
<!-- version: 1 -->
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Snake POC Tauri</title>
<meta name="theme-color" content="#101418" />
<title>Snake POC — Tauri Android</title>
</head>
<body>
<main class="bootstrap-panel" aria-labelledby="runtime-title">
<p class="eyebrow">games.sasedev</p>
<h1 id="runtime-title">Snake — Tauri Android</h1>
<p id="runtime-status">Initialisation du runtime…</p>
<output id="runtime-provenance" aria-live="polite">provenance en attente</output>
<header class="app-header">
<div>
<p class="eyebrow">games.sasedev</p>
<h1>Snake — Tauri Android</h1>
</div>
<span id="runtime-status" class="status-badge" role="status" aria-live="polite">Initialisation…</span>
</header>
<main class="app-main">
<section class="game-panel" aria-label="Partie Snake">
<div class="game-summary">
<output id="score">Score : 0</output>
<output id="length">Longueur : 0</output>
</div>
<div class="game-stage">
<canvas id="game" width="360" height="600" tabindex="0" aria-label="Zone de jeu Snake"></canvas>
</div>
</section>
<aside class="control-panel" aria-label="Contrôles et runtime">
<div>
<h2>Contrôles</h2>
<p>Flèches, WASD/ZQSD ou boutons tactiles.</p>
</div>
<div class="direction-pad" aria-label="Contrôles directionnels tactiles">
<button class="direction-up" type="button" data-direction="up" aria-label="Haut"></button>
<button class="direction-left" type="button" data-direction="left" aria-label="Gauche"></button>
<button class="direction-down" type="button" data-direction="down" aria-label="Bas"></button>
<button class="direction-right" type="button" data-direction="right" aria-label="Droite"></button>
</div>
<dl class="runtime-details">
<div>
<dt>Provenance</dt>
<dd><output id="runtime-provenance">android / tauri-webview / wasm / unknown / unknown</output></dd>
</div>
<div>
<dt>Assets</dt>
<dd><output id="asset-status">chargement…</output></dd>
</div>
</dl>
<p id="startup-error" class="startup-error" role="alert" hidden></p>
</aside>
</main>
<footer class="app-footer">POC de référence Tauri Android — gameplay Rust partagé</footer>
<script type="module" src="/ts/main.ts"></script>
</body>
</html>

View 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,
};
}

View 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();
},
};
}

View 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");
});
}
}

View File

@@ -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}`);
});

View 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}`);
}
}