@idosgames/mcp 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +135 -135
  5. package/registry/modules/ai.json +3 -3
  6. package/registry/modules/board-game.json +15 -15
  7. package/registry/modules/boosts.json +3 -3
  8. package/registry/modules/character.json +8 -8
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +3 -3
  11. package/registry/modules/coop-events.json +3 -3
  12. package/registry/modules/craft.json +3 -3
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +7 -7
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/hero-run.json +20 -16
  17. package/registry/modules/idle-rpg.json +17 -13
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/leaderboard.json +7 -7
  21. package/registry/modules/lootboxes.json +7 -7
  22. package/registry/modules/mailbox.json +7 -7
  23. package/registry/modules/marketplace.json +3 -3
  24. package/registry/modules/mini-game.json +7 -7
  25. package/registry/modules/multiplayer.json +3 -3
  26. package/registry/modules/premium.json +3 -3
  27. package/registry/modules/profile.json +3 -3
  28. package/registry/modules/pvp.json +3 -3
  29. package/registry/modules/quests.json +3 -3
  30. package/registry/modules/rewards.json +5 -5
  31. package/registry/modules/season.json +3 -3
  32. package/registry/modules/store.json +7 -7
  33. package/registry/modules/tutorials.json +7 -7
  34. package/registry/modules/video-studio.json +4 -4
  35. package/registry/modules/voxelcraft.json +36 -28
  36. package/registry/modules/workshop.json +3 -3
  37. package/registry/skills/ai-generation-system.json +2 -2
  38. package/registry/skills/idosgames-ui-kit.json +1 -1
  39. package/registry/skills/rewarded-ads.json +1 -1
  40. package/registry/skills/store-system.json +1 -1
@@ -40,7 +40,7 @@
40
40
  "name": "iDos Games",
41
41
  "url": "https://idosgames.com"
42
42
  },
43
- "version": "0.1.1",
43
+ "version": "0.1.2",
44
44
  "events": {
45
45
  "emits": [
46
46
  {
@@ -69,13 +69,13 @@
69
69
  }
70
70
  },
71
71
  "dependencies": {
72
- "@idosgames/core": "0.24.2",
73
- "@idosgames/module-sdk": "0.13.1",
74
- "@idosgames/react": "0.6.2",
72
+ "@idosgames/core": "0.25.0",
73
+ "@idosgames/module-sdk": "0.13.2",
74
+ "@idosgames/react": "0.6.3",
75
75
  "phaser": "4.2.1",
76
76
  "react": "19.2.7"
77
77
  },
78
- "contentHash": "366e0e5848d4204b3e15e63a64a5fb1b9ee4245578f458131771a3dc18ec2e3c",
78
+ "contentHash": "0a95a09bfcc557220462eecf8ee89848e06c845df9fe631dea52dbeb46df2d1d",
79
79
  "files": [
80
80
  {
81
81
  "path": "character/art/svg.ts",
@@ -155,7 +155,7 @@
155
155
  },
156
156
  {
157
157
  "path": "character/editor/styles.ts",
158
- "content": "// The editor's look — dark slate side panels, a brown parts panel whose worn parts\n// are blue tiles, chunky white capitals. One stylesheet, every class prefixed \"ce-\".\n\nexport const EDITOR_CSS = `\n.ce-root { display: flex; gap: 0; align-items: stretch; font-family: \"Trebuchet MS\", \"Segoe UI\", sans-serif;\n color: #fff; background: #1f2a22; border-radius: 14px; overflow: hidden; min-height: 600px; user-select: none; }\n.ce-title { font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; text-align: center;\n font-size: 14px; margin: 14px 0 10px; text-shadow: 0 2px 0 #0006; }\n.ce-presets { width: 200px; background: #3a3d45; padding: 0 10px 12px; flex: none; }\n.ce-preset-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }\n.ce-preset { background: transparent; border: 2px solid transparent; border-radius: 8px; padding: 0;\n cursor: pointer; aspect-ratio: 3 / 4; }\n.ce-preset:hover { background: #ffffff12; }\n.ce-preset.ce-on { border-color: #fff; background: #ffffff1a; }\n.ce-preset canvas { width: 100%; height: 100%; display: block; }\n.ce-scene { position: relative; flex: 1; min-width: 360px; display: flex; flex-direction: column;\n align-items: center; justify-content: flex-end;\n background: radial-gradient(ellipse 34% 9% at 50% 84%, #6d4a35 0 70%, transparent 72%),\n radial-gradient(ellipse 60% 30% at 50% 82%, #7a5a40 0 40%, transparent 70%),\n linear-gradient(#3f6e45, #4f7f4c 55%, #446f43); }\n.ce-stage { flex: none; max-width: 100%; }\n.ce-stage canvas { width: 100% !important; height: auto !important; display: block; }\n.ce-actions { display: flex; gap: 14px; margin: 6px 0 22px; }\n.ce-big { font: inherit; font-weight: 800; font-size: 17px; letter-spacing: 0.05em; text-transform: uppercase;\n color: #fff; border: 3px solid #1d1d2a; border-radius: 6px; padding: 12px 22px; cursor: pointer;\n box-shadow: inset 0 -5px 0 #0003; text-shadow: 0 2px 0 #0007; }\n.ce-big:active { transform: translateY(2px); box-shadow: inset 0 -2px 0 #0003; }\n.ce-random { background: #5b55d9; }\n.ce-reset { background: #f28a3a; }\n.ce-options { width: 290px; background: #2d2f37; flex: none; display: flex; flex-direction: column; }\n.ce-options-head { display: flex; align-items: center; justify-content: center; position: relative;\n min-height: 46px; }\n.ce-close { position: absolute; right: 10px; top: 9px; width: 28px; height: 28px; border-radius: 6px;\n border: 2px solid #1d1d2a; background: #e2433f; color: #fff; font-weight: 800; cursor: pointer; }\n.ce-option-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 10px; overflow-y: auto;\n flex: 1; align-content: start; }\n.ce-option { background: #3a3d48; border: 2px solid transparent; border-radius: 6px; aspect-ratio: 1; padding: 0;\n cursor: pointer; position: relative; }\n.ce-option:hover { background: #474b58; }\n.ce-option.ce-on { background: #56607c; border-color: #dfe6ff; }\n.ce-option canvas { width: 100%; height: 100%; display: block; }\n.ce-none { position: absolute; inset: 22%; border: 3px solid #8a8fa0; border-radius: 50%; }\n.ce-none::after { content: \"\"; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 3px;\n background: #8a8fa0; transform: rotate(45deg); }\n.ce-picker { padding: 4px 18px 12px; }\n.ce-sv { position: relative; height: 130px; border-radius: 4px; border: 2px solid #1d1d2a; cursor: crosshair;\n touch-action: none; }\n.ce-hue { position: relative; height: 12px; margin-top: 12px; border-radius: 6px; cursor: pointer; touch-action: none;\n background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); }\n.ce-knob { position: absolute; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%;\n border: 2px solid #fff; box-shadow: 0 0 0 1px #0008; pointer-events: none; }\n.ce-sv .ce-knob { margin: -6px 0 0 -6px; }\n.ce-paint-reset { font: inherit; font-size: 11px; margin-top: 8px; background: none; color: #c8cbe0;\n border: 1px solid #555a6c; border-radius: 4px; padding: 2px 8px; cursor: pointer; }\n.ce-parts { width: 300px; background: #6b4532; padding: 0 12px 12px; flex: none; display: flex; flex-direction: column; }\n.ce-tile-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }\n.ce-tile { position: relative; aspect-ratio: 1.1; background: #8a5a3e; border: 3px solid #4a2c20; border-radius: 4px;\n padding: 0; cursor: pointer; }\n.ce-tile.ce-worn { background: #2f73b5; }\n.ce-tile.ce-open { border-color: #fff; }\n.ce-tile canvas { width: 100%; height: 100%; display: block; }\n.ce-tile.ce-hidden canvas { opacity: 0.35; }\n.ce-eye { position: absolute; right: 3px; top: 3px; width: 20px; height: 20px; border-radius: 50%; border: none;\n background: #fff; color: #2b2b38; font-size: 11px; line-height: 20px; padding: 0; cursor: pointer; }\n.ce-eye.ce-off { background: #9a9aa8; }\n.ce-anim { margin-top: auto; padding-top: 14px; }\n.ce-anim-row { display: flex; align-items: stretch; gap: 0; }\n.ce-arrow { width: 38px; border: 3px solid #1d1d2a; background: #f0a03a; color: #1d1d2a; font-weight: 900;\n font-size: 16px; cursor: pointer; border-radius: 4px; }\n.ce-anim-name { flex: 1; background: #111; display: flex; align-items: center; justify-content: center;\n font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; font-size: 13px; min-height: 36px; }\n@media (max-width: 1200px) {\n .ce-root { flex-wrap: wrap; }\n .ce-presets { width: 100%; }\n .ce-preset-grid { grid-template-columns: repeat(8, 1fr); }\n .ce-preset { aspect-ratio: 1; }\n .ce-scene { min-width: 300px; }\n .ce-options { width: 240px; }\n .ce-parts { width: 260px; }\n}\n@media (max-width: 760px) {\n .ce-scene, .ce-options, .ce-parts { width: 100%; min-width: 100%; }\n}\n`;\n"
158
+ "content": "// The editor's look — dark slate side panels, a brown parts panel whose worn parts\n// are blue tiles, chunky white capitals. One stylesheet, every class prefixed \"ce-\".\n\nexport const EDITOR_CSS = `\n.ce-root { display: flex; gap: 0; align-items: stretch; font-family: \"Trebuchet MS\", \"Segoe UI\", sans-serif;\n color: #fff; background: #1f2a22; border-radius: 14px; overflow: hidden; min-height: 600px; user-select: none; }\n.ce-title { font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; text-align: center;\n font-size: 14px; margin: 14px 0 10px; text-shadow: none; }\n.ce-presets { width: 200px; background: #3a3d45; padding: 0 10px 12px; flex: none; }\n.ce-preset-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }\n.ce-preset { background: transparent; border: 2px solid transparent; border-radius: 8px; padding: 0;\n cursor: pointer; aspect-ratio: 3 / 4; }\n.ce-preset:hover { background: #ffffff12; }\n.ce-preset.ce-on { border-color: #fff; background: #ffffff1a; }\n.ce-preset canvas { width: 100%; height: 100%; display: block; }\n.ce-scene { position: relative; flex: 1; min-width: 360px; display: flex; flex-direction: column;\n align-items: center; justify-content: flex-end;\n background: radial-gradient(ellipse 34% 9% at 50% 84%, #6d4a35 0 70%, transparent 72%),\n radial-gradient(ellipse 60% 30% at 50% 82%, #7a5a40 0 40%, transparent 70%),\n linear-gradient(#3f6e45, #4f7f4c 55%, #446f43); }\n.ce-stage { flex: none; max-width: 100%; }\n.ce-stage canvas { width: 100% !important; height: auto !important; display: block; }\n.ce-actions { display: flex; gap: 14px; margin: 6px 0 22px; }\n.ce-big { font: inherit; font-weight: 800; font-size: 17px; letter-spacing: 0.05em; text-transform: uppercase;\n color: #fff; border: 3px solid #1d1d2a; border-radius: 6px; padding: 12px 22px; cursor: pointer;\n box-shadow: inset 0 -5px 0 #0003; text-shadow: none; }\n.ce-big:active { transform: translateY(2px); box-shadow: inset 0 -2px 0 #0003; }\n.ce-random { background: #5b55d9; }\n.ce-reset { background: #f28a3a; }\n.ce-options { width: 290px; background: #2d2f37; flex: none; display: flex; flex-direction: column; }\n.ce-options-head { display: flex; align-items: center; justify-content: center; position: relative;\n min-height: 46px; }\n.ce-close { position: absolute; right: 10px; top: 9px; width: 28px; height: 28px; border-radius: 6px;\n border: 2px solid #1d1d2a; background: #e2433f; color: #fff; font-weight: 800; cursor: pointer; }\n.ce-option-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 10px; overflow-y: auto;\n flex: 1; align-content: start; }\n.ce-option { background: #3a3d48; border: 2px solid transparent; border-radius: 6px; aspect-ratio: 1; padding: 0;\n cursor: pointer; position: relative; }\n.ce-option:hover { background: #474b58; }\n.ce-option.ce-on { background: #56607c; border-color: #dfe6ff; }\n.ce-option canvas { width: 100%; height: 100%; display: block; }\n.ce-none { position: absolute; inset: 22%; border: 3px solid #8a8fa0; border-radius: 50%; }\n.ce-none::after { content: \"\"; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 3px;\n background: #8a8fa0; transform: rotate(45deg); }\n.ce-picker { padding: 4px 18px 12px; }\n.ce-sv { position: relative; height: 130px; border-radius: 4px; border: 2px solid #1d1d2a; cursor: crosshair;\n touch-action: none; }\n.ce-hue { position: relative; height: 12px; margin-top: 12px; border-radius: 6px; cursor: pointer; touch-action: none;\n background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); }\n.ce-knob { position: absolute; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%;\n border: 2px solid #fff; box-shadow: 0 0 0 1px #0008; pointer-events: none; }\n.ce-sv .ce-knob { margin: -6px 0 0 -6px; }\n.ce-paint-reset { font: inherit; font-size: 11px; margin-top: 8px; background: none; color: #c8cbe0;\n border: 1px solid #555a6c; border-radius: 4px; padding: 2px 8px; cursor: pointer; }\n.ce-parts { width: 300px; background: #6b4532; padding: 0 12px 12px; flex: none; display: flex; flex-direction: column; }\n.ce-tile-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }\n.ce-tile { position: relative; aspect-ratio: 1.1; background: #8a5a3e; border: 3px solid #4a2c20; border-radius: 4px;\n padding: 0; cursor: pointer; }\n.ce-tile.ce-worn { background: #2f73b5; }\n.ce-tile.ce-open { border-color: #fff; }\n.ce-tile canvas { width: 100%; height: 100%; display: block; }\n.ce-tile.ce-hidden canvas { opacity: 0.35; }\n.ce-eye { position: absolute; right: 3px; top: 3px; width: 20px; height: 20px; border-radius: 50%; border: none;\n background: #fff; color: #2b2b38; font-size: 11px; line-height: 20px; padding: 0; cursor: pointer; }\n.ce-eye.ce-off { background: #9a9aa8; }\n.ce-anim { margin-top: auto; padding-top: 14px; }\n.ce-anim-row { display: flex; align-items: stretch; gap: 0; }\n.ce-arrow { width: 38px; border: 3px solid #1d1d2a; background: #f0a03a; color: #1d1d2a; font-weight: 900;\n font-size: 16px; cursor: pointer; border-radius: 4px; }\n.ce-anim-name { flex: 1; background: #111; display: flex; align-items: center; justify-content: center;\n font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; font-size: 13px; min-height: 36px; }\n@media (max-width: 1200px) {\n .ce-root { flex-wrap: wrap; }\n .ce-presets { width: 100%; }\n .ce-preset-grid { grid-template-columns: repeat(8, 1fr); }\n .ce-preset { aspect-ratio: 1; }\n .ce-scene { min-width: 300px; }\n .ce-options { width: 240px; }\n .ce-parts { width: 260px; }\n}\n@media (max-width: 760px) {\n .ce-scene, .ce-options, .ce-parts { width: 100%; min-width: 100%; }\n}\n`;\n"
159
159
  },
160
160
  {
161
161
  "path": "character/index.ts",
@@ -263,7 +263,7 @@
263
263
  },
264
264
  {
265
265
  "path": "character/render/canvas/live.ts",
266
- "content": "import {\n Animator,\n type AnimationEvent,\n type PlayOptions,\n} from \"../../core/animator\";\nimport { worldGeometry } from \"../../core/attachments\";\nimport type { ArtCatalog } from \"../../core/catalog\";\nimport { skinImages } from \"../../core/data\";\nimport type { CharacterLook } from \"../../core/look\";\nimport { PartsManager } from \"../../core/PartsManager\";\nimport type { RigDef } from \"../../core/rig\";\nimport { Skeleton } from \"../../core/skeleton\";\nimport { hexToNumber } from \"../../core/types\";\nimport {\n createThumbnailer,\n vectorPictures,\n type PictureArt,\n} from \"./thumbnail\";\n\n// A character on a page without an engine — a hero on a screen of the UI (the heroes screen): one\n// <canvas> filling its host, the figure standing on the bottom edge, centred. Live, it plays like the\n// game's CharacterView (the same frame: the animation, the stance for what is held, the face for the\n// animation, worn colours, the world, every slot's triangles) on a small WebGL drawer of its own; still,\n// it is one frame drawn by the thumbnailer on a 2D canvas (a list of many heroes — a page may hold only\n// a few WebGL canvases). Without WebGL a live one falls back to still.\n\nexport interface CharacterCanvasOptions {\n rig: RigDef;\n catalog: ArtCatalog;\n look?: CharacterLook;\n /** 1 — the rig's own facing, -1 — mirrored. */\n facing?: 1 | -1;\n /** \"live\" (default) — moving; \"still\" — one frame of the rig's default animation. */\n mode?: \"live\" | \"still\";\n /** The animation to start with (default the rig's). */\n animation?: string;\n /**\n * How far the canvas reaches past its host, as shares of the host's size: the standing body fills\n * the host, while what sticks out — a long weapon, a sword raised — is drawn over the space around\n * (the canvas lets clicks through). Default: live on WebGL { side: 0.7, top: 0.5 }, still — none.\n */\n overflow?: { side: number; top: number };\n}\n\n/** Room round the standing body inside the host, as shares of it. */\nconst ROOM = { side: 0.04, top: 0.04 };\n\nexport class CharacterCanvas {\n readonly canvas: HTMLCanvasElement;\n readonly parts: PartsManager;\n readonly animator: Animator;\n private readonly skeleton: Skeleton;\n private readonly rig: RigDef;\n private readonly facing: 1 | -1;\n private readonly gl: Gl | null;\n private readonly overflow: { side: number; top: number };\n private readonly offs: Array<() => void> = [];\n private asked: { name: string; options?: PlayOptions } | null = null;\n private skinsDirty = true;\n private frame = 0;\n private last = 0;\n private visible = true;\n private dead = false;\n private stillDrawing: Promise<void> | null = null;\n private stillAgain = false;\n\n constructor(\n host: HTMLElement,\n private readonly options: CharacterCanvasOptions,\n ) {\n const rig = (this.rig = options.rig);\n this.facing = options.facing ?? 1;\n this.skeleton = new Skeleton(rig.data);\n const catalog = options.catalog;\n this.parts = new PartsManager(rig, (skin) =>\n skinImages(rig.data, skin).every((p) => catalog.has(p)),\n );\n if (options.look) this.parts.applyLook(options.look);\n this.animator = new Animator(this.skeleton, rig.data, rig.mix);\n this.animator.setBlink(rig.blink ?? null);\n this.play(options.animation ?? rig.defaultAnimation, { mix: 0 });\n\n const canvas = (this.canvas = document.createElement(\"canvas\"));\n this.gl = options.mode === \"still\" ? null : Gl.create(canvas, rig, catalog);\n // a still frame is framed on what is drawn: no room past the host\n const over = (this.overflow =\n options.overflow ??\n (this.gl ? { side: 0.7, top: 0.5 } : { side: 0, top: 0 }));\n canvas.style.cssText =\n `position:absolute;display:block;pointer-events:none;` +\n `left:${-over.side * 100}%;width:${(1 + 2 * over.side) * 100}%;` +\n `top:${-over.top * 100}%;height:${(1 + over.top) * 100}%`;\n if (getComputedStyle(host).position === \"static\")\n host.style.position = \"relative\";\n host.appendChild(canvas);\n\n this.offs.push(\n this.parts.on(() => {\n this.skinsDirty = true;\n if (!this.gl) this.drawStill();\n }),\n );\n const resize = new ResizeObserver(() => this.resize());\n resize.observe(canvas);\n this.offs.push(() => resize.disconnect());\n this.resize();\n if (this.gl) {\n // off screen (a closed tab of the lobby) it stops drawing\n const seen = new IntersectionObserver(([e]) => {\n this.visible = !!e?.isIntersecting;\n });\n seen.observe(canvas);\n this.offs.push(() => seen.disconnect());\n const loop = (now: number) => {\n if (this.dead) return;\n const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 0;\n this.last = now;\n if (this.visible) this.update(dt);\n this.frame = requestAnimationFrame(loop);\n };\n this.frame = requestAnimationFrame(loop);\n }\n }\n\n /** Plays an animation — or the rig's stance of it for what is worn (a walk with a rifle). */\n play(animation: string, options?: PlayOptions): boolean {\n this.asked = { name: animation, ...(options ? { options } : {}) };\n return this.animator.play(this.stanceOf(animation), options);\n }\n\n /** An animation event, \"end\", or \"*\". Returns the unsubscribe. */\n on(event: string, listener: (e: AnimationEvent) => void): () => void {\n const off = this.animator.on(event, listener);\n this.offs.push(off);\n return off;\n }\n\n setLook(look: CharacterLook): void {\n this.parts.reset();\n this.parts.applyLook(look);\n }\n\n destroy(): void {\n this.dead = true;\n cancelAnimationFrame(this.frame);\n for (const off of this.offs.splice(0)) off();\n this.gl?.destroy();\n this.canvas.remove();\n }\n\n private stanceOf(animation: string): string {\n const parts = this.parts;\n return (\n this.rig.stance?.(animation, (t) =>\n parts.isWorn(t) ? parts.part(t) : null,\n ) ?? animation\n );\n }\n\n private resize(): void {\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr));\n const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr));\n if (w === this.canvas.width && h === this.canvas.height) return;\n this.canvas.width = w;\n this.canvas.height = h;\n if (!this.gl) this.drawStill();\n }\n\n /** Where the rig's px land on the canvas: x → a·x + b, y (up) → c − a·y. */\n private view(): { a: number; b: number; c: number } {\n const d = this.rig.data;\n const side = d.width * ROOM.side;\n const w = d.width + side * 2;\n const h = d.height * (1 + ROOM.top);\n const a = Math.min(\n this.canvas.width / (1 + 2 * this.overflow.side) / w,\n this.canvas.height / (1 + this.overflow.top) / h,\n );\n const mid = (d.x + d.width / 2) * this.facing;\n return {\n a,\n b: this.canvas.width / 2 - mid * a,\n c: this.canvas.height + d.y * a,\n };\n }\n\n private update(dt: number): void {\n const gl = this.gl!;\n const sk = this.skeleton;\n const faces = this.rig.expressions;\n if (faces)\n this.parts.setExpression(\n faces.byAnimation[this.animator.current() ?? \"\"] ?? null,\n );\n if (this.skinsDirty) {\n this.skinsDirty = false;\n sk.setSkins(\n this.parts.wornSkins().flatMap((n) => {\n const s = this.rig.data.skins.get(n);\n return s ? [s] : [];\n }),\n );\n const asked = this.asked;\n if (asked && this.stanceOf(asked.name) !== this.animator.current())\n this.animator.play(this.stanceOf(asked.name), {\n ...asked.options,\n mix: 0.15,\n });\n }\n this.animator.update(dt);\n sk.slots.forEach((slot, i) => {\n const c = this.parts.slotColor(i);\n if (!c) return;\n const n = hexToNumber(c);\n slot.color.r = ((n >> 16) & 255) / 255;\n slot.color.g = ((n >> 8) & 255) / 255;\n slot.color.b = (n & 255) / 255;\n slot.color.a = 1;\n });\n sk.updateWorldTransform();\n const { a, b, c } = this.view();\n gl.begin(this.canvas.width, this.canvas.height, a * this.facing, b, c, a);\n sk.slots.forEach((slot, i) => {\n const at = slot.attachment;\n if (!at || slot.color.a * at.color.a <= 0) return;\n const g = worldGeometry(sk, slot, at, null);\n gl.draw(\n at.path,\n this.parts.artColor(i),\n g.positions,\n g.uvs,\n g.triangles,\n [\n slot.color.r * at.color.r,\n slot.color.g * at.color.g,\n slot.color.b * at.color.b,\n ],\n slot.color.a * at.color.a,\n );\n });\n }\n\n /** One frame on a 2D canvas (still, or no WebGL); a change while drawing draws again after. */\n private drawStill(): void {\n if (this.dead) return;\n if (this.stillDrawing) {\n this.stillAgain = true;\n return;\n }\n const parts = this.parts;\n const flipped = this.facing < 0;\n const target = flipped ? document.createElement(\"canvas\") : this.canvas;\n target.width = this.canvas.width;\n target.height = this.canvas.height;\n this.stillDrawing = stillThumbnailer(this.rig, this.options.catalog)\n .draw(target, {\n skins: parts.wornSkins(),\n slotColor: (i) => parts.slotColor(i),\n artColor: (i) => parts.artColor(i),\n frame: \"content\",\n padding: 0.04,\n })\n .then(() => {\n if (!flipped || this.dead) return;\n const ctx = this.canvas.getContext(\"2d\")!;\n ctx.setTransform(-1, 0, 0, 1, this.canvas.width, 0);\n ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);\n ctx.drawImage(target, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n })\n .finally(() => {\n this.stillDrawing = null;\n if (this.stillAgain) {\n this.stillAgain = false;\n this.drawStill();\n }\n });\n }\n}\n\nconst thumbnailers = new WeakMap<\n RigDef,\n ReturnType<typeof createThumbnailer>\n>();\n\nfunction stillThumbnailer(rig: RigDef, catalog: ArtCatalog) {\n let t = thumbnailers.get(rig);\n if (!t)\n thumbnailers.set(\n rig,\n (t = createThumbnailer(rig, pictures(rig, catalog, 3))),\n );\n return t;\n}\n\n/** The rasterized art of a rig, made once per resolution and shared by every canvas. */\nconst pictureCache = new WeakMap<RigDef, Map<number, PictureArt>>();\n\nfunction pictures(\n rig: RigDef,\n catalog: ArtCatalog,\n resolution: number,\n): PictureArt {\n let byRes = pictureCache.get(rig);\n if (!byRes) pictureCache.set(rig, (byRes = new Map()));\n let p = byRes.get(resolution);\n if (!p) byRes.set(resolution, (p = vectorPictures(rig, catalog, resolution)));\n return p;\n}\n\n// ── The WebGL drawer ─────────────────────────────────────────────────────────────────────────────\n\nconst VERTEX = `\nattribute vec2 a_pos;\nattribute vec2 a_uv;\nuniform vec4 u_view;\nvarying vec2 v_uv;\nvoid main() {\n v_uv = a_uv;\n gl_Position = vec4(a_pos * u_view.xy + u_view.zw, 0.0, 1.0);\n}`;\n\nconst FRAGMENT = `\nprecision mediump float;\nuniform sampler2D u_tex;\nuniform vec4 u_tint;\nvarying vec2 v_uv;\nvoid main() {\n gl_FragColor = texture2D(u_tex, v_uv) * vec4(u_tint.rgb * u_tint.a, u_tint.a);\n}`;\n\n/** Triangles of the art on one WebGL canvas: a texture per image, premultiplied alpha. */\nclass Gl {\n private readonly textures = new Map<string, WebGLTexture | \"loading\">();\n private readonly pos: WebGLBuffer;\n private readonly uv: WebGLBuffer;\n private readonly index: WebGLBuffer;\n private readonly program: WebGLProgram;\n private readonly uView: WebGLUniformLocation;\n private readonly uTint: WebGLUniformLocation;\n private readonly art: PictureArt;\n private lost = false;\n\n static create(\n canvas: HTMLCanvasElement,\n rig: RigDef,\n catalog: ArtCatalog,\n ): Gl | null {\n const gl = canvas.getContext(\"webgl\", {\n premultipliedAlpha: true,\n alpha: true,\n antialias: true,\n });\n if (!gl) return null;\n try {\n return new Gl(gl, canvas, pictures(rig, catalog, 3));\n } catch {\n return null;\n }\n }\n\n private constructor(\n private readonly gl: WebGLRenderingContext,\n canvas: HTMLCanvasElement,\n art: PictureArt,\n ) {\n this.art = art;\n const shader = (type: number, src: string) => {\n const s = gl.createShader(type)!;\n gl.shaderSource(s, src);\n gl.compileShader(s);\n if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))\n throw new Error(gl.getShaderInfoLog(s) ?? \"shader\");\n return s;\n };\n const p = (this.program = gl.createProgram()!);\n gl.attachShader(p, shader(gl.VERTEX_SHADER, VERTEX));\n gl.attachShader(p, shader(gl.FRAGMENT_SHADER, FRAGMENT));\n gl.linkProgram(p);\n if (!gl.getProgramParameter(p, gl.LINK_STATUS))\n throw new Error(gl.getProgramInfoLog(p) ?? \"program\");\n gl.useProgram(p);\n this.uView = gl.getUniformLocation(p, \"u_view\")!;\n this.uTint = gl.getUniformLocation(p, \"u_tint\")!;\n this.pos = gl.createBuffer()!;\n this.uv = gl.createBuffer()!;\n this.index = gl.createBuffer()!;\n const aPos = gl.getAttribLocation(p, \"a_pos\");\n const aUv = gl.getAttribLocation(p, \"a_uv\");\n gl.bindBuffer(gl.ARRAY_BUFFER, this.pos);\n gl.enableVertexAttribArray(aPos);\n gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.uv);\n gl.enableVertexAttribArray(aUv);\n gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 0, 0);\n gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.index);\n gl.enable(gl.BLEND);\n gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);\n canvas.addEventListener(\"webglcontextlost\", () => (this.lost = true));\n }\n\n /** Clears the canvas; px of the rig map to canvas px as x → sx·x + tx, y → ty − sy·y. */\n begin(\n width: number,\n height: number,\n sx: number,\n tx: number,\n ty: number,\n sy: number,\n ): void {\n const gl = this.gl;\n if (this.lost) return;\n gl.viewport(0, 0, width, height);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n // canvas px → clip space: x/w·2 − 1, 1 − y/h·2\n gl.uniform4f(\n this.uView,\n (sx / width) * 2,\n (sy / height) * 2,\n (tx / width) * 2 - 1,\n 1 - (ty / height) * 2,\n );\n }\n\n draw(\n path: string,\n paint: string | null,\n positions: Float32Array,\n uvs: Float32Array,\n triangles: Uint16Array,\n tint: [number, number, number],\n alpha: number,\n ): void {\n if (this.lost) return;\n const tex = this.texture(path, paint);\n if (!tex) return;\n const gl = this.gl;\n gl.bindTexture(gl.TEXTURE_2D, tex);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.pos);\n gl.bufferData(gl.ARRAY_BUFFER, positions, gl.DYNAMIC_DRAW);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.uv);\n gl.bufferData(gl.ARRAY_BUFFER, uvs, gl.DYNAMIC_DRAW);\n gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, triangles, gl.DYNAMIC_DRAW);\n gl.uniform4f(this.uTint, tint[0], tint[1], tint[2], alpha);\n gl.drawElements(gl.TRIANGLES, triangles.length, gl.UNSIGNED_SHORT, 0);\n }\n\n destroy(): void {\n const gl = this.gl;\n for (const t of this.textures.values())\n if (t !== \"loading\") gl.deleteTexture(t);\n this.textures.clear();\n gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n }\n\n /** The image's texture, or null while it is being made. */\n private texture(path: string, paint: string | null): WebGLTexture | null {\n const key = paint ? `${path}${paint}` : path;\n const t = this.textures.get(key);\n if (t) return t === \"loading\" ? null : t;\n this.textures.set(key, \"loading\");\n void this.art.image(path, paint).then((img) => {\n if (!img || this.lost) return;\n const gl = this.gl;\n const tex = gl.createTexture()!;\n gl.bindTexture(gl.TEXTURE_2D, tex);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);\n this.textures.set(key, tex);\n });\n return null;\n }\n}\n"
266
+ "content": "import {\n Animator,\n type AnimationEvent,\n type PlayOptions,\n} from \"../../core/animator\";\nimport { worldGeometry } from \"../../core/attachments\";\nimport type { ArtCatalog } from \"../../core/catalog\";\nimport { skinImages } from \"../../core/data\";\nimport type { CharacterLook } from \"../../core/look\";\nimport { PartsManager } from \"../../core/PartsManager\";\nimport type { RigDef } from \"../../core/rig\";\nimport { Skeleton } from \"../../core/skeleton\";\nimport { hexToNumber } from \"../../core/types\";\nimport {\n createThumbnailer,\n vectorPictures,\n type PictureArt,\n} from \"./thumbnail\";\n\n// A character on a page without an engine — a hero on a screen of the UI (the heroes screen): one\n// <canvas> filling its host, the figure standing on the bottom edge, centred. Live, it plays like the\n// game's CharacterView (the same frame: the animation, the stance for what is held, the face for the\n// animation, worn colours, the world, every slot's triangles) on a small WebGL drawer of its own; still,\n// it is one frame drawn by the thumbnailer on a 2D canvas (a list of many heroes — a page may hold only\n// a few WebGL canvases). Without WebGL a live one falls back to still.\n\nexport interface CharacterCanvasOptions {\n rig: RigDef;\n catalog: ArtCatalog;\n look?: CharacterLook;\n /** 1 — the rig's own facing, -1 — mirrored. */\n facing?: 1 | -1;\n /** \"live\" (default) — moving; \"still\" — one frame of the rig's default animation. */\n mode?: \"live\" | \"still\";\n /** The animation to start with (default the rig's). */\n animation?: string;\n /**\n * How far the canvas reaches past its host, as shares of the host's size: the standing body fills\n * the host, while what sticks out — a long weapon, a sword raised — is drawn over the space around\n * (the canvas lets clicks through). Default: live on WebGL { side: 0.7, top: 0.5 }, still — none.\n */\n overflow?: { side: number; top: number };\n}\n\n/** Room round the standing body inside the host, as shares of it. */\nconst ROOM = { side: 0.04, top: 0.04 };\n\nexport class CharacterCanvas {\n readonly canvas: HTMLCanvasElement;\n readonly parts: PartsManager;\n readonly animator: Animator;\n private readonly skeleton: Skeleton;\n private readonly rig: RigDef;\n private readonly facing: 1 | -1;\n private readonly gl: Gl | null;\n private readonly overflow: { side: number; top: number };\n private readonly offs: Array<() => void> = [];\n private asked: { name: string; options?: PlayOptions } | null = null;\n private skinsDirty = true;\n private frame = 0;\n private last = 0;\n private visible = true;\n private dead = false;\n private stillDrawing: Promise<void> | null = null;\n private stillAgain = false;\n\n constructor(\n host: HTMLElement,\n private readonly options: CharacterCanvasOptions,\n ) {\n const rig = (this.rig = options.rig);\n this.facing = options.facing ?? 1;\n this.skeleton = new Skeleton(rig.data);\n const catalog = options.catalog;\n this.parts = new PartsManager(rig, (skin) =>\n skinImages(rig.data, skin).every((p) => catalog.has(p)),\n );\n if (options.look) this.parts.applyLook(options.look);\n this.animator = new Animator(this.skeleton, rig.data, rig.mix);\n this.animator.setBlink(rig.blink ?? null);\n this.play(options.animation ?? rig.defaultAnimation, { mix: 0 });\n\n const canvas = (this.canvas = document.createElement(\"canvas\"));\n this.gl = options.mode === \"still\" ? null : Gl.create(canvas, rig, catalog);\n // a still frame is framed on what is drawn: no room past the host\n const over = (this.overflow =\n options.overflow ??\n (this.gl ? { side: 0.7, top: 0.5 } : { side: 0, top: 0 }));\n canvas.style.cssText =\n `position:absolute;display:block;pointer-events:none;` +\n `left:${-over.side * 100}%;width:${(1 + 2 * over.side) * 100}%;` +\n `top:${-over.top * 100}%;height:${(1 + over.top) * 100}%`;\n if (getComputedStyle(host).position === \"static\")\n host.style.position = \"relative\";\n host.appendChild(canvas);\n\n this.offs.push(\n this.parts.on(() => {\n this.skinsDirty = true;\n if (!this.gl) this.drawStill();\n }),\n );\n const resize = new ResizeObserver(() => this.resize());\n resize.observe(canvas);\n this.offs.push(() => resize.disconnect());\n this.resize();\n if (this.gl) {\n // off screen (a closed tab of the lobby) it stops drawing\n const seen = new IntersectionObserver(([e]) => {\n this.visible = !!e?.isIntersecting;\n });\n seen.observe(canvas);\n this.offs.push(() => seen.disconnect());\n const loop = (now: number) => {\n if (this.dead) return;\n const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 0;\n this.last = now;\n if (this.visible) this.update(dt);\n this.frame = requestAnimationFrame(loop);\n };\n this.frame = requestAnimationFrame(loop);\n }\n }\n\n /** Plays an animation — or the rig's stance of it for what is worn (a walk with a rifle). */\n play(animation: string, options?: PlayOptions): boolean {\n this.asked = { name: animation, ...(options ? { options } : {}) };\n return this.animator.play(this.stanceOf(animation), options);\n }\n\n /** An animation event, \"end\", or \"*\". Returns the unsubscribe. */\n on(event: string, listener: (e: AnimationEvent) => void): () => void {\n const off = this.animator.on(event, listener);\n this.offs.push(off);\n return off;\n }\n\n setLook(look: CharacterLook): void {\n this.parts.reset();\n this.parts.applyLook(look);\n }\n\n destroy(): void {\n this.dead = true;\n cancelAnimationFrame(this.frame);\n for (const off of this.offs.splice(0)) off();\n this.gl?.destroy();\n this.canvas.remove();\n }\n\n private stanceOf(animation: string): string {\n const parts = this.parts;\n return (\n this.rig.stance?.(animation, (t) =>\n parts.isWorn(t) ? parts.part(t) : null,\n ) ?? animation\n );\n }\n\n private resize(): void {\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n // × the CSS zoom it is drawn at: inside a zoomed kit surface (a small phone, the UI kit's fit.ts)\n // clientWidth is the unzoomed box, and the canvas took up to 1.8× the pixels it shows. Not\n // getBoundingClientRect: that one also shrinks with a popup's opening scale, and the canvas would\n // stay at that size.\n const zoom =\n (this.canvas as HTMLCanvasElement & { currentCSSZoom?: number })\n .currentCSSZoom || 1;\n const w = Math.max(1, Math.round(this.canvas.clientWidth * zoom * dpr));\n const h = Math.max(1, Math.round(this.canvas.clientHeight * zoom * dpr));\n if (w === this.canvas.width && h === this.canvas.height) return;\n this.canvas.width = w;\n this.canvas.height = h;\n if (!this.gl) this.drawStill();\n }\n\n /** Where the rig's px land on the canvas: x → a·x + b, y (up) → c − a·y. */\n private view(): { a: number; b: number; c: number } {\n const d = this.rig.data;\n const side = d.width * ROOM.side;\n const w = d.width + side * 2;\n const h = d.height * (1 + ROOM.top);\n const a = Math.min(\n this.canvas.width / (1 + 2 * this.overflow.side) / w,\n this.canvas.height / (1 + this.overflow.top) / h,\n );\n const mid = (d.x + d.width / 2) * this.facing;\n return {\n a,\n b: this.canvas.width / 2 - mid * a,\n c: this.canvas.height + d.y * a,\n };\n }\n\n private update(dt: number): void {\n const gl = this.gl!;\n const sk = this.skeleton;\n const faces = this.rig.expressions;\n if (faces)\n this.parts.setExpression(\n faces.byAnimation[this.animator.current() ?? \"\"] ?? null,\n );\n if (this.skinsDirty) {\n this.skinsDirty = false;\n sk.setSkins(\n this.parts.wornSkins().flatMap((n) => {\n const s = this.rig.data.skins.get(n);\n return s ? [s] : [];\n }),\n );\n const asked = this.asked;\n if (asked && this.stanceOf(asked.name) !== this.animator.current())\n this.animator.play(this.stanceOf(asked.name), {\n ...asked.options,\n mix: 0.15,\n });\n }\n this.animator.update(dt);\n sk.slots.forEach((slot, i) => {\n const c = this.parts.slotColor(i);\n if (!c) return;\n const n = hexToNumber(c);\n slot.color.r = ((n >> 16) & 255) / 255;\n slot.color.g = ((n >> 8) & 255) / 255;\n slot.color.b = (n & 255) / 255;\n slot.color.a = 1;\n });\n sk.updateWorldTransform();\n const { a, b, c } = this.view();\n gl.begin(this.canvas.width, this.canvas.height, a * this.facing, b, c, a);\n sk.slots.forEach((slot, i) => {\n const at = slot.attachment;\n if (!at || slot.color.a * at.color.a <= 0) return;\n const g = worldGeometry(sk, slot, at, null);\n gl.draw(\n at.path,\n this.parts.artColor(i),\n g.positions,\n g.uvs,\n g.triangles,\n [\n slot.color.r * at.color.r,\n slot.color.g * at.color.g,\n slot.color.b * at.color.b,\n ],\n slot.color.a * at.color.a,\n );\n });\n }\n\n /** One frame on a 2D canvas (still, or no WebGL); a change while drawing draws again after. */\n private drawStill(): void {\n if (this.dead) return;\n if (this.stillDrawing) {\n this.stillAgain = true;\n return;\n }\n const parts = this.parts;\n const flipped = this.facing < 0;\n const target = flipped ? document.createElement(\"canvas\") : this.canvas;\n target.width = this.canvas.width;\n target.height = this.canvas.height;\n this.stillDrawing = stillThumbnailer(this.rig, this.options.catalog)\n .draw(target, {\n skins: parts.wornSkins(),\n slotColor: (i) => parts.slotColor(i),\n artColor: (i) => parts.artColor(i),\n frame: \"content\",\n padding: 0.04,\n })\n .then(() => {\n if (!flipped || this.dead) return;\n const ctx = this.canvas.getContext(\"2d\")!;\n ctx.setTransform(-1, 0, 0, 1, this.canvas.width, 0);\n ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);\n ctx.drawImage(target, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n })\n .finally(() => {\n this.stillDrawing = null;\n if (this.stillAgain) {\n this.stillAgain = false;\n this.drawStill();\n }\n });\n }\n}\n\nconst thumbnailers = new WeakMap<\n RigDef,\n ReturnType<typeof createThumbnailer>\n>();\n\nfunction stillThumbnailer(rig: RigDef, catalog: ArtCatalog) {\n let t = thumbnailers.get(rig);\n if (!t)\n thumbnailers.set(\n rig,\n (t = createThumbnailer(rig, pictures(rig, catalog, 3))),\n );\n return t;\n}\n\n/** The rasterized art of a rig, made once per resolution and shared by every canvas. */\nconst pictureCache = new WeakMap<RigDef, Map<number, PictureArt>>();\n\nfunction pictures(\n rig: RigDef,\n catalog: ArtCatalog,\n resolution: number,\n): PictureArt {\n let byRes = pictureCache.get(rig);\n if (!byRes) pictureCache.set(rig, (byRes = new Map()));\n let p = byRes.get(resolution);\n if (!p) byRes.set(resolution, (p = vectorPictures(rig, catalog, resolution)));\n return p;\n}\n\n// ── The WebGL drawer ─────────────────────────────────────────────────────────────────────────────\n\nconst VERTEX = `\nattribute vec2 a_pos;\nattribute vec2 a_uv;\nuniform vec4 u_view;\nvarying vec2 v_uv;\nvoid main() {\n v_uv = a_uv;\n gl_Position = vec4(a_pos * u_view.xy + u_view.zw, 0.0, 1.0);\n}`;\n\nconst FRAGMENT = `\nprecision mediump float;\nuniform sampler2D u_tex;\nuniform vec4 u_tint;\nvarying vec2 v_uv;\nvoid main() {\n gl_FragColor = texture2D(u_tex, v_uv) * vec4(u_tint.rgb * u_tint.a, u_tint.a);\n}`;\n\n/** Triangles of the art on one WebGL canvas: a texture per image, premultiplied alpha. */\nclass Gl {\n private readonly textures = new Map<string, WebGLTexture | \"loading\">();\n private readonly pos: WebGLBuffer;\n private readonly uv: WebGLBuffer;\n private readonly index: WebGLBuffer;\n private readonly program: WebGLProgram;\n private readonly uView: WebGLUniformLocation;\n private readonly uTint: WebGLUniformLocation;\n private readonly art: PictureArt;\n private lost = false;\n\n static create(\n canvas: HTMLCanvasElement,\n rig: RigDef,\n catalog: ArtCatalog,\n ): Gl | null {\n const gl = canvas.getContext(\"webgl\", {\n premultipliedAlpha: true,\n alpha: true,\n antialias: true,\n });\n if (!gl) return null;\n try {\n return new Gl(gl, canvas, pictures(rig, catalog, 3));\n } catch {\n return null;\n }\n }\n\n private constructor(\n private readonly gl: WebGLRenderingContext,\n canvas: HTMLCanvasElement,\n art: PictureArt,\n ) {\n this.art = art;\n const shader = (type: number, src: string) => {\n const s = gl.createShader(type)!;\n gl.shaderSource(s, src);\n gl.compileShader(s);\n if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))\n throw new Error(gl.getShaderInfoLog(s) ?? \"shader\");\n return s;\n };\n const p = (this.program = gl.createProgram()!);\n gl.attachShader(p, shader(gl.VERTEX_SHADER, VERTEX));\n gl.attachShader(p, shader(gl.FRAGMENT_SHADER, FRAGMENT));\n gl.linkProgram(p);\n if (!gl.getProgramParameter(p, gl.LINK_STATUS))\n throw new Error(gl.getProgramInfoLog(p) ?? \"program\");\n gl.useProgram(p);\n this.uView = gl.getUniformLocation(p, \"u_view\")!;\n this.uTint = gl.getUniformLocation(p, \"u_tint\")!;\n this.pos = gl.createBuffer()!;\n this.uv = gl.createBuffer()!;\n this.index = gl.createBuffer()!;\n const aPos = gl.getAttribLocation(p, \"a_pos\");\n const aUv = gl.getAttribLocation(p, \"a_uv\");\n gl.bindBuffer(gl.ARRAY_BUFFER, this.pos);\n gl.enableVertexAttribArray(aPos);\n gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.uv);\n gl.enableVertexAttribArray(aUv);\n gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 0, 0);\n gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.index);\n gl.enable(gl.BLEND);\n gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);\n canvas.addEventListener(\"webglcontextlost\", () => (this.lost = true));\n }\n\n /** Clears the canvas; px of the rig map to canvas px as x → sx·x + tx, y → ty − sy·y. */\n begin(\n width: number,\n height: number,\n sx: number,\n tx: number,\n ty: number,\n sy: number,\n ): void {\n const gl = this.gl;\n if (this.lost) return;\n gl.viewport(0, 0, width, height);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n // canvas px → clip space: x/w·2 − 1, 1 − y/h·2\n gl.uniform4f(\n this.uView,\n (sx / width) * 2,\n (sy / height) * 2,\n (tx / width) * 2 - 1,\n 1 - (ty / height) * 2,\n );\n }\n\n draw(\n path: string,\n paint: string | null,\n positions: Float32Array,\n uvs: Float32Array,\n triangles: Uint16Array,\n tint: [number, number, number],\n alpha: number,\n ): void {\n if (this.lost) return;\n const tex = this.texture(path, paint);\n if (!tex) return;\n const gl = this.gl;\n gl.bindTexture(gl.TEXTURE_2D, tex);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.pos);\n gl.bufferData(gl.ARRAY_BUFFER, positions, gl.DYNAMIC_DRAW);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.uv);\n gl.bufferData(gl.ARRAY_BUFFER, uvs, gl.DYNAMIC_DRAW);\n gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, triangles, gl.DYNAMIC_DRAW);\n gl.uniform4f(this.uTint, tint[0], tint[1], tint[2], alpha);\n gl.drawElements(gl.TRIANGLES, triangles.length, gl.UNSIGNED_SHORT, 0);\n }\n\n destroy(): void {\n const gl = this.gl;\n for (const t of this.textures.values())\n if (t !== \"loading\") gl.deleteTexture(t);\n this.textures.clear();\n gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n }\n\n /** The image's texture, or null while it is being made. */\n private texture(path: string, paint: string | null): WebGLTexture | null {\n const key = paint ? `${path}${paint}` : path;\n const t = this.textures.get(key);\n if (t) return t === \"loading\" ? null : t;\n this.textures.set(key, \"loading\");\n void this.art.image(path, paint).then((img) => {\n if (!img || this.lost) return;\n const gl = this.gl;\n const tex = gl.createTexture()!;\n gl.bindTexture(gl.TEXTURE_2D, tex);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);\n this.textures.set(key, tex);\n });\n return null;\n }\n}\n"
267
267
  },
268
268
  {
269
269
  "path": "character/render/canvas/thumbnail.ts",
@@ -279,7 +279,7 @@
279
279
  },
280
280
  {
281
281
  "path": "character/render/phaser/stage.ts",
282
- "content": "import Phaser from \"phaser\";\nimport type { ArtCatalog } from \"../../core/catalog\";\nimport type { CharacterLook } from \"../../core/look\";\nimport type { RigDef } from \"../../core/rig\";\nimport { vectorArt, type ArtSource } from \"./artSource\";\nimport { CharacterView } from \"./CharacterView\";\n\n// A character on its own little canvas, for screens outside a game scene: the maker page, a hero's\n// portrait, a shop preview. One Phaser game per stage — destroy it when the screen goes.\n\nexport interface CharacterStageOptions {\n rig: RigDef;\n catalog: ArtCatalog;\n look?: CharacterLook;\n width: number;\n height: number;\n /** The character's scale; it stands centred, its feet on a line 88% down. */\n scale?: number;\n /** Background colour; omitted — transparent. */\n background?: string;\n}\n\nexport interface CharacterStage {\n view: CharacterView;\n game: Phaser.Game;\n /** Our vector art for this stage (to switch back to after another source). */\n vector: ArtSource;\n destroy(): void;\n}\n\nexport function createCharacterStage(\n parent: HTMLElement,\n options: CharacterStageOptions,\n): Promise<CharacterStage> {\n return new Promise((resolve) => {\n const { width, height } = options;\n class StageScene extends Phaser.Scene {\n create(): void {\n const scale = options.scale ?? 1;\n const vector = vectorArt(\n this.textures,\n options.catalog,\n Math.max(2, Math.ceil(scale * 1.5)),\n );\n const view = new CharacterView(this, {\n rig: options.rig,\n art: vector,\n ...(options.look ? { look: options.look } : {}),\n x: width / 2,\n y: Math.round(height * 0.88),\n scale,\n });\n resolve({ view, game, vector, destroy: () => game.destroy(true) });\n }\n }\n const game = new Phaser.Game({\n type: Phaser.WEBGL,\n parent,\n width,\n height,\n transparent: !options.background,\n ...(options.background ? { backgroundColor: options.background } : {}),\n scene: StageScene,\n banner: false,\n audio: { noAudio: true },\n });\n });\n}\n"
282
+ "content": "import Phaser from \"phaser\";\nimport type { ArtCatalog } from \"../../core/catalog\";\nimport type { CharacterLook } from \"../../core/look\";\nimport type { RigDef } from \"../../core/rig\";\nimport { vectorArt, type ArtSource } from \"./artSource\";\nimport { CharacterView } from \"./CharacterView\";\n\n// A character on its own little canvas, for screens outside a game scene: the maker page, a hero's\n// portrait, a shop preview. One Phaser game per stage — destroy it when the screen goes.\n\nexport interface CharacterStageOptions {\n rig: RigDef;\n catalog: ArtCatalog;\n look?: CharacterLook;\n width: number;\n height: number;\n /** The character's scale; it stands centred, its feet on a line 88% down. */\n scale?: number;\n /** Background colour; omitted — transparent. */\n background?: string;\n}\n\nexport interface CharacterStage {\n view: CharacterView;\n game: Phaser.Game;\n /** Our vector art for this stage (to switch back to after another source). */\n vector: ArtSource;\n destroy(): void;\n}\n\nexport function createCharacterStage(\n parent: HTMLElement,\n options: CharacterStageOptions,\n): Promise<CharacterStage> {\n return new Promise((resolve) => {\n const { width, height } = options;\n // Drawn at the screen's density (at most 2), the camera zoomed by it — the stage keeps thinking in\n // CSS pixels. Phaser alone draws in CSS pixels, and a phone stretches that canvas: soap.\n const k = Math.min(\n 2,\n Math.max(\n 1,\n (typeof devicePixelRatio === \"number\" && devicePixelRatio) || 1,\n ),\n );\n class StageScene extends Phaser.Scene {\n create(): void {\n this.cameras.main.setOrigin(0, 0).setZoom(k);\n const scale = options.scale ?? 1;\n const vector = vectorArt(\n this.textures,\n options.catalog,\n Math.max(2, Math.ceil(scale * k * 1.5)),\n );\n const view = new CharacterView(this, {\n rig: options.rig,\n art: vector,\n ...(options.look ? { look: options.look } : {}),\n x: width / 2,\n y: Math.round(height * 0.88),\n scale,\n });\n resolve({ view, game, vector, destroy: () => game.destroy(true) });\n }\n }\n const game = new Phaser.Game({\n type: Phaser.WEBGL,\n parent,\n width: Math.ceil(width * k),\n height: Math.ceil(height * k),\n scale: { mode: Phaser.Scale.NONE, zoom: 1 / k },\n // every part of a character is its own texture; one texture per draw (Phaser's phone default)\n // made each part a separate draw call\n render: { autoMobileTextures: false },\n transparent: !options.background,\n ...(options.background ? { backgroundColor: options.background } : {}),\n scene: StageScene,\n banner: false,\n audio: { noAudio: true },\n });\n });\n}\n"
283
283
  },
284
284
  {
285
285
  "path": "character/render/phaser/textures.ts",
@@ -363,7 +363,7 @@
363
363
  },
364
364
  {
365
365
  "path": "game/HeroRunController.ts",
366
- "content": "import Phaser from \"phaser\";\nimport { BattleScene } from \"../phaser/BattleScene\";\nimport type { RunSession } from \"./session\";\n\n/**\n * Owns the Phaser game that draws the session's fight. Created on mount (Phaser needs the host\n * element), destroyed with the mode — the session (and with it the fight and the progress) lives on.\n */\nexport class HeroRunController {\n private readonly game: Phaser.Game;\n private readonly sizeWatch: ResizeObserver | null;\n\n constructor(\n parent: HTMLElement,\n readonly session: RunSession,\n ) {\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: parent.clientWidth || 480,\n height: parent.clientHeight || 800,\n backgroundColor: \"#7ec8ff\",\n scene: new BattleScene(session),\n scale: {\n // The canvas fills the full-bleed host; the scene draws the field into its top part and the\n // module's panel covers the rest (layout.ts).\n mode: Phaser.Scale.RESIZE,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n\n // The canvas follows its host, not only the window: the lobby lays its header and tabs around the\n // game after it mounts (and Phaser's own first measure can come before that), and a canvas left\n // at another size overflows the frame — which then scrolls, the road sliding up under the HUD.\n const fit = () => {\n const w = parent.clientWidth;\n const h = parent.clientHeight;\n if (\n w > 0 &&\n h > 0 &&\n (w !== this.game.scale.width || h !== this.game.scale.height)\n )\n this.game.scale.resize(w, h);\n };\n this.sizeWatch =\n typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(fit);\n this.sizeWatch?.observe(parent);\n this.game.scale.on(\"resize\", () => queueMicrotask(fit));\n this.game.events.once(\"ready\", fit);\n }\n\n /** Whether the Phaser loop is running (the agent's \"am I even running\" flag). */\n get running(): boolean {\n return this.game.loop.running;\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs) — and the fight with it. */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.sizeWatch?.disconnect();\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
366
+ "content": "import Phaser from \"phaser\";\nimport { BattleScene } from \"../phaser/BattleScene\";\nimport { screenDensity } from \"../phaser/density\";\nimport { BATTLE_MAX_PX, BATTLE_SHARE } from \"../layout\";\nimport type { RunSession } from \"./session\";\n\n/** Under this many frames a second the field tries a coarser canvas. */\nconst SLOW_FPS = 40;\n/** How often the frame rate is looked at, and how many looks make one judgement (their average). */\nconst WATCH_MS = 2000;\nconst LOOKS = 2;\n/** A coarser canvas is kept only if it brought this much more speed; else the density goes back. */\nconst MIN_GAIN = 1.15;\n/** The first seconds after a start, a wake or a change are not judged: textures upload, the page settles. */\nconst SETTLE_MS = 4000;\n\n/** What this page learned about its phone — a remount starts from it instead of trying again. */\nconst learned = {\n /** The density the phone keeps up with. */\n cap: Number.POSITIVE_INFINITY,\n /** A try showed the density is not what slows the phone: no more tries. */\n settled: false,\n};\n\n/**\n * Owns the Phaser game that draws the session's fight. Created on mount (Phaser needs the host\n * element), destroyed with the mode — the session (and with it the fight and the progress) lives on.\n *\n * The canvas covers the FIELD only (the top part of the host, layout.ts), not the whole host: the\n * Enhance panel below is opaque, and a canvas under it was a third more pixels filled every frame for\n * nothing. It is drawn at the screen's density (phaser/density.ts) — sharp on a phone — and tries a\n * coarser canvas when the phone cannot keep up (`watchSpeed`).\n */\nexport class HeroRunController {\n private readonly game: Phaser.Game;\n private readonly sizeWatch: ResizeObserver | null;\n private readonly speedWatch: ReturnType<typeof setInterval> | null;\n private density: number;\n private judgedFrom = 0;\n private looks: number[] = [];\n /**\n * A coarser canvas on trial: the density before it, the frame rate it had, and — once measured — the\n * frame rate at the coarser one (then the canvas is back at `from` for the second look at it).\n */\n private trial: {\n from: number;\n to: number;\n before: number;\n coarse?: number;\n } | null = null;\n\n constructor(\n private readonly parent: HTMLElement,\n readonly session: RunSession,\n ) {\n this.density = Math.min(screenDensity(), learned.cap);\n const box = this.fieldBox() ?? { w: 480, h: 360 };\n // ⚠ The canvas is rounded UP to whole pixels, so its box can stick out of the host by a fraction\n // of a CSS pixel: clipped here, or the frame would get a scroll for it.\n parent.style.overflow = \"hidden\";\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: this.pixels(box.w),\n height: this.pixels(box.h),\n backgroundColor: \"#7ec8ff\",\n scene: new BattleScene(session),\n scale: {\n // Our own size, not Phaser's RESIZE: that one makes the canvas the host's size in CSS pixels.\n mode: Phaser.Scale.NONE,\n zoom: 1 / this.density,\n },\n render: {\n // ⚠ Phaser on a phone batches ONE texture per draw by default (it was slower on old mobile\n // GPUs), and every part of a character is its own texture: the fight was ~145 draws a frame.\n // With all the texture units it is ~15, a few frames a second faster on the owner's phone.\n autoMobileTextures: false,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n\n // The canvas follows its host, not only the window: the lobby lays its header and tabs around the\n // game after it mounts, and a canvas left at another size overflows the frame — which then\n // scrolls, the road sliding up under the HUD.\n //\n // ⚠ Compare with a 1px tolerance, never `!==`: the host's box is FRACTIONAL on a phone (392.73 at a\n // DPR of 2.75) and the canvas is whole pixels. With RESIZE and `!==` that once looped forever\n // (resize → refresh → \"resize\" → fit…), freezing the whole app (see IdleRpgController).\n this.sizeWatch =\n typeof ResizeObserver === \"undefined\"\n ? null\n : new ResizeObserver(() => this.fit());\n this.sizeWatch?.observe(parent);\n this.game.events.once(\"ready\", () => this.fit());\n this.speedWatch =\n typeof window === \"undefined\"\n ? null\n : setInterval(() => this.watchSpeed(), WATCH_MS);\n this.judgedFrom = Date.now() + SETTLE_MS;\n }\n\n /** The field's box in CSS pixels: the host's width, the top share of its height (layout.ts). */\n private fieldBox(): { w: number; h: number } | null {\n const rect = this.parent.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return null;\n return {\n w: rect.width,\n // rounded up, so no line of the host shows between the field and the panel under it\n h: Math.min(Math.ceil(rect.height * BATTLE_SHARE), BATTLE_MAX_PX),\n };\n }\n\n private pixels(css: number): number {\n return Math.max(1, Math.ceil(css * this.density));\n }\n\n private fit(): void {\n const box = this.fieldBox();\n if (!box) return;\n const scale = this.game.scale;\n const zoom = 1 / this.density;\n const zoomed = Math.abs(scale.zoom - zoom) > 1e-6;\n const w = this.pixels(box.w);\n const h = this.pixels(box.h);\n if (\n !zoomed &&\n Math.abs(w - scale.width) < 1 &&\n Math.abs(h - scale.height) < 1\n )\n return;\n // ⚠ The new zoom goes in BEFORE the resize, so the one \"resize\" the scene hears already has both\n // (setZoom first would lay the field out once with the new zoom and the old pixels — and repaint\n // the backdrop for a wrong height). setZoom after it only makes Phaser rewrite the canvas's CSS\n // size, which `resize` skips at a zoom of 1.\n scale.zoom = zoom;\n scale.resize(w, h);\n if (zoomed) scale.setZoom(zoom);\n }\n\n /**\n * A cheap GPU pays for every pixel: at twice the density the field is four times the fill. When the\n * loop keeps under SLOW_FPS, the canvas TRIES half a pixel less for a few seconds, goes back, and\n * keeps the coarser one only if it was at least MIN_GAIN faster than both looks at the old one;\n * otherwise it is never tried again on this page. ⚠ Not a plain \"slow → coarser\": on the owner's\n * phone (04.11.2026) a density of 2 and of 1 ran equally fast — the frames were lost elsewhere (the\n * UI kit's icon filters) — and a plain rule took the sharpness for nothing (once even on a slow start\n * alone, so the old density is looked at twice). Only a running, visible game is judged.\n */\n private watchSpeed(): void {\n const loop = this.game.loop;\n const visible =\n typeof document === \"undefined\" || document.visibilityState === \"visible\";\n if (!loop.running || !visible || Date.now() < this.judgedFrom) {\n this.looks = [];\n return;\n }\n this.looks.push(loop.actualFps);\n if (this.looks.length < LOOKS) return;\n const fps = this.looks.reduce((a, b) => a + b, 0) / this.looks.length;\n this.looks = [];\n\n const trial = this.trial;\n if (trial && trial.coarse === undefined) {\n // measured at the coarser density: now the same look again at the old one\n trial.coarse = fps;\n this.change(trial.from);\n return;\n }\n if (trial) {\n // A, B, A: the coarser canvas has to beat BOTH looks at the old one, so a slow moment that was\n // not about pixels (the start, a boss arriving, a busy panel) does not pass for a slow GPU.\n this.trial = null;\n if (trial.coarse! >= Math.max(trial.before, fps) * MIN_GAIN) {\n learned.cap = trial.to;\n this.change(trial.to);\n return;\n }\n learned.settled = true;\n return;\n }\n if (learned.settled || fps >= SLOW_FPS || this.density <= 1) return;\n const to = Math.max(1, Math.ceil(this.density * 2 - 1) / 2);\n this.trial = { from: this.density, to, before: fps };\n this.change(to);\n }\n\n private change(density: number): void {\n this.density = density;\n this.judgedFrom = Date.now() + SETTLE_MS;\n this.fit();\n }\n\n /** Whether the Phaser loop is running (the agent's \"am I even running\" flag). */\n get running(): boolean {\n return this.game.loop.running;\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs) — and the fight with it. */\n setRunning(running: boolean): void {\n if (running) {\n this.game.loop.wake();\n this.judgedFrom = Date.now() + SETTLE_MS;\n } else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.sizeWatch?.disconnect();\n if (this.speedWatch) clearInterval(this.speedWatch);\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
367
367
  },
368
368
  {
369
369
  "path": "game/heroStats.ts",
@@ -419,19 +419,23 @@
419
419
  },
420
420
  {
421
421
  "path": "phaser/art.ts",
422
- "content": "import Phaser from \"phaser\";\n\n// The fight's art is drawn here in code and baked into textures once (Graphics → generateTexture): the\n// module ships no image files, so it builds offline and runs in the preview as is. Cute and chunky,\n// thick dark outlines. The hero is not here — it is the character system's (../character, hero.ts).\n\nconst OUTLINE = 0x2b1d33;\n\ntype G = Phaser.GameObjects.Graphics;\n\nfunction bake(\n scene: Phaser.Scene,\n key: string,\n w: number,\n h: number,\n draw: (g: G) => void,\n): string {\n if (scene.textures.exists(key)) return key;\n const g = scene.make.graphics({ x: 0, y: 0 }, false);\n draw(g);\n g.generateTexture(key, w, h);\n g.destroy();\n return key;\n}\n\n/** A darker / lighter shade of a colour. */\nexport function shade(color: number, k: number): number {\n const c = Phaser.Display.Color.IntegerToColor(color);\n const f = (v: number) =>\n Math.max(\n 0,\n Math.min(255, Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k)),\n );\n return Phaser.Display.Color.GetColor(f(c.red), f(c.green), f(c.blue));\n}\n\n// ── The hero's colour (its shots) ─────────────────────────────────────────────────────────────\n\nexport const CLASS_COLORS: Record<string, number> = {\n Warrior: 0x4fa8ff,\n Ranger: 0x5fd36a,\n Mage: 0xb36bff,\n};\n\nexport function heroColor(classID: string, heroID: string): number {\n const known = CLASS_COLORS[classID];\n if (known) return known;\n let h = 0;\n for (const ch of heroID) h = (h * 31 + ch.charCodeAt(0)) >>> 0;\n return Phaser.Display.Color.HSVToRGB((h % 360) / 360, 0.55, 0.95).color;\n}\n\n// ── Small things ─────────────────────────────────────────────────────────────────────────────\n\nexport function bakeCommon(scene: Phaser.Scene): void {\n bake(scene, \"coin\", 26, 26, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(13, 13, 12);\n g.fillStyle(0xf2a516, 1);\n g.fillCircle(13, 13, 10);\n g.fillStyle(0xffd34d, 1);\n g.fillCircle(13, 13, 7);\n g.fillStyle(0xfff3b0, 1);\n g.fillCircle(10, 10, 2.5);\n });\n bake(scene, \"spark\", 16, 16, (g) => {\n g.fillStyle(0xffffff, 0.35);\n g.fillCircle(8, 8, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(8, 8, 4);\n });\n bake(scene, \"puff\", 32, 32, (g) => {\n g.fillStyle(0xffffff, 0.9);\n g.fillCircle(16, 16, 14);\n });\n bake(scene, \"cloud\", 170, 70, (g) => {\n g.fillStyle(0xffffff, 0.95);\n g.fillCircle(40, 44, 24);\n g.fillCircle(72, 32, 30);\n g.fillCircle(108, 38, 26);\n g.fillCircle(136, 48, 20);\n g.fillRoundedRect(30, 44, 120, 24, 12);\n });\n bake(scene, \"meteor\", 48, 48, (g) => {\n g.fillStyle(0xff7a1a, 0.4);\n g.fillCircle(24, 24, 22);\n g.fillStyle(0xffb03a, 1);\n g.fillCircle(24, 24, 14);\n g.fillStyle(0xfff0a0, 1);\n g.fillCircle(20, 20, 6);\n });\n}\n\n/** The hero's shot: an orb in the hero's colour. */\n/** A soft round shadow (drawn squashed under a character's feet). */\nexport function shadowTexture(scene: Phaser.Scene): string {\n const key = \"idle-shadow\";\n if (scene.textures.exists(key)) return key;\n const tex = scene.textures.createCanvas(key, 128, 128)!;\n const ctx = tex.getContext();\n const g = ctx.createRadialGradient(64, 64, 6, 64, 64, 62);\n g.addColorStop(0, \"rgba(20, 12, 24, .7)\");\n g.addColorStop(0.5, \"rgba(20, 12, 24, .45)\");\n g.addColorStop(1, \"rgba(20, 12, 24, 0)\");\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 128, 128);\n tex.refresh();\n return key;\n}\n\nexport function shotTexture(scene: Phaser.Scene, color: number): string {\n return bake(scene, `shot-${color.toString(16)}`, 28, 28, (g) => {\n g.fillStyle(color, 0.35);\n g.fillCircle(14, 14, 13);\n g.fillStyle(shade(color, 0.3), 1);\n g.fillCircle(14, 14, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(12, 12, 3.5);\n });\n}\n\n/**\n * What stands on the road between the waves (no outline, like the backdrop — light from the upper\n * left): the camp — a tent by a fire; a fountain — a stone basin, its water green for health, red for\n * rage; a chest — wood bound in gold. Its foot is the texture's bottom middle.\n */\nexport function featureTexture(\n scene: Phaser.Scene,\n kind: \"camp\" | \"fountain\" | \"chest\",\n gives?: \"heal\" | \"rage\",\n): string {\n if (kind === \"camp\")\n return bake(scene, \"feature-camp\", 150, 110, (g) => {\n // the tent\n g.fillStyle(0x8a5a34, 1);\n g.fillTriangle(10, 104, 62, 18, 114, 104);\n g.fillStyle(0xb07a46, 1);\n g.fillTriangle(10, 104, 62, 18, 62, 104);\n g.fillStyle(0x3a2418, 1);\n g.fillTriangle(48, 104, 62, 60, 76, 104);\n g.fillStyle(0x6b4428, 1);\n g.fillRect(60, 10, 4, 12);\n // the fire: logs, flames, a glow\n g.fillStyle(0xffb03a, 0.25);\n g.fillCircle(130, 92, 18);\n g.fillStyle(0x5a3a22, 1);\n g.fillRoundedRect(114, 100, 34, 6, 3);\n g.fillStyle(0xff6a1a, 1);\n g.fillTriangle(118, 100, 130, 70, 142, 100);\n g.fillStyle(0xffd24a, 1);\n g.fillTriangle(123, 100, 130, 82, 137, 100);\n });\n if (kind === \"fountain\") {\n const water = gives === \"rage\" ? 0xff4d3a : 0x4ad8a0;\n return bake(scene, `feature-fountain-${gives ?? \"heal\"}`, 110, 110, (g) => {\n // the glow of what it gives\n g.fillStyle(water, 0.22);\n g.fillCircle(55, 40, 34);\n // the basin and the column\n g.fillStyle(0x8f96a8, 1);\n g.fillRoundedRect(8, 74, 94, 30, 10);\n g.fillStyle(0xb8bfd0, 1);\n g.fillRoundedRect(8, 74, 94, 12, { tl: 10, tr: 10, bl: 0, br: 0 });\n g.fillStyle(0x7d8496, 1);\n g.fillRect(47, 40, 16, 36);\n g.fillStyle(0xa9b0c2, 1);\n g.fillRoundedRect(32, 32, 46, 12, 6);\n // the water\n g.fillStyle(shade(water, -0.15), 1);\n g.fillRoundedRect(14, 76, 82, 8, 4);\n g.fillStyle(water, 1);\n g.fillCircle(55, 24, 9);\n g.fillStyle(shade(water, 0.5), 1);\n g.fillCircle(52, 21, 3);\n g.fillStyle(water, 0.8);\n g.fillRect(53, 30, 4, 10);\n });\n }\n return bake(scene, \"feature-chest\", 90, 74, (g) => {\n g.fillStyle(0x7a4a26, 1);\n g.fillRoundedRect(6, 30, 78, 42, 6);\n g.fillStyle(0x9a5f30, 1);\n g.fillRoundedRect(6, 8, 78, 30, { tl: 16, tr: 16, bl: 0, br: 0 });\n g.fillStyle(0xb87840, 1);\n g.fillRoundedRect(10, 10, 34, 10, 5);\n // the gold bands and the lock\n g.fillStyle(0xe8b33a, 1);\n g.fillRect(6, 34, 78, 6);\n g.fillRect(20, 8, 6, 64);\n g.fillRect(64, 8, 6, 64);\n g.fillStyle(0xffe08a, 1);\n g.fillRoundedRect(38, 30, 14, 16, 3);\n g.fillStyle(0x7a4a26, 1);\n g.fillCircle(45, 38, 2.5);\n });\n}\n\n/** A thrown axe (the cards' Axe): a wooden haft and a double steel head; the scene spins it. */\nexport function axeTexture(scene: Phaser.Scene): string {\n return bake(scene, \"card-axe\", 44, 44, (g) => {\n g.fillStyle(0x8a5a34, 1);\n g.fillRoundedRect(20, 4, 5, 38, 2);\n g.fillStyle(0xc9ced8, 1);\n g.fillTriangle(24, 8, 40, 4, 38, 22);\n g.fillTriangle(21, 8, 4, 4, 6, 22);\n g.fillStyle(0xf2f4f8, 1);\n g.fillTriangle(25, 9, 36, 7, 35, 15);\n });\n}\n\n/** A shot that is not an orb, flying right (towards the monsters): an arrow, a crossbow bolt, a\n * bullet with a streak of light behind it. Crits are tinted by the scene. */\nexport function missileTexture(\n scene: Phaser.Scene,\n kind: \"arrow\" | \"bolt\" | \"bullet\" | \"slash\" | \"thrust\",\n): string {\n if (kind === \"slash\")\n // The wave of a swing: a crescent of air, its round side forward, a white edge in a pale glow.\n return bake(scene, \"shot-slash\", 60, 100, (g) => {\n const crescent = (\n r: number,\n back: number,\n alpha: number,\n color: number,\n ) => {\n g.fillStyle(color, alpha);\n g.beginPath();\n g.arc(14, 50, r, -1.25, 1.25, false);\n g.arc(14 - back, 50, r + back * 0.35, 1.05, -1.05, true);\n g.closePath();\n g.fillPath();\n };\n crescent(45, 30, 0.3, 0x9fe8ff);\n crescent(42, 22, 0.6, 0xc8f4ff);\n crescent(39, 14, 1, 0xffffff);\n });\n if (kind === \"thrust\")\n // The wave of a stab: a narrow spike of air with a bright point.\n return bake(scene, \"shot-thrust\", 50, 18, (g) => {\n g.fillStyle(0x9fe8ff, 0.3);\n g.fillTriangle(0, 3, 49, 9, 0, 15);\n g.fillStyle(0xc8f4ff, 0.6);\n g.fillTriangle(8, 6, 48, 9, 8, 12);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(22, 7.5, 47, 9, 22, 10.5);\n });\n if (kind === \"arrow\")\n return bake(scene, \"shot-arrow\", 52, 14, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillRect(6, 5, 36, 4);\n g.fillTriangle(40, 1, 51, 7, 40, 13);\n g.fillStyle(0xc8a070, 1);\n g.fillRect(7, 6, 34, 2);\n g.fillStyle(0xd8dde4, 1);\n g.fillTriangle(41, 3, 48.5, 7, 41, 11);\n g.fillStyle(0xe04040, 1);\n g.fillTriangle(1, 1, 12, 7, 5, 7);\n g.fillTriangle(1, 13, 12, 7, 5, 7);\n });\n if (kind === \"bolt\")\n return bake(scene, \"shot-bolt\", 38, 12, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillRect(4, 4, 26, 4);\n g.fillTriangle(28, 0, 37, 6, 28, 12);\n g.fillStyle(0xb08860, 1);\n g.fillRect(5, 5, 24, 2);\n g.fillStyle(0xc0c8d0, 1);\n g.fillTriangle(29, 2, 35, 6, 29, 10);\n g.fillStyle(0xe04040, 1);\n g.fillTriangle(0, 2, 9, 6, 3, 6);\n g.fillTriangle(0, 10, 9, 6, 3, 6);\n });\n return bake(scene, \"shot-bullet\", 34, 10, (g) => {\n g.fillStyle(0xffe08a, 0.25);\n g.fillRect(0, 3, 24, 4);\n g.fillStyle(0xffe08a, 0.55);\n g.fillRect(10, 3.5, 16, 3);\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(28, 5, 12, 8);\n g.fillStyle(0xf2c040, 1);\n g.fillEllipse(28, 5, 8, 4.6);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(29.5, 4, 1.2);\n });\n}\n"
422
+ "content": "import Phaser from \"phaser\";\nimport { markDensity, screenDensity } from \"./density\";\n\n// The fight's art is drawn here in code and baked into textures once (Graphics → generateTexture): the\n// module ships no image files, so it builds offline and runs in the preview as is. Cute and chunky,\n// thick dark outlines. The hero is not here — it is the character system's (../character, hero.ts).\n\nconst OUTLINE = 0x2b1d33;\n\ntype G = Phaser.GameObjects.Graphics;\n\n/**\n * Draws `w`×`h` of art (in its own units) into a texture. For images it is baked at the screen's\n * density (`markDensity`: same size on screen, that many times the pixels — sharp on a phone);\n * `sharp: false` for a particle's texture, which an emitter would show that many times bigger.\n */\nfunction bake(\n scene: Phaser.Scene,\n key: string,\n w: number,\n h: number,\n draw: (g: G) => void,\n sharp = true,\n): string {\n if (scene.textures.exists(key)) return key;\n const k = sharp ? screenDensity() : 1;\n const g = scene.make.graphics({ x: 0, y: 0 }, false).setScale(k);\n draw(g);\n g.generateTexture(key, Math.ceil(w * k), Math.ceil(h * k));\n g.destroy();\n markDensity(scene.textures.get(key), k);\n return key;\n}\n\n/** A darker / lighter shade of a colour. */\nexport function shade(color: number, k: number): number {\n const c = Phaser.Display.Color.IntegerToColor(color);\n const f = (v: number) =>\n Math.max(\n 0,\n Math.min(255, Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k)),\n );\n return Phaser.Display.Color.GetColor(f(c.red), f(c.green), f(c.blue));\n}\n\n// ── The hero's colour (its shots) ─────────────────────────────────────────────────────────────\n\nexport const CLASS_COLORS: Record<string, number> = {\n Warrior: 0x4fa8ff,\n Ranger: 0x5fd36a,\n Mage: 0xb36bff,\n};\n\nexport function heroColor(classID: string, heroID: string): number {\n const known = CLASS_COLORS[classID];\n if (known) return known;\n let h = 0;\n for (const ch of heroID) h = (h * 31 + ch.charCodeAt(0)) >>> 0;\n return Phaser.Display.Color.HSVToRGB((h % 360) / 360, 0.55, 0.95).color;\n}\n\n// ── Small things ─────────────────────────────────────────────────────────────────────────────\n\nexport function bakeCommon(scene: Phaser.Scene): void {\n bake(scene, \"coin\", 26, 26, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(13, 13, 12);\n g.fillStyle(0xf2a516, 1);\n g.fillCircle(13, 13, 10);\n g.fillStyle(0xffd34d, 1);\n g.fillCircle(13, 13, 7);\n g.fillStyle(0xfff3b0, 1);\n g.fillCircle(10, 10, 2.5);\n });\n // the two particle textures stay at 1× (see bake)\n bake(\n scene,\n \"spark\",\n 16,\n 16,\n (g) => {\n g.fillStyle(0xffffff, 0.35);\n g.fillCircle(8, 8, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(8, 8, 4);\n },\n false,\n );\n bake(\n scene,\n \"puff\",\n 32,\n 32,\n (g) => {\n g.fillStyle(0xffffff, 0.9);\n g.fillCircle(16, 16, 14);\n },\n false,\n );\n bake(scene, \"cloud\", 170, 70, (g) => {\n g.fillStyle(0xffffff, 0.95);\n g.fillCircle(40, 44, 24);\n g.fillCircle(72, 32, 30);\n g.fillCircle(108, 38, 26);\n g.fillCircle(136, 48, 20);\n g.fillRoundedRect(30, 44, 120, 24, 12);\n });\n bake(scene, \"meteor\", 48, 48, (g) => {\n g.fillStyle(0xff7a1a, 0.4);\n g.fillCircle(24, 24, 22);\n g.fillStyle(0xffb03a, 1);\n g.fillCircle(24, 24, 14);\n g.fillStyle(0xfff0a0, 1);\n g.fillCircle(20, 20, 6);\n });\n}\n\n/** The hero's shot: an orb in the hero's colour. */\n/** A soft round shadow (drawn squashed under a character's feet). */\nexport function shadowTexture(scene: Phaser.Scene): string {\n const key = \"idle-shadow\";\n if (scene.textures.exists(key)) return key;\n const tex = scene.textures.createCanvas(key, 128, 128)!;\n const ctx = tex.getContext();\n const g = ctx.createRadialGradient(64, 64, 6, 64, 64, 62);\n g.addColorStop(0, \"rgba(20, 12, 24, .7)\");\n g.addColorStop(0.5, \"rgba(20, 12, 24, .45)\");\n g.addColorStop(1, \"rgba(20, 12, 24, 0)\");\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 128, 128);\n tex.refresh();\n return key;\n}\n\nexport function shotTexture(scene: Phaser.Scene, color: number): string {\n return bake(scene, `shot-${color.toString(16)}`, 28, 28, (g) => {\n g.fillStyle(color, 0.35);\n g.fillCircle(14, 14, 13);\n g.fillStyle(shade(color, 0.3), 1);\n g.fillCircle(14, 14, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(12, 12, 3.5);\n });\n}\n\n/**\n * What stands on the road between the waves (no outline, like the backdrop — light from the upper\n * left): the camp — a tent by a fire; a fountain — a stone basin, its water green for health, red for\n * rage; a chest — wood bound in gold. Its foot is the texture's bottom middle.\n */\nexport function featureTexture(\n scene: Phaser.Scene,\n kind: \"camp\" | \"fountain\" | \"chest\",\n gives?: \"heal\" | \"rage\",\n): string {\n if (kind === \"camp\")\n return bake(scene, \"feature-camp\", 150, 110, (g) => {\n // the tent\n g.fillStyle(0x8a5a34, 1);\n g.fillTriangle(10, 104, 62, 18, 114, 104);\n g.fillStyle(0xb07a46, 1);\n g.fillTriangle(10, 104, 62, 18, 62, 104);\n g.fillStyle(0x3a2418, 1);\n g.fillTriangle(48, 104, 62, 60, 76, 104);\n g.fillStyle(0x6b4428, 1);\n g.fillRect(60, 10, 4, 12);\n // the fire: logs, flames, a glow\n g.fillStyle(0xffb03a, 0.25);\n g.fillCircle(130, 92, 18);\n g.fillStyle(0x5a3a22, 1);\n g.fillRoundedRect(114, 100, 34, 6, 3);\n g.fillStyle(0xff6a1a, 1);\n g.fillTriangle(118, 100, 130, 70, 142, 100);\n g.fillStyle(0xffd24a, 1);\n g.fillTriangle(123, 100, 130, 82, 137, 100);\n });\n if (kind === \"fountain\") {\n const water = gives === \"rage\" ? 0xff4d3a : 0x4ad8a0;\n return bake(scene, `feature-fountain-${gives ?? \"heal\"}`, 110, 110, (g) => {\n // the glow of what it gives\n g.fillStyle(water, 0.22);\n g.fillCircle(55, 40, 34);\n // the basin and the column\n g.fillStyle(0x8f96a8, 1);\n g.fillRoundedRect(8, 74, 94, 30, 10);\n g.fillStyle(0xb8bfd0, 1);\n g.fillRoundedRect(8, 74, 94, 12, { tl: 10, tr: 10, bl: 0, br: 0 });\n g.fillStyle(0x7d8496, 1);\n g.fillRect(47, 40, 16, 36);\n g.fillStyle(0xa9b0c2, 1);\n g.fillRoundedRect(32, 32, 46, 12, 6);\n // the water\n g.fillStyle(shade(water, -0.15), 1);\n g.fillRoundedRect(14, 76, 82, 8, 4);\n g.fillStyle(water, 1);\n g.fillCircle(55, 24, 9);\n g.fillStyle(shade(water, 0.5), 1);\n g.fillCircle(52, 21, 3);\n g.fillStyle(water, 0.8);\n g.fillRect(53, 30, 4, 10);\n });\n }\n return bake(scene, \"feature-chest\", 90, 74, (g) => {\n g.fillStyle(0x7a4a26, 1);\n g.fillRoundedRect(6, 30, 78, 42, 6);\n g.fillStyle(0x9a5f30, 1);\n g.fillRoundedRect(6, 8, 78, 30, { tl: 16, tr: 16, bl: 0, br: 0 });\n g.fillStyle(0xb87840, 1);\n g.fillRoundedRect(10, 10, 34, 10, 5);\n // the gold bands and the lock\n g.fillStyle(0xe8b33a, 1);\n g.fillRect(6, 34, 78, 6);\n g.fillRect(20, 8, 6, 64);\n g.fillRect(64, 8, 6, 64);\n g.fillStyle(0xffe08a, 1);\n g.fillRoundedRect(38, 30, 14, 16, 3);\n g.fillStyle(0x7a4a26, 1);\n g.fillCircle(45, 38, 2.5);\n });\n}\n\n/** A thrown axe (the cards' Axe): a wooden haft and a double steel head; the scene spins it. */\nexport function axeTexture(scene: Phaser.Scene): string {\n return bake(scene, \"card-axe\", 44, 44, (g) => {\n g.fillStyle(0x8a5a34, 1);\n g.fillRoundedRect(20, 4, 5, 38, 2);\n g.fillStyle(0xc9ced8, 1);\n g.fillTriangle(24, 8, 40, 4, 38, 22);\n g.fillTriangle(21, 8, 4, 4, 6, 22);\n g.fillStyle(0xf2f4f8, 1);\n g.fillTriangle(25, 9, 36, 7, 35, 15);\n });\n}\n\n/** A shot that is not an orb, flying right (towards the monsters): an arrow, a crossbow bolt, a\n * bullet with a streak of light behind it. Crits are tinted by the scene. */\nexport function missileTexture(\n scene: Phaser.Scene,\n kind: \"arrow\" | \"bolt\" | \"bullet\" | \"slash\" | \"thrust\",\n): string {\n if (kind === \"slash\")\n // The wave of a swing: a crescent of air, its round side forward, a white edge in a pale glow.\n return bake(scene, \"shot-slash\", 60, 100, (g) => {\n const crescent = (\n r: number,\n back: number,\n alpha: number,\n color: number,\n ) => {\n g.fillStyle(color, alpha);\n g.beginPath();\n g.arc(14, 50, r, -1.25, 1.25, false);\n g.arc(14 - back, 50, r + back * 0.35, 1.05, -1.05, true);\n g.closePath();\n g.fillPath();\n };\n crescent(45, 30, 0.3, 0x9fe8ff);\n crescent(42, 22, 0.6, 0xc8f4ff);\n crescent(39, 14, 1, 0xffffff);\n });\n if (kind === \"thrust\")\n // The wave of a stab: a narrow spike of air with a bright point.\n return bake(scene, \"shot-thrust\", 50, 18, (g) => {\n g.fillStyle(0x9fe8ff, 0.3);\n g.fillTriangle(0, 3, 49, 9, 0, 15);\n g.fillStyle(0xc8f4ff, 0.6);\n g.fillTriangle(8, 6, 48, 9, 8, 12);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(22, 7.5, 47, 9, 22, 10.5);\n });\n if (kind === \"arrow\")\n return bake(scene, \"shot-arrow\", 52, 14, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillRect(6, 5, 36, 4);\n g.fillTriangle(40, 1, 51, 7, 40, 13);\n g.fillStyle(0xc8a070, 1);\n g.fillRect(7, 6, 34, 2);\n g.fillStyle(0xd8dde4, 1);\n g.fillTriangle(41, 3, 48.5, 7, 41, 11);\n g.fillStyle(0xe04040, 1);\n g.fillTriangle(1, 1, 12, 7, 5, 7);\n g.fillTriangle(1, 13, 12, 7, 5, 7);\n });\n if (kind === \"bolt\")\n return bake(scene, \"shot-bolt\", 38, 12, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillRect(4, 4, 26, 4);\n g.fillTriangle(28, 0, 37, 6, 28, 12);\n g.fillStyle(0xb08860, 1);\n g.fillRect(5, 5, 24, 2);\n g.fillStyle(0xc0c8d0, 1);\n g.fillTriangle(29, 2, 35, 6, 29, 10);\n g.fillStyle(0xe04040, 1);\n g.fillTriangle(0, 2, 9, 6, 3, 6);\n g.fillTriangle(0, 10, 9, 6, 3, 6);\n });\n return bake(scene, \"shot-bullet\", 34, 10, (g) => {\n g.fillStyle(0xffe08a, 0.25);\n g.fillRect(0, 3, 24, 4);\n g.fillStyle(0xffe08a, 0.55);\n g.fillRect(10, 3.5, 16, 3);\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(28, 5, 12, 8);\n g.fillStyle(0xf2c040, 1);\n g.fillEllipse(28, 5, 8, 4.6);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(29.5, 4, 1.2);\n });\n}\n"
423
423
  },
424
424
  {
425
425
  "path": "phaser/backdrop.ts",
426
- "content": "import type Phaser from \"phaser\";\nimport { seededRandom } from \"../game/random\";\nimport type { Theme } from \"../game/themes\";\n\n// The world behind the fight — a painted casual landscape per chapter theme, with no outlines: soft\n// gradients, light from the upper left (lit faces, shaded sides), a haze that fades the far planes\n// into the sky and contact shadows on the ground. Drawn once per theme and field size on canvases and\n// tiled sideways, so the road scrolls without end; the layers move at their own pace as the hero runs\n// (parallax): the sky with its sun stays, the far skyline barely moves, the hills behind and the near\n// props (cacti, palms, trees, rocks) faster, the road exactly with the world — the fighters stand on\n// it — and a strip of foreground at the bottom edge faster still.\n//\n// Every scrolling layer is one TILE wide and repeats seamlessly: skylines are periodic over the tile\n// and props keep clear of its edges.\n\nconst TILE = 1536;\n/** The sky does not scroll: wide enough for any screen. */\nconst SKY_W = 2560;\n\nexport interface BackdropLayout {\n w: number;\n h: number;\n /** The top edge of the road (the line the near props stand on). */\n horizon: number;\n}\n\ninterface Layer {\n sprite: Phaser.GameObjects.TileSprite;\n factor: number;\n}\n\ntype Painter = (\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n) => void;\n\nconst LAYERS: ReadonlyArray<{\n name: string;\n factor: number;\n width: number;\n paint: Painter;\n /** Over the fighters (the foreground strip) rather than behind them. */\n front?: boolean;\n}> = [\n { name: \"sky\", factor: 0, width: SKY_W, paint: paintSky },\n { name: \"far\", factor: 0.08, width: TILE, paint: paintFar },\n { name: \"hills\", factor: 0.22, width: TILE, paint: paintHills },\n { name: \"near\", factor: 0.45, width: TILE, paint: paintNear },\n { name: \"road\", factor: 1, width: TILE, paint: paintRoad },\n { name: \"front\", factor: 1.35, width: TILE, paint: paintFront, front: true },\n];\n\nexport class Backdrop {\n private layers: Layer[] = [];\n private key = \"\";\n\n constructor(private readonly scene: Phaser.Scene) {}\n\n /**\n * (Re)builds the layers for a theme and a field size; cheap when nothing changed. `depth` — the\n * layers behind the fighters start there, `frontDepth` — the foreground strip over them.\n */\n layout(\n theme: Theme,\n at: BackdropLayout,\n depth: number,\n frontDepth: number,\n ): void {\n const key = `${theme.id}-${Math.round(at.h)}-${Math.round(at.horizon)}`;\n if (key === this.key) {\n for (const l of this.layers) l.sprite.setSize(at.w, at.h);\n return;\n }\n this.key = key;\n for (const l of this.layers) l.sprite.destroy();\n this.layers = LAYERS.map((layer, i) => {\n const tex = `idle-bd2-${layer.name}-${key}`;\n if (!this.scene.textures.exists(tex)) {\n const canvas = this.scene.textures.createCanvas(\n tex,\n layer.width,\n at.h,\n )!;\n layer.paint(canvas.getContext(), theme, at.h, at.horizon);\n canvas.refresh();\n }\n const sprite = this.scene.add\n .tileSprite(0, 0, at.w, at.h, tex)\n .setOrigin(0, 0)\n .setDepth(layer.front ? frontDepth : depth + i * 0.1);\n return { sprite, factor: layer.factor };\n });\n }\n\n /** Scrolls the layers: `px` — how far the road has moved, in screen pixels. */\n scroll(px: number): void {\n for (const l of this.layers) l.sprite.tilePositionX = px * l.factor;\n }\n}\n\n// ── Colour helpers ───────────────────────────────────────────────────────────────────────────\n\ntype Rgb = [number, number, number];\nconst rgbOf = (n: number): Rgb => [(n >> 16) & 255, (n >> 8) & 255, n & 255];\nconst css = ([r, g, b]: Rgb, a = 1): string =>\n `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})`;\n\n/** `a` blended towards `b` by `t` (0 — a, 1 — b). */\nfunction mix(a: number, b: number, t: number): Rgb {\n const x = rgbOf(a);\n const y = rgbOf(b);\n return [0, 1, 2].map((i) => x[i]! + (y[i]! - x[i]!) * t) as Rgb;\n}\n\n/** Lighter (+) or darker (−) by a share. */\nfunction shade(c: number, amount: number): Rgb {\n return amount >= 0 ? mix(c, 0xffffff, amount) : mix(c, 0x000000, -amount);\n}\nconst hex = ([r, g, b]: Rgb): number =>\n (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);\n\n/** A far colour: the theme's colour sunk into the sky's haze. */\nfunction hazed(c: number, theme: Theme, amount: number): number {\n return hex(mix(c, theme.sky[1], amount));\n}\n\nfunction vertical(\n ctx: CanvasRenderingContext2D,\n y0: number,\n y1: number,\n stops: ReadonlyArray<[number, string]>,\n): CanvasGradient {\n const g = ctx.createLinearGradient(0, y0, 0, y1);\n for (const [at, c] of stops) g.addColorStop(at, c);\n return g;\n}\n\n/** A soft dark ellipse on the ground under a prop. */\nfunction contactShadow(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n w: number,\n alpha = 0.28,\n): void {\n ctx.save();\n ctx.translate(x, y);\n ctx.scale(1, 0.22);\n const g = ctx.createRadialGradient(0, 0, 0, 0, 0, w);\n g.addColorStop(0, `rgba(30, 18, 28, ${alpha})`);\n g.addColorStop(1, \"rgba(30, 18, 28, 0)\");\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(0, 0, w, 0, Math.PI * 2);\n ctx.fill();\n ctx.restore();\n}\n\n/** A height profile periodic over the tile (whole waves only), so the skyline joins at the seam. */\nfunction profile(\n seed: number,\n waves: ReadonlyArray<[cycles: number, amp: number]>,\n): (x: number) => number {\n const rnd = seededRandom(seed);\n const phases = waves.map(() => rnd() * Math.PI * 2);\n return (x) =>\n waves.reduce(\n (sum, [k, a], i) =>\n sum + a * Math.sin((x / TILE) * Math.PI * 2 * k + phases[i]!),\n 0,\n );\n}\n\n/** A band from `base` up to a periodic top, filled with a vertical gradient, a soft lit rim on top. */\nfunction band(\n ctx: CanvasRenderingContext2D,\n top: (x: number) => number,\n base: number,\n fill: string | CanvasGradient,\n rim: string | null,\n): void {\n ctx.beginPath();\n ctx.moveTo(-10, base + 10);\n for (let x = -10; x <= TILE + 10; x += 6) ctx.lineTo(x, top(x));\n ctx.lineTo(TILE + 10, base + 10);\n ctx.closePath();\n ctx.fillStyle = fill;\n ctx.fill();\n if (!rim) return;\n ctx.beginPath();\n ctx.moveTo(-10, top(-10) + 1.5);\n for (let x = -4; x <= TILE + 10; x += 6) ctx.lineTo(x, top(x) + 1.5);\n ctx.lineWidth = 3;\n ctx.strokeStyle = rim;\n ctx.stroke();\n}\n\n/** Spots for props along the tile, clear of its edges (a prop never straddles the seam). */\nfunction spots(seed: number, count: number, margin: number): number[] {\n const rnd = seededRandom(seed);\n const step = (TILE - margin * 2) / count;\n return Array.from(\n { length: count },\n (_, i) => margin + step * (i + 0.2 + rnd() * 0.6),\n );\n}\n\n// ── The sky ──────────────────────────────────────────────────────────────────────────────────\n\nfunction paintSky(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const [top, low] = theme.sky;\n ctx.fillStyle = vertical(ctx, 0, horizon, [\n [0, css(rgbOf(top))],\n [0.7, css(mix(top, low, 0.75))],\n [1, css(rgbOf(low))],\n ]);\n ctx.fillRect(0, 0, SKY_W, horizon + 2);\n // the sun (a red moon over the volcano) with a wide soft glow\n const night = theme.id === \"volcano\";\n const sx = SKY_W * 0.36;\n const sy = horizon * 0.42;\n const r = Math.max(18, h * 0.07);\n const glow = ctx.createRadialGradient(sx, sy, r * 0.6, sx, sy, r * 6);\n glow.addColorStop(\n 0,\n night ? \"rgba(255, 120, 80, .45)\" : \"rgba(255, 252, 225, .7)\",\n );\n glow.addColorStop(\n 1,\n night ? \"rgba(255, 120, 80, 0)\" : \"rgba(255, 252, 225, 0)\",\n );\n ctx.fillStyle = glow;\n ctx.fillRect(sx - r * 6, sy - r * 6, r * 12, r * 12);\n const disc = ctx.createRadialGradient(\n sx - r * 0.3,\n sy - r * 0.3,\n 0,\n sx,\n sy,\n r,\n );\n disc.addColorStop(0, night ? \"#ffd2b0\" : \"#fffef2\");\n disc.addColorStop(1, night ? \"#ff7a4a\" : \"#fff1b0\");\n ctx.fillStyle = disc;\n ctx.beginPath();\n ctx.arc(sx, sy, r, 0, Math.PI * 2);\n ctx.fill();\n}\n\n// ── The far skyline ──────────────────────────────────────────────────────────────────────────\n\nfunction paintFar(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const far = hazed(theme.hillFar, theme, 0.42);\n const rnd = seededRandom(11);\n if (theme.id === \"desert\") {\n // Mesas: flat-topped plateaus, the lit top face, the banded cliffs, the shaded right side.\n for (const x of spots(12, 5, 150)) {\n const w = 150 + rnd() * 150;\n const top = horizon - h * (0.17 + rnd() * 0.14);\n const slant = 16 + rnd() * 18;\n const left = x - w / 2;\n const right = x + w / 2;\n ctx.beginPath();\n ctx.moveTo(left - slant, horizon + 4);\n ctx.lineTo(left, top + 6);\n ctx.quadraticCurveTo(left + 3, top, left + 14, top);\n ctx.lineTo(right - 14, top);\n ctx.quadraticCurveTo(right - 3, top, right, top + 6);\n ctx.lineTo(right + slant, horizon + 4);\n ctx.closePath();\n const side = ctx.createLinearGradient(left - slant, 0, right + slant, 0);\n side.addColorStop(0, css(shade(far, 0.1)));\n side.addColorStop(0.62, css(rgbOf(far)));\n side.addColorStop(1, css(shade(far, -0.16)));\n ctx.fillStyle = side;\n ctx.fill();\n ctx.save();\n ctx.clip();\n for (let y = top + 16, i = 0; y < horizon; y += 20 + (i % 2) * 6, i++) {\n ctx.fillStyle = css(shade(far, -0.1), 0.55);\n ctx.fillRect(left - slant, y, w + slant * 2, 5);\n }\n ctx.fillStyle = css(shade(far, 0.22));\n ctx.fillRect(left - 2, top, w + 4, 5);\n ctx.restore();\n }\n band(\n ctx,\n (x) =>\n horizon -\n 12 +\n profile(13, [\n [3, 5],\n [7, 3],\n ])(x),\n horizon,\n css(rgbOf(hazed(theme.hillFar, theme, 0.25))),\n null,\n );\n return;\n }\n if (theme.id === \"snow\" || theme.id === \"volcano\") {\n // Mountains lit from the left: a light face and a shaded one split along the ridge.\n for (const x of spots(22, 6, 130)) {\n const w = 190 + rnd() * 170;\n const peak = horizon - h * (0.22 + rnd() * 0.18);\n const px = x + (rnd() - 0.5) * w * 0.2;\n ctx.beginPath();\n ctx.moveTo(x - w / 2, horizon + 4);\n ctx.quadraticCurveTo(px - w * 0.12, peak + 18, px, peak);\n ctx.quadraticCurveTo(px + w * 0.14, peak + 22, x + w / 2, horizon + 4);\n ctx.closePath();\n ctx.fillStyle = css(shade(far, 0.06));\n ctx.fill();\n ctx.beginPath();\n ctx.moveTo(px, peak);\n ctx.quadraticCurveTo(px + w * 0.14, peak + 22, x + w / 2, horizon + 4);\n ctx.lineTo(px + w * 0.05, horizon + 4);\n ctx.quadraticCurveTo(px + w * 0.02, peak + 40, px, peak);\n ctx.fillStyle = css(shade(far, -0.14), 0.9);\n ctx.fill();\n if (theme.id === \"snow\") {\n ctx.beginPath();\n ctx.moveTo(px, peak);\n ctx.quadraticCurveTo(\n px - w * 0.08,\n peak + 22,\n px - w * 0.16,\n peak + 40,\n );\n ctx.quadraticCurveTo(px - w * 0.05, peak + 34, px, peak + 44);\n ctx.quadraticCurveTo(\n px + w * 0.06,\n peak + 30,\n px + w * 0.15,\n peak + 42,\n );\n ctx.quadraticCurveTo(px + w * 0.08, peak + 20, px, peak);\n ctx.fillStyle = \"rgba(255, 255, 255, .95)\";\n ctx.fill();\n } else {\n const lava = ctx.createRadialGradient(\n px,\n peak + 4,\n 0,\n px,\n peak + 4,\n 40,\n );\n lava.addColorStop(0, \"rgba(255, 170, 70, .95)\");\n lava.addColorStop(1, \"rgba(255, 90, 40, 0)\");\n ctx.fillStyle = lava;\n ctx.fillRect(px - 40, peak - 36, 80, 80);\n for (let i = 0; i < 4; i++) {\n ctx.beginPath();\n ctx.arc(\n px + i * 9 - 8,\n peak - 18 - i * 16,\n 12 + i * 5,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"rgba(60, 40, 50, .28)\";\n ctx.fill();\n }\n }\n }\n band(\n ctx,\n (x) =>\n horizon -\n 16 +\n profile(23, [\n [4, 7],\n [9, 3],\n ])(x),\n horizon,\n css(rgbOf(hazed(theme.hillFar, theme, 0.25))),\n null,\n );\n return;\n }\n // Meadow and forest: a far row of soft rolling hills (the forest's crowned with pines).\n const top = (x: number) =>\n horizon -\n h * 0.19 +\n profile(31, [\n [2, h * 0.05],\n [5, h * 0.025],\n ])(x);\n band(\n ctx,\n top,\n horizon,\n vertical(ctx, horizon - h * 0.26, horizon, [\n [0, css(shade(far, 0.1))],\n [1, css(rgbOf(far))],\n ]),\n css(shade(far, 0.25), 0.6),\n );\n if (theme.id === \"forest\")\n for (let x = 20; x < TILE - 20; x += 22 + (x % 3) * 4)\n pine(ctx, x, top(x) + 10, 24, hazed(theme.grass, theme, 0.4), false);\n}\n\n// ── The hills behind the road ────────────────────────────────────────────────────────────────\n\nfunction paintHills(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const c = hazed(theme.hillNear, theme, 0.18);\n const top = (x: number) =>\n horizon -\n h * 0.085 +\n profile(35, [\n [3, h * 0.035],\n [7, h * 0.015],\n ])(x);\n band(\n ctx,\n top,\n horizon,\n vertical(ctx, horizon - h * 0.14, horizon, [\n [0, css(shade(c, 0.12))],\n [1, css(shade(c, -0.08))],\n ]),\n css(shade(c, 0.3), 0.55),\n );\n // small hazed silhouettes on the crest — distance in between\n for (const x of spots(36, 14, 40)) {\n const y = top(x) + 3;\n const size = h * 0.06;\n const tint = hazed(\n theme.id === \"desert\"\n ? 0x5fa84a\n : theme.id === \"volcano\"\n ? 0x4a3436\n : theme.grass,\n theme,\n 0.3,\n );\n if (theme.id === \"desert\") cactus(ctx, x, y, size, tint, false);\n else if (theme.id === \"meadow\")\n roundTree(ctx, x, y, size * 1.3, tint, false);\n else if (theme.id === \"volcano\") rock(ctx, x, y, size * 0.5, tint, false);\n else\n pine(ctx, x, y, size * 1.4, theme.id === \"snow\" ? 0x6f9a8c : tint, false);\n }\n}\n\n// ── The near props ───────────────────────────────────────────────────────────────────────────\n\nfunction paintNear(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n // a low dune / bank right behind the road\n const c = theme.hillNear;\n band(\n ctx,\n (x) =>\n horizon -\n h * 0.035 +\n profile(41, [\n [4, h * 0.018],\n [11, 3],\n ])(x),\n horizon,\n vertical(ctx, horizon - h * 0.06, horizon, [\n [0, css(shade(c, 0.08))],\n [1, css(shade(c, -0.12))],\n ]),\n css(shade(c, 0.3), 0.6),\n );\n const rnd = seededRandom(43);\n const base = horizon + 3;\n spots(44, 8, 110).forEach((x, i) => {\n const size = h * (0.17 + rnd() * 0.09);\n const lean: 1 | -1 = rnd() > 0.5 ? 1 : -1;\n switch (theme.id) {\n case \"desert\":\n if (i % 3 === 0) palm(ctx, x, base, size * 1.6, lean);\n else if (i % 3 === 1) cactus(ctx, x, base, size, 0x5fb04a, true);\n else rock(ctx, x, base, size * 0.42, 0xd8a86a, true);\n break;\n case \"meadow\":\n if (i % 3 === 2) bush(ctx, x, base, size * 0.5, theme.grass);\n else roundTree(ctx, x, base, size * 1.25, theme.grass, true);\n break;\n case \"forest\":\n if (i % 4 === 3) rock(ctx, x, base, size * 0.4, 0x8a8a96, true);\n else pine(ctx, x, base, size * 1.45, theme.grass, true);\n break;\n case \"snow\":\n if (i % 3 === 2) rock(ctx, x, base, size * 0.45, 0xb9d3ec, true);\n else snowyPine(ctx, x, base, size * 1.3);\n break;\n case \"volcano\":\n if (i % 3 === 0) deadTree(ctx, x, base, size * 1.15);\n else rock(ctx, x, base, size * 0.5, 0x4a3a40, true, true);\n break;\n }\n });\n}\n\n// ── The road ─────────────────────────────────────────────────────────────────────────────────\n\nfunction paintRoad(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const g = theme.ground;\n ctx.fillStyle = vertical(ctx, horizon, h, [\n [0, css(shade(g, 0.1))],\n [0.5, css(rgbOf(g))],\n [1, css(shade(g, -0.1))],\n ]);\n ctx.fillRect(0, horizon, TILE, h - horizon);\n // the far edge of the road, softly shaded by the bank behind it\n ctx.fillStyle = vertical(ctx, horizon, horizon + 14, [\n [0, css(shade(g, -0.18), 0.55)],\n [1, css(shade(g, -0.18), 0)],\n ]);\n ctx.fillRect(0, horizon, TILE, 14);\n const rnd = seededRandom(51);\n const span = h - horizon;\n if (theme.id === \"desert\" || theme.id === \"snow\") {\n // wind ripples in the sand / the snow\n for (let i = 0; i < 26; i++) {\n const x = 40 + rnd() * (TILE - 160);\n const y = horizon + 14 + rnd() * (span - 24);\n const w = 40 + rnd() * 70;\n ctx.beginPath();\n ctx.moveTo(x, y);\n ctx.quadraticCurveTo(x + w / 2, y - 4, x + w, y);\n ctx.lineWidth = 2.5;\n ctx.strokeStyle = css(shade(g, theme.id === \"snow\" ? -0.08 : 0.18), 0.7);\n ctx.stroke();\n ctx.beginPath();\n ctx.moveTo(x + 2, y + 3);\n ctx.quadraticCurveTo(x + w / 2, y - 1, x + w - 2, y + 3);\n ctx.lineWidth = 2;\n ctx.strokeStyle = css(shade(g, -0.14), 0.45);\n ctx.stroke();\n }\n } else if (theme.id === \"volcano\") {\n for (const x of spots(54, 7, 90)) {\n const y = horizon + 18 + rnd() * (span - 30);\n ctx.beginPath();\n ctx.moveTo(x - 30, y);\n ctx.lineTo(x - 10, y - 4);\n ctx.lineTo(x + 6, y + 2);\n ctx.lineTo(x + 28, y - 3);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"rgba(255, 110, 40, .75)\";\n ctx.stroke();\n }\n } else {\n // a grassy fringe along the far edge, worn patches on the path\n for (let x = 4; x < TILE - 4; x += 7) {\n const tall = 5 + ((x * 7) % 9);\n ctx.beginPath();\n ctx.moveTo(x - 3, horizon + 3);\n ctx.lineTo(x, horizon + 3 - tall);\n ctx.lineTo(x + 3, horizon + 3);\n ctx.fillStyle = css(shade(theme.grass, (x % 3) * 0.08));\n ctx.fill();\n }\n for (const x of spots(55, 10, 80)) {\n const y = horizon + 16 + rnd() * (span - 30);\n contactShadow(ctx, x, y, 40 + rnd() * 30, 0.12);\n }\n }\n // pebbles and tufts, clear of the tile's edges\n for (const x of spots(52, 24, 30)) {\n const y = horizon + 12 + rnd() * (span - 20);\n if (rnd() < 0.6) pebble(ctx, x, y, 3 + rnd() * 5, g);\n else\n tuft(\n ctx,\n x,\n y + 4,\n theme.id === \"snow\" ? 0x9fc0dd : theme.grass,\n 0.8 + rnd() * 0.5,\n );\n }\n}\n\n// ── The foreground strip ─────────────────────────────────────────────────────────────────────\n\nfunction paintFront(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n): void {\n // a few large, darker shapes cut by the bottom edge — close to the eye, moving fastest\n const rnd = seededRandom(61);\n const c =\n theme.id === \"snow\"\n ? 0xc9dcef\n : theme.id === \"desert\"\n ? 0xc99a58\n : theme.grass;\n for (const x of spots(62, 5, 140)) {\n const r = h * (0.07 + rnd() * 0.05);\n const y = h + r * 0.35;\n if (theme.id === \"meadow\" || theme.id === \"forest\") {\n for (let i = -3; i <= 3; i++) {\n ctx.beginPath();\n ctx.moveTo(x + i * r * 0.28 - r * 0.12, h + 2);\n ctx.quadraticCurveTo(\n x + i * r * 0.3,\n h - r * (1.1 - Math.abs(i) * 0.14),\n x + i * r * 0.34 + r * 0.18,\n h - r * (1.25 - Math.abs(i) * 0.14),\n );\n ctx.quadraticCurveTo(\n x + i * r * 0.3 + r * 0.06,\n h - r * 0.4,\n x + i * r * 0.28 + r * 0.12,\n h + 2,\n );\n ctx.fillStyle = css(shade(c, -0.25 + (i % 2) * 0.06));\n ctx.fill();\n }\n } else {\n const gr = ctx.createRadialGradient(\n x - r * 0.3,\n y - r * 0.6,\n r * 0.1,\n x,\n y,\n r * 1.2,\n );\n gr.addColorStop(0, css(shade(c, -0.05)));\n gr.addColorStop(1, css(shade(c, -0.35)));\n ctx.fillStyle = gr;\n ctx.beginPath();\n ctx.ellipse(x, y, r * 1.4, r, 0, Math.PI, 0);\n ctx.fill();\n }\n }\n}\n\n// ── Props (light from the upper left, a contact shadow under each) ───────────────────────────\n\nfunction cactus(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n): void {\n const w = size * 0.24;\n if (detail) contactShadow(ctx, x + w * 0.4, base, size * 0.5);\n const body = (x0: number, x1: number): CanvasGradient => {\n const g = ctx.createLinearGradient(x0, 0, x1, 0);\n g.addColorStop(0, css(shade(color, 0.18)));\n g.addColorStop(0.45, css(rgbOf(color)));\n g.addColorStop(1, css(shade(color, -0.25)));\n return g;\n };\n const arm = (dir: 1 | -1, at: number, up: number) => {\n const ax = x + dir * w * 0.45;\n const tip = ax + dir * w * 1.3;\n ctx.beginPath();\n ctx.moveTo(ax, base - at);\n ctx.lineTo(tip - dir * w * 0.3, base - at);\n ctx.quadraticCurveTo(tip, base - at, tip, base - at - w * 0.4);\n ctx.lineTo(tip, base - at - up);\n ctx.arc(\n tip - dir * w * 0.3,\n base - at - up,\n w * 0.3,\n dir > 0 ? 0 : Math.PI,\n dir > 0 ? Math.PI : 0,\n dir > 0,\n );\n ctx.lineTo(tip - dir * w * 0.6, base - at - w * 0.45);\n ctx.lineTo(ax, base - at - w * 0.45);\n ctx.closePath();\n ctx.fillStyle = body(Math.min(ax, tip) - 2, Math.max(ax, tip) + 2);\n ctx.fill();\n };\n arm(-1, size * 0.42, size * 0.28);\n arm(1, size * 0.58, size * 0.22);\n ctx.beginPath();\n ctx.moveTo(x - w / 2, base);\n ctx.lineTo(x - w / 2, base - size + w / 2);\n ctx.arc(x, base - size + w / 2, w / 2, Math.PI, 0);\n ctx.lineTo(x + w / 2, base);\n ctx.closePath();\n ctx.fillStyle = body(x - w / 2, x + w / 2);\n ctx.fill();\n if (!detail) return;\n // ribs: lit lines, then the flower\n for (const dx of [-0.22, 0.12]) {\n ctx.beginPath();\n ctx.moveTo(x + dx * w, base - 4);\n ctx.lineTo(x + dx * w, base - size + w * 0.8);\n ctx.lineWidth = 1.6;\n ctx.strokeStyle = css(shade(color, dx < 0 ? 0.35 : -0.35), 0.6);\n ctx.stroke();\n }\n const fx = x;\n const fy = base - size + 1;\n for (let i = 0; i < 5; i++) {\n const a = (i / 5) * Math.PI * 2;\n ctx.beginPath();\n ctx.ellipse(\n fx + Math.cos(a) * w * 0.18,\n fy + Math.sin(a) * w * 0.12,\n w * 0.16,\n w * 0.1,\n a,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"#ff6f96\";\n ctx.fill();\n }\n ctx.beginPath();\n ctx.arc(fx, fy, w * 0.09, 0, Math.PI * 2);\n ctx.fillStyle = \"#ffe066\";\n ctx.fill();\n}\n\nfunction palm(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n lean: 1 | -1,\n): void {\n const topX = x + lean * size * 0.24;\n const topY = base - size;\n contactShadow(ctx, x + size * 0.1, base, size * 0.32);\n // the trunk: curved, banded, lit on the left\n ctx.beginPath();\n ctx.moveTo(x - 9, base);\n ctx.quadraticCurveTo(\n x + lean * size * 0.04,\n base - size * 0.5,\n topX - 5,\n topY,\n );\n ctx.lineTo(topX + 5, topY);\n ctx.quadraticCurveTo(x + lean * size * 0.13, base - size * 0.5, x + 9, base);\n ctx.closePath();\n const trunk = ctx.createLinearGradient(x - 10, 0, x + 12, 0);\n trunk.addColorStop(0, \"#e2a868\");\n trunk.addColorStop(1, \"#9a6236\");\n ctx.fillStyle = trunk;\n ctx.fill();\n for (let i = 1; i < 8; i++) {\n const t = i / 8;\n const sx = x + (topX - x) * t * t;\n const sy = base - size * t;\n ctx.beginPath();\n ctx.ellipse(sx, sy, 8 - t * 3, 2.2, lean * 0.15 * t, 0, Math.PI);\n ctx.fillStyle = \"rgba(110, 60, 30, .45)\";\n ctx.fill();\n }\n // fronds: two-tone leaves arching out of the crown\n const leaves: Array<[number, number]> = [\n [-1, 0.32],\n [-0.85, -0.3],\n [-0.25, -0.62],\n [0.35, -0.55],\n [0.9, -0.22],\n [1, 0.36],\n ];\n for (const [dx, dy] of leaves) {\n const ex = topX + dx * size * 0.46;\n const ey = topY + dy * size * 0.3 + size * 0.13;\n const cx = topX + dx * size * 0.25;\n const cy = topY + dy * size * 0.36 - size * 0.12;\n ctx.beginPath();\n ctx.moveTo(topX, topY);\n ctx.quadraticCurveTo(cx, cy, ex, ey);\n ctx.quadraticCurveTo(\n topX + dx * size * 0.22,\n topY + dy * size * 0.2 + 4,\n topX,\n topY + 7,\n );\n ctx.closePath();\n const leaf = ctx.createLinearGradient(topX, topY - size * 0.2, ex, ey + 6);\n leaf.addColorStop(0, \"#7fd660\");\n leaf.addColorStop(1, \"#3b8c3a\");\n ctx.fillStyle = leaf;\n ctx.fill();\n ctx.beginPath();\n ctx.moveTo(topX, topY + 2);\n ctx.quadraticCurveTo(cx, cy + 5, ex, ey);\n ctx.lineWidth = 1.4;\n ctx.strokeStyle = \"rgba(240, 255, 200, .45)\";\n ctx.stroke();\n }\n for (const [dx, dy] of [\n [-4, 7],\n [5, 9],\n [0, 12],\n ] as const) {\n const g = ctx.createRadialGradient(\n topX + dx - 1.5,\n topY + dy - 1.5,\n 0.5,\n topX + dx,\n topY + dy,\n 5,\n );\n g.addColorStop(0, \"#b98050\");\n g.addColorStop(1, \"#6a4022\");\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(topX + dx, topY + dy, 5, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nfunction rock(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n glow = false,\n): void {\n if (detail) contactShadow(ctx, x + size * 0.15, base, size * 1.3, 0.3);\n ctx.beginPath();\n ctx.moveTo(x - size, base);\n ctx.quadraticCurveTo(\n x - size * 1.05,\n base - size * 0.7,\n x - size * 0.4,\n base - size * 0.88,\n );\n ctx.quadraticCurveTo(\n x + size * 0.2,\n base - size * 1.08,\n x + size * 0.75,\n base - size * 0.6,\n );\n ctx.quadraticCurveTo(x + size * 1.05, base - size * 0.3, x + size, base);\n ctx.closePath();\n const g = ctx.createRadialGradient(\n x - size * 0.4,\n base - size * 0.8,\n size * 0.1,\n x,\n base - size * 0.3,\n size * 1.3,\n );\n g.addColorStop(0, css(shade(color, 0.22)));\n g.addColorStop(0.6, css(rgbOf(color)));\n g.addColorStop(1, css(shade(color, -0.3)));\n ctx.fillStyle = g;\n ctx.fill();\n if (!detail) return;\n ctx.beginPath();\n ctx.ellipse(\n x - size * 0.3,\n base - size * 0.7,\n size * 0.26,\n size * 0.1,\n -0.35,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"rgba(255, 255, 255, .28)\";\n ctx.fill();\n if (glow) {\n ctx.beginPath();\n ctx.moveTo(x - size * 0.5, base - size * 0.2);\n ctx.lineTo(x - size * 0.1, base - size * 0.45);\n ctx.lineTo(x + size * 0.3, base - size * 0.25);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"#ff8a3a\";\n ctx.stroke();\n }\n}\n\nfunction pebble(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n r: number,\n ground: number,\n): void {\n contactShadow(ctx, x + r * 0.4, y + r * 0.4, r * 1.6, 0.25);\n const g = ctx.createRadialGradient(\n x - r * 0.4,\n y - r * 0.4,\n 0,\n x,\n y,\n r * 1.2,\n );\n g.addColorStop(0, css(shade(ground, 0.12)));\n g.addColorStop(1, css(shade(ground, -0.28)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.ellipse(x, y, r * 1.3, r * 0.75, 0, 0, Math.PI * 2);\n ctx.fill();\n}\n\nfunction roundTree(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n green: number,\n detail: boolean,\n): void {\n if (detail) contactShadow(ctx, x + size * 0.08, base, size * 0.42);\n ctx.beginPath();\n ctx.moveTo(x - size * 0.05, base);\n ctx.lineTo(x - size * 0.035, base - size * 0.52);\n ctx.lineTo(x + size * 0.035, base - size * 0.52);\n ctx.lineTo(x + size * 0.05, base);\n ctx.closePath();\n const trunk = ctx.createLinearGradient(\n x - size * 0.05,\n 0,\n x + size * 0.05,\n 0,\n );\n trunk.addColorStop(0, \"#b07a48\");\n trunk.addColorStop(1, \"#6e4626\");\n ctx.fillStyle = trunk;\n ctx.fill();\n const r = size * 0.3;\n const blobs: Array<[number, number, number]> = [\n [-0.62, -0.58, 0.8],\n [0.62, -0.6, 0.8],\n [0, -0.82, 1],\n [-0.3, -0.98, 0.62],\n [0.32, -0.96, 0.6],\n ];\n for (const [dx, dy, k] of blobs) {\n const cx = x + r * dx;\n const cy = base + size * dy;\n const g = ctx.createRadialGradient(\n cx - r * k * 0.35,\n cy - r * k * 0.4,\n r * k * 0.1,\n cx,\n cy,\n r * k,\n );\n g.addColorStop(0, css(shade(green, 0.28)));\n g.addColorStop(0.7, css(shade(green, 0.02)));\n g.addColorStop(1, css(shade(green, -0.22)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(cx, cy, r * k, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nfunction bush(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n green: number,\n): void {\n contactShadow(ctx, x + size * 0.1, base, size * 1.3);\n const blobs: Array<[number, number, number]> = [\n [-0.5, -0.32, 0.45],\n [0.2, -0.46, 0.55],\n [0.8, -0.3, 0.38],\n ];\n for (const [dx, dy, k] of blobs) {\n const cx = x + size * dx;\n const cy = base + size * dy;\n const r = size * k;\n const g = ctx.createRadialGradient(\n cx - r * 0.35,\n cy - r * 0.4,\n r * 0.1,\n cx,\n cy,\n r,\n );\n g.addColorStop(0, css(shade(green, 0.3)));\n g.addColorStop(1, css(shade(green, -0.18)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(cx, cy, r, Math.PI, 0);\n ctx.lineTo(cx + r, base);\n ctx.lineTo(cx - r, base);\n ctx.closePath();\n ctx.fill();\n }\n for (const [dx, c] of [\n [-0.45, \"#ffffff\"],\n [0.25, \"#ffd34d\"],\n [0.75, \"#ff7aa8\"],\n ] as const) {\n ctx.beginPath();\n ctx.arc(x + size * dx, base - size * 0.52, 3, 0, Math.PI * 2);\n ctx.fillStyle = c;\n ctx.fill();\n }\n}\n\nfunction pine(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n): void {\n if (detail) {\n contactShadow(ctx, x + size * 0.06, base, size * 0.34);\n ctx.fillStyle = \"#7a5a3a\";\n ctx.fillRect(x - size * 0.035, base - size * 0.2, size * 0.07, size * 0.2);\n }\n for (let i = 0; i < 3; i++) {\n const y = base - size * (0.15 + i * 0.27);\n const w = size * (0.42 - i * 0.1);\n const tip = y - size * 0.42;\n ctx.beginPath();\n ctx.moveTo(x - w, y);\n ctx.lineTo(x, tip);\n ctx.lineTo(x + w, y);\n ctx.quadraticCurveTo(x, y + size * 0.05, x - w, y);\n ctx.closePath();\n if (detail) {\n const g = ctx.createLinearGradient(x - w, 0, x + w, 0);\n g.addColorStop(0, css(shade(color, 0.2)));\n g.addColorStop(0.5, css(rgbOf(color)));\n g.addColorStop(0.5, css(shade(color, -0.16)));\n g.addColorStop(1, css(shade(color, -0.28)));\n ctx.fillStyle = g;\n } else ctx.fillStyle = css(rgbOf(color));\n ctx.fill();\n }\n}\n\nfunction snowyPine(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n): void {\n pine(ctx, x, base, size, 0x3f7a62, true);\n for (let i = 0; i < 3; i++) {\n const y = base - size * (0.15 + i * 0.27) - size * 0.31;\n const w = size * (0.2 - i * 0.045);\n ctx.beginPath();\n ctx.moveTo(x - w, y + size * 0.09);\n ctx.lineTo(x, y - size * 0.11);\n ctx.lineTo(x + w, y + size * 0.09);\n ctx.quadraticCurveTo(x, y + size * 0.03, x - w, y + size * 0.09);\n ctx.fillStyle = \"rgba(255, 255, 255, .95)\";\n ctx.fill();\n }\n}\n\nfunction deadTree(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n): void {\n contactShadow(ctx, x + size * 0.1, base, size * 0.34);\n ctx.lineCap = \"round\";\n const branch = (\n x0: number,\n y0: number,\n x1: number,\n y1: number,\n w: number,\n ) => {\n const g = ctx.createLinearGradient(x0 - w, y0, x0 + w, y0);\n g.addColorStop(0, \"#8a6a58\");\n g.addColorStop(1, \"#4a3430\");\n ctx.beginPath();\n ctx.moveTo(x0, y0);\n ctx.lineTo(x1, y1);\n ctx.lineWidth = w;\n ctx.strokeStyle = g;\n ctx.stroke();\n };\n branch(x, base, x + size * 0.05, base - size * 0.75, 10);\n branch(\n x + size * 0.03,\n base - size * 0.45,\n x - size * 0.3,\n base - size * 0.72,\n 6,\n );\n branch(\n x + size * 0.05,\n base - size * 0.6,\n x + size * 0.32,\n base - size * 0.85,\n 6,\n );\n branch(\n x - size * 0.2,\n base - size * 0.64,\n x - size * 0.28,\n base - size * 0.9,\n 4,\n );\n}\n\nfunction tuft(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n color: number,\n k: number,\n): void {\n const blades: Array<[number, number]> = [\n [-6, 8],\n [-2.5, 12],\n [1, 10],\n [4.5, 13],\n [7, 7],\n ];\n for (const [dx, tall] of blades) {\n ctx.beginPath();\n ctx.moveTo(x + dx * k - 2, y);\n ctx.quadraticCurveTo(\n x + dx * k,\n y - tall * k * 0.6,\n x + dx * k + dx * 0.25,\n y - tall * k,\n );\n ctx.quadraticCurveTo(x + dx * k + 1, y - tall * k * 0.5, x + dx * k + 2, y);\n ctx.closePath();\n ctx.fillStyle = css(shade(color, dx < 0 ? 0.15 : -0.12));\n ctx.fill();\n }\n}\n"
426
+ "content": "import type Phaser from \"phaser\";\nimport { seededRandom } from \"../game/random\";\nimport type { Theme } from \"../game/themes\";\nimport { screenDensity } from \"./density\";\n\n// The world behind the fight — a painted casual landscape per chapter theme, with no outlines: soft\n// gradients, light from the upper left (lit faces, shaded sides), a haze that fades the far planes\n// into the sky and contact shadows on the ground. Drawn once per theme and field size on canvases and\n// tiled sideways, so the road scrolls without end; the layers move at their own pace as the hero runs\n// (parallax): the sky with its sun stays, the far skyline barely moves, the hills behind and the near\n// props (cacti, palms, trees, rocks) faster, the road exactly with the world — the fighters stand on\n// it — and a strip of foreground at the bottom edge faster still.\n//\n// Every scrolling layer is one TILE wide and repeats seamlessly: skylines are periodic over the tile\n// and props keep clear of its edges.\n\nconst TILE = 1536;\n/** The sky does not scroll: wide enough for any screen. */\nconst SKY_W = 2560;\n/** A layer's canvas is never wider than this: past 4096 a phone's GPU may refuse the texture. */\nconst MAX_TEXTURE_PX = 4096;\n\nexport interface BackdropLayout {\n w: number;\n h: number;\n /** The top edge of the road (the line the near props stand on). */\n horizon: number;\n}\n\ninterface Layer {\n sprite: Phaser.GameObjects.TileSprite;\n factor: number;\n /** Canvas pixels per screen pixel of this layer's texture. */\n density: number;\n}\n\ntype Painter = (\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n) => void;\n\nconst LAYERS: ReadonlyArray<{\n name: string;\n factor: number;\n width: number;\n paint: Painter;\n /** Over the fighters (the foreground strip) rather than behind them. */\n front?: boolean;\n}> = [\n { name: \"sky\", factor: 0, width: SKY_W, paint: paintSky },\n { name: \"far\", factor: 0.08, width: TILE, paint: paintFar },\n { name: \"hills\", factor: 0.22, width: TILE, paint: paintHills },\n { name: \"near\", factor: 0.45, width: TILE, paint: paintNear },\n { name: \"road\", factor: 1, width: TILE, paint: paintRoad },\n { name: \"front\", factor: 1.35, width: TILE, paint: paintFront, front: true },\n];\n\nexport class Backdrop {\n private layers: Layer[] = [];\n private key = \"\";\n /** The textures of the current layers: dropped when the theme or the size changes. */\n private textures: string[] = [];\n\n constructor(private readonly scene: Phaser.Scene) {}\n\n /**\n * (Re)builds the layers for a theme and a field size; cheap when nothing changed. `depth` — the\n * layers behind the fighters start there, `frontDepth` — the foreground strip over them.\n *\n * The layers are painted at the screen's density (sharp on a phone) and shown at their tiles' scale\n * `1 / density`. ⚠ The textures of the previous theme or size are REMOVED, not kept for later: six\n * layers are some 40 MB at a density of 2, and every chapter and every resize used to add another set.\n */\n layout(\n theme: Theme,\n at: BackdropLayout,\n depth: number,\n frontDepth: number,\n ): void {\n const key = `${theme.id}-${Math.round(at.h)}-${Math.round(at.horizon)}`;\n if (key === this.key) {\n for (const l of this.layers) l.sprite.setSize(at.w, at.h);\n return;\n }\n this.key = key;\n for (const l of this.layers) l.sprite.destroy();\n for (const tex of this.textures)\n if (this.scene.textures.exists(tex)) this.scene.textures.remove(tex);\n this.textures = [];\n const k = screenDensity();\n this.layers = LAYERS.map((layer, i) => {\n const tex = `idle-bd2-${layer.name}-${key}`;\n const density = Math.min(k, MAX_TEXTURE_PX / layer.width);\n if (!this.scene.textures.exists(tex)) {\n const canvas = this.scene.textures.createCanvas(\n tex,\n Math.round(layer.width * density),\n Math.ceil(at.h * density),\n )!;\n const ctx = canvas.getContext();\n ctx.scale(density, density);\n layer.paint(ctx, theme, at.h, at.horizon);\n canvas.refresh();\n }\n this.textures.push(tex);\n const sprite = this.scene.add\n .tileSprite(0, 0, at.w, at.h, tex)\n .setOrigin(0, 0)\n .setTileScale(1 / density)\n .setDepth(layer.front ? frontDepth : depth + i * 0.1);\n return { sprite, factor: layer.factor, density };\n });\n }\n\n /** Scrolls the layers: `px` — how far the road has moved, in screen pixels. */\n scroll(px: number): void {\n // a tile's position is in its texture's pixels\n for (const l of this.layers)\n l.sprite.tilePositionX = px * l.factor * l.density;\n }\n}\n\n// ── Colour helpers ───────────────────────────────────────────────────────────────────────────\n\ntype Rgb = [number, number, number];\nconst rgbOf = (n: number): Rgb => [(n >> 16) & 255, (n >> 8) & 255, n & 255];\nconst css = ([r, g, b]: Rgb, a = 1): string =>\n `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})`;\n\n/** `a` blended towards `b` by `t` (0 — a, 1 — b). */\nfunction mix(a: number, b: number, t: number): Rgb {\n const x = rgbOf(a);\n const y = rgbOf(b);\n return [0, 1, 2].map((i) => x[i]! + (y[i]! - x[i]!) * t) as Rgb;\n}\n\n/** Lighter (+) or darker (−) by a share. */\nfunction shade(c: number, amount: number): Rgb {\n return amount >= 0 ? mix(c, 0xffffff, amount) : mix(c, 0x000000, -amount);\n}\nconst hex = ([r, g, b]: Rgb): number =>\n (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);\n\n/** A far colour: the theme's colour sunk into the sky's haze. */\nfunction hazed(c: number, theme: Theme, amount: number): number {\n return hex(mix(c, theme.sky[1], amount));\n}\n\nfunction vertical(\n ctx: CanvasRenderingContext2D,\n y0: number,\n y1: number,\n stops: ReadonlyArray<[number, string]>,\n): CanvasGradient {\n const g = ctx.createLinearGradient(0, y0, 0, y1);\n for (const [at, c] of stops) g.addColorStop(at, c);\n return g;\n}\n\n/** A soft dark ellipse on the ground under a prop. */\nfunction contactShadow(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n w: number,\n alpha = 0.28,\n): void {\n ctx.save();\n ctx.translate(x, y);\n ctx.scale(1, 0.22);\n const g = ctx.createRadialGradient(0, 0, 0, 0, 0, w);\n g.addColorStop(0, `rgba(30, 18, 28, ${alpha})`);\n g.addColorStop(1, \"rgba(30, 18, 28, 0)\");\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(0, 0, w, 0, Math.PI * 2);\n ctx.fill();\n ctx.restore();\n}\n\n/** A height profile periodic over the tile (whole waves only), so the skyline joins at the seam. */\nfunction profile(\n seed: number,\n waves: ReadonlyArray<[cycles: number, amp: number]>,\n): (x: number) => number {\n const rnd = seededRandom(seed);\n const phases = waves.map(() => rnd() * Math.PI * 2);\n return (x) =>\n waves.reduce(\n (sum, [k, a], i) =>\n sum + a * Math.sin((x / TILE) * Math.PI * 2 * k + phases[i]!),\n 0,\n );\n}\n\n/** A band from `base` up to a periodic top, filled with a vertical gradient, a soft lit rim on top. */\nfunction band(\n ctx: CanvasRenderingContext2D,\n top: (x: number) => number,\n base: number,\n fill: string | CanvasGradient,\n rim: string | null,\n): void {\n ctx.beginPath();\n ctx.moveTo(-10, base + 10);\n for (let x = -10; x <= TILE + 10; x += 6) ctx.lineTo(x, top(x));\n ctx.lineTo(TILE + 10, base + 10);\n ctx.closePath();\n ctx.fillStyle = fill;\n ctx.fill();\n if (!rim) return;\n ctx.beginPath();\n ctx.moveTo(-10, top(-10) + 1.5);\n for (let x = -4; x <= TILE + 10; x += 6) ctx.lineTo(x, top(x) + 1.5);\n ctx.lineWidth = 3;\n ctx.strokeStyle = rim;\n ctx.stroke();\n}\n\n/** Spots for props along the tile, clear of its edges (a prop never straddles the seam). */\nfunction spots(seed: number, count: number, margin: number): number[] {\n const rnd = seededRandom(seed);\n const step = (TILE - margin * 2) / count;\n return Array.from(\n { length: count },\n (_, i) => margin + step * (i + 0.2 + rnd() * 0.6),\n );\n}\n\n// ── The sky ──────────────────────────────────────────────────────────────────────────────────\n\nfunction paintSky(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const [top, low] = theme.sky;\n ctx.fillStyle = vertical(ctx, 0, horizon, [\n [0, css(rgbOf(top))],\n [0.7, css(mix(top, low, 0.75))],\n [1, css(rgbOf(low))],\n ]);\n ctx.fillRect(0, 0, SKY_W, horizon + 2);\n // the sun (a red moon over the volcano) with a wide soft glow\n const night = theme.id === \"volcano\";\n const sx = SKY_W * 0.36;\n const sy = horizon * 0.42;\n const r = Math.max(18, h * 0.07);\n const glow = ctx.createRadialGradient(sx, sy, r * 0.6, sx, sy, r * 6);\n glow.addColorStop(\n 0,\n night ? \"rgba(255, 120, 80, .45)\" : \"rgba(255, 252, 225, .7)\",\n );\n glow.addColorStop(\n 1,\n night ? \"rgba(255, 120, 80, 0)\" : \"rgba(255, 252, 225, 0)\",\n );\n ctx.fillStyle = glow;\n ctx.fillRect(sx - r * 6, sy - r * 6, r * 12, r * 12);\n const disc = ctx.createRadialGradient(\n sx - r * 0.3,\n sy - r * 0.3,\n 0,\n sx,\n sy,\n r,\n );\n disc.addColorStop(0, night ? \"#ffd2b0\" : \"#fffef2\");\n disc.addColorStop(1, night ? \"#ff7a4a\" : \"#fff1b0\");\n ctx.fillStyle = disc;\n ctx.beginPath();\n ctx.arc(sx, sy, r, 0, Math.PI * 2);\n ctx.fill();\n}\n\n// ── The far skyline ──────────────────────────────────────────────────────────────────────────\n\nfunction paintFar(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const far = hazed(theme.hillFar, theme, 0.42);\n const rnd = seededRandom(11);\n if (theme.id === \"desert\") {\n // Mesas: flat-topped plateaus, the lit top face, the banded cliffs, the shaded right side.\n for (const x of spots(12, 5, 150)) {\n const w = 150 + rnd() * 150;\n const top = horizon - h * (0.17 + rnd() * 0.14);\n const slant = 16 + rnd() * 18;\n const left = x - w / 2;\n const right = x + w / 2;\n ctx.beginPath();\n ctx.moveTo(left - slant, horizon + 4);\n ctx.lineTo(left, top + 6);\n ctx.quadraticCurveTo(left + 3, top, left + 14, top);\n ctx.lineTo(right - 14, top);\n ctx.quadraticCurveTo(right - 3, top, right, top + 6);\n ctx.lineTo(right + slant, horizon + 4);\n ctx.closePath();\n const side = ctx.createLinearGradient(left - slant, 0, right + slant, 0);\n side.addColorStop(0, css(shade(far, 0.1)));\n side.addColorStop(0.62, css(rgbOf(far)));\n side.addColorStop(1, css(shade(far, -0.16)));\n ctx.fillStyle = side;\n ctx.fill();\n ctx.save();\n ctx.clip();\n for (let y = top + 16, i = 0; y < horizon; y += 20 + (i % 2) * 6, i++) {\n ctx.fillStyle = css(shade(far, -0.1), 0.55);\n ctx.fillRect(left - slant, y, w + slant * 2, 5);\n }\n ctx.fillStyle = css(shade(far, 0.22));\n ctx.fillRect(left - 2, top, w + 4, 5);\n ctx.restore();\n }\n band(\n ctx,\n (x) =>\n horizon -\n 12 +\n profile(13, [\n [3, 5],\n [7, 3],\n ])(x),\n horizon,\n css(rgbOf(hazed(theme.hillFar, theme, 0.25))),\n null,\n );\n return;\n }\n if (theme.id === \"snow\" || theme.id === \"volcano\") {\n // Mountains lit from the left: a light face and a shaded one split along the ridge.\n for (const x of spots(22, 6, 130)) {\n const w = 190 + rnd() * 170;\n const peak = horizon - h * (0.22 + rnd() * 0.18);\n const px = x + (rnd() - 0.5) * w * 0.2;\n ctx.beginPath();\n ctx.moveTo(x - w / 2, horizon + 4);\n ctx.quadraticCurveTo(px - w * 0.12, peak + 18, px, peak);\n ctx.quadraticCurveTo(px + w * 0.14, peak + 22, x + w / 2, horizon + 4);\n ctx.closePath();\n ctx.fillStyle = css(shade(far, 0.06));\n ctx.fill();\n ctx.beginPath();\n ctx.moveTo(px, peak);\n ctx.quadraticCurveTo(px + w * 0.14, peak + 22, x + w / 2, horizon + 4);\n ctx.lineTo(px + w * 0.05, horizon + 4);\n ctx.quadraticCurveTo(px + w * 0.02, peak + 40, px, peak);\n ctx.fillStyle = css(shade(far, -0.14), 0.9);\n ctx.fill();\n if (theme.id === \"snow\") {\n ctx.beginPath();\n ctx.moveTo(px, peak);\n ctx.quadraticCurveTo(\n px - w * 0.08,\n peak + 22,\n px - w * 0.16,\n peak + 40,\n );\n ctx.quadraticCurveTo(px - w * 0.05, peak + 34, px, peak + 44);\n ctx.quadraticCurveTo(\n px + w * 0.06,\n peak + 30,\n px + w * 0.15,\n peak + 42,\n );\n ctx.quadraticCurveTo(px + w * 0.08, peak + 20, px, peak);\n ctx.fillStyle = \"rgba(255, 255, 255, .95)\";\n ctx.fill();\n } else {\n const lava = ctx.createRadialGradient(\n px,\n peak + 4,\n 0,\n px,\n peak + 4,\n 40,\n );\n lava.addColorStop(0, \"rgba(255, 170, 70, .95)\");\n lava.addColorStop(1, \"rgba(255, 90, 40, 0)\");\n ctx.fillStyle = lava;\n ctx.fillRect(px - 40, peak - 36, 80, 80);\n for (let i = 0; i < 4; i++) {\n ctx.beginPath();\n ctx.arc(\n px + i * 9 - 8,\n peak - 18 - i * 16,\n 12 + i * 5,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"rgba(60, 40, 50, .28)\";\n ctx.fill();\n }\n }\n }\n band(\n ctx,\n (x) =>\n horizon -\n 16 +\n profile(23, [\n [4, 7],\n [9, 3],\n ])(x),\n horizon,\n css(rgbOf(hazed(theme.hillFar, theme, 0.25))),\n null,\n );\n return;\n }\n // Meadow and forest: a far row of soft rolling hills (the forest's crowned with pines).\n const top = (x: number) =>\n horizon -\n h * 0.19 +\n profile(31, [\n [2, h * 0.05],\n [5, h * 0.025],\n ])(x);\n band(\n ctx,\n top,\n horizon,\n vertical(ctx, horizon - h * 0.26, horizon, [\n [0, css(shade(far, 0.1))],\n [1, css(rgbOf(far))],\n ]),\n css(shade(far, 0.25), 0.6),\n );\n if (theme.id === \"forest\")\n for (let x = 20; x < TILE - 20; x += 22 + (x % 3) * 4)\n pine(ctx, x, top(x) + 10, 24, hazed(theme.grass, theme, 0.4), false);\n}\n\n// ── The hills behind the road ────────────────────────────────────────────────────────────────\n\nfunction paintHills(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const c = hazed(theme.hillNear, theme, 0.18);\n const top = (x: number) =>\n horizon -\n h * 0.085 +\n profile(35, [\n [3, h * 0.035],\n [7, h * 0.015],\n ])(x);\n band(\n ctx,\n top,\n horizon,\n vertical(ctx, horizon - h * 0.14, horizon, [\n [0, css(shade(c, 0.12))],\n [1, css(shade(c, -0.08))],\n ]),\n css(shade(c, 0.3), 0.55),\n );\n // small hazed silhouettes on the crest — distance in between\n for (const x of spots(36, 14, 40)) {\n const y = top(x) + 3;\n const size = h * 0.06;\n const tint = hazed(\n theme.id === \"desert\"\n ? 0x5fa84a\n : theme.id === \"volcano\"\n ? 0x4a3436\n : theme.grass,\n theme,\n 0.3,\n );\n if (theme.id === \"desert\") cactus(ctx, x, y, size, tint, false);\n else if (theme.id === \"meadow\")\n roundTree(ctx, x, y, size * 1.3, tint, false);\n else if (theme.id === \"volcano\") rock(ctx, x, y, size * 0.5, tint, false);\n else\n pine(ctx, x, y, size * 1.4, theme.id === \"snow\" ? 0x6f9a8c : tint, false);\n }\n}\n\n// ── The near props ───────────────────────────────────────────────────────────────────────────\n\nfunction paintNear(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n // a low dune / bank right behind the road\n const c = theme.hillNear;\n band(\n ctx,\n (x) =>\n horizon -\n h * 0.035 +\n profile(41, [\n [4, h * 0.018],\n [11, 3],\n ])(x),\n horizon,\n vertical(ctx, horizon - h * 0.06, horizon, [\n [0, css(shade(c, 0.08))],\n [1, css(shade(c, -0.12))],\n ]),\n css(shade(c, 0.3), 0.6),\n );\n const rnd = seededRandom(43);\n const base = horizon + 3;\n spots(44, 8, 110).forEach((x, i) => {\n const size = h * (0.17 + rnd() * 0.09);\n const lean: 1 | -1 = rnd() > 0.5 ? 1 : -1;\n switch (theme.id) {\n case \"desert\":\n if (i % 3 === 0) palm(ctx, x, base, size * 1.6, lean);\n else if (i % 3 === 1) cactus(ctx, x, base, size, 0x5fb04a, true);\n else rock(ctx, x, base, size * 0.42, 0xd8a86a, true);\n break;\n case \"meadow\":\n if (i % 3 === 2) bush(ctx, x, base, size * 0.5, theme.grass);\n else roundTree(ctx, x, base, size * 1.25, theme.grass, true);\n break;\n case \"forest\":\n if (i % 4 === 3) rock(ctx, x, base, size * 0.4, 0x8a8a96, true);\n else pine(ctx, x, base, size * 1.45, theme.grass, true);\n break;\n case \"snow\":\n if (i % 3 === 2) rock(ctx, x, base, size * 0.45, 0xb9d3ec, true);\n else snowyPine(ctx, x, base, size * 1.3);\n break;\n case \"volcano\":\n if (i % 3 === 0) deadTree(ctx, x, base, size * 1.15);\n else rock(ctx, x, base, size * 0.5, 0x4a3a40, true, true);\n break;\n }\n });\n}\n\n// ── The road ─────────────────────────────────────────────────────────────────────────────────\n\nfunction paintRoad(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const g = theme.ground;\n ctx.fillStyle = vertical(ctx, horizon, h, [\n [0, css(shade(g, 0.1))],\n [0.5, css(rgbOf(g))],\n [1, css(shade(g, -0.1))],\n ]);\n ctx.fillRect(0, horizon, TILE, h - horizon);\n // the far edge of the road, softly shaded by the bank behind it\n ctx.fillStyle = vertical(ctx, horizon, horizon + 14, [\n [0, css(shade(g, -0.18), 0.55)],\n [1, css(shade(g, -0.18), 0)],\n ]);\n ctx.fillRect(0, horizon, TILE, 14);\n const rnd = seededRandom(51);\n const span = h - horizon;\n if (theme.id === \"desert\" || theme.id === \"snow\") {\n // wind ripples in the sand / the snow\n for (let i = 0; i < 26; i++) {\n const x = 40 + rnd() * (TILE - 160);\n const y = horizon + 14 + rnd() * (span - 24);\n const w = 40 + rnd() * 70;\n ctx.beginPath();\n ctx.moveTo(x, y);\n ctx.quadraticCurveTo(x + w / 2, y - 4, x + w, y);\n ctx.lineWidth = 2.5;\n ctx.strokeStyle = css(shade(g, theme.id === \"snow\" ? -0.08 : 0.18), 0.7);\n ctx.stroke();\n ctx.beginPath();\n ctx.moveTo(x + 2, y + 3);\n ctx.quadraticCurveTo(x + w / 2, y - 1, x + w - 2, y + 3);\n ctx.lineWidth = 2;\n ctx.strokeStyle = css(shade(g, -0.14), 0.45);\n ctx.stroke();\n }\n } else if (theme.id === \"volcano\") {\n for (const x of spots(54, 7, 90)) {\n const y = horizon + 18 + rnd() * (span - 30);\n ctx.beginPath();\n ctx.moveTo(x - 30, y);\n ctx.lineTo(x - 10, y - 4);\n ctx.lineTo(x + 6, y + 2);\n ctx.lineTo(x + 28, y - 3);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"rgba(255, 110, 40, .75)\";\n ctx.stroke();\n }\n } else {\n // a grassy fringe along the far edge, worn patches on the path\n for (let x = 4; x < TILE - 4; x += 7) {\n const tall = 5 + ((x * 7) % 9);\n ctx.beginPath();\n ctx.moveTo(x - 3, horizon + 3);\n ctx.lineTo(x, horizon + 3 - tall);\n ctx.lineTo(x + 3, horizon + 3);\n ctx.fillStyle = css(shade(theme.grass, (x % 3) * 0.08));\n ctx.fill();\n }\n for (const x of spots(55, 10, 80)) {\n const y = horizon + 16 + rnd() * (span - 30);\n contactShadow(ctx, x, y, 40 + rnd() * 30, 0.12);\n }\n }\n // pebbles and tufts, clear of the tile's edges\n for (const x of spots(52, 24, 30)) {\n const y = horizon + 12 + rnd() * (span - 20);\n if (rnd() < 0.6) pebble(ctx, x, y, 3 + rnd() * 5, g);\n else\n tuft(\n ctx,\n x,\n y + 4,\n theme.id === \"snow\" ? 0x9fc0dd : theme.grass,\n 0.8 + rnd() * 0.5,\n );\n }\n}\n\n// ── The foreground strip ─────────────────────────────────────────────────────────────────────\n\nfunction paintFront(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n): void {\n // a few large, darker shapes cut by the bottom edge — close to the eye, moving fastest\n const rnd = seededRandom(61);\n const c =\n theme.id === \"snow\"\n ? 0xc9dcef\n : theme.id === \"desert\"\n ? 0xc99a58\n : theme.grass;\n for (const x of spots(62, 5, 140)) {\n const r = h * (0.07 + rnd() * 0.05);\n const y = h + r * 0.35;\n if (theme.id === \"meadow\" || theme.id === \"forest\") {\n for (let i = -3; i <= 3; i++) {\n ctx.beginPath();\n ctx.moveTo(x + i * r * 0.28 - r * 0.12, h + 2);\n ctx.quadraticCurveTo(\n x + i * r * 0.3,\n h - r * (1.1 - Math.abs(i) * 0.14),\n x + i * r * 0.34 + r * 0.18,\n h - r * (1.25 - Math.abs(i) * 0.14),\n );\n ctx.quadraticCurveTo(\n x + i * r * 0.3 + r * 0.06,\n h - r * 0.4,\n x + i * r * 0.28 + r * 0.12,\n h + 2,\n );\n ctx.fillStyle = css(shade(c, -0.25 + (i % 2) * 0.06));\n ctx.fill();\n }\n } else {\n const gr = ctx.createRadialGradient(\n x - r * 0.3,\n y - r * 0.6,\n r * 0.1,\n x,\n y,\n r * 1.2,\n );\n gr.addColorStop(0, css(shade(c, -0.05)));\n gr.addColorStop(1, css(shade(c, -0.35)));\n ctx.fillStyle = gr;\n ctx.beginPath();\n ctx.ellipse(x, y, r * 1.4, r, 0, Math.PI, 0);\n ctx.fill();\n }\n }\n}\n\n// ── Props (light from the upper left, a contact shadow under each) ───────────────────────────\n\nfunction cactus(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n): void {\n const w = size * 0.24;\n if (detail) contactShadow(ctx, x + w * 0.4, base, size * 0.5);\n const body = (x0: number, x1: number): CanvasGradient => {\n const g = ctx.createLinearGradient(x0, 0, x1, 0);\n g.addColorStop(0, css(shade(color, 0.18)));\n g.addColorStop(0.45, css(rgbOf(color)));\n g.addColorStop(1, css(shade(color, -0.25)));\n return g;\n };\n const arm = (dir: 1 | -1, at: number, up: number) => {\n const ax = x + dir * w * 0.45;\n const tip = ax + dir * w * 1.3;\n ctx.beginPath();\n ctx.moveTo(ax, base - at);\n ctx.lineTo(tip - dir * w * 0.3, base - at);\n ctx.quadraticCurveTo(tip, base - at, tip, base - at - w * 0.4);\n ctx.lineTo(tip, base - at - up);\n ctx.arc(\n tip - dir * w * 0.3,\n base - at - up,\n w * 0.3,\n dir > 0 ? 0 : Math.PI,\n dir > 0 ? Math.PI : 0,\n dir > 0,\n );\n ctx.lineTo(tip - dir * w * 0.6, base - at - w * 0.45);\n ctx.lineTo(ax, base - at - w * 0.45);\n ctx.closePath();\n ctx.fillStyle = body(Math.min(ax, tip) - 2, Math.max(ax, tip) + 2);\n ctx.fill();\n };\n arm(-1, size * 0.42, size * 0.28);\n arm(1, size * 0.58, size * 0.22);\n ctx.beginPath();\n ctx.moveTo(x - w / 2, base);\n ctx.lineTo(x - w / 2, base - size + w / 2);\n ctx.arc(x, base - size + w / 2, w / 2, Math.PI, 0);\n ctx.lineTo(x + w / 2, base);\n ctx.closePath();\n ctx.fillStyle = body(x - w / 2, x + w / 2);\n ctx.fill();\n if (!detail) return;\n // ribs: lit lines, then the flower\n for (const dx of [-0.22, 0.12]) {\n ctx.beginPath();\n ctx.moveTo(x + dx * w, base - 4);\n ctx.lineTo(x + dx * w, base - size + w * 0.8);\n ctx.lineWidth = 1.6;\n ctx.strokeStyle = css(shade(color, dx < 0 ? 0.35 : -0.35), 0.6);\n ctx.stroke();\n }\n const fx = x;\n const fy = base - size + 1;\n for (let i = 0; i < 5; i++) {\n const a = (i / 5) * Math.PI * 2;\n ctx.beginPath();\n ctx.ellipse(\n fx + Math.cos(a) * w * 0.18,\n fy + Math.sin(a) * w * 0.12,\n w * 0.16,\n w * 0.1,\n a,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"#ff6f96\";\n ctx.fill();\n }\n ctx.beginPath();\n ctx.arc(fx, fy, w * 0.09, 0, Math.PI * 2);\n ctx.fillStyle = \"#ffe066\";\n ctx.fill();\n}\n\nfunction palm(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n lean: 1 | -1,\n): void {\n const topX = x + lean * size * 0.24;\n const topY = base - size;\n contactShadow(ctx, x + size * 0.1, base, size * 0.32);\n // the trunk: curved, banded, lit on the left\n ctx.beginPath();\n ctx.moveTo(x - 9, base);\n ctx.quadraticCurveTo(\n x + lean * size * 0.04,\n base - size * 0.5,\n topX - 5,\n topY,\n );\n ctx.lineTo(topX + 5, topY);\n ctx.quadraticCurveTo(x + lean * size * 0.13, base - size * 0.5, x + 9, base);\n ctx.closePath();\n const trunk = ctx.createLinearGradient(x - 10, 0, x + 12, 0);\n trunk.addColorStop(0, \"#e2a868\");\n trunk.addColorStop(1, \"#9a6236\");\n ctx.fillStyle = trunk;\n ctx.fill();\n for (let i = 1; i < 8; i++) {\n const t = i / 8;\n const sx = x + (topX - x) * t * t;\n const sy = base - size * t;\n ctx.beginPath();\n ctx.ellipse(sx, sy, 8 - t * 3, 2.2, lean * 0.15 * t, 0, Math.PI);\n ctx.fillStyle = \"rgba(110, 60, 30, .45)\";\n ctx.fill();\n }\n // fronds: two-tone leaves arching out of the crown\n const leaves: Array<[number, number]> = [\n [-1, 0.32],\n [-0.85, -0.3],\n [-0.25, -0.62],\n [0.35, -0.55],\n [0.9, -0.22],\n [1, 0.36],\n ];\n for (const [dx, dy] of leaves) {\n const ex = topX + dx * size * 0.46;\n const ey = topY + dy * size * 0.3 + size * 0.13;\n const cx = topX + dx * size * 0.25;\n const cy = topY + dy * size * 0.36 - size * 0.12;\n ctx.beginPath();\n ctx.moveTo(topX, topY);\n ctx.quadraticCurveTo(cx, cy, ex, ey);\n ctx.quadraticCurveTo(\n topX + dx * size * 0.22,\n topY + dy * size * 0.2 + 4,\n topX,\n topY + 7,\n );\n ctx.closePath();\n const leaf = ctx.createLinearGradient(topX, topY - size * 0.2, ex, ey + 6);\n leaf.addColorStop(0, \"#7fd660\");\n leaf.addColorStop(1, \"#3b8c3a\");\n ctx.fillStyle = leaf;\n ctx.fill();\n ctx.beginPath();\n ctx.moveTo(topX, topY + 2);\n ctx.quadraticCurveTo(cx, cy + 5, ex, ey);\n ctx.lineWidth = 1.4;\n ctx.strokeStyle = \"rgba(240, 255, 200, .45)\";\n ctx.stroke();\n }\n for (const [dx, dy] of [\n [-4, 7],\n [5, 9],\n [0, 12],\n ] as const) {\n const g = ctx.createRadialGradient(\n topX + dx - 1.5,\n topY + dy - 1.5,\n 0.5,\n topX + dx,\n topY + dy,\n 5,\n );\n g.addColorStop(0, \"#b98050\");\n g.addColorStop(1, \"#6a4022\");\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(topX + dx, topY + dy, 5, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nfunction rock(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n glow = false,\n): void {\n if (detail) contactShadow(ctx, x + size * 0.15, base, size * 1.3, 0.3);\n ctx.beginPath();\n ctx.moveTo(x - size, base);\n ctx.quadraticCurveTo(\n x - size * 1.05,\n base - size * 0.7,\n x - size * 0.4,\n base - size * 0.88,\n );\n ctx.quadraticCurveTo(\n x + size * 0.2,\n base - size * 1.08,\n x + size * 0.75,\n base - size * 0.6,\n );\n ctx.quadraticCurveTo(x + size * 1.05, base - size * 0.3, x + size, base);\n ctx.closePath();\n const g = ctx.createRadialGradient(\n x - size * 0.4,\n base - size * 0.8,\n size * 0.1,\n x,\n base - size * 0.3,\n size * 1.3,\n );\n g.addColorStop(0, css(shade(color, 0.22)));\n g.addColorStop(0.6, css(rgbOf(color)));\n g.addColorStop(1, css(shade(color, -0.3)));\n ctx.fillStyle = g;\n ctx.fill();\n if (!detail) return;\n ctx.beginPath();\n ctx.ellipse(\n x - size * 0.3,\n base - size * 0.7,\n size * 0.26,\n size * 0.1,\n -0.35,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"rgba(255, 255, 255, .28)\";\n ctx.fill();\n if (glow) {\n ctx.beginPath();\n ctx.moveTo(x - size * 0.5, base - size * 0.2);\n ctx.lineTo(x - size * 0.1, base - size * 0.45);\n ctx.lineTo(x + size * 0.3, base - size * 0.25);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"#ff8a3a\";\n ctx.stroke();\n }\n}\n\nfunction pebble(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n r: number,\n ground: number,\n): void {\n contactShadow(ctx, x + r * 0.4, y + r * 0.4, r * 1.6, 0.25);\n const g = ctx.createRadialGradient(\n x - r * 0.4,\n y - r * 0.4,\n 0,\n x,\n y,\n r * 1.2,\n );\n g.addColorStop(0, css(shade(ground, 0.12)));\n g.addColorStop(1, css(shade(ground, -0.28)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.ellipse(x, y, r * 1.3, r * 0.75, 0, 0, Math.PI * 2);\n ctx.fill();\n}\n\nfunction roundTree(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n green: number,\n detail: boolean,\n): void {\n if (detail) contactShadow(ctx, x + size * 0.08, base, size * 0.42);\n ctx.beginPath();\n ctx.moveTo(x - size * 0.05, base);\n ctx.lineTo(x - size * 0.035, base - size * 0.52);\n ctx.lineTo(x + size * 0.035, base - size * 0.52);\n ctx.lineTo(x + size * 0.05, base);\n ctx.closePath();\n const trunk = ctx.createLinearGradient(\n x - size * 0.05,\n 0,\n x + size * 0.05,\n 0,\n );\n trunk.addColorStop(0, \"#b07a48\");\n trunk.addColorStop(1, \"#6e4626\");\n ctx.fillStyle = trunk;\n ctx.fill();\n const r = size * 0.3;\n const blobs: Array<[number, number, number]> = [\n [-0.62, -0.58, 0.8],\n [0.62, -0.6, 0.8],\n [0, -0.82, 1],\n [-0.3, -0.98, 0.62],\n [0.32, -0.96, 0.6],\n ];\n for (const [dx, dy, k] of blobs) {\n const cx = x + r * dx;\n const cy = base + size * dy;\n const g = ctx.createRadialGradient(\n cx - r * k * 0.35,\n cy - r * k * 0.4,\n r * k * 0.1,\n cx,\n cy,\n r * k,\n );\n g.addColorStop(0, css(shade(green, 0.28)));\n g.addColorStop(0.7, css(shade(green, 0.02)));\n g.addColorStop(1, css(shade(green, -0.22)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(cx, cy, r * k, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nfunction bush(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n green: number,\n): void {\n contactShadow(ctx, x + size * 0.1, base, size * 1.3);\n const blobs: Array<[number, number, number]> = [\n [-0.5, -0.32, 0.45],\n [0.2, -0.46, 0.55],\n [0.8, -0.3, 0.38],\n ];\n for (const [dx, dy, k] of blobs) {\n const cx = x + size * dx;\n const cy = base + size * dy;\n const r = size * k;\n const g = ctx.createRadialGradient(\n cx - r * 0.35,\n cy - r * 0.4,\n r * 0.1,\n cx,\n cy,\n r,\n );\n g.addColorStop(0, css(shade(green, 0.3)));\n g.addColorStop(1, css(shade(green, -0.18)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(cx, cy, r, Math.PI, 0);\n ctx.lineTo(cx + r, base);\n ctx.lineTo(cx - r, base);\n ctx.closePath();\n ctx.fill();\n }\n for (const [dx, c] of [\n [-0.45, \"#ffffff\"],\n [0.25, \"#ffd34d\"],\n [0.75, \"#ff7aa8\"],\n ] as const) {\n ctx.beginPath();\n ctx.arc(x + size * dx, base - size * 0.52, 3, 0, Math.PI * 2);\n ctx.fillStyle = c;\n ctx.fill();\n }\n}\n\nfunction pine(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n): void {\n if (detail) {\n contactShadow(ctx, x + size * 0.06, base, size * 0.34);\n ctx.fillStyle = \"#7a5a3a\";\n ctx.fillRect(x - size * 0.035, base - size * 0.2, size * 0.07, size * 0.2);\n }\n for (let i = 0; i < 3; i++) {\n const y = base - size * (0.15 + i * 0.27);\n const w = size * (0.42 - i * 0.1);\n const tip = y - size * 0.42;\n ctx.beginPath();\n ctx.moveTo(x - w, y);\n ctx.lineTo(x, tip);\n ctx.lineTo(x + w, y);\n ctx.quadraticCurveTo(x, y + size * 0.05, x - w, y);\n ctx.closePath();\n if (detail) {\n const g = ctx.createLinearGradient(x - w, 0, x + w, 0);\n g.addColorStop(0, css(shade(color, 0.2)));\n g.addColorStop(0.5, css(rgbOf(color)));\n g.addColorStop(0.5, css(shade(color, -0.16)));\n g.addColorStop(1, css(shade(color, -0.28)));\n ctx.fillStyle = g;\n } else ctx.fillStyle = css(rgbOf(color));\n ctx.fill();\n }\n}\n\nfunction snowyPine(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n): void {\n pine(ctx, x, base, size, 0x3f7a62, true);\n for (let i = 0; i < 3; i++) {\n const y = base - size * (0.15 + i * 0.27) - size * 0.31;\n const w = size * (0.2 - i * 0.045);\n ctx.beginPath();\n ctx.moveTo(x - w, y + size * 0.09);\n ctx.lineTo(x, y - size * 0.11);\n ctx.lineTo(x + w, y + size * 0.09);\n ctx.quadraticCurveTo(x, y + size * 0.03, x - w, y + size * 0.09);\n ctx.fillStyle = \"rgba(255, 255, 255, .95)\";\n ctx.fill();\n }\n}\n\nfunction deadTree(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n): void {\n contactShadow(ctx, x + size * 0.1, base, size * 0.34);\n ctx.lineCap = \"round\";\n const branch = (\n x0: number,\n y0: number,\n x1: number,\n y1: number,\n w: number,\n ) => {\n const g = ctx.createLinearGradient(x0 - w, y0, x0 + w, y0);\n g.addColorStop(0, \"#8a6a58\");\n g.addColorStop(1, \"#4a3430\");\n ctx.beginPath();\n ctx.moveTo(x0, y0);\n ctx.lineTo(x1, y1);\n ctx.lineWidth = w;\n ctx.strokeStyle = g;\n ctx.stroke();\n };\n branch(x, base, x + size * 0.05, base - size * 0.75, 10);\n branch(\n x + size * 0.03,\n base - size * 0.45,\n x - size * 0.3,\n base - size * 0.72,\n 6,\n );\n branch(\n x + size * 0.05,\n base - size * 0.6,\n x + size * 0.32,\n base - size * 0.85,\n 6,\n );\n branch(\n x - size * 0.2,\n base - size * 0.64,\n x - size * 0.28,\n base - size * 0.9,\n 4,\n );\n}\n\nfunction tuft(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n color: number,\n k: number,\n): void {\n const blades: Array<[number, number]> = [\n [-6, 8],\n [-2.5, 12],\n [1, 10],\n [4.5, 13],\n [7, 7],\n ];\n for (const [dx, tall] of blades) {\n ctx.beginPath();\n ctx.moveTo(x + dx * k - 2, y);\n ctx.quadraticCurveTo(\n x + dx * k,\n y - tall * k * 0.6,\n x + dx * k + dx * 0.25,\n y - tall * k,\n );\n ctx.quadraticCurveTo(x + dx * k + 1, y - tall * k * 0.5, x + dx * k + 2, y);\n ctx.closePath();\n ctx.fillStyle = css(shade(color, dx < 0 ? 0.15 : -0.12));\n ctx.fill();\n }\n}\n"
427
427
  },
428
428
  {
429
429
  "path": "phaser/bars.ts",
430
- "content": "import type Phaser from \"phaser\";\nimport { formatBig } from \"../game/format\";\n\n// The bars under a fighter's feet (Obsidian Knight's sheet): the red health bar with the health left\n// on it (the number alone, no \"/max\"), and — for the hero and for an enemy in plate, while any armor\n// is left — the blue armor bar right under it, which takes the blows first. Drawn on one Graphics\n// plus a text per bar; the texts change only when the numbers do.\n\n// the hero's sheet under the road (ui/RunStats.tsx) draws its health and armor bars with these too\nexport const INK = 0x2b2230;\nexport const HP = { fill: 0xe8433f, light: 0xff7a6a, track: 0x5a2230 };\nexport const ARMOR = { fill: 0x3d8ef5, light: 0x8fc3ff, track: 0x1f2f5a };\n\nexport interface BarValues {\n hp: number;\n maxHp: number;\n armor: number;\n maxArmor: number;\n}\n\nexport class StatusBars {\n private readonly g: Phaser.GameObjects.Graphics;\n private readonly hpText: Phaser.GameObjects.Text;\n private readonly armorText: Phaser.GameObjects.Text;\n private shown = { hp: \"\", armor: \"\" };\n\n constructor(scene: Phaser.Scene, depth: number) {\n this.g = scene.add.graphics().setDepth(depth);\n const text = () =>\n scene.add\n .text(0, 0, \"\", {\n fontFamily: \"Rubik, system-ui, sans-serif\",\n fontStyle: \"900\",\n fontSize: \"11px\",\n color: \"#ffffff\",\n stroke: \"#2b2230\",\n strokeThickness: 3,\n })\n .setOrigin(0.5, 0.5)\n .setDepth(depth + 0.1);\n this.hpText = text();\n this.armorText = text();\n }\n\n /**\n * Draws the bars centred at `x`, their top at `y`, `w` wide. The armor bar shows only while there is\n * armor left — a broken one is gone until the armor is whole again.\n */\n draw(x: number, y: number, w: number, v: BarValues): void {\n const g = this.g.clear();\n const h = 13;\n this.bar(g, x, y, w, h, v.hp / Math.max(1, v.maxHp), HP);\n this.label(this.hpText, \"hp\", num(v.hp), x, y + h / 2);\n const armored = v.maxArmor > 0 && v.armor > 0;\n this.armorText.setVisible(armored);\n if (!armored) return;\n const ay = y + h + 3;\n this.bar(g, x, ay, w, h - 2, v.armor / v.maxArmor, ARMOR);\n this.label(this.armorText, \"armor\", num(v.armor), x, ay + (h - 2) / 2);\n }\n\n setVisible(visible: boolean): void {\n this.g.setVisible(visible);\n this.hpText.setVisible(visible);\n if (!visible) this.armorText.setVisible(false);\n }\n\n destroy(): void {\n this.g.destroy();\n this.hpText.destroy();\n this.armorText.destroy();\n }\n\n private bar(\n g: Phaser.GameObjects.Graphics,\n x: number,\n y: number,\n w: number,\n h: number,\n share: number,\n c: { fill: number; light: number; track: number },\n ): void {\n const s = Math.max(0, Math.min(1, share));\n const left = x - w / 2;\n g.fillStyle(INK, 1);\n g.fillRoundedRect(left - 2, y - 2, w + 4, h + 4, 5);\n g.fillStyle(c.track, 1);\n g.fillRoundedRect(left, y, w, h, 3);\n if (s <= 0) return;\n const fw = Math.max(4, w * s);\n g.fillStyle(c.fill, 1);\n g.fillRoundedRect(left, y, fw, h, 3);\n // the lit top half\n g.fillStyle(c.light, 0.55);\n g.fillRoundedRect(left + 1, y + 1, Math.max(2, fw - 2), h * 0.4, 2);\n }\n\n private label(\n t: Phaser.GameObjects.Text,\n key: \"hp\" | \"armor\",\n value: string,\n x: number,\n y: number,\n ): void {\n if (this.shown[key] !== value) {\n this.shown[key] = value;\n t.setText(value);\n }\n t.setPosition(x, y);\n }\n}\n\nconst num = (n: number): string => formatBig(Math.max(0, Math.ceil(n)));\n"
430
+ "content": "import type Phaser from \"phaser\";\nimport { canvasDensity } from \"./density\";\nimport { formatBig } from \"../game/format\";\n\n// The bars under a fighter's feet (Obsidian Knight's sheet): the red health bar with the health left\n// on it (the number alone, no \"/max\"), and — for the hero and for an enemy in plate, while any armor\n// is left — the blue armor bar right under it, which takes the blows first. Drawn on one Graphics\n// plus a text per bar; the texts change only when the numbers do.\n\n// the hero's sheet under the road (ui/RunStats.tsx) draws its health and armor bars with these too\nexport const INK = 0x2b2230;\nexport const HP = { fill: 0xe8433f, light: 0xff7a6a, track: 0x5a2230 };\nexport const ARMOR = { fill: 0x3d8ef5, light: 0x8fc3ff, track: 0x1f2f5a };\n\nexport interface BarValues {\n hp: number;\n maxHp: number;\n armor: number;\n maxArmor: number;\n}\n\nexport class StatusBars {\n private readonly g: Phaser.GameObjects.Graphics;\n private readonly hpText: Phaser.GameObjects.Text;\n private readonly armorText: Phaser.GameObjects.Text;\n private shown = { hp: \"\", armor: \"\" };\n\n constructor(scene: Phaser.Scene, depth: number) {\n this.g = scene.add.graphics().setDepth(depth);\n const text = () =>\n scene.add\n .text(0, 0, \"\", {\n fontFamily: \"Rubik, system-ui, sans-serif\",\n fontStyle: \"900\",\n fontSize: \"11px\",\n color: \"#ffffff\",\n stroke: \"#2b2230\",\n strokeThickness: 3,\n resolution: canvasDensity(scene),\n })\n .setOrigin(0.5, 0.5)\n .setDepth(depth + 0.1);\n this.hpText = text();\n this.armorText = text();\n }\n\n /**\n * Draws the bars centred at `x`, their top at `y`, `w` wide. The armor bar shows only while there is\n * armor left — a broken one is gone until the armor is whole again.\n */\n draw(x: number, y: number, w: number, v: BarValues): void {\n const g = this.g.clear();\n const h = 13;\n this.bar(g, x, y, w, h, v.hp / Math.max(1, v.maxHp), HP);\n this.label(this.hpText, \"hp\", num(v.hp), x, y + h / 2);\n const armored = v.maxArmor > 0 && v.armor > 0;\n this.armorText.setVisible(armored);\n if (!armored) return;\n const ay = y + h + 3;\n this.bar(g, x, ay, w, h - 2, v.armor / v.maxArmor, ARMOR);\n this.label(this.armorText, \"armor\", num(v.armor), x, ay + (h - 2) / 2);\n }\n\n setVisible(visible: boolean): void {\n this.g.setVisible(visible);\n this.hpText.setVisible(visible);\n if (!visible) this.armorText.setVisible(false);\n }\n\n destroy(): void {\n this.g.destroy();\n this.hpText.destroy();\n this.armorText.destroy();\n }\n\n private bar(\n g: Phaser.GameObjects.Graphics,\n x: number,\n y: number,\n w: number,\n h: number,\n share: number,\n c: { fill: number; light: number; track: number },\n ): void {\n const s = Math.max(0, Math.min(1, share));\n const left = x - w / 2;\n g.fillStyle(INK, 1);\n g.fillRoundedRect(left - 2, y - 2, w + 4, h + 4, 5);\n g.fillStyle(c.track, 1);\n g.fillRoundedRect(left, y, w, h, 3);\n if (s <= 0) return;\n const fw = Math.max(4, w * s);\n g.fillStyle(c.fill, 1);\n g.fillRoundedRect(left, y, fw, h, 3);\n // the lit top half\n g.fillStyle(c.light, 0.55);\n g.fillRoundedRect(left + 1, y + 1, Math.max(2, fw - 2), h * 0.4, 2);\n }\n\n private label(\n t: Phaser.GameObjects.Text,\n key: \"hp\" | \"armor\",\n value: string,\n x: number,\n y: number,\n ): void {\n if (this.shown[key] !== value) {\n this.shown[key] = value;\n t.setText(value);\n }\n t.setPosition(x, y);\n }\n}\n\nconst num = (n: number): string => formatBig(Math.max(0, Math.ceil(n)));\n"
431
431
  },
432
432
  {
433
433
  "path": "phaser/BattleScene.ts",
434
- "content": "import Phaser from \"phaser\";\nimport {\n BOSS_SIZE,\n HERO_X,\n type Battle,\n type BattleEvent,\n type Monster,\n} from \"../game/battle\";\nimport type { Bolt } from \"../game/cards\";\nimport type { Effect } from \"../game/damage\";\nimport { formatBig } from \"../game/format\";\nimport type { RunSession } from \"../game/session\";\nimport type { Theme } from \"../game/themes\";\nimport { LANE_MAX_PX, battleHeight } from \"../layout\";\nimport {\n axeTexture,\n bakeCommon,\n featureTexture,\n heroColor,\n missileTexture,\n shadowTexture,\n shotTexture,\n} from \"./art\";\nimport { heroRig, type LookSource } from \"../character\";\nimport { Backdrop } from \"./backdrop\";\nimport { StatusBars } from \"./bars\";\nimport { EnemyActor } from \"./enemy\";\nimport { footPixels } from \"./gait\";\nimport { HERO_SCALE, HeroActor } from \"./hero\";\n\n// Draws the run the session simulates: it owns no rules. Each frame it advances the session's\n// battle, turns the returned events into effects (numbers, coins, bursts) and puts the sprites where\n// the simulation says things are. The road scrolls under the hero (it stays at HERO_X on screen):\n// the backdrop's layers follow it at their own pace (parallax), the troops — people dressed in the\n// gear that gives their types — stand on the road, and so do the camp, the fountains and the chests.\n// A new run (a new Battle) clears the road. The field is the top part of the canvas (layout.ts); the\n// panel covers the rest. A blow's number is coloured by the damage table: green — the attack type\n// is strong against that armor, grey — weak.\n\ninterface EnemyView {\n actor: EnemyActor;\n bars: StatusBars;\n /** Where it last stood on the road — a fallen enemy stays there while the road moves on. */\n x: number;\n /** Who it is (a fallen one keeps its size through a resize). */\n monster: Monster;\n}\n\nexport interface BattleSceneOptions {\n /** How the hero is dressed (character/looks): the default chain when omitted; a project or a dev\n * page adds a source (the player's saved choice, a weapon picked in a preview). */\n lookChain?: readonly LookSource[];\n}\n\n/** Enemies stand a size bigger than the hero only when they are the boss (the battle keeps them that much farther apart). */\nconst BOSS_SCALE = BOSS_SIZE;\n/** The run is played at most this fast: a wider road walks the hero slower, never skating. */\nconst MAX_GAIT = 2.2;\n/** Characters stand at most this share of the field's height. */\nconst HERO_SHARE = 0.42;\n/** The bars start this far under the feet (below the shadow). */\nconst BARS_BELOW = 10;\n/** The rig's height, skeleton units (× scale = a character's height on screen). */\nconst RIG_HEIGHT = heroRig.data.height;\n/** Screen pixels a lane width of the road takes at unit 1 (the size the sprites were drawn for). */\nconst ROAD_PX = 620;\n\ntype FloatKind = \"hit\" | \"strong\" | \"weak\" | \"crit\" | \"hurt\" | \"armor\";\nconst FLOAT_COLORS: Record<FloatKind, string> = {\n hit: \"#ffffff\",\n strong: \"#8dff6a\",\n weak: \"#b9b6c8\",\n crit: \"#ffb02e\",\n hurt: \"#ff5a4f\",\n armor: \"#7fc0ff\",\n};\n/** A blow's number by how its attack type fares against the armor (damage.ts). */\nconst EFFECT_KIND: Record<Effect, FloatKind> = {\n strong: \"strong\",\n even: \"hit\",\n weak: \"weak\",\n};\n\nexport class BattleScene extends Phaser.Scene {\n private field = {\n w: 480,\n h: 360,\n laneLeft: 0,\n laneW: 480,\n horizon: 220,\n ground: 290,\n unit: 1,\n road: ROAD_PX,\n };\n private sky?: Phaser.GameObjects.Graphics;\n private backdrop?: Backdrop;\n private clouds: Phaser.GameObjects.Image[] = [];\n private theme: Theme | null = null;\n private hero?: HeroActor;\n private heroBars?: StatusBars;\n private enemies = new Map<number, EnemyView>();\n /** Killed (or left) enemies still on the road until they fade. */\n private corpses: EnemyView[] = [];\n private killed = new Set<number>();\n private features = new Map<number, Phaser.GameObjects.Image>();\n /** The run the views were made for — a new one clears the road. */\n private drawn: Battle | null = null;\n private heroShadow?: Phaser.GameObjects.Image;\n private shots = new Map<number, Phaser.GameObjects.Image>();\n private enemyShots = new Map<number, Phaser.GameObjects.Image>();\n private sparks?: Phaser.GameObjects.Particles.ParticleEmitter;\n private puffs?: Phaser.GameObjects.Particles.ParticleEmitter;\n private heals?: Phaser.GameObjects.Particles.ParticleEmitter;\n private fire?: Phaser.GameObjects.Particles.ParticleEmitter;\n private frenzyTween?: Phaser.Tweens.Tween;\n /** Pixels the hero is pushed back by its own blow; decays every frame. */\n private recoil = 0;\n\n constructor(\n private readonly session: RunSession,\n private readonly options: BattleSceneOptions = {},\n ) {\n super(\"hero-run-battle\");\n }\n\n create(): void {\n bakeCommon(this);\n this.sky = this.add.graphics().setDepth(0);\n this.backdrop = new Backdrop(this);\n for (let i = 0; i < 4; i++)\n this.clouds.push(this.add.image(0, 0, \"cloud\").setDepth(1).setAlpha(0.9));\n this.heroBars = new StatusBars(this, 30);\n this.heroShadow = this.add.image(0, 0, shadowTexture(this)).setDepth(12);\n this.hero = new HeroActor(this, this.options.lookChain);\n this.hero.view.setDepth(20);\n\n const particles = (\n texture: string,\n config: Phaser.Types.GameObjects.Particles.ParticleEmitterConfig,\n ) =>\n this.add\n .particles(0, 0, texture, { emitting: false, ...config })\n .setDepth(40);\n this.sparks = particles(\"spark\", {\n speed: { min: 80, max: 260 },\n lifespan: 420,\n scale: { start: 0.9, end: 0 },\n tint: [0xffffff, 0xffe27a, 0xffa53d],\n });\n this.puffs = particles(\"puff\", {\n speed: { min: 30, max: 120 },\n lifespan: 520,\n scale: { start: 0.9, end: 0.1 },\n alpha: { start: 0.8, end: 0 },\n });\n this.heals = particles(\"spark\", {\n speedY: { min: -140, max: -60 },\n speedX: { min: -40, max: 40 },\n lifespan: 900,\n scale: { start: 1, end: 0 },\n tint: 0x6dff8a,\n });\n this.fire = particles(\"spark\", {\n speed: { min: 120, max: 340 },\n lifespan: 520,\n scale: { start: 1.6, end: 0 },\n tint: [0xff4d1a, 0xffa53d, 0xffe27a],\n });\n\n this.scale.on(\"resize\", () => this.layout());\n this.layout();\n }\n\n // ── Layout ─────────────────────────────────────────────────────────────────────────────────\n\n private layout(): void {\n const w = this.scale.width;\n const h = battleHeight(this.scale.height);\n const laneW = Math.min(w, LANE_MAX_PX);\n this.field = {\n w,\n h,\n laneLeft: (w - laneW) / 2,\n laneW,\n // the road starts a little below the middle; the fighters stand on its far third\n horizon: Math.round(h * 0.56),\n ground: Math.round(h * 0.8),\n // Sprites are drawn for a ~620 px lane: a phone shrinks them so the hero does not cover the\n // first enemy, a wide field grows them — never taller than HERO_SHARE of the field.\n unit: Math.min(\n 1.35,\n Math.max(0.6, laneW / 620),\n (h * HERO_SHARE) / (RIG_HEIGHT * HERO_SCALE),\n ),\n road: ROAD_PX,\n };\n // The road is measured in the people standing on it, not in the screen: a lane width is as many\n // pixels as the characters' size makes it, so the line, the reach and the gaps keep their look on\n // any screen and through a resize (a narrow screen shows fewer enemies, never squeezed ones).\n this.field.road = ROAD_PX * this.field.unit;\n this.theme = null; // repaint the background for the new size\n this.paintBackground();\n this.clouds.forEach((c, i) =>\n c.setPosition(\n ((i + 0.3) / this.clouds.length) * w,\n h * (0.1 + 0.08 * (i % 2)),\n ),\n );\n }\n\n /** A point of the road on screen. */\n private roadX(x: number): number {\n return this.heroPos().x + (x - this.session.battle.heroX) * this.field.road;\n }\n\n private paintBackground(): void {\n const theme = this.session.battle.level.island.theme;\n if (theme === this.theme || !this.sky || !this.backdrop) return;\n this.theme = theme;\n const { w, h, horizon } = this.field;\n this.cameras.main.setBackgroundColor(theme.sky[0]);\n const sky = this.sky.clear();\n sky.fillGradientStyle(\n theme.sky[0],\n theme.sky[0],\n theme.sky[1],\n theme.sky[1],\n 1,\n );\n sky.fillRect(0, 0, w, horizon + 4);\n // behind everything from depth 2; the foreground strip over the fighters, under shots and bars\n this.backdrop.layout(theme, { w, h, horizon }, 2, 22);\n }\n\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\n\n override update(_time: number, deltaMs: number): void {\n const dt = deltaMs / 1000;\n const battle = this.session.battle;\n if (battle !== this.drawn) this.clearRoad(battle);\n if (this.hero) {\n // what the hero holds decides how close it fights (a sword at the enemy, a bow from afar)\n battle.setReach(this.hero.isMelee() ? \"melee\" : \"ranged\");\n // the first blow on someone comes a whole swing after the stop, and plays out before the run\n const swing = this.hero.swing();\n battle.setSwing(swing.windup, swing.recover);\n // the road moves no faster than the legs of the hero carry it at its size (no skating)\n battle.setWalkSpeed(\n (footPixels(this.hero.view) * MAX_GAIT) / this.field.road,\n );\n }\n // the line keeps a person's width and a bit between its people (their weapons stick out)\n battle.setMonsterGap(\n (HERO_SCALE * this.field.unit * RIG_HEIGHT * 0.85) / this.field.road,\n );\n // a new wave stands just past the right edge of what the screen shows\n battle.setWaveAhead(\n (this.field.w - this.heroPos().x) / this.field.road + 0.06,\n );\n const events = this.session.tick(dt);\n this.paintBackground();\n this.backdrop?.scroll(this.session.battle.heroX * this.field.road);\n this.syncHero();\n for (const e of events) this.react(e);\n this.syncEnemies();\n this.syncShots();\n this.syncEnemyShots();\n this.syncFeatures();\n this.driftClouds(dt);\n }\n\n /** A new run: the old road's troops, bodies, shots and camps are gone; the hero stands again. */\n private clearRoad(battle: Battle): void {\n this.drawn = battle;\n for (const v of this.enemies.values()) {\n v.bars.destroy();\n v.actor.destroy();\n }\n for (const c of this.corpses) c.actor.destroy();\n for (const img of [\n ...this.shots.values(),\n ...this.enemyShots.values(),\n ...this.features.values(),\n ])\n img.destroy();\n this.enemies.clear();\n this.corpses = [];\n this.killed.clear();\n this.shots.clear();\n this.enemyShots.clear();\n this.features.clear();\n this.hero?.rise();\n this.theme = null;\n }\n\n /** The camp, the fountain, the chest on the road ahead. */\n private syncFeatures(): void {\n const f = this.session.battle.feature;\n for (const [id, img] of this.features)\n if (f?.id !== id) {\n this.tweens.add({\n targets: img,\n alpha: 0,\n y: img.y - 20,\n duration: 300,\n onComplete: () => img.destroy(),\n });\n this.features.delete(id);\n }\n if (!f) return;\n let img = this.features.get(f.id);\n if (!img) {\n img = this.add\n .image(0, 0, featureTexture(this, f.kind, f.gives))\n .setOrigin(0.5, 1)\n .setDepth(14);\n this.features.set(f.id, img);\n }\n img\n .setScale(this.field.unit)\n .setPosition(this.roadX(f.x), this.field.ground + 4);\n }\n\n private heroPos(): { x: number; y: number } {\n return {\n x: this.field.laneLeft + HERO_X * this.field.laneW,\n y: this.field.ground,\n };\n }\n\n /** The hero's height on screen. */\n private heroHeight(): number {\n return (this.hero?.view.displayHeight ?? 110) * 1.02;\n }\n\n private syncHero(): void {\n const hero = this.session.hero;\n const actor = this.hero;\n if (!actor) return;\n const { x, y } = this.heroPos();\n if (actor.setHero(hero)) this.burstAt(this.puffs, x, y - 40, 14);\n actor.tick(\n this.session.battle,\n this.session.battle.walkSpeed * this.field.road,\n );\n this.recoil *= 0.75;\n actor.view.setPosition(x - this.recoil, y);\n actor.view.setScale(HERO_SCALE * this.field.unit);\n actor.view.setVisible(!!hero);\n const sw = actor.view.displayHeight * 0.62;\n this.heroShadow\n ?.setPosition(x - this.recoil, y + 2)\n .setDisplaySize(sw, sw * 0.24)\n .setVisible(!!hero);\n\n // health and armor under the feet, with their numbers\n const b = this.session.battle;\n const bars = this.heroBars!;\n bars.setVisible(!!hero && !b.heroDown);\n if (!hero || b.heroDown) return;\n bars.draw(x, y + BARS_BELOW, this.barWidth(actor.view.displayHeight), {\n hp: b.heroHp,\n maxHp: b.heroMaxHp(),\n armor: b.heroShield,\n maxArmor: b.heroMaxShield(),\n });\n }\n\n /** The bars are about a character wide. */\n private barWidth(height: number): number {\n return Math.max(58, Math.round(height * 0.55));\n }\n\n private syncEnemies(): void {\n const battle = this.session.battle;\n const alive = new Set<number>();\n battle.monsters.forEach((m, i) => {\n alive.add(m.id);\n const view = this.enemies.get(m.id) ?? this.spawnView(m);\n const x = this.roadX(m.x);\n view.x = m.x;\n // the size follows the screen like the hero's (a resize must not leave the line at its old size)\n view.actor.view.setScale(this.enemyScale(m));\n // the line stands a touch staggered in depth, the nearer one in front\n const y = this.field.ground + (i % 2) * 3;\n view.actor.place(x, y);\n view.actor.view.setDepth(m.boss ? 18 : 16 - i * 0.01);\n view.actor.tick(battle, m, m.speed * battle.walkSpeed * this.field.road);\n view.bars.draw(\n x,\n y + BARS_BELOW,\n this.barWidth(view.actor.view.displayHeight),\n { hp: m.hp, maxHp: m.maxHp, armor: m.shield, maxArmor: m.maxShield },\n );\n // a stunned troop is tinted until it shakes it off\n view.actor.setMarked(m.stunned > 0);\n });\n for (const [id, view] of this.enemies)\n if (!alive.has(id)) {\n view.bars.destroy();\n // killed — it falls where it stood; gone without a fight — it fades there\n if (this.killed.delete(id)) view.actor.die();\n else view.actor.vanish();\n this.enemies.delete(id);\n this.corpses.push(view);\n }\n // the fallen stay on their spot of the road while the hero runs on\n this.corpses = this.corpses.filter((c) => !c.actor.gone);\n for (const c of this.corpses) {\n c.actor.view.setScale(this.enemyScale(c.monster));\n c.actor.place(this.roadX(c.x), this.field.ground);\n }\n }\n\n private enemyScale(m: Monster): number {\n return HERO_SCALE * this.field.unit * (m.boss ? BOSS_SCALE : 1);\n }\n\n private spawnView(m: Monster): EnemyView {\n const actor = new EnemyActor(this, m, shadowTexture(this));\n actor.view\n .setScale(this.enemyScale(m))\n .setPosition(this.roadX(m.x), this.field.ground);\n const view: EnemyView = {\n actor,\n bars: new StatusBars(this, 31),\n x: m.x,\n monster: m,\n };\n this.enemies.set(m.id, view);\n return view;\n }\n\n private syncShots(): void {\n const live = new Set<number>();\n const y = this.hero?.handY() ?? this.field.ground - 50;\n for (const p of this.session.battle.projectiles) {\n live.add(p.id);\n let img = this.shots.get(p.id);\n if (!img) {\n const hero = this.session.hero;\n // What the weapon in hand shoots: an arrow, a bolt, a bullet — or the class's glowing orb.\n const kind = this.hero?.shotKind() ?? \"orb\";\n const color = p.crit\n ? 0xff7a1a\n : heroColor(hero?.classID ?? \"\", hero?.id ?? \"\");\n img = this.add\n .image(\n this.roadX(p.x),\n y,\n kind === \"orb\"\n ? shotTexture(this, color)\n : missileTexture(this, kind),\n )\n .setDepth(25)\n .setScale(p.crit ? 1.35 : p.extra ? 0.75 : 1)\n .setData(\"spin\", kind === \"orb\");\n if (kind !== \"orb\" && p.crit) img.setTint(0xffb070);\n this.shots.set(p.id, img);\n }\n img.setPosition(this.roadX(p.x), y + (p.extra ? 10 : 0));\n if (img.getData(\"spin\")) img.rotation += 0.3;\n }\n for (const [id, img] of this.shots)\n if (!live.has(id)) {\n img.destroy();\n this.shots.delete(id);\n }\n }\n\n /** The enemies' shots fly left, at the hero — what their weapon shoots. */\n private syncEnemyShots(): void {\n const live = new Set<number>();\n for (const s of this.session.battle.enemyShots) {\n live.add(s.id);\n let img = this.enemyShots.get(s.id);\n if (!img) {\n img = this.add.image(this.roadX(s.x), 0, shotTexture(this, 0xff5a3c));\n img.setDepth(25).setData(\"spin\", true);\n this.enemyShots.set(s.id, img);\n }\n img.setPosition(\n this.roadX(s.x),\n img.getData(\"y\") ?? this.field.ground - 46,\n );\n if (img.getData(\"spin\")) img.rotation -= 0.3;\n }\n for (const [id, img] of this.enemyShots)\n if (!live.has(id)) {\n img.destroy();\n this.enemyShots.delete(id);\n }\n }\n\n private driftClouds(dt: number): void {\n // the clouds drift on their own and a little with the road (the farthest layer of all)\n const walking = this.session.battle.walking ? 1 : 0;\n for (const [i, c] of this.clouds.entries()) {\n c.x -= (6 + i * 3 + walking * this.field.road * 0.02) * dt;\n if (c.x < -100) c.x = this.field.w + 100;\n }\n }\n\n // ── Effects ────────────────────────────────────────────────────────────────────────────────\n\n private react(e: BattleEvent): void {\n const { ground } = this.field;\n switch (e.type) {\n case \"shoot\":\n case \"strike\":\n this.recoil = e.type === \"strike\" ? -5 : 3;\n break;\n case \"enemyShoot\": {\n const view = this.enemies.get(e.monsterId);\n // a bow looses an arrow, a crossbow a bolt, a gun a bullet; a staff — a red orb\n const kind = view?.actor.shotKind() ?? \"orb\";\n const missile =\n kind === \"arrow\" || kind === \"bolt\" || kind === \"bullet\";\n const img = this.add\n .image(\n this.roadX(e.shot.x),\n 0,\n missile ? missileTexture(this, kind) : shotTexture(this, 0xff5a3c),\n )\n .setDepth(25)\n .setFlipX(true);\n // the enemy's shots run red, so a bullet is never taken for a coin\n if (missile) img.setTint(0xff6a5a);\n img.setData(\"spin\", !missile);\n img.setData(\"y\", view?.actor.handY() ?? ground - 46);\n this.enemyShots.set(e.shot.id, img);\n break;\n }\n case \"hit\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n const top = ground - (view?.actor.view.displayHeight ?? 70) * 0.75;\n this.floatText(\n formatBig(Math.round(e.damage)),\n x,\n top,\n e.crit ? \"crit\" : EFFECT_KIND[e.effect],\n );\n this.burstAt(this.sparks, x - 10, top + 30, e.crit ? 10 : 4);\n view?.actor.view.flash(0xffffff, 80);\n break;\n }\n case \"kill\": {\n this.killed.add(e.monsterId);\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.puffs, x, ground - 30, e.boss ? 30 : 10);\n this.coinBurst(x, ground - 30, e.boss ? 14 : 3);\n if (e.boss) this.cameras.main.shake(260, 0.012);\n break;\n }\n case \"heroHit\":\n if (this.hero) {\n const { x } = this.heroPos();\n // a blow the shield took whole shows blue, one that reached the health red, one turned into\n // healing green\n const onShield = e.shield >= e.damage - 1e-9;\n this.floatText(\n `${e.healed ? \"+\" : \"-\"}${formatBig(Math.round(e.damage))}`,\n x,\n ground - this.heroHeight(),\n e.healed ? \"strong\" : onShield ? \"armor\" : \"hurt\",\n );\n this.hero.view.flash(\n e.healed ? 0x8dff6a : onShield ? 0x9fc8ff : 0xff6060,\n 90,\n );\n }\n break;\n case \"blast\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.fire, x, ground - 30, 22);\n this.cameras.main.shake(140, 0.006);\n break;\n }\n case \"stun\": {\n const view = this.enemies.get(e.monsterId);\n if (view)\n this.burstAt(\n this.sparks,\n view.actor.view.root.x,\n ground - view.actor.view.displayHeight,\n 6,\n );\n break;\n }\n case \"bolt\":\n for (const id of e.targets) this.boltFx(e.bolt, id);\n break;\n case \"guard\": {\n const { x } = this.heroPos();\n this.floatText(\"🛡\", x, ground - this.heroHeight(), \"armor\");\n break;\n }\n case \"charge\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 10);\n break;\n case \"potion\":\n this.burstAt(this.heals, this.heroPos().x, ground - 40, 18);\n break;\n case \"levelUp\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 60, 16);\n break;\n case \"dodge\": {\n const { x } = this.heroPos();\n this.floatText(\"MISS\", x, ground - this.heroHeight(), \"hit\");\n break;\n }\n case \"heroDown\":\n this.hero?.fall();\n this.burstAt(this.puffs, this.heroPos().x, ground - 30, 16);\n break;\n case \"bossStart\":\n this.cameras.main.flash(200, 255, 80, 60);\n break;\n case \"end\":\n if (e.result.cleared) {\n this.cameras.main.flash(260, 255, 240, 160);\n this.coinBurst(this.field.w / 2, ground - 60, 10);\n }\n break;\n case \"ult\":\n this.skillFx(e.ult);\n break;\n case \"ultBack\":\n // a card brought the ultimate back\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 20);\n this.cameras.main.flash(160, 200, 160, 255);\n break;\n default:\n break;\n }\n }\n\n private skillFx(skill: \"meteor\" | \"frenzy\"): void {\n const { ground } = this.field;\n if (skill === \"meteor\") {\n for (const [, view] of this.enemies) {\n const tx = view.actor.view.root.x;\n if (tx > this.field.w) continue;\n const m = this.add\n .image(tx + 120, -40, \"meteor\")\n .setDepth(35)\n .setScale(1.2);\n this.tweens.add({\n targets: m,\n x: tx,\n y: ground - 30,\n duration: 280,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.fire, tx, ground - 30, 16);\n m.destroy();\n },\n });\n }\n this.cameras.main.shake(200, 0.008);\n } else if (skill === \"frenzy\" && this.hero) {\n this.frenzyTween?.stop();\n const view = this.hero.view;\n view.setOverlay(0xffa0a0);\n const glow = { a: 1 };\n this.frenzyTween = this.tweens.add({\n targets: glow,\n a: 0.75,\n yoyo: true,\n repeat: 11,\n duration: 250,\n onUpdate: () => view.setAlpha(glow.a),\n onComplete: () => {\n view.setOverlay(null);\n view.setAlpha(1);\n },\n });\n }\n }\n\n /** A card's missile on its way to a troop: the damage is already dealt, this is how it looks. */\n private boltFx(kind: Bolt, monsterId: number): void {\n const view = this.enemies.get(monsterId);\n const { ground } = this.field;\n const hero = this.heroPos();\n const tx = view?.actor.view.root.x ?? this.field.w * 0.8;\n const ty = ground - (view?.actor.view.displayHeight ?? 70) * 0.55;\n if (kind === \"lightning\") {\n // a zigzag from the sky onto the troop, white in blue, gone in a blink\n const g = this.add.graphics().setDepth(36);\n const points: Array<[number, number]> = [];\n for (let k = 0; k <= 6; k++)\n points.push([\n tx + (k % 2 ? 12 : -12) * (k < 6 ? 1 : 0),\n -10 + ((ty + 10) * k) / 6,\n ]);\n for (const [w, c, a] of [\n [9, 0x3aa0ff, 0.55],\n [4, 0xffffff, 1],\n ] as const) {\n g.lineStyle(w, c, a);\n g.beginPath();\n g.moveTo(points[0]![0], points[0]![1]);\n for (const [x, y] of points.slice(1)) g.lineTo(x, y);\n g.strokePath();\n }\n this.burstAt(this.sparks, tx, ty, 10);\n this.tweens.add({\n targets: g,\n alpha: 0,\n duration: 260,\n onComplete: () => g.destroy(),\n });\n return;\n }\n const texture =\n kind === \"axe\"\n ? axeTexture(this)\n : missileTexture(this, kind === \"spear\" ? \"thrust\" : \"arrow\");\n const img = this.add.image(hero.x + 20, ground - 50, texture).setDepth(36);\n if (kind === \"spear\") {\n // through the whole line, off the screen's edge\n img.setScale(1.4, 1);\n this.tweens.add({\n targets: img,\n x: this.field.w + 60,\n duration: 320,\n ease: \"Quad.easeIn\",\n onComplete: () => img.destroy(),\n });\n return;\n }\n this.tweens.add({\n targets: img,\n x: tx,\n y: ty,\n angle: kind === \"axe\" ? 720 : 0,\n duration: kind === \"axe\" ? 300 : 220,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.sparks, tx, ty, 6);\n img.destroy();\n },\n });\n }\n\n private burstAt(\n emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,\n x: number,\n y: number,\n count: number,\n ): void {\n emitter?.explode(count, x, y);\n }\n\n /** Coins pop out of a kill and fly down into the gold counter of the panel. */\n private coinBurst(x: number, y: number, count: number): void {\n const target = { x: this.field.w / 2, y: this.field.h + 24 };\n for (let i = 0; i < count; i++) {\n const coin = this.add.image(x, y, \"coin\").setDepth(45).setScale(0.9);\n const ox = Phaser.Math.Between(-50, 50);\n const oy = Phaser.Math.Between(-70, -30);\n this.tweens.add({\n targets: coin,\n x: x + ox,\n y: y + oy,\n duration: 260,\n ease: \"Quad.easeOut\",\n onComplete: () =>\n this.tweens.add({\n targets: coin,\n x: target.x,\n y: target.y,\n scale: 0.5,\n delay: 120 + i * 30,\n duration: 520,\n ease: \"Cubic.easeIn\",\n onComplete: () => coin.destroy(),\n }),\n });\n }\n }\n\n private floatText(text: string, x: number, y: number, kind: FloatKind): void {\n const t = this.add\n .text(x + Phaser.Math.Between(-12, 12), y, text, {\n fontFamily: \"system-ui, sans-serif\",\n fontSize: kind === \"crit\" ? \"26px\" : \"18px\",\n fontStyle: \"bold\",\n color: FLOAT_COLORS[kind],\n stroke: \"#2b1d33\",\n strokeThickness: 4,\n })\n .setOrigin(0.5, 1)\n .setDepth(50);\n if (kind === \"crit\") t.setScale(1.4);\n this.tweens.add({\n targets: t,\n y: y - 46,\n scale: 1,\n alpha: 0,\n duration: 750,\n ease: \"Cubic.easeOut\",\n onComplete: () => t.destroy(),\n });\n }\n}\n"
434
+ "content": "import Phaser from \"phaser\";\nimport {\n BOSS_SIZE,\n HERO_X,\n type Battle,\n type BattleEvent,\n type Monster,\n} from \"../game/battle\";\nimport type { Bolt } from \"../game/cards\";\nimport type { Effect } from \"../game/damage\";\nimport { formatBig } from \"../game/format\";\nimport type { RunSession } from \"../game/session\";\nimport type { Theme } from \"../game/themes\";\nimport { LANE_MAX_PX } from \"../layout\";\nimport {\n axeTexture,\n bakeCommon,\n featureTexture,\n heroColor,\n missileTexture,\n shadowTexture,\n shotTexture,\n} from \"./art\";\nimport { heroRig, type LookSource } from \"../character\";\nimport { Backdrop } from \"./backdrop\";\nimport { canvasDensity } from \"./density\";\nimport { StatusBars } from \"./bars\";\nimport { EnemyActor } from \"./enemy\";\nimport { footPixels } from \"./gait\";\nimport { HERO_SCALE, HeroActor } from \"./hero\";\n\n// Draws the run the session simulates: it owns no rules. Each frame it advances the session's\n// battle, turns the returned events into effects (numbers, coins, bursts) and puts the sprites where\n// the simulation says things are. The road scrolls under the hero (it stays at HERO_X on screen):\n// the backdrop's layers follow it at their own pace (parallax), the troops — people dressed in the\n// gear that gives their types — stand on the road, and so do the camp, the fountains and the chests.\n// A new run (a new Battle) clears the road. The field is the top part of the canvas (layout.ts); the\n// panel covers the rest. A blow's number is coloured by the damage table: green — the attack type\n// is strong against that armor, grey — weak.\n\ninterface EnemyView {\n actor: EnemyActor;\n bars: StatusBars;\n /** Where it last stood on the road — a fallen enemy stays there while the road moves on. */\n x: number;\n /** Who it is (a fallen one keeps its size through a resize). */\n monster: Monster;\n}\n\nexport interface BattleSceneOptions {\n /** How the hero is dressed (character/looks): the default chain when omitted; a project or a dev\n * page adds a source (the player's saved choice, a weapon picked in a preview). */\n lookChain?: readonly LookSource[];\n}\n\n/** Enemies stand a size bigger than the hero only when they are the boss (the battle keeps them that much farther apart). */\nconst BOSS_SCALE = BOSS_SIZE;\n/** The run is played at most this fast: a wider road walks the hero slower, never skating. */\nconst MAX_GAIT = 2.2;\n/** Characters stand at most this share of the field's height. */\nconst HERO_SHARE = 0.42;\n/** The bars start this far under the feet (below the shadow). */\nconst BARS_BELOW = 10;\n/** The rig's height, skeleton units (× scale = a character's height on screen). */\nconst RIG_HEIGHT = heroRig.data.height;\n/** Screen pixels a lane width of the road takes at unit 1 (the size the sprites were drawn for). */\nconst ROAD_PX = 620;\n\ntype FloatKind = \"hit\" | \"strong\" | \"weak\" | \"crit\" | \"hurt\" | \"armor\";\nconst FLOAT_COLORS: Record<FloatKind, string> = {\n hit: \"#ffffff\",\n strong: \"#8dff6a\",\n weak: \"#b9b6c8\",\n crit: \"#ffb02e\",\n hurt: \"#ff5a4f\",\n armor: \"#7fc0ff\",\n};\n/** A blow's number by how its attack type fares against the armor (damage.ts). */\nconst EFFECT_KIND: Record<Effect, FloatKind> = {\n strong: \"strong\",\n even: \"hit\",\n weak: \"weak\",\n};\n\nexport class BattleScene extends Phaser.Scene {\n private field = {\n w: 480,\n h: 360,\n laneLeft: 0,\n laneW: 480,\n horizon: 220,\n ground: 290,\n unit: 1,\n road: ROAD_PX,\n };\n private sky?: Phaser.GameObjects.Graphics;\n private backdrop?: Backdrop;\n private clouds: Phaser.GameObjects.Image[] = [];\n private theme: Theme | null = null;\n private hero?: HeroActor;\n private heroBars?: StatusBars;\n private enemies = new Map<number, EnemyView>();\n /** Killed (or left) enemies still on the road until they fade. */\n private corpses: EnemyView[] = [];\n private killed = new Set<number>();\n private features = new Map<number, Phaser.GameObjects.Image>();\n /** The run the views were made for — a new one clears the road. */\n private drawn: Battle | null = null;\n private heroShadow?: Phaser.GameObjects.Image;\n private shots = new Map<number, Phaser.GameObjects.Image>();\n private enemyShots = new Map<number, Phaser.GameObjects.Image>();\n private sparks?: Phaser.GameObjects.Particles.ParticleEmitter;\n private puffs?: Phaser.GameObjects.Particles.ParticleEmitter;\n private heals?: Phaser.GameObjects.Particles.ParticleEmitter;\n private fire?: Phaser.GameObjects.Particles.ParticleEmitter;\n private frenzyTween?: Phaser.Tweens.Tween;\n /** Pixels the hero is pushed back by its own blow; decays every frame. */\n private recoil = 0;\n\n constructor(\n private readonly session: RunSession,\n private readonly options: BattleSceneOptions = {},\n ) {\n super(\"hero-run-battle\");\n }\n\n create(): void {\n bakeCommon(this);\n this.sky = this.add.graphics().setDepth(0);\n this.backdrop = new Backdrop(this);\n for (let i = 0; i < 4; i++)\n this.clouds.push(this.add.image(0, 0, \"cloud\").setDepth(1).setAlpha(0.9));\n this.heroBars = new StatusBars(this, 30);\n this.heroShadow = this.add.image(0, 0, shadowTexture(this)).setDepth(12);\n this.hero = new HeroActor(this, this.options.lookChain);\n this.hero.view.setDepth(20);\n\n const particles = (\n texture: string,\n config: Phaser.Types.GameObjects.Particles.ParticleEmitterConfig,\n ) =>\n this.add\n .particles(0, 0, texture, { emitting: false, ...config })\n .setDepth(40);\n this.sparks = particles(\"spark\", {\n speed: { min: 80, max: 260 },\n lifespan: 420,\n scale: { start: 0.9, end: 0 },\n tint: [0xffffff, 0xffe27a, 0xffa53d],\n });\n this.puffs = particles(\"puff\", {\n speed: { min: 30, max: 120 },\n lifespan: 520,\n scale: { start: 0.9, end: 0.1 },\n alpha: { start: 0.8, end: 0 },\n });\n this.heals = particles(\"spark\", {\n speedY: { min: -140, max: -60 },\n speedX: { min: -40, max: 40 },\n lifespan: 900,\n scale: { start: 1, end: 0 },\n tint: 0x6dff8a,\n });\n this.fire = particles(\"spark\", {\n speed: { min: 120, max: 340 },\n lifespan: 520,\n scale: { start: 1.6, end: 0 },\n tint: [0xff4d1a, 0xffa53d, 0xffe27a],\n });\n\n this.scale.on(\"resize\", () => this.layout());\n this.layout();\n }\n\n // ── Layout ─────────────────────────────────────────────────────────────────────────────────\n\n private layout(): void {\n // The canvas is the field alone, `k` canvas pixels per CSS pixel (the controller sizes it): the\n // camera zooms by `k` from the top-left corner, so everything below is in CSS pixels.\n const k = canvasDensity(this);\n this.cameras.main.setOrigin(0, 0).setZoom(k);\n const w = this.scale.width / k;\n const h = this.scale.height / k;\n const laneW = Math.min(w, LANE_MAX_PX);\n this.field = {\n w,\n h,\n laneLeft: (w - laneW) / 2,\n laneW,\n // the road starts a little below the middle; the fighters stand on its far third\n horizon: Math.round(h * 0.56),\n ground: Math.round(h * 0.8),\n // Sprites are drawn for a ~620 px lane: a phone shrinks them so the hero does not cover the\n // first enemy, a wide field grows them — never taller than HERO_SHARE of the field.\n unit: Math.min(\n 1.35,\n Math.max(0.6, laneW / 620),\n (h * HERO_SHARE) / (RIG_HEIGHT * HERO_SCALE),\n ),\n road: ROAD_PX,\n };\n // The road is measured in the people standing on it, not in the screen: a lane width is as many\n // pixels as the characters' size makes it, so the line, the reach and the gaps keep their look on\n // any screen and through a resize (a narrow screen shows fewer enemies, never squeezed ones).\n this.field.road = ROAD_PX * this.field.unit;\n this.theme = null; // repaint the background for the new size\n this.paintBackground();\n this.clouds.forEach((c, i) =>\n c.setPosition(\n ((i + 0.3) / this.clouds.length) * w,\n h * (0.1 + 0.08 * (i % 2)),\n ),\n );\n }\n\n /** A point of the road on screen. */\n private roadX(x: number): number {\n return this.heroPos().x + (x - this.session.battle.heroX) * this.field.road;\n }\n\n private paintBackground(): void {\n const theme = this.session.battle.level.island.theme;\n if (theme === this.theme || !this.sky || !this.backdrop) return;\n this.theme = theme;\n const { w, h, horizon } = this.field;\n this.cameras.main.setBackgroundColor(theme.sky[0]);\n const sky = this.sky.clear();\n sky.fillGradientStyle(\n theme.sky[0],\n theme.sky[0],\n theme.sky[1],\n theme.sky[1],\n 1,\n );\n sky.fillRect(0, 0, w, horizon + 4);\n // behind everything from depth 2; the foreground strip over the fighters, under shots and bars\n this.backdrop.layout(theme, { w, h, horizon }, 2, 22);\n }\n\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\n\n override update(_time: number, deltaMs: number): void {\n const dt = deltaMs / 1000;\n const battle = this.session.battle;\n if (battle !== this.drawn) this.clearRoad(battle);\n if (this.hero) {\n // what the hero holds decides how close it fights (a sword at the enemy, a bow from afar)\n battle.setReach(this.hero.isMelee() ? \"melee\" : \"ranged\");\n // the first blow on someone comes a whole swing after the stop, and plays out before the run\n const swing = this.hero.swing();\n battle.setSwing(swing.windup, swing.recover);\n // the road moves no faster than the legs of the hero carry it at its size (no skating)\n battle.setWalkSpeed(\n (footPixels(this.hero.view) * MAX_GAIT) / this.field.road,\n );\n }\n // the line keeps a person's width and a bit between its people (their weapons stick out)\n battle.setMonsterGap(\n (HERO_SCALE * this.field.unit * RIG_HEIGHT * 0.85) / this.field.road,\n );\n // a new wave stands just past the right edge of what the screen shows\n battle.setWaveAhead(\n (this.field.w - this.heroPos().x) / this.field.road + 0.06,\n );\n const events = this.session.tick(dt);\n this.paintBackground();\n this.backdrop?.scroll(this.session.battle.heroX * this.field.road);\n this.syncHero();\n for (const e of events) this.react(e);\n this.syncEnemies();\n this.syncShots();\n this.syncEnemyShots();\n this.syncFeatures();\n this.driftClouds(dt);\n }\n\n /** A new run: the old road's troops, bodies, shots and camps are gone; the hero stands again. */\n private clearRoad(battle: Battle): void {\n this.drawn = battle;\n for (const v of this.enemies.values()) {\n v.bars.destroy();\n v.actor.destroy();\n }\n for (const c of this.corpses) c.actor.destroy();\n for (const img of [\n ...this.shots.values(),\n ...this.enemyShots.values(),\n ...this.features.values(),\n ])\n img.destroy();\n this.enemies.clear();\n this.corpses = [];\n this.killed.clear();\n this.shots.clear();\n this.enemyShots.clear();\n this.features.clear();\n this.hero?.rise();\n this.theme = null;\n }\n\n /** The camp, the fountain, the chest on the road ahead. */\n private syncFeatures(): void {\n const f = this.session.battle.feature;\n for (const [id, img] of this.features)\n if (f?.id !== id) {\n this.tweens.add({\n targets: img,\n alpha: 0,\n y: img.y - 20,\n duration: 300,\n onComplete: () => img.destroy(),\n });\n this.features.delete(id);\n }\n if (!f) return;\n let img = this.features.get(f.id);\n if (!img) {\n img = this.add\n .image(0, 0, featureTexture(this, f.kind, f.gives))\n .setOrigin(0.5, 1)\n .setDepth(14);\n this.features.set(f.id, img);\n }\n img\n .setScale(this.field.unit)\n .setPosition(this.roadX(f.x), this.field.ground + 4);\n }\n\n private heroPos(): { x: number; y: number } {\n return {\n x: this.field.laneLeft + HERO_X * this.field.laneW,\n y: this.field.ground,\n };\n }\n\n /** The hero's height on screen. */\n private heroHeight(): number {\n return (this.hero?.view.displayHeight ?? 110) * 1.02;\n }\n\n private syncHero(): void {\n const hero = this.session.hero;\n const actor = this.hero;\n if (!actor) return;\n const { x, y } = this.heroPos();\n if (actor.setHero(hero)) this.burstAt(this.puffs, x, y - 40, 14);\n actor.tick(\n this.session.battle,\n this.session.battle.walkSpeed * this.field.road,\n );\n this.recoil *= 0.75;\n actor.view.setPosition(x - this.recoil, y);\n actor.view.setScale(HERO_SCALE * this.field.unit);\n actor.view.setVisible(!!hero);\n const sw = actor.view.displayHeight * 0.62;\n this.heroShadow\n ?.setPosition(x - this.recoil, y + 2)\n .setDisplaySize(sw, sw * 0.24)\n .setVisible(!!hero);\n\n // health and armor under the feet, with their numbers\n const b = this.session.battle;\n const bars = this.heroBars!;\n bars.setVisible(!!hero && !b.heroDown);\n if (!hero || b.heroDown) return;\n bars.draw(x, y + BARS_BELOW, this.barWidth(actor.view.displayHeight), {\n hp: b.heroHp,\n maxHp: b.heroMaxHp(),\n armor: b.heroShield,\n maxArmor: b.heroMaxShield(),\n });\n }\n\n /** The bars are about a character wide. */\n private barWidth(height: number): number {\n return Math.max(58, Math.round(height * 0.55));\n }\n\n private syncEnemies(): void {\n const battle = this.session.battle;\n const alive = new Set<number>();\n battle.monsters.forEach((m, i) => {\n alive.add(m.id);\n const view = this.enemies.get(m.id) ?? this.spawnView(m);\n const x = this.roadX(m.x);\n view.x = m.x;\n // the size follows the screen like the hero's (a resize must not leave the line at its old size)\n view.actor.view.setScale(this.enemyScale(m));\n // the line stands a touch staggered in depth, the nearer one in front\n const y = this.field.ground + (i % 2) * 3;\n view.actor.place(x, y);\n view.actor.view.setDepth(m.boss ? 18 : 16 - i * 0.01);\n view.actor.tick(battle, m, m.speed * battle.walkSpeed * this.field.road);\n view.bars.draw(\n x,\n y + BARS_BELOW,\n this.barWidth(view.actor.view.displayHeight),\n { hp: m.hp, maxHp: m.maxHp, armor: m.shield, maxArmor: m.maxShield },\n );\n // a stunned troop is tinted until it shakes it off\n view.actor.setMarked(m.stunned > 0);\n });\n for (const [id, view] of this.enemies)\n if (!alive.has(id)) {\n view.bars.destroy();\n // killed — it falls where it stood; gone without a fight — it fades there\n if (this.killed.delete(id)) view.actor.die();\n else view.actor.vanish();\n this.enemies.delete(id);\n this.corpses.push(view);\n }\n // the fallen stay on their spot of the road while the hero runs on\n this.corpses = this.corpses.filter((c) => !c.actor.gone);\n for (const c of this.corpses) {\n c.actor.view.setScale(this.enemyScale(c.monster));\n c.actor.place(this.roadX(c.x), this.field.ground);\n }\n }\n\n private enemyScale(m: Monster): number {\n return HERO_SCALE * this.field.unit * (m.boss ? BOSS_SCALE : 1);\n }\n\n private spawnView(m: Monster): EnemyView {\n const actor = new EnemyActor(this, m, shadowTexture(this));\n actor.view\n .setScale(this.enemyScale(m))\n .setPosition(this.roadX(m.x), this.field.ground);\n const view: EnemyView = {\n actor,\n bars: new StatusBars(this, 31),\n x: m.x,\n monster: m,\n };\n this.enemies.set(m.id, view);\n return view;\n }\n\n private syncShots(): void {\n const live = new Set<number>();\n const y = this.hero?.handY() ?? this.field.ground - 50;\n for (const p of this.session.battle.projectiles) {\n live.add(p.id);\n let img = this.shots.get(p.id);\n if (!img) {\n const hero = this.session.hero;\n // What the weapon in hand shoots: an arrow, a bolt, a bullet — or the class's glowing orb.\n const kind = this.hero?.shotKind() ?? \"orb\";\n const color = p.crit\n ? 0xff7a1a\n : heroColor(hero?.classID ?? \"\", hero?.id ?? \"\");\n img = this.add\n .image(\n this.roadX(p.x),\n y,\n kind === \"orb\"\n ? shotTexture(this, color)\n : missileTexture(this, kind),\n )\n .setDepth(25)\n .setScale(p.crit ? 1.35 : p.extra ? 0.75 : 1)\n .setData(\"spin\", kind === \"orb\");\n if (kind !== \"orb\" && p.crit) img.setTint(0xffb070);\n this.shots.set(p.id, img);\n }\n img.setPosition(this.roadX(p.x), y + (p.extra ? 10 : 0));\n if (img.getData(\"spin\")) img.rotation += 0.3;\n }\n for (const [id, img] of this.shots)\n if (!live.has(id)) {\n img.destroy();\n this.shots.delete(id);\n }\n }\n\n /** The enemies' shots fly left, at the hero — what their weapon shoots. */\n private syncEnemyShots(): void {\n const live = new Set<number>();\n for (const s of this.session.battle.enemyShots) {\n live.add(s.id);\n let img = this.enemyShots.get(s.id);\n if (!img) {\n img = this.add.image(this.roadX(s.x), 0, shotTexture(this, 0xff5a3c));\n img.setDepth(25).setData(\"spin\", true);\n this.enemyShots.set(s.id, img);\n }\n img.setPosition(\n this.roadX(s.x),\n img.getData(\"y\") ?? this.field.ground - 46,\n );\n if (img.getData(\"spin\")) img.rotation -= 0.3;\n }\n for (const [id, img] of this.enemyShots)\n if (!live.has(id)) {\n img.destroy();\n this.enemyShots.delete(id);\n }\n }\n\n private driftClouds(dt: number): void {\n // the clouds drift on their own and a little with the road (the farthest layer of all)\n const walking = this.session.battle.walking ? 1 : 0;\n for (const [i, c] of this.clouds.entries()) {\n c.x -= (6 + i * 3 + walking * this.field.road * 0.02) * dt;\n if (c.x < -100) c.x = this.field.w + 100;\n }\n }\n\n // ── Effects ────────────────────────────────────────────────────────────────────────────────\n\n private react(e: BattleEvent): void {\n const { ground } = this.field;\n switch (e.type) {\n case \"shoot\":\n case \"strike\":\n this.recoil = e.type === \"strike\" ? -5 : 3;\n break;\n case \"enemyShoot\": {\n const view = this.enemies.get(e.monsterId);\n // a bow looses an arrow, a crossbow a bolt, a gun a bullet; a staff — a red orb\n const kind = view?.actor.shotKind() ?? \"orb\";\n const missile =\n kind === \"arrow\" || kind === \"bolt\" || kind === \"bullet\";\n const img = this.add\n .image(\n this.roadX(e.shot.x),\n 0,\n missile ? missileTexture(this, kind) : shotTexture(this, 0xff5a3c),\n )\n .setDepth(25)\n .setFlipX(true);\n // the enemy's shots run red, so a bullet is never taken for a coin\n if (missile) img.setTint(0xff6a5a);\n img.setData(\"spin\", !missile);\n img.setData(\"y\", view?.actor.handY() ?? ground - 46);\n this.enemyShots.set(e.shot.id, img);\n break;\n }\n case \"hit\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n const top = ground - (view?.actor.view.displayHeight ?? 70) * 0.75;\n this.floatText(\n formatBig(Math.round(e.damage)),\n x,\n top,\n e.crit ? \"crit\" : EFFECT_KIND[e.effect],\n );\n this.burstAt(this.sparks, x - 10, top + 30, e.crit ? 10 : 4);\n view?.actor.view.flash(0xffffff, 80);\n break;\n }\n case \"kill\": {\n this.killed.add(e.monsterId);\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.puffs, x, ground - 30, e.boss ? 30 : 10);\n this.coinBurst(x, ground - 30, e.boss ? 14 : 3);\n if (e.boss) this.cameras.main.shake(260, 0.012);\n break;\n }\n case \"heroHit\":\n if (this.hero) {\n const { x } = this.heroPos();\n // a blow the shield took whole shows blue, one that reached the health red, one turned into\n // healing green\n const onShield = e.shield >= e.damage - 1e-9;\n this.floatText(\n `${e.healed ? \"+\" : \"-\"}${formatBig(Math.round(e.damage))}`,\n x,\n ground - this.heroHeight(),\n e.healed ? \"strong\" : onShield ? \"armor\" : \"hurt\",\n );\n this.hero.view.flash(\n e.healed ? 0x8dff6a : onShield ? 0x9fc8ff : 0xff6060,\n 90,\n );\n }\n break;\n case \"blast\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.fire, x, ground - 30, 22);\n this.cameras.main.shake(140, 0.006);\n break;\n }\n case \"stun\": {\n const view = this.enemies.get(e.monsterId);\n if (view)\n this.burstAt(\n this.sparks,\n view.actor.view.root.x,\n ground - view.actor.view.displayHeight,\n 6,\n );\n break;\n }\n case \"bolt\":\n for (const id of e.targets) this.boltFx(e.bolt, id);\n break;\n case \"guard\": {\n const { x } = this.heroPos();\n this.floatText(\"🛡\", x, ground - this.heroHeight(), \"armor\");\n break;\n }\n case \"charge\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 10);\n break;\n case \"potion\":\n this.burstAt(this.heals, this.heroPos().x, ground - 40, 18);\n break;\n case \"levelUp\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 60, 16);\n break;\n case \"dodge\": {\n const { x } = this.heroPos();\n this.floatText(\"MISS\", x, ground - this.heroHeight(), \"hit\");\n break;\n }\n case \"heroDown\":\n this.hero?.fall();\n this.burstAt(this.puffs, this.heroPos().x, ground - 30, 16);\n break;\n case \"bossStart\":\n this.cameras.main.flash(200, 255, 80, 60);\n break;\n case \"end\":\n if (e.result.cleared) {\n this.cameras.main.flash(260, 255, 240, 160);\n this.coinBurst(this.field.w / 2, ground - 60, 10);\n }\n break;\n case \"ult\":\n this.skillFx(e.ult);\n break;\n case \"ultBack\":\n // a card brought the ultimate back\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 20);\n this.cameras.main.flash(160, 200, 160, 255);\n break;\n default:\n break;\n }\n }\n\n private skillFx(skill: \"meteor\" | \"frenzy\"): void {\n const { ground } = this.field;\n if (skill === \"meteor\") {\n for (const [, view] of this.enemies) {\n const tx = view.actor.view.root.x;\n if (tx > this.field.w) continue;\n const m = this.add\n .image(tx + 120, -40, \"meteor\")\n .setDepth(35)\n .setScale(1.2);\n this.tweens.add({\n targets: m,\n x: tx,\n y: ground - 30,\n duration: 280,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.fire, tx, ground - 30, 16);\n m.destroy();\n },\n });\n }\n this.cameras.main.shake(200, 0.008);\n } else if (skill === \"frenzy\" && this.hero) {\n this.frenzyTween?.stop();\n const view = this.hero.view;\n view.setOverlay(0xffa0a0);\n const glow = { a: 1 };\n this.frenzyTween = this.tweens.add({\n targets: glow,\n a: 0.75,\n yoyo: true,\n repeat: 11,\n duration: 250,\n onUpdate: () => view.setAlpha(glow.a),\n onComplete: () => {\n view.setOverlay(null);\n view.setAlpha(1);\n },\n });\n }\n }\n\n /** A card's missile on its way to a troop: the damage is already dealt, this is how it looks. */\n private boltFx(kind: Bolt, monsterId: number): void {\n const view = this.enemies.get(monsterId);\n const { ground } = this.field;\n const hero = this.heroPos();\n const tx = view?.actor.view.root.x ?? this.field.w * 0.8;\n const ty = ground - (view?.actor.view.displayHeight ?? 70) * 0.55;\n if (kind === \"lightning\") {\n // a zigzag from the sky onto the troop, white in blue, gone in a blink\n const g = this.add.graphics().setDepth(36);\n const points: Array<[number, number]> = [];\n for (let k = 0; k <= 6; k++)\n points.push([\n tx + (k % 2 ? 12 : -12) * (k < 6 ? 1 : 0),\n -10 + ((ty + 10) * k) / 6,\n ]);\n for (const [w, c, a] of [\n [9, 0x3aa0ff, 0.55],\n [4, 0xffffff, 1],\n ] as const) {\n g.lineStyle(w, c, a);\n g.beginPath();\n g.moveTo(points[0]![0], points[0]![1]);\n for (const [x, y] of points.slice(1)) g.lineTo(x, y);\n g.strokePath();\n }\n this.burstAt(this.sparks, tx, ty, 10);\n this.tweens.add({\n targets: g,\n alpha: 0,\n duration: 260,\n onComplete: () => g.destroy(),\n });\n return;\n }\n const texture =\n kind === \"axe\"\n ? axeTexture(this)\n : missileTexture(this, kind === \"spear\" ? \"thrust\" : \"arrow\");\n const img = this.add.image(hero.x + 20, ground - 50, texture).setDepth(36);\n if (kind === \"spear\") {\n // through the whole line, off the screen's edge\n img.setScale(1.4, 1);\n this.tweens.add({\n targets: img,\n x: this.field.w + 60,\n duration: 320,\n ease: \"Quad.easeIn\",\n onComplete: () => img.destroy(),\n });\n return;\n }\n this.tweens.add({\n targets: img,\n x: tx,\n y: ty,\n angle: kind === \"axe\" ? 720 : 0,\n duration: kind === \"axe\" ? 300 : 220,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.sparks, tx, ty, 6);\n img.destroy();\n },\n });\n }\n\n private burstAt(\n emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,\n x: number,\n y: number,\n count: number,\n ): void {\n emitter?.explode(count, x, y);\n }\n\n /** Coins pop out of a kill and fly down into the gold counter of the panel. */\n private coinBurst(x: number, y: number, count: number): void {\n const target = { x: this.field.w / 2, y: this.field.h + 24 };\n for (let i = 0; i < count; i++) {\n const coin = this.add.image(x, y, \"coin\").setDepth(45).setScale(0.9);\n const ox = Phaser.Math.Between(-50, 50);\n const oy = Phaser.Math.Between(-70, -30);\n this.tweens.add({\n targets: coin,\n x: x + ox,\n y: y + oy,\n duration: 260,\n ease: \"Quad.easeOut\",\n onComplete: () =>\n this.tweens.add({\n targets: coin,\n x: target.x,\n y: target.y,\n scale: 0.5,\n delay: 120 + i * 30,\n duration: 520,\n ease: \"Cubic.easeIn\",\n onComplete: () => coin.destroy(),\n }),\n });\n }\n }\n\n private floatText(text: string, x: number, y: number, kind: FloatKind): void {\n const t = this.add\n .text(x + Phaser.Math.Between(-12, 12), y, text, {\n fontFamily: \"system-ui, sans-serif\",\n fontSize: kind === \"crit\" ? \"26px\" : \"18px\",\n fontStyle: \"bold\",\n color: FLOAT_COLORS[kind],\n stroke: \"#2b1d33\",\n strokeThickness: 4,\n resolution: canvasDensity(this),\n })\n .setOrigin(0.5, 1)\n .setDepth(50);\n if (kind === \"crit\") t.setScale(1.4);\n this.tweens.add({\n targets: t,\n y: y - 46,\n scale: 1,\n alpha: 0,\n duration: 750,\n ease: \"Cubic.easeOut\",\n onComplete: () => t.destroy(),\n });\n }\n}\n"
435
+ },
436
+ {
437
+ "path": "phaser/density.ts",
438
+ "content": "import type Phaser from \"phaser\";\n\n// How many canvas pixels the field draws per CSS pixel.\n//\n// ⚠ Phaser on its own draws in CSS pixels: a 392×265 field is a 392×265 canvas, and a phone screen\n// 2.75 times denser stretches it — the fight looked soapy next to the sharp menus around it (the\n// owner's phone, 04.11.2026). So the canvas is `density` times bigger than its box, the camera zooms\n// by the same number (the scene keeps thinking in CSS pixels), the texts get the same `resolution`,\n// and the art baked in code is baked that many times bigger (`markDensity`). The cap keeps the fill\n// cost sane: past 2 the eye sees no difference and the GPU of a cheap phone pays four times more.\n\n/** Past this the screen is not sharper to the eye, only slower to fill. */\nexport const MAX_DENSITY = 2;\n\n/** The screen's density, capped. Baked art uses it once; the canvas may drop below it (see the controller). */\nexport function screenDensity(): number {\n const dpr =\n typeof devicePixelRatio === \"number\" && devicePixelRatio > 0\n ? devicePixelRatio\n : 1;\n return Math.min(MAX_DENSITY, Math.max(1, dpr));\n}\n\n/** The density the scene's canvas is drawn at right now (the Scale Manager's zoom is its inverse). */\nexport function canvasDensity(scene: Phaser.Scene): number {\n const zoom = scene.scale.zoom;\n return zoom > 0 ? 1 / zoom : 1;\n}\n\n/**\n * Tells Phaser a texture was drawn `k` times bigger than it should show: an image of it keeps the\n * size it would have at `k = 1` (its `width`, `displayWidth`, `setDisplaySize`, hit area), only with\n * `k` times the pixels. Phaser draws a texture divided by its source's `resolution` (that is how its\n * own Text is sharp) but sizes a game object by the frame's `realWidth`, so both are set.\n *\n * ⚠ Only for textures shown by images and sprites. A particle emitter sizes its particles by the\n * frame's PIXELS and ignores the resolution: a marked texture there comes out `k` times bigger.\n * A TileSprite neither: it maps by pixels too (`Backdrop` scales its tiles itself).\n */\nexport function markDensity(texture: Phaser.Textures.Texture, k: number): void {\n if (k === 1) return;\n const source = texture.source[0];\n if (source) source.resolution = k;\n for (const name of texture.getFrameNames(true)) {\n const frame = texture.get(name) as Phaser.Textures.Frame & {\n data: { sourceSize: { w: number; h: number } };\n };\n frame.data.sourceSize.w = frame.cutWidth / k;\n frame.data.sourceSize.h = frame.cutHeight / k;\n }\n}\n"
435
439
  },
436
440
  {
437
441
  "path": "phaser/enemy.ts",
@@ -463,7 +467,7 @@
463
467
  },
464
468
  {
465
469
  "path": "ui/GamePanel.tsx",
466
- "content": "import {\n useEffect,\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n EmptyState,\n errorText,\n outlined,\n screenBackground,\n useCatalog,\n useNotify,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { BATTLE_HEIGHT_CSS } from \"../layout\";\nimport { t } from \"../i18n\";\nimport { BattleHud } from \"./BattleHud\";\nimport { BottomNav } from \"./BottomNav\";\nimport { GuideQuest } from \"./GuideQuest\";\nimport { MapPanel, TipPopup } from \"./MapPanel\";\nimport { RunBar } from \"./RunBar\";\nimport { RunStats } from \"./RunStats\";\nimport { CampPopup, CardPick, RunEnd } from \"./RunPopups\";\n\n// The game's whole interface over the canvas: the HUD on top of the road, and under it the panel —\n// between runs the map (levels, scouting, the ultimate, Run); during a run its controls and the\n// hero's sheet (its numbers with what the cards add, the cards' effects, the cards taken); the popups\n// that stop a run (a card, the camp, the end). The hero grows between runs from its gear and rank —\n// the Heroes screen, not this one. Next to the base the game sits\n// inside the lobby, whose tabs open the systems — then the game's own bar is not drawn (`nav`).\n\nexport function makeGamePanel(\n session: RunSession,\n features: FeatureRegistry,\n nav = true,\n): ComponentType {\n return function HeroRunPanel(): ReactNode {\n useSyncExternalStore(session.subscribe, session.getVersion);\n useNotices(session);\n const hero = session.hero;\n\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n fontFamily: v.font,\n color: v.text,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 0,\n left: 0,\n right: 0,\n height: BATTLE_HEIGHT_CSS,\n }}\n >\n <BattleHud session={session} />\n </div>\n <div\n className=\"idos-screen\"\n style={{\n position: \"absolute\",\n top: BATTLE_HEIGHT_CSS,\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n flexDirection: \"column\",\n background: screenBackground,\n pointerEvents: \"auto\",\n }}\n >\n <div\n className=\"idos-scroll\"\n style={{\n flex: 1,\n minHeight: 0,\n overflowY: \"auto\",\n padding: \"8px 10px 12px\",\n }}\n >\n <div\n style={{\n maxWidth: 1120,\n margin: \"0 auto\",\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 8,\n }}\n >\n {!hero ? (\n session.loaded ? (\n <EmptyState glyph=\"hero\" text={t(\"noHero\")} />\n ) : (\n <div\n style={{\n ...outlined,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"loading\")}\n </div>\n )\n ) : session.running ? (\n <>\n <RunBar session={session} />\n <RunStats session={session} />\n </>\n ) : (\n <>\n <GuideQuest features={features} />\n <MapPanel session={session} />\n </>\n )}\n </div>\n </div>\n {nav ? <BottomNav features={features} /> : null}\n </div>\n <CardPick session={session} />\n <CampPopup session={session} />\n <RunEnd session={session} />\n <TipPopup session={session} />\n </div>\n );\n };\n}\n\n/** The session's refusals as toasts (\"not enough energy\", a server error). */\nfunction useNotices(session: RunSession): void {\n const notify = useNotify();\n const catalog = useCatalog();\n const notice = session.notice;\n useEffect(() => {\n if (!notice) return;\n if (notice.kind === \"notEnough\")\n notify.toast({\n level: \"warning\",\n key: \"hero-run-not-enough\",\n title: `${t(\"notEnough\")}: ${catalog.currencyName(notice.currencyID)}`,\n });\n else notify.toast({ level: \"error\", message: errorText(notice.text) });\n // Keyed on the notice's time: each refusal shows once.\n }, [notice?.at]);\n}\n"
470
+ "content": "import {\n useEffect,\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n EmptyState,\n errorText,\n outlined,\n screenBackground,\n useCatalog,\n useNotify,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { BATTLE_HEIGHT_CSS } from \"../layout\";\nimport { t } from \"../i18n\";\nimport { BattleHud } from \"./BattleHud\";\nimport { BottomNav } from \"./BottomNav\";\nimport { GuideQuest } from \"./GuideQuest\";\nimport { MapPanel, TipPopup } from \"./MapPanel\";\nimport { RunBar } from \"./RunBar\";\nimport { RunStats } from \"./RunStats\";\nimport { CampPopup, CardPick, RunEnd } from \"./RunPopups\";\n\n// The game's whole interface over the canvas: the HUD on top of the road, and under it the panel —\n// between runs the map (levels, scouting, the ultimate, Run); during a run its controls and the\n// hero's sheet (its numbers with what the cards add, the cards' effects, the cards taken); the popups\n// that stop a run (a card, the camp, the end). The hero grows between runs from its gear and rank —\n// the Heroes screen, not this one. Next to the base the game sits\n// inside the lobby, whose tabs open the systems — then the game's own bar is not drawn (`nav`).\n\nexport function makeGamePanel(\n session: RunSession,\n features: FeatureRegistry,\n nav = true,\n): ComponentType {\n return function HeroRunPanel(): ReactNode {\n useSyncExternalStore(session.subscribe, session.getVersion);\n useNotices(session);\n const hero = session.hero;\n\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n fontFamily: v.font,\n color: v.text,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 0,\n left: 0,\n right: 0,\n height: BATTLE_HEIGHT_CSS,\n }}\n >\n {/* a surface over the canvas (never the canvas itself): smaller on a small phone */}\n <div className=\"idos-fit\" style={{ position: \"absolute\", inset: 0 }}>\n <BattleHud session={session} />\n </div>\n </div>\n <div\n className=\"idos-screen\"\n style={{\n position: \"absolute\",\n top: BATTLE_HEIGHT_CSS,\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n flexDirection: \"column\",\n background: screenBackground,\n pointerEvents: \"auto\",\n }}\n >\n <div\n className=\"idos-scroll idos-fit\"\n style={{\n flex: 1,\n minHeight: 0,\n overflowY: \"auto\",\n padding: \"8px 10px 12px\",\n }}\n >\n <div\n style={{\n maxWidth: 1120,\n margin: \"0 auto\",\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 8,\n }}\n >\n {!hero ? (\n session.loaded ? (\n <EmptyState glyph=\"hero\" text={t(\"noHero\")} />\n ) : (\n <div\n style={{\n ...outlined,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"loading\")}\n </div>\n )\n ) : session.running ? (\n <>\n <RunBar session={session} />\n <RunStats session={session} />\n </>\n ) : (\n <>\n <GuideQuest features={features} />\n <MapPanel session={session} />\n </>\n )}\n </div>\n </div>\n {nav ? (\n <div className=\"idos-fit\">\n <BottomNav features={features} />\n </div>\n ) : null}\n </div>\n <CardPick session={session} />\n <CampPopup session={session} />\n <RunEnd session={session} />\n <TipPopup session={session} />\n </div>\n );\n };\n}\n\n/** The session's refusals as toasts (\"not enough energy\", a server error). */\nfunction useNotices(session: RunSession): void {\n const notify = useNotify();\n const catalog = useCatalog();\n const notice = session.notice;\n useEffect(() => {\n if (!notice) return;\n if (notice.kind === \"notEnough\")\n notify.toast({\n level: \"warning\",\n key: \"hero-run-not-enough\",\n title: `${t(\"notEnough\")}: ${catalog.currencyName(notice.currencyID)}`,\n });\n else notify.toast({ level: \"error\", message: errorText(notice.text) });\n // Keyed on the notice's time: each refusal shows once.\n }, [notice?.at]);\n}\n"
467
471
  },
468
472
  {
469
473
  "path": "ui/GuideQuest.tsx",
@@ -479,7 +483,7 @@
479
483
  },
480
484
  {
481
485
  "path": "ui/RunPopups.tsx",
482
- "content": "import { useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n KitPortal,\n Popup,\n Ribbon,\n grantedBy,\n outlined,\n useCelebrate,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { CARD_BY_ID, type Rarity } from \"../game/cards\";\nimport { CAMP_FEAST_GEMS, GEMS_ID } from \"../game/economy\";\nimport { formatDuration } from \"../game/format\";\nimport { t, type Key } from \"../i18n\";\nimport { levelTitle } from \"./MapPanel\";\nimport { CardIconBadge } from \"./cardIcons\";\nimport { InfoPopup, StatStrip } from \"./RunStats\";\nimport { CardText } from \"./types\";\n\n// The run's moments that stop it: the choice of an upgrade when the rage fills (Obsidian Knight's\n// \"New level!\" — the hero's numbers on top, three wide cards, Refresh and Info under them), the camp,\n// and the end — victory or defeat, and the chest the run earned.\n\n/** A card's frame and its rarity tag by rarity (the octagon is cardIcons.tsx). */\nexport const RARITY_LOOK: Readonly<\n Record<Rarity, { tag: string; ink: string; glow: string }>\n> = {\n common: { tag: \"#b9bec9\", ink: \"#2b2e36\", glow: \"rgba(255,255,255,.55)\" },\n rare: { tag: \"#5fb0ff\", ink: \"#0f2a52\", glow: \"rgba(120,190,255,.6)\" },\n legendary: { tag: \"#f2c443\", ink: \"#4a3000\", glow: \"rgba(255,214,64,.75)\" },\n};\n\nexport function CardPick({ session }: { session: RunSession }): ReactNode {\n const pending = session.battle.pending;\n const kit = useUiKit();\n const [info, setInfo] = useState(false);\n if (pending?.kind !== \"cards\") return null;\n const snap = session.battle.snapshot();\n const cards = pending.options\n .map((id) => CARD_BY_ID.get(id)!)\n .filter(Boolean);\n const best = cards.some((c) => c.rarity === \"legendary\")\n ? \"legendary\"\n : cards.some((c) => c.rarity === \"rare\")\n ? \"rare\"\n : \"common\";\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n // under the kit's popups (20): \"Info\" opens on top of the choice, not behind it\n zIndex: 19,\n pointerEvents: \"auto\",\n overflowY: \"auto\",\n background: \"rgba(12, 12, 20, .82)\",\n display: \"grid\",\n justifyItems: \"center\",\n alignContent: \"start\",\n padding:\n \"max(14px, env(safe-area-inset-top)) 12px max(18px, env(safe-area-inset-bottom))\",\n fontFamily: v.font,\n }}\n >\n <div\n style={{\n width: \"min(560px, 100%)\",\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n <StatStrip session={session} />\n <div\n style={{\n width: \"100%\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n paddingTop: 34,\n // the burst behind the ribbon: gold when a legendary is among the three\n background: `radial-gradient(ellipse 50% 60% at 50% 30%, ${RARITY_LOOK[best].glow}, transparent 70%)`,\n }}\n >\n <Ribbon tone=\"slate\" stars>\n {t(\"newLevel\")}\n </Ribbon>\n <div style={{ ...outlined, fontSize: 22, textAlign: \"center\" }}>\n {t(\"chooseUpgrade\")}\n </div>\n <div style={{ ...outlined, fontSize: 15 }}>\n {t(\"upgradeN\")}:{\" \"}\n <span style={{ color: \"#ff9a2e\" }}>\n {pending.index}/{pending.total}\n </span>\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 12, width: \"100%\" }}>\n {cards.map((card, i) => {\n const look = RARITY_LOOK[card.rarity];\n return (\n <button\n key={`${card.id}:${i}`}\n type=\"button\"\n className=\"idos-press idos-pop-in\"\n data-tutorial-anchor={`hero:card:${card.id}`}\n onClick={() => {\n if (session.takeCard(card.id)) kit.play(\"claim\");\n }}\n style={{\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n width: \"100%\",\n minHeight: 104,\n padding: \"18px 14px 12px 12px\",\n borderRadius: 8,\n background: \"linear-gradient(180deg, #3e414a, #33363e)\",\n border: \"4px solid #17181d\",\n boxShadow: \"inset 0 0 0 2px #4d505a, 0 4px 0 #0d0e12\",\n textAlign: \"left\",\n cursor: \"pointer\",\n animationDelay: `${i * 70}ms`,\n }}\n >\n <span\n style={{\n position: \"absolute\",\n top: -4,\n left: -4,\n padding: \"2px 22px 2px 92px\",\n background: look.tag,\n color: look.ink,\n fontWeight: 900,\n fontSize: 13,\n letterSpacing: 0.3,\n textTransform: \"uppercase\",\n clipPath:\n \"polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%)\",\n border: \"2px solid #17181d\",\n }}\n >\n {t(`rarity.${card.rarity}` as Key)}\n </span>\n <CardIconBadge\n icon={card.icon}\n rarity={card.rarity}\n size={84}\n />\n <CardText card={card} size={16} />\n </button>\n );\n })}\n </div>\n <div\n style={{\n position: \"relative\",\n width: \"100%\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n marginTop: 6,\n }}\n >\n <Button\n tone=\"blue\"\n size=\"lg\"\n disabled={snap.rerollsLeft <= 0}\n onClick={() => {\n if (session.reroll()) kit.play(\"tab\");\n }}\n data-tutorial-anchor=\"hero:reroll\"\n >\n {t(\"refresh\")} <Icon glyph=\"clapper\" size={22} />\n </Button>\n <span style={{ ...outlined, fontSize: 14 }}>\n {t(\"left\")}:{\" \"}\n <span style={{ color: \"#ff9a2e\" }}>\n {snap.rerollsLeft}/{session.battle.rerollsTotal}\n </span>\n </span>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setInfo(true)}\n style={{\n ...outlined,\n position: \"absolute\",\n right: 0,\n top: 0,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 13,\n }}\n >\n <Icon glyph=\"scroll\" size={40} />\n {t(\"info\")}\n </button>\n </div>\n </div>\n </div>\n {info ? (\n <InfoPopup session={session} onClose={() => setInfo(false)} />\n ) : null}\n </KitPortal>\n );\n}\n\nexport function CampPopup({ session }: { session: RunSession }): ReactNode {\n if (session.battle.pending?.kind !== \"camp\") return null;\n const feast = !!session.economy?.gemsToXp;\n return (\n <Popup title={t(\"camp\")} width={420} variant=\"center\" dismissible={false}>\n <div style={{ display: \"grid\", gap: 10, justifyItems: \"center\" }}>\n <Icon glyph=\"flame\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"campText\")}</div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button tone=\"green\" onClick={() => void session.chooseCamp(\"heal\")}>\n <Icon glyph=\"heart\" size={18} /> {t(\"campHeal\")} +40%\n </Button>\n <Button tone=\"red\" onClick={() => void session.chooseCamp(\"rage\")}>\n <Icon glyph=\"flame\" size={18} /> {t(\"campRage\")} +5\n </Button>\n </div>\n {feast ? (\n <Button\n tone=\"purple\"\n busy={session.busy}\n disabled={session.balance(GEMS_ID) < CAMP_FEAST_GEMS}\n onClick={() => void session.chooseCamp(\"both\")}\n >\n {t(\"campBoth\")}: <Icon glyph=\"heart\" size={16} />+\n <Icon glyph=\"flame\" size={16} /> · <Icon glyph=\"gem\" size={16} />\n {CAMP_FEAST_GEMS}\n </Button>\n ) : null}\n </div>\n </Popup>\n );\n}\n\nconst CHEST_NAME = {\n defeat: \"chestDefeat\",\n victory: \"chestVictory\",\n treasure: \"chestTreasure\",\n} as const;\n\nexport function RunEnd({ session }: { session: RunSession }): ReactNode {\n const ending = session.ending;\n const celebrate = useCelebrate();\n const kit = useUiKit();\n if (!ending) return null;\n const r = ending.result;\n const open = async () => {\n const data = await session.claim();\n if (data) celebrate(grantedBy(data), { sound: \"levelUp\" });\n };\n return (\n <Popup\n title={r.cleared ? t(\"victory\") : t(\"defeat\")}\n width={400}\n variant=\"center\"\n dismissible={false}\n >\n <div\n style={{\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n textAlign: \"center\",\n }}\n >\n <div style={{ ...outlined, fontSize: 15, color: v.textDim }}>\n {t(\"level\")} {levelTitle(r.level)}\n </div>\n <div style={{ display: \"flex\", gap: 16, ...outlined, fontSize: 14 }}>\n <span>\n <Icon glyph=\"swords\" size={16} /> {t(\"kills\")}: {r.kills}\n </span>\n <span>\n <Icon glyph=\"flame\" size={16} /> {t(\"reached\")}: {r.runLevel}\n </span>\n <span>\n <Icon glyph=\"clock\" size={16} /> {formatDuration(r.seconds)}\n </span>\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph=\"chest\" size={72} />\n </div>\n <div style={{ ...outlined, fontSize: 18, color: v.gold }}>\n {t(CHEST_NAME[ending.tier])}\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={ending.claiming}\n onClick={() => {\n kit.play(\"click\");\n void open();\n }}\n data-tutorial-anchor=\"hero:claim\"\n >\n {session.economy ? t(\"open\") : t(\"toMap\")}\n </Button>\n </div>\n </Popup>\n );\n}\n"
486
+ "content": "import { useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n KitPortal,\n Popup,\n Ribbon,\n grantedBy,\n outlined,\n useCelebrate,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { CARD_BY_ID, type Rarity } from \"../game/cards\";\nimport { CAMP_FEAST_GEMS, GEMS_ID } from \"../game/economy\";\nimport { formatDuration } from \"../game/format\";\nimport { t, type Key } from \"../i18n\";\nimport { levelTitle } from \"./MapPanel\";\nimport { CardIconBadge } from \"./cardIcons\";\nimport { InfoPopup, StatStrip } from \"./RunStats\";\nimport { CardText } from \"./types\";\n\n// The run's moments that stop it: the choice of an upgrade when the rage fills (Obsidian Knight's\n// \"New level!\" — the hero's numbers on top, three wide cards, Refresh and Info under them), the camp,\n// and the end — victory or defeat, and the chest the run earned.\n\n/** A card's frame and its rarity tag by rarity (the octagon is cardIcons.tsx). */\nexport const RARITY_LOOK: Readonly<\n Record<Rarity, { tag: string; ink: string; glow: string }>\n> = {\n common: { tag: \"#b9bec9\", ink: \"#2b2e36\", glow: \"rgba(255,255,255,.55)\" },\n rare: { tag: \"#5fb0ff\", ink: \"#0f2a52\", glow: \"rgba(120,190,255,.6)\" },\n legendary: { tag: \"#f2c443\", ink: \"#4a3000\", glow: \"rgba(255,214,64,.75)\" },\n};\n\nexport function CardPick({ session }: { session: RunSession }): ReactNode {\n const pending = session.battle.pending;\n const kit = useUiKit();\n const [info, setInfo] = useState(false);\n if (pending?.kind !== \"cards\") return null;\n const snap = session.battle.snapshot();\n const cards = pending.options\n .map((id) => CARD_BY_ID.get(id)!)\n .filter(Boolean);\n const best = cards.some((c) => c.rarity === \"legendary\")\n ? \"legendary\"\n : cards.some((c) => c.rarity === \"rare\")\n ? \"rare\"\n : \"common\";\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n // under the kit's popups (20): \"Info\" opens on top of the choice, not behind it\n zIndex: 19,\n pointerEvents: \"auto\",\n overflowY: \"auto\",\n background: \"rgba(12, 12, 20, .82)\",\n display: \"grid\",\n justifyItems: \"center\",\n alignContent: \"start\",\n padding:\n \"max(14px, env(safe-area-inset-top)) 12px max(18px, env(safe-area-inset-bottom))\",\n fontFamily: v.font,\n }}\n >\n <div\n style={{\n width: \"min(560px, 100%)\",\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n <StatStrip session={session} />\n <div\n style={{\n width: \"100%\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n paddingTop: 34,\n // the burst behind the ribbon: gold when a legendary is among the three\n background: `radial-gradient(ellipse 50% 60% at 50% 30%, ${RARITY_LOOK[best].glow}, transparent 70%)`,\n }}\n >\n <Ribbon tone=\"slate\" stars>\n {t(\"newLevel\")}\n </Ribbon>\n <div style={{ ...outlined, fontSize: 22, textAlign: \"center\" }}>\n {t(\"chooseUpgrade\")}\n </div>\n <div style={{ ...outlined, fontSize: 15 }}>\n {t(\"upgradeN\")}:{\" \"}\n <span style={{ color: \"#ff9a2e\" }}>\n {pending.index}/{pending.total}\n </span>\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 12, width: \"100%\" }}>\n {cards.map((card, i) => {\n const look = RARITY_LOOK[card.rarity];\n return (\n <button\n key={`${card.id}:${i}`}\n type=\"button\"\n className=\"idos-press idos-pop-in\"\n data-tutorial-anchor={`hero:card:${card.id}`}\n onClick={() => {\n if (session.takeCard(card.id)) kit.play(\"claim\");\n }}\n style={{\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n width: \"100%\",\n minHeight: 104,\n padding: \"18px 14px 12px 12px\",\n borderRadius: 8,\n background: \"linear-gradient(180deg, #3e414a, #33363e)\",\n border: \"4px solid #17181d\",\n boxShadow: \"inset 0 0 0 2px #4d505a\",\n textAlign: \"left\",\n cursor: \"pointer\",\n animationDelay: `${i * 70}ms`,\n }}\n >\n <span\n style={{\n position: \"absolute\",\n top: -4,\n left: -4,\n padding: \"2px 22px 2px 92px\",\n background: look.tag,\n color: look.ink,\n fontWeight: 900,\n fontSize: 13,\n letterSpacing: 0.3,\n textTransform: \"uppercase\",\n clipPath:\n \"polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%)\",\n border: \"2px solid #17181d\",\n }}\n >\n {t(`rarity.${card.rarity}` as Key)}\n </span>\n <CardIconBadge\n icon={card.icon}\n rarity={card.rarity}\n size={84}\n />\n <CardText card={card} size={16} />\n </button>\n );\n })}\n </div>\n <div\n style={{\n position: \"relative\",\n width: \"100%\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n marginTop: 6,\n }}\n >\n <Button\n tone=\"blue\"\n size=\"lg\"\n disabled={snap.rerollsLeft <= 0}\n onClick={() => {\n if (session.reroll()) kit.play(\"tab\");\n }}\n data-tutorial-anchor=\"hero:reroll\"\n >\n {t(\"refresh\")} <Icon glyph=\"clapper\" size={22} />\n </Button>\n <span style={{ ...outlined, fontSize: 14 }}>\n {t(\"left\")}:{\" \"}\n <span style={{ color: \"#ff9a2e\" }}>\n {snap.rerollsLeft}/{session.battle.rerollsTotal}\n </span>\n </span>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setInfo(true)}\n style={{\n ...outlined,\n position: \"absolute\",\n right: 0,\n top: 0,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 13,\n }}\n >\n <Icon glyph=\"scroll\" size={40} />\n {t(\"info\")}\n </button>\n </div>\n </div>\n </div>\n {info ? (\n <InfoPopup session={session} onClose={() => setInfo(false)} />\n ) : null}\n </KitPortal>\n );\n}\n\nexport function CampPopup({ session }: { session: RunSession }): ReactNode {\n if (session.battle.pending?.kind !== \"camp\") return null;\n const feast = !!session.economy?.gemsToXp;\n return (\n <Popup title={t(\"camp\")} width={420} variant=\"center\" dismissible={false}>\n <div style={{ display: \"grid\", gap: 10, justifyItems: \"center\" }}>\n <Icon glyph=\"flame\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"campText\")}</div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button tone=\"green\" onClick={() => void session.chooseCamp(\"heal\")}>\n <Icon glyph=\"heart\" size={18} /> {t(\"campHeal\")} +40%\n </Button>\n <Button tone=\"red\" onClick={() => void session.chooseCamp(\"rage\")}>\n <Icon glyph=\"flame\" size={18} /> {t(\"campRage\")} +5\n </Button>\n </div>\n {feast ? (\n <Button\n tone=\"purple\"\n busy={session.busy}\n disabled={session.balance(GEMS_ID) < CAMP_FEAST_GEMS}\n onClick={() => void session.chooseCamp(\"both\")}\n >\n {t(\"campBoth\")}: <Icon glyph=\"heart\" size={16} />+\n <Icon glyph=\"flame\" size={16} /> · <Icon glyph=\"gem\" size={16} />\n {CAMP_FEAST_GEMS}\n </Button>\n ) : null}\n </div>\n </Popup>\n );\n}\n\nconst CHEST_NAME = {\n defeat: \"chestDefeat\",\n victory: \"chestVictory\",\n treasure: \"chestTreasure\",\n} as const;\n\nexport function RunEnd({ session }: { session: RunSession }): ReactNode {\n const ending = session.ending;\n const celebrate = useCelebrate();\n const kit = useUiKit();\n if (!ending) return null;\n const r = ending.result;\n const open = async () => {\n const data = await session.claim();\n if (data) celebrate(grantedBy(data), { sound: \"levelUp\" });\n };\n return (\n <Popup\n title={r.cleared ? t(\"victory\") : t(\"defeat\")}\n width={400}\n variant=\"center\"\n dismissible={false}\n >\n <div\n style={{\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n textAlign: \"center\",\n }}\n >\n <div style={{ ...outlined, fontSize: 15, color: v.textDim }}>\n {t(\"level\")} {levelTitle(r.level)}\n </div>\n <div style={{ display: \"flex\", gap: 16, ...outlined, fontSize: 14 }}>\n <span>\n <Icon glyph=\"swords\" size={16} /> {t(\"kills\")}: {r.kills}\n </span>\n <span>\n <Icon glyph=\"flame\" size={16} /> {t(\"reached\")}: {r.runLevel}\n </span>\n <span>\n <Icon glyph=\"clock\" size={16} /> {formatDuration(r.seconds)}\n </span>\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph=\"chest\" size={72} />\n </div>\n <div style={{ ...outlined, fontSize: 18, color: v.gold }}>\n {t(CHEST_NAME[ending.tier])}\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={ending.claiming}\n onClick={() => {\n kit.play(\"click\");\n void open();\n }}\n data-tutorial-anchor=\"hero:claim\"\n >\n {session.economy ? t(\"open\") : t(\"toMap\")}\n </Button>\n </div>\n </Popup>\n );\n}\n"
483
487
  },
484
488
  {
485
489
  "path": "ui/RunStats.tsx",
@@ -491,7 +495,7 @@
491
495
  },
492
496
  {
493
497
  "path": "module.meta.json",
494
- "content": "{\n \"id\": \"hero-run\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\", \"inventory\"],\n \"summary\": \"Roguelike runs in the style of Obsidian Knight with attack and armor types: the hero runs a level on its own, rage fills and each run level is a choice of one of three cards; a weapon's attack type meets a troop's armor type.\",\n \"description\": \"A roguelike auto-runner on Phaser with a React screen. The campaign is islands of six levels; a level is ONE run along a road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls in painted parallax per island theme): waves of troops standing in line (brawlers one at a time face to face, shooters fire once the hero is in their range), a camp (health or rage, both for gems), fountains, a chest, a boss at the end. Kills fill the RAGE; every threshold (3, 7, 14, 22, … as in Obsidian Knight) is a run level and a pause to take one of three cards — 58 upgrades in Obsidian Knight's form, a trigger and an effect (passive; on hit, crit, kill, dodge, counter, being hit, heal, overheal, the active skill, rest; every N-th attack; a new run level) throwing Lightning, Axes, Piercing spears and Arrows, stunning, healing, repairing the armor, giving charges (Focus, Divine shield, lightning); one Refresh a run. Death ends the run. Damage goes by type: seven attack types (normal, pierce, siege, magic, chaos, spells, hero) × six armor types (light, medium, heavy, fortified, hero, unarmored) by a damage table, and armor points by the formula 0.06·A/(1+0.06·A) — negative armor amplifies. Types come from the gear and read off the picture: troops (people on the hero's rig) are dressed in what gives their types, the hero's weapon and top give its own (an item may name them in CustomData AttackType / ArmorType), a shield is a blue pool that takes the blows first. The types come in island by island (the first is casual), with a scouting screen and one tip per new type. One ultimate slot (meteor or frenzy) used once a run — only a card's chance (at a kill, at a new run level) brings it back; potions taken into the run; no health regen. A run costs ENERGY (Recharge) converted into a RUN_TICKET and ends in a reward chest opened with that ticket (lootboxes run_chest_defeat / victory / treasure); XP is a currency, the player's level its balance. Without these currencies the runs are free and pay nothing. The hero's fight numbers are the server's character stats (a mirror of PvPBattleEngine.CalculateStats) and are NOT sold: between runs they grow from gear (item levels) and rank on the Heroes screen, in a run from the cards; during a run the panel under the road shows the hero's sheet — every number with what the cards add (+15), the cards taken and their effects. Progress is saved in the player's private custom data (hero_run_progress); cleared levels and finished runs are reported to quests that count level_cleared / run_finished.\",\n \"provides\": [\n \"roguelike runs of levels: waves, camp, fountains, chest, boss\",\n \"rage and run levels with a choice of 1 of 3 cards (58 trigger-and-effect upgrades, a refresh)\",\n \"attack types × armor types and the armor formula\",\n \"types from the gear: troops and the hero read off their look\",\n \"scouting of a level and type tips introduced island by island\",\n \"one ultimate a run (cards may bring it back) and potions\",\n \"energy → run ticket → reward chest (server-side through currency and lootboxes)\",\n \"2D procedural art (Phaser) with a layered 2D character\",\n \"the run's hero sheet: numbers with the cards' bonuses, card effects\"\n ],\n \"tags\": [\n \"roguelike\",\n \"rpg\",\n \"auto-battler\",\n \"obsidian-knight\",\n \"cards\",\n \"phaser\",\n \"2d\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"hero-run:character-upgraded@1\",\n \"when\": \"a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)\",\n \"payload\": {\n \"characterId\": \"string\",\n \"level\": \"number\"\n }\n },\n {\n \"topic\": \"hero-run:level-cleared@1\",\n \"when\": \"the hero beat a level's boss; island-level is the level just cleared\",\n \"payload\": {\n \"island\": \"number\",\n \"level\": \"number\"\n }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"why\": \"fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)\"\n }\n ]\n }\n}\n"
498
+ "content": "{\n \"id\": \"hero-run\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\", \"inventory\"],\n \"summary\": \"Roguelike runs in the style of Obsidian Knight with attack and armor types: the hero runs a level on its own, rage fills and each run level is a choice of one of three cards; a weapon's attack type meets a troop's armor type.\",\n \"description\": \"A roguelike auto-runner on Phaser with a React screen. The campaign is islands of six levels; a level is ONE run along a road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls in painted parallax per island theme): waves of troops standing in line (brawlers one at a time face to face, shooters fire once the hero is in their range), a camp (health or rage, both for gems), fountains, a chest, a boss at the end. Kills fill the RAGE; every threshold (3, 7, 14, 22, … as in Obsidian Knight) is a run level and a pause to take one of three cards — 58 upgrades in Obsidian Knight's form, a trigger and an effect (passive; on hit, crit, kill, dodge, counter, being hit, heal, overheal, the active skill, rest; every N-th attack; a new run level) throwing Lightning, Axes, Piercing spears and Arrows, stunning, healing, repairing the armor, giving charges (Focus, Divine shield, lightning); one Refresh a run. Death ends the run. Damage goes by type: seven attack types (normal, pierce, siege, magic, chaos, spells, hero) × six armor types (light, medium, heavy, fortified, hero, unarmored) by a damage table, and armor points by the formula 0.06·A/(1+0.06·A) — negative armor amplifies. Types come from the gear and read off the picture: troops (people on the hero's rig) are dressed in what gives their types, the hero's weapon and top give its own (an item may name them in CustomData AttackType / ArmorType), a shield is a blue pool that takes the blows first. The types come in island by island (the first is casual), with a scouting screen and one tip per new type. One ultimate slot (meteor or frenzy) used once a run — only a card's chance (at a kill, at a new run level) brings it back; potions taken into the run; no health regen. A run costs ENERGY (Recharge) converted into a RUN_TICKET and ends in a reward chest opened with that ticket (lootboxes run_chest_defeat / victory / treasure); XP is a currency, the player's level its balance. Without these currencies the runs are free and pay nothing. The hero's fight numbers are the server's character stats (a mirror of PvPBattleEngine.CalculateStats) and are NOT sold: between runs they grow from gear (item levels) and rank on the Heroes screen, in a run from the cards; during a run the panel under the road shows the hero's sheet — every number with what the cards add (+15), the cards taken and their effects. Progress is saved in the player's private custom data (hero_run_progress); cleared levels and finished runs are reported to quests that count level_cleared / run_finished.\",\n \"provides\": [\n \"roguelike runs of levels: waves, camp, fountains, chest, boss\",\n \"rage and run levels with a choice of 1 of 3 cards (58 trigger-and-effect upgrades, a refresh)\",\n \"attack types × armor types and the armor formula\",\n \"types from the gear: troops and the hero read off their look\",\n \"scouting of a level and type tips introduced island by island\",\n \"one ultimate a run (cards may bring it back) and potions\",\n \"energy → run ticket → reward chest (server-side through currency and lootboxes)\",\n \"2D procedural art (Phaser) with a layered 2D character\",\n \"the run's hero sheet: numbers with the cards' bonuses, card effects\"\n ],\n \"tags\": [\n \"roguelike\",\n \"rpg\",\n \"auto-battler\",\n \"obsidian-knight\",\n \"cards\",\n \"phaser\",\n \"2d\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"hero-run:character-upgraded@1\",\n \"when\": \"a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)\",\n \"payload\": {\n \"characterId\": \"string\",\n \"level\": \"number\"\n }\n },\n {\n \"topic\": \"hero-run:level-cleared@1\",\n \"when\": \"the hero beat a level's boss; island-level is the level just cleared\",\n \"payload\": {\n \"island\": \"number\",\n \"level\": \"number\"\n }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"why\": \"fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)\"\n }\n ]\n }\n}\n"
495
499
  }
496
500
  ]
497
501
  }