web: fix PixiJS texture frame (Rectangle), renderer mount error state; admin certification writes game_versions; docs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 changed files +14 −7
modified
apps/web/src/components/game/game-client.tsx
+7 −1
@@ -87,7 +87,13 @@ export function GameClient({ game, definition }: Props) { | |||||
| 87 | let cancelled = false; | 87 | let cancelled = false; | ||
| 88 | (async () => { | 88 | (async () => { | ||
| 89 | setProgress(15); | 89 | setProgress(15); | ||
| 90 | − await renderer.mount(canvasRef.current!); | 90 | + try { | ||
| 91 | + await renderer.mount(canvasRef.current!); | ||||
| 92 | + } catch (e) { | ||||
| 93 | + console.error("renderer mount failed", e); | ||||
| 94 | + if (!cancelled) setError({ code: "GAME_UNAVAILABLE", message: "Your browser could not start the game renderer (WebGL required)." }); | ||||
| 95 | + return; | ||||
| 96 | + } | ||||
| 91 | setProgress(60); | 97 | setProgress(60); | ||
| 92 | try { | 98 | try { | ||
| 93 | const launch = await api<{ state: ClientOutcome["stateAfter"] | null }>(`/api/games/${game.slug}/launch`, { method: "POST" }); | 99 | const launch = await api<{ state: ClientOutcome["stateAfter"] | null }>(`/api/games/${game.slug}/launch`, { method: "POST" }); | ||
modified
apps/web/src/components/game/renderer.ts
+2 −2
@@ -1,6 +1,6 @@ | |||||
| 1 | "use client"; | 1 | "use client"; | ||
| 2 | 2 | ||||
| 3 | −import { Application, Container, Graphics, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js"; | 3 | +import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js"; | ||
| 4 | import type { SpinStep } from "@spinza/game-core/client"; | 4 | import type { SpinStep } from "@spinza/game-core/client"; | ||
| 5 | import type { ClientDefinition } from "./types"; | 5 | import type { ClientDefinition } from "./types"; | ||
| 6 | import { drawSymbol, hexColor, lighten } from "./symbol-art"; | 6 | import { drawSymbol, hexColor, lighten } from "./symbol-art"; | ||
@@ -350,7 +350,7 @@ export class SlotRenderer { | |||||
| 350 | const size = 176; | 350 | const size = 176; | ||
| 351 | for (const s of this.opts.def.symbols) { | 351 | for (const s of this.opts.def.symbols) { | ||
| 352 | const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind }); | 352 | const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind }); | ||
| 353 | − const tex = renderer.generateTexture({ target: art, resolution: 2, frame: { x: -size / 2, y: -size / 2, width: size, height: size } as never }); | 353 | + const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) }); | ||
| 354 | this.textures.set(s.id, tex); | 354 | this.textures.set(s.id, tex); | ||
| 355 | art.destroy({ children: true }); | 355 | art.destroy({ children: true }); | ||
| 356 | } | 356 | } | ||
modified
apps/web/src/components/lobby/game-art.tsx
+4 −3
@@ -701,7 +701,7 @@ export function GameArt({ slug, name, palette, backdrop, particles, variant = "c | |||||
| 701 | const { w: W, h: H, pad, maxFont, oneLine } = DIMS[variant]; | 701 | const { w: W, h: H, pad, maxFont, oneLine } = DIMS[variant]; | ||
| 702 | const rnd = mulberry32(hashString(`${slug}:${variant}`)); | 702 | const rnd = mulberry32(hashString(`${slug}:${variant}`)); | ||
| 703 | const ctx: Ctx = { W, H, rnd, p: palette, id }; | 703 | const ctx: Ctx = { W, H, rnd, p: palette, id }; | ||
| 704 | − const Motif = MOTIFS[motifKey]; | 704 | + const motif = MOTIFS[motifKey]; | ||
| 705 | 705 | ||||
| 706 | return ( | 706 | return ( | ||
| 707 | <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid slice" className={cn("block h-full w-full", className)} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}> | 707 | <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid slice" className={cn("block h-full w-full", className)} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}> | ||
@@ -750,8 +750,9 @@ export function GameArt({ slug, name, palette, backdrop, particles, variant = "c | |||||
| 750 | </filter> | 750 | </filter> | ||
| 751 | </defs> | 751 | </defs> | ||
| 752 | <rect width={W} height={H} fill={`url(#${id}-bg)`} /> | 752 | <rect width={W} height={H} fill={`url(#${id}-bg)`} /> | ||
| 753 | − <Motif {...ctx} /> | 753 | + {/* Called as plain functions (not components) so the seeded PRNG sequence is consumed exactly once per render — keeps SSR and client output identical even under StrictMode double-rendering. */} | ||
| 754 | − <Particles {...ctx} kind={pt} /> | 754 | + {motif(ctx)} | ||
| 755 | + {Particles({ ...ctx, kind: pt })} | ||||
| 755 | <rect width={W} height={H} fill={`url(#${id}-vignette)`} /> | 756 | <rect width={W} height={H} fill={`url(#${id}-vignette)`} /> | ||
| 756 | <rect x="0.5" y="0.5" width={W - 1} height={H - 1} fill="none" stroke="#fff" strokeOpacity="0.06" /> | 757 | <rect x="0.5" y="0.5" width={W - 1} height={H - 1} fill="none" stroke="#fff" strokeOpacity="0.06" /> | ||
| 757 | {showName ? <Title name={name} W={W} H={H} pad={pad} maxFont={maxFont} oneLine={oneLine} p={palette} /> : null} | 758 | {showName ? <Title name={name} W={W} H={H} pad={pad} maxFont={maxFont} oneLine={oneLine} p={palette} /> : null} | ||
modified
qa/e2e.mjs
+1 −1
@@ -13,7 +13,7 @@ let chromium; | |||||
| 13 | try { | 13 | try { | ||
| 14 | ({ chromium } = require("playwright")); | 14 | ({ chromium } = require("playwright")); | ||
| 15 | } catch { | 15 | } catch { | ||
| 16 | − ({ chromium } = require(path.join(process.env.HOME ?? "", "Desktop/Projets/apps-web/lou-ka/frontend/node_modules/playwright"))); | 16 | + ({ chromium } = require(path.join(process.env.HOME ?? "", "Desktop/Projets/apps-web/fetcha/qa/node_modules/playwright"))); | ||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | const BASE = (process.env.BASE ?? "http://localhost:8230").replace(/\/$/, ""); | 19 | const BASE = (process.env.BASE ?? "http://localhost:8230").replace(/\/$/, ""); | ||
| 20 | 20 | ||||