@idosgames/mcp 0.1.35 → 0.1.36
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.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +124 -120
- package/registry/modules/ai.json +3 -3
- package/registry/modules/board-game.json +37 -37
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +15 -15
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +3 -3
- package/registry/modules/coop-events.json +3 -3
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +3 -3
- package/registry/modules/events.json +8 -8
- package/registry/modules/friends.json +3 -3
- package/registry/modules/idle-rpg.json +3 -3
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/leaderboard.json +12 -12
- package/registry/modules/lootboxes.json +8 -8
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +28 -16
- package/registry/modules/premium.json +3 -3
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +10 -10
- package/registry/modules/rewards.json +3 -3
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +3 -3
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/voxelcraft.json +13 -13
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/leaderboard-system.json +2 -2
- package/registry/skills/multiplayer-realtime.json +1 -1
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"name": "iDos Games",
|
|
35
35
|
"url": "https://idosgames.com"
|
|
36
36
|
},
|
|
37
|
-
"version": "0.2.
|
|
37
|
+
"version": "0.2.1",
|
|
38
38
|
"events": {
|
|
39
39
|
"listens": [
|
|
40
40
|
{
|
|
@@ -45,17 +45,17 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@idosgames/core": "0.
|
|
49
|
-
"@idosgames/module-sdk": "0.
|
|
50
|
-
"@idosgames/react": "0.5.
|
|
51
|
-
"@idosgames/wallet": "0.6.
|
|
48
|
+
"@idosgames/core": "0.22.0",
|
|
49
|
+
"@idosgames/module-sdk": "0.12.0",
|
|
50
|
+
"@idosgames/react": "0.5.2",
|
|
51
|
+
"@idosgames/wallet": "0.6.5",
|
|
52
52
|
"@tanstack/react-query": "5.101.2",
|
|
53
53
|
"react": "19.2.7",
|
|
54
54
|
"three": "0.185.1",
|
|
55
55
|
"viem": "2.55.2",
|
|
56
56
|
"wagmi": "3.7.2"
|
|
57
57
|
},
|
|
58
|
-
"contentHash": "
|
|
58
|
+
"contentHash": "d7dc114191828dacafe9fe27ed7ebffdfeb46749827dfd3a5780e3c1e73a7065",
|
|
59
59
|
"files": [
|
|
60
60
|
{
|
|
61
61
|
"path": "chrome.ts",
|
|
@@ -63,35 +63,35 @@
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"path": "components/AttackPanel.tsx",
|
|
66
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { BoardPendingInteraction, ResourceGrant } from \"@idosgames/core\";\nimport { grantLines, useSound, useToast, useUiKit, type ResourceLine } from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { resolveStageBuildings } from \"../data/boardConfig\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport { AimArt, BuildingArt, FireArt, LightningArt, ShieldArt, StageBackdrop, SwordArt } from \"./modal/art\";\nimport {\n ResultBanner,\n Stage,\n TargetInfo,\n at,\n easeOutBack,\n sleep,\n tween,\n type StageHandle,\n type TargetInfoHandle,\n} from \"./modal/stage\";\n\n// The attack (the reference's AttackPanel + ElectricSwordAnimator, BoardSurvival BoardScene): the\n// board goes dark, the target's city comes up on its stage backdrop with the target's avatar on top\n// and a pulsing aim over every building. The player taps one; the electric sword swings, a bolt\n// strikes it — a shield pops and deflects it (BLOCKED!) or the building catches fire (HIT!) — and the\n// result banner rises with the loot counting up, which then flies to the counters.\n\n/** Slot centres and size in the reference canvas (AttackPanel/BuildingSlots, SlotIndex 0..4). */\nconst SLOTS: Array<[number, number]> = [\n [-255, -450],\n [255, -450],\n [-295, 200],\n [295, 200],\n [0, -66],\n];\nconst SLOT_SIZE = 450;\n\ntype Phase = \"choose\" | \"striking\" | \"result\";\ninterface Outcome {\n hit: boolean;\n slot: number;\n line: ResourceLine | null;\n amount: number;\n lines: ResourceLine[];\n}\n\nexport function AttackPanel({ pending, onDone }: { pending: BoardPendingInteraction; onDone: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const stage = useRef<StageHandle>(null);\n const target = useRef<TargetInfoHandle>(null);\n const sword = useRef<HTMLDivElement>(null);\n const bolt = useRef<HTMLDivElement>(null);\n const shield = useRef<HTMLDivElement>(null);\n const fire = useRef<HTMLDivElement>(null);\n const slotEls = useRef<Array<HTMLDivElement | null>>([]);\n const [phase, setPhase] = useState<Phase>(\"choose\");\n const [chosen, setChosen] = useState<number | null>(null);\n const [damaged, setDamaged] = useState<number | null>(null);\n const [outcome, setOutcome] = useState<Outcome | null>(null);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n // The target's buildings: the slots its stage has, at the levels the server sent.\n const stageLevel = board?.StageLevel ?? 1;\n const defs = resolveStageBuildings(client, stageLevel);\n const states = pending.TargetBuildingStates ?? [];\n const slots = (defs.length > 0 ? defs.map((d) => d.SlotIndex ?? 0) : states.map((s) => s.SlotIndex))\n .filter((i) => i >= 0 && i < SLOTS.length)\n .map((i) => {\n const s = states.find((x) => x.SlotIndex === i);\n return { index: i, level: s?.Level ?? 0, damaged: s?.IsDamaged ?? false };\n });\n\n const name = pending.TargetPublicData?.Username ?? (pending.TargetUserID ? \"Player\" : \"???\");\n\n const strike = async (slotIndex: number): Promise<void> => {\n if (phase !== \"choose\") return;\n setPhase(\"striking\");\n setChosen(slotIndex);\n play(\"click\");\n stage.current?.shake(6, 0.12, 26);\n\n const request = client.gameLoop.boardLoopAttack(slotIndex);\n\n // Sword: wind up, swing — the blow lands on the swing's last frame.\n const sw = sword.current;\n await tween(0.25, (t) => {\n if (sw) sw.style.transform = `rotate(${-28 * easeOutBack(t)}deg)`;\n });\n await tween(0.12, (t) => {\n if (sw) sw.style.transform = `rotate(${-28 + 62 * t}deg)`;\n });\n stage.current?.shake(14, 0.25, 24);\n\n const result = await request;\n if (!result.ok) {\n toast(result.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const data = result.data;\n const hit = data.Outcome === \"Hit\";\n const slot = (data.BuildingIndexHit ?? -1) >= 0 ? (data.BuildingIndexHit as number) : slotIndex;\n\n // Bolt from above onto the building, the impact zoom and the full-frame flash.\n const b = bolt.current;\n const pos = SLOTS[slot] ?? SLOTS[slotIndex] ?? [0, 0];\n if (b) {\n Object.assign(b.style, at(pos[0], pos[1] + 350, 300, 900));\n b.style.opacity = \"1\";\n }\n await sleep(hit ? 0.08 : 0.05);\n stage.current?.punchZoom(hit ? 0.12 : 0.05, hit ? 0.22 : 0.12, hit);\n stage.current?.impactFlash(hit ? \"rgba(255,255,255,.85)\" : \"rgba(153,191,255,.7)\", 0.12);\n void tween(0.25, (t) => {\n if (b) b.style.opacity = String(1 - t);\n });\n if (sw) void tween(0.3, (t) => (sw.style.transform = `rotate(${34 * (1 - t)}deg)`));\n\n if (hit) {\n play(\"purchase\");\n setDamaged(slot);\n const f = fire.current;\n if (f) Object.assign(f.style, at(pos[0], pos[1] + 30, 300, 300));\n const el = slotEls.current[slot];\n void tween(0.35, (t) => {\n if (el) el.style.translate = `${Math.sin(t * Math.PI * 9) * 14 * (1 - t)}px 0`;\n });\n await tween(0.26, (t) => {\n if (f) {\n f.style.opacity = String(t);\n f.style.transform = `scale(${easeOutBack(t)})`;\n }\n });\n void tween(0.85, (t) => {\n if (f) f.style.transform = `scale(${1 + Math.sin(t * Math.PI * 2 * 7 * 0.85) * 0.09})`;\n });\n } else {\n play(\"close\");\n const s = shield.current;\n if (s) Object.assign(s.style, at(pos[0], pos[1] + 40, 400, 400));\n await tween(0.3, (t) => {\n if (s) {\n s.style.opacity = \"1\";\n s.style.transform = `scale(${easeOutBack(t)})`;\n }\n });\n await sleep(0.55);\n await tween(0.2, (t) => {\n if (s) s.style.transform = `scale(${1 - t})`;\n });\n }\n\n // The loot: a bot target pays through Operation, a real player through the attacker's half of DualResult.\n const grant: ResourceGrant | null | undefined = data.IsBotTarget\n ? data.Operation?.Grant\n : (data.DualResult?.FromResult?.Grant ?? data.Operation?.Grant);\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n const first = lines[0] ?? null;\n kit.fx.reserve(lines);\n setOutcome({ hit, slot, line: first, amount: first?.amount ?? 0, lines });\n setPhase(\"result\");\n\n stage.current?.shake(hit ? 10 : 3, hit ? 0.2 : 0.1, 24);\n stage.current?.edgeFlash(hit ? \"rgba(255,217,64,.4)\" : \"rgba(102,140,255,.35)\", 0.35);\n target.current?.react(hit);\n play(hit ? \"reward\" : \"error\");\n await sleep(0.1 + 0.35 + 0.28 + (first ? 0.8 + 0.2 : 0));\n if (first) kit.fx.burst(document.querySelector(\"[data-board-reward-icon]\"));\n await sleep(2.0);\n\n // Out: the panel fades, the loot flies from the banner to the counters.\n const icon = document.querySelector(\"[data-board-reward-icon]\")?.getBoundingClientRect() ?? null;\n const fade = stage.current?.fadeOut();\n kit.fx.fly(lines, () => icon);\n await fade;\n onDone();\n };\n\n return (\n <Stage ref={stage} backdrop={<StageBackdrop />} dim=\"rgba(0,0,0,.6)\">\n <TargetInfo ref={target} name={name} avatarUrl={pending.TargetPublicData?.AvatarUrl} top={80} />\n {slots.map((s) => {\n const pos = SLOTS[s.index] ?? [0, 0];\n const isDamaged = s.damaged || damaged === s.index;\n const showAim = phase === \"choose\" || (phase === \"striking\" && chosen === s.index);\n return (\n <div\n key={s.index}\n ref={(el) => {\n slotEls.current[s.index] = el;\n }}\n onClick={() => void strike(s.index)}\n style={{\n ...at(pos[0], pos[1], SLOT_SIZE, SLOT_SIZE),\n cursor: phase === \"choose\" ? \"pointer\" : \"default\",\n }}\n >\n <BuildingArt slot={s.index} level={isDamaged ? 1 : s.level} damaged={isDamaged} />\n <div\n style={{\n position: \"absolute\",\n left: (SLOT_SIZE - 350) / 2,\n top: (SLOT_SIZE - 350) / 2,\n width: 350,\n height: 350,\n pointerEvents: \"none\",\n animation: showAim\n ? \"idos-board-aim .9s ease-in-out infinite\"\n : \"idos-board-aim-out .4s ease-in both\",\n }}\n >\n <AimArt />\n </div>\n </div>\n );\n })}\n <div ref={fire} style={{ ...at(0, 0, 300, 300), opacity: 0, pointerEvents: \"none\", transformOrigin: \"50% 80%\" }}>\n <FireArt />\n </div>\n <div ref={shield} style={{ ...at(0, 0, 400, 400), opacity: 0, pointerEvents: \"none\" }}>\n <ShieldArt />\n </div>\n <div ref={bolt} style={{ ...at(0, 0, 300, 900), opacity: 0, pointerEvents: \"none\" }}>\n <LightningArt />\n </div>\n {/* The electric sword stands at the bottom centre, pivot at its handle. */}\n <div\n ref={sword}\n style={{\n position: \"absolute\",\n left: 540 - 224 * 0.55,\n top: 1920 - 672 * 0.55 - 30,\n width: 448 * 0.55,\n height: 672 * 0.55,\n transformOrigin: \"50% 100%\",\n pointerEvents: \"none\",\n opacity: phase === \"result\" ? 0 : 1,\n transition: \"opacity .25s\",\n }}\n >\n <SwordArt />\n </div>\n {phase === \"result\" && outcome ? (\n <ResultBanner\n title={outcome.hit ? \"HIT!\" : \"BLOCKED!\"}\n line={outcome.line}\n amount={outcome.amount}\n tickSeconds={0.8}\n bottom={250}\n />\n ) : null}\n </Stage>\n );\n}\n"
|
|
66
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { BoardPendingInteraction, ResourceGrant } from \"@idosgames/core\";\nimport {\n grantLines,\n useSound,\n useToast,\n useUiKit,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { resolveStageBuildings } from \"../data/boardConfig\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport {\n AimArt,\n BuildingArt,\n FireArt,\n LightningArt,\n ShieldArt,\n StageBackdrop,\n SwordArt,\n} from \"./modal/art\";\nimport {\n ResultBanner,\n Stage,\n TargetInfo,\n at,\n easeOutBack,\n sleep,\n tween,\n type StageHandle,\n type TargetInfoHandle,\n} from \"./modal/stage\";\n\n// The attack (the reference's AttackPanel + ElectricSwordAnimator, BoardSurvival BoardScene): the\n// board goes dark, the target's city comes up on its stage backdrop with the target's avatar on top\n// and a pulsing aim over every building. The player taps one; the electric sword swings, a bolt\n// strikes it — a shield pops and deflects it (BLOCKED!) or the building catches fire (HIT!) — and the\n// result banner rises with the loot counting up, which then flies to the counters.\n\n/** Slot centres and size in the reference canvas (AttackPanel/BuildingSlots, SlotIndex 0..4). */\nconst SLOTS: Array<[number, number]> = [\n [-255, -450],\n [255, -450],\n [-295, 200],\n [295, 200],\n [0, -66],\n];\nconst SLOT_SIZE = 450;\n\ntype Phase = \"choose\" | \"striking\" | \"result\";\ninterface Outcome {\n hit: boolean;\n slot: number;\n line: ResourceLine | null;\n amount: number;\n lines: ResourceLine[];\n}\n\nexport function AttackPanel({\n pending,\n onDone,\n}: {\n pending: BoardPendingInteraction;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const stage = useRef<StageHandle>(null);\n const target = useRef<TargetInfoHandle>(null);\n const sword = useRef<HTMLDivElement>(null);\n const bolt = useRef<HTMLDivElement>(null);\n const shield = useRef<HTMLDivElement>(null);\n const fire = useRef<HTMLDivElement>(null);\n const slotEls = useRef<Array<HTMLDivElement | null>>([]);\n const [phase, setPhase] = useState<Phase>(\"choose\");\n const [chosen, setChosen] = useState<number | null>(null);\n const [damaged, setDamaged] = useState<number | null>(null);\n const [outcome, setOutcome] = useState<Outcome | null>(null);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n // The target's buildings: the slots its stage has, at the levels the server sent.\n const stageLevel = board?.StageLevel ?? 1;\n const defs = resolveStageBuildings(client, stageLevel);\n const states = pending.TargetBuildingStates ?? [];\n const slots = (\n defs.length > 0\n ? defs.map((d) => d.SlotIndex ?? 0)\n : states.map((s) => s.SlotIndex)\n )\n .filter((i) => i >= 0 && i < SLOTS.length)\n .map((i) => {\n const s = states.find((x) => x.SlotIndex === i);\n return { index: i, level: s?.Level ?? 0, damaged: s?.IsDamaged ?? false };\n });\n\n const name =\n pending.TargetPublicData?.Username ??\n (pending.TargetUserID ? \"Player\" : \"???\");\n\n const strike = async (slotIndex: number): Promise<void> => {\n if (phase !== \"choose\") return;\n setPhase(\"striking\");\n setChosen(slotIndex);\n play(\"click\");\n stage.current?.shake(6, 0.12, 26);\n\n const request = client.gameLoop.boardLoopAttack(slotIndex);\n\n // Sword: wind up, swing — the blow lands on the swing's last frame.\n const sw = sword.current;\n await tween(0.25, (t) => {\n if (sw) sw.style.transform = `rotate(${-28 * easeOutBack(t)}deg)`;\n });\n await tween(0.12, (t) => {\n if (sw) sw.style.transform = `rotate(${-28 + 62 * t}deg)`;\n });\n stage.current?.shake(14, 0.25, 24);\n\n const result = await request;\n if (!result.ok) {\n toast(result.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const data = result.data;\n const hit = data.Outcome === \"Hit\";\n const slot =\n (data.BuildingIndexHit ?? -1) >= 0\n ? (data.BuildingIndexHit as number)\n : slotIndex;\n\n // Bolt from above onto the building, the impact zoom and the full-frame flash.\n const b = bolt.current;\n const pos = SLOTS[slot] ?? SLOTS[slotIndex] ?? [0, 0];\n if (b) {\n Object.assign(b.style, at(pos[0], pos[1] + 350, 300, 900));\n b.style.opacity = \"1\";\n }\n await sleep(hit ? 0.08 : 0.05);\n stage.current?.punchZoom(hit ? 0.12 : 0.05, hit ? 0.22 : 0.12, hit);\n stage.current?.impactFlash(\n hit ? \"rgba(255,255,255,.85)\" : \"rgba(153,191,255,.7)\",\n 0.12,\n );\n void tween(0.25, (t) => {\n if (b) b.style.opacity = String(1 - t);\n });\n if (sw)\n void tween(\n 0.3,\n (t) => (sw.style.transform = `rotate(${34 * (1 - t)}deg)`),\n );\n\n if (hit) {\n play(\"purchase\");\n setDamaged(slot);\n const f = fire.current;\n if (f) Object.assign(f.style, at(pos[0], pos[1] + 30, 300, 300));\n const el = slotEls.current[slot];\n void tween(0.35, (t) => {\n if (el)\n el.style.translate = `${Math.sin(t * Math.PI * 9) * 14 * (1 - t)}px 0`;\n });\n await tween(0.26, (t) => {\n if (f) {\n f.style.opacity = String(t);\n f.style.transform = `scale(${easeOutBack(t)})`;\n }\n });\n void tween(0.85, (t) => {\n if (f)\n f.style.transform = `scale(${1 + Math.sin(t * Math.PI * 2 * 7 * 0.85) * 0.09})`;\n });\n } else {\n play(\"close\");\n const s = shield.current;\n if (s) Object.assign(s.style, at(pos[0], pos[1] + 40, 400, 400));\n await tween(0.3, (t) => {\n if (s) {\n s.style.opacity = \"1\";\n s.style.transform = `scale(${easeOutBack(t)})`;\n }\n });\n await sleep(0.55);\n await tween(0.2, (t) => {\n if (s) s.style.transform = `scale(${1 - t})`;\n });\n }\n\n // The loot: a bot target pays through Operation, a real player through the attacker's half of DualResult.\n const grant: ResourceGrant | null | undefined = data.IsBotTarget\n ? data.Operation?.Grant\n : (data.DualResult?.FromResult?.Grant ?? data.Operation?.Grant);\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n const first = lines[0] ?? null;\n kit.fx.reserve(lines);\n setOutcome({ hit, slot, line: first, amount: first?.amount ?? 0, lines });\n setPhase(\"result\");\n\n stage.current?.shake(hit ? 10 : 3, hit ? 0.2 : 0.1, 24);\n stage.current?.edgeFlash(\n hit ? \"rgba(255,217,64,.4)\" : \"rgba(102,140,255,.35)\",\n 0.35,\n );\n target.current?.react(hit);\n play(hit ? \"reward\" : \"error\");\n await sleep(0.1 + 0.35 + 0.28 + (first ? 0.8 + 0.2 : 0));\n if (first) kit.fx.burst(document.querySelector(\"[data-board-reward-icon]\"));\n await sleep(2.0);\n\n // Out: the panel fades, the loot flies from the banner to the counters.\n const icon =\n document\n .querySelector(\"[data-board-reward-icon]\")\n ?.getBoundingClientRect() ?? null;\n const fade = stage.current?.fadeOut();\n kit.fx.fly(lines, () => icon);\n await fade;\n onDone();\n };\n\n return (\n <Stage ref={stage} backdrop={<StageBackdrop />} dim=\"rgba(0,0,0,.6)\">\n <TargetInfo\n ref={target}\n name={name}\n avatarUrl={pending.TargetPublicData?.AvatarUrl}\n top={80}\n />\n {slots.map((s) => {\n const pos = SLOTS[s.index] ?? [0, 0];\n const isDamaged = s.damaged || damaged === s.index;\n const showAim =\n phase === \"choose\" || (phase === \"striking\" && chosen === s.index);\n return (\n <div\n key={s.index}\n ref={(el) => {\n slotEls.current[s.index] = el;\n }}\n onClick={() => void strike(s.index)}\n style={{\n ...at(pos[0], pos[1], SLOT_SIZE, SLOT_SIZE),\n cursor: phase === \"choose\" ? \"pointer\" : \"default\",\n }}\n >\n <BuildingArt\n slot={s.index}\n level={isDamaged ? 1 : s.level}\n damaged={isDamaged}\n />\n <div\n style={{\n position: \"absolute\",\n left: (SLOT_SIZE - 350) / 2,\n top: (SLOT_SIZE - 350) / 2,\n width: 350,\n height: 350,\n pointerEvents: \"none\",\n animation: showAim\n ? \"idos-board-aim .9s ease-in-out infinite\"\n : \"idos-board-aim-out .4s ease-in both\",\n }}\n >\n <AimArt />\n </div>\n </div>\n );\n })}\n <div\n ref={fire}\n style={{\n ...at(0, 0, 300, 300),\n opacity: 0,\n pointerEvents: \"none\",\n transformOrigin: \"50% 80%\",\n }}\n >\n <FireArt />\n </div>\n <div\n ref={shield}\n style={{ ...at(0, 0, 400, 400), opacity: 0, pointerEvents: \"none\" }}\n >\n <ShieldArt />\n </div>\n <div\n ref={bolt}\n style={{ ...at(0, 0, 300, 900), opacity: 0, pointerEvents: \"none\" }}\n >\n <LightningArt />\n </div>\n {/* The electric sword stands at the bottom centre, pivot at its handle. */}\n <div\n ref={sword}\n style={{\n position: \"absolute\",\n left: 540 - 224 * 0.55,\n top: 1920 - 672 * 0.55 - 30,\n width: 448 * 0.55,\n height: 672 * 0.55,\n transformOrigin: \"50% 100%\",\n pointerEvents: \"none\",\n opacity: phase === \"result\" ? 0 : 1,\n transition: \"opacity .25s\",\n }}\n >\n <SwordArt />\n </div>\n {phase === \"result\" && outcome ? (\n <ResultBanner\n title={outcome.hit ? \"HIT!\" : \"BLOCKED!\"}\n line={outcome.line}\n amount={outcome.amount}\n tickSeconds={0.8}\n bottom={250}\n />\n ) : null}\n </Stage>\n );\n}\n"
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"path": "components/boardCss.ts",
|
|
70
|
-
"content": "// Keyframes of the board's own HUD (the roll button, reward pop-ups). Drawn once by BoardRoot in a\
|
|
70
|
+
"content": "// Keyframes of the board's own HUD (the roll button, reward pop-ups). Drawn once by BoardRoot in a\n// <style> tag, so the board looks the same with or without the host's UI kit provider.\n\nexport const BOARD_CSS = `\n@keyframes idos-board-spin { to { transform: rotate(360deg); } }\n@keyframes idos-board-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.045); } }\n@keyframes idos-board-shake {\n 0%,100% { transform: translateX(0); } 20% { transform: translateX(-6px); }\n 40% { transform: translateX(5px); } 60% { transform: translateX(-4px); } 80% { transform: translateX(3px); }\n}\n@keyframes idos-board-float {\n 0% { opacity: 0; transform: translate(-50%, 10px) scale(.6); }\n 15% { opacity: 1; transform: translate(-50%, 0) scale(1.15); }\n 30% { transform: translate(-50%, -6px) scale(1); }\n 100% { opacity: 0; transform: translate(-50%, -90px) scale(1); }\n}\n@keyframes idos-board-flash { 0% { color: #ffd23a; transform: scale(1.35); } 100% { transform: scale(1); } }\n@keyframes idos-board-rise { from { opacity: 0; transform: translateY(16px) scale(.96); } to { opacity: 1; transform: none; } }\n.idos-board-panel { animation: idos-board-rise .28s cubic-bezier(.2,.9,.3,1.2) both; }\n/* Full-screen panels (AttackPanel aim: pulse 0.12 over 0.9 s, punch-and-spin out 0.4 s). */\n@keyframes idos-board-aim { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } }\n@keyframes idos-board-aim-out {\n 0% { transform: scale(1) rotate(0); opacity: 1; }\n 30% { transform: scale(1.3) rotate(40deg); opacity: 1; }\n 100% { transform: scale(0) rotate(220deg); opacity: 0; }\n}\n/* Raid near-miss heartbeat. */\n@keyframes idos-board-heartbeat { 0%,100% { transform: scale(1); } 15% { transform: scale(1.12); } 30% { transform: scale(1); } 45% { transform: scale(1.08); } }\n@keyframes idos-board-card-in { from { opacity: 0; transform: scale(.3); } to { opacity: 1; transform: scale(1); } }\n`;\n"
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
"path": "components/BoardHud.tsx",
|
|
74
|
-
"content": "import {\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { ResourceCounter } from \"@idosgames/react/ui\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useUserState, useBoardState } from \"../react/hooks\";\nimport {\n readBoardConfig,\n resolveStageName,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport type { NewsStore } from \"../chrome\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap:
|
|
74
|
+
"content": "import {\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { ResourceCounter, U, uText } from \"@idosgames/react/ui\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useUserState, useBoardState } from \"../react/hooks\";\nimport {\n readBoardConfig,\n resolveStageName,\n resolveTiles,\n stageProgress,\n} from \"../data/boardConfig\";\nimport type { NewsStore } from \"../chrome\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst ribbon: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n height: 32,\n padding: \"0 11px\",\n borderRadius: 16,\n border: `3px solid ${U.ink}`,\n background: \"linear-gradient(180deg, #b46cff 0%, #8a3cf0 100%)\",\n boxShadow: \"inset 0 3px 0 rgba(255,255,255,.35), 0 3px 0 #5a1fb0\",\n ...uText(13),\n};\n// BorderFrame_Oval: a dark pill with a faint inner line, outlined game text.\nconst chip: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n height: 28,\n padding: \"0 9px\",\n borderRadius: 14,\n background: \"#1f1f29\",\n border: \"2px solid rgba(255,255,255,.22)\",\n boxShadow: `0 0 0 2px ${U.ink}`,\n ...uText(12),\n};\nconst newsChip: CSSProperties = {\n ...chip,\n background: \"linear-gradient(180deg, #4f8cff, #2a5fd6)\",\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n};\n\nconst noSubscribe = (): (() => void) => () => {};\nconst noNews = (): string | null => null;\n\nexport function BoardHud({\n showBalances = true,\n news,\n}: {\n /** Coin and shield counters — hidden when a shared-UI provider (the base's lobby) draws balances. */\n showBalances?: boolean;\n /** News from other modules; click the chip to dismiss it. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const board = useBoardState();\n const cfg = readBoardConfig(client);\n const headline = useSyncExternalStore(\n news?.subscribe ?? noSubscribe,\n news?.get ?? noNews,\n );\n\n const currencies: Record<string, UserVirtualCurrencyState> =\n state?.InventoryV2?.VirtualCurrencies ?? {};\n const amount = (id?: string): number =>\n id ? (currencies[id]?.Amount ?? 0) : 0;\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n const stageName = resolveStageName(cfg, stage);\n\n const progress = stageProgress(client, stage, board?.BuildingStates ?? []);\n\n // The rolls are drawn by the roll button itself (they are what it spends); coins and shields here,\n // unless the lobby draws the balances.\n return (\n <div style={{ display: \"grid\", gap: 8, pointerEvents: \"auto\" }}>\n <StageBar\n stage={stage}\n fraction={progress.max > 0 ? progress.built / progress.max : 0}\n stageCount={progress.stageCount}\n />\n <div style={row}>\n <span style={ribbon}>\n ★ Board {stage}\n {stageName ? ` · ${stageName}` : \"\"}\n </span>\n {showBalances && cfg.SoftCurrencyID ? (\n <ResourceCounter\n currencyID={cfg.SoftCurrencyID}\n amount={amount(cfg.SoftCurrencyID)}\n size={22}\n />\n ) : null}\n {showBalances && cfg.ShieldCurrencyID ? (\n <ResourceCounter\n currencyID={cfg.ShieldCurrencyID}\n amount={amount(cfg.ShieldCurrencyID)}\n size={22}\n />\n ) : null}\n <span style={chip}>\n Tile {board?.Position ?? 0}/{tileCount > 0 ? tileCount - 1 : 0}\n </span>\n <span style={chip}>Laps {board?.CyclesCompleted ?? 0}</span>\n {headline ? (\n <button type=\"button\" style={newsChip} onClick={() => news?.clear()}>\n {headline} ✕\n </button>\n ) : null}\n </div>\n </div>\n );\n}\n\nconst STAGE_TONES = {\n passed: [\"#14adfa\", \"#3dffff\"],\n current: [\"#ffd600\", \"#ffff7d\"],\n future: [\"#474a66\", \"#a1a3c7\"],\n} as const;\n\n/**\n * Slider_StageHorizontal_l (Unity StageProgressView): numbered stage icons on a dark track — passed\n * stages blue, the current one gold, the next ones slate — with the cyan fill reaching the current\n * stage plus how much of it is built. A window of five stages around the current one.\n */\nfunction StageBar({\n stage,\n fraction,\n stageCount,\n}: {\n stage: number;\n fraction: number;\n stageCount: number | null;\n}): ReactNode {\n const last = stageCount ?? Number.POSITIVE_INFINITY;\n const first = Math.max(1, Math.min(stage - 1, last - 4));\n const stages = Array.from({ length: 5 }, (_, i) => first + i).filter(\n (n) => n <= last,\n );\n const span = Math.max(1, stages.length - 1);\n const at = stages.indexOf(stage);\n const fill =\n at < 0 ? 0 : Math.min(1, (at + Math.max(0, Math.min(1, fraction))) / span);\n return (\n <div\n style={{\n position: \"relative\",\n height: 40,\n margin: \"0 6px\",\n maxWidth: 420,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 18,\n right: 18,\n top: 13,\n height: 14,\n borderRadius: 7,\n background: \"#232639\",\n border: `2px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${fill * 100}%`,\n height: \"100%\",\n background: \"linear-gradient(180deg, #9cffff 0%, #3dffff 100%)\",\n transition: \"width .6s ease\",\n }}\n />\n </div>\n {stages.map((n, i) => {\n const tone = n < stage ? \"passed\" : n === stage ? \"current\" : \"future\";\n const [outer, inner] = STAGE_TONES[tone];\n const size = n === stage ? 40 : 34;\n return (\n <span\n key={n}\n style={{\n position: \"absolute\",\n left: `calc(18px + (100% - 36px) * ${i / span} - ${size / 2}px)`,\n top: (40 - size) / 2,\n width: size,\n height: size,\n borderRadius: \"50%\",\n background: outer,\n border: `3px solid ${U.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n animation:\n n === stage\n ? \"idos-board-heartbeat 1.6s ease-in-out infinite\"\n : undefined,\n }}\n >\n <span\n style={{\n position: \"absolute\",\n inset: size * 0.2,\n borderRadius: \"50%\",\n background: inner,\n opacity: 0.55,\n }}\n />\n <span\n style={{ position: \"relative\", ...uText(n === stage ? 17 : 14) }}\n >\n {n}\n </span>\n </span>\n );\n })}\n </div>\n );\n}\n"
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"path": "components/BoardRoot.tsx",
|
|
78
|
-
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\
|
|
78
|
+
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"../chrome\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\nimport { BOARD_CSS } from \"./boardCss\";\nimport { panelCard, panelText, panelTitle } from \"./panelStyles\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: \"16px 16px 20px\",\n fontFamily: \"var(--idos-ui-font, system-ui, sans-serif)\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"linear-gradient(180deg, #ffd85a 0%, #f0a020 100%)\",\n color: \"#5a2d00\",\n border: \"2px solid #fff\",\n borderRadius: 16,\n boxShadow: \"0 3px 0 #b06a08\",\n padding: \"6px 14px\",\n fontWeight: 900,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot({\n chrome = FULL_CHROME,\n news,\n}: {\n /** Which shared chrome to draw here (the rest is drawn by a shared-UI provider such as the base). */\n chrome?: BoardChrome;\n /** News from other modules, shown as a chip in the board HUD. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [configured, setConfigured] = useState(true);\n const [walletOpen, setWalletOpen] = useState(false);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.gameLoop.getBoardDefinition();\n await client.gameLoop.getUserBoardState();\n if (!active) return;\n const cfg = readBoardConfig(client);\n const board = client.data.user.state?.GameLoop?.Board;\n const stage = board?.StageLevel ?? 1;\n setConfigured(resolveTiles(cfg, stage).length > 0);\n controller.loadBoard(\n resolveTiles(cfg, stage),\n resolveBuildingCount(cfg, stage),\n );\n controller.setPosition(board?.Position ?? 0);\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n setReady(true);\n })();\n return () => {\n active = false;\n };\n }, [client, controller]);\n\n // Keep city pad heights in sync with building levels as the cache updates.\n useEffect(() => {\n const off = client.on(\"user:anyUpdated\", () => {\n const board = client.data.user.state?.GameLoop?.Board;\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n });\n return off;\n }, [client, controller]);\n\n // No StatusProvider of our own: the host wraps every module in ONE root provider, and a nested one\n // would swallow the board's messages — a shared status line would never see them.\n return (\n <div style={overlay}>\n <style>{BOARD_CSS}</style>\n {chrome.wallet && (\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n )}\n {chrome.wallet && walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <div style={chrome.lobbyButton ? { paddingLeft: 52 } : undefined}>\n <BoardHud showBalances={chrome.balances} news={news} />\n </div>\n <div style={footer}>\n {ready && !configured ? (\n <div\n className=\"idos-board-panel\"\n style={{ ...panelCard, textAlign: \"center\" }}\n >\n <div style={{ ...panelTitle, justifyContent: \"center\" }}>\n The board is not set up yet\n </div>\n <div style={panelText}>\n This title has no board settings (GameLoop → Board). Add them in\n the dashboard and reload — the tiles, dice and city appear here.\n </div>\n </div>\n ) : ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n {chrome.status && <StatusBar />}\n </div>\n </div>\n );\n}\n"
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
81
|
"path": "components/BuildPanel.tsx",
|
|
82
|
-
"content": "import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { Icon, formatAmount, useCatalog, useSound, useToast, useUiKit } from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState, useUserState } from \"../react/hooks\";\nimport { useRunAction } from \"../react/useRunAction\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport { buildCost, resolveStageBuildings } from \"../data/boardConfig\";\nimport { BuildingArt, FireArt, StageBackdrop } from \"./modal/art\";\nimport { Stage, at, easeOutBack, outlinedText, tween, type StageHandle } from \"./modal/stage\";\n\n// The city (the reference's Building_Window, BoardSurvival BoardScene): the stage backdrop with the\n// five buildings standing where they stand on the stage, each at its level, and a row of cards along\n// the bottom — the next level's picture, the price, a star per level. Tapping a card builds; a hammer\n// beat squashes and pops the building on the stage. The board HUD keeps a \"City\" button that opens it.\n\n/** Building positions on the stage (Building_Window/Buildings, SlotIndex 0..4), canvas units. */\nconst STAGE_SLOTS: Array<[number, number]> = [\n [-320, -360],\n [320, -360],\n [-320, 200],\n [320, 200],\n [0, 0],\n];\n/** Cards: 260 × 350, centres 263 apart along the bottom, the row fitted to the width (FitRowToWidth). */\nconst CARD_W = 260;\nconst CARD_H = 350;\nconst CARD_STEP = 263;\n\nconst launcher: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n height: 44,\n padding: \"0 16px\",\n borderRadius: 22,\n border: \"3px solid #fff\",\n background: \"linear-gradient(180deg, #7ad35a 0%, #3e9e35 100%)\",\n boxShadow: \"0 4px 0 #2a6e22, 0 8px 14px rgba(0,0,0,.3)\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 17,\n textShadow: \"0 2px 0 rgba(0,0,0,.3)\",\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n};\n\nexport function BuildPanel(): ReactNode {\n const board = useBoardState();\n const [open, setOpen] = useState(false);\n if ((board?.BuildingStates ?? []).length === 0) return null;\n return (\n <>\n <button type=\"button\" style={launcher} onClick={() => setOpen(true)}>\n <HouseGlyph /> City\n </button>\n {open ? <CityWindow onClose={() => setOpen(false)} /> : null}\n </>\n );\n}\n\nfunction CityWindow({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const { busy, run } = useRunAction();\n const stage = useRef<StageHandle>(null);\n const buildingEls = useRef<Array<HTMLDivElement | null>>([]);\n const [upgrading, setUpgrading] = useState<number | null>(null);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n const stageLevel = board?.StageLevel ?? 1;\n const defs = resolveStageBuildings(client, stageLevel);\n const buildings = board?.BuildingStates ?? [];\n const balance = (id: string): number => state?.InventoryV2?.VirtualCurrencies?.[id]?.Amount ?? 0;\n\n const close = async (): Promise<void> => {\n play(\"close\");\n await stage.current?.fadeOut(0.25);\n onClose();\n };\n\n const build = async (slot: number): Promise<void> => {\n if (busy || upgrading !== null) return;\n setUpgrading(slot);\n play(\"click\");\n let ok = false;\n await run(`Build #${slot}`, async () => {\n const res = await client.gameLoop.boardLoopBuild(slot);\n ok = res.ok;\n if (!res.ok) toast(res.error, \"error\");\n return res;\n });\n if (ok) {\n // The hammer beat (StageBuilding.PlayUpgradeEffect): squash, pop, settle, sparks.\n const el = buildingEls.current[slot];\n play(\"purchase\");\n kit.fx.burst(el);\n await tween(0.1, (t) => {\n if (el) el.style.transform = `scale(${1 - 0.12 * t})`;\n });\n await tween(0.16, (t) => {\n if (el) el.style.transform = `scale(${0.88 + 0.24 * easeOutBack(t)})`;\n });\n await tween(0.12, (t) => {\n if (el) el.style.transform = `scale(${1.12 - 0.12 * t})`;\n });\n stage.current?.shake(5, 0.15, 24);\n }\n setUpgrading(null);\n };\n\n const rowWidth = CARD_STEP * 4 + CARD_W;\n const rowScale = Math.min(1, 1040 / rowWidth);\n\n return (\n <Stage ref={stage} backdrop={<StageBackdrop />} dim=\"rgba(0,0,0,.5)\">\n {/* The stage: the buildings at their levels. */}\n {buildings.map((b) => {\n const pos = STAGE_SLOTS[b.SlotIndex];\n if (!pos) return null;\n const level = b.Level ?? 0;\n return (\n <div\n key={b.SlotIndex}\n ref={(el) => {\n buildingEls.current[b.SlotIndex] = el;\n }}\n style={{ ...at(pos[0], pos[1] + 137, 450, 450) }}\n >\n <BuildingArt slot={b.SlotIndex} level={b.IsDamaged ? 1 : level} damaged={!!b.IsDamaged} />\n {b.IsDamaged ? (\n <div\n style={{\n position: \"absolute\",\n left: 140,\n top: 60,\n width: 170,\n height: 200,\n animation: \"idos-board-breathe .5s ease-in-out infinite\",\n }}\n >\n <FireArt />\n </div>\n ) : null}\n </div>\n );\n })}\n\n {/* Title plate + close */}\n <div style={{ ...at(0, 860, 560, 110), display: \"grid\", placeItems: \"center\" }}>\n <div\n style={{\n padding: \"10px 46px\",\n borderRadius: 26,\n background: \"linear-gradient(180deg,#5cc8ff,#1c8fe6)\",\n border: \"7px solid #10172e\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.4), 0 8px 0 #0d5aa8\",\n ...outlinedText(56),\n }}\n >\n CITY\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => void close()}\n aria-label=\"Close\"\n style={{\n ...at(440, 860, 110, 110),\n borderRadius: 30,\n border: \"7px solid #10172e\",\n background: \"linear-gradient(180deg,#ff7a6b,#e83b2e)\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.4), 0 8px 0 #8f1a12\",\n cursor: \"pointer\",\n ...outlinedText(60),\n lineHeight: 1,\n fontFamily: \"inherit\",\n }}\n >\n ✕\n </button>\n\n {/* The row of cards */}\n <div\n style={{\n position: \"absolute\",\n left: 540 - (rowWidth * rowScale) / 2,\n bottom: 180,\n width: rowWidth,\n height: CARD_H,\n transform: `scale(${rowScale})`,\n transformOrigin: \"0 100%\",\n }}\n >\n {buildings.map((b, i) => {\n const def = defs.find((d) => d.SlotIndex === b.SlotIndex);\n const level = b.Level ?? 0;\n const max = def?.MaxLevel ?? 6;\n const maxed = max > 0 && level >= max;\n const price =\n b.IsDamaged || maxed ? { amount: 0, currencyID: undefined } : buildCost(client, stageLevel, b.SlotIndex, level);\n const short =\n price.amount > 0 && price.currencyID !== undefined && balance(price.currencyID) < price.amount;\n const claimed = !!b.MaxLevelRewardClaimed;\n const disabled = busy || upgrading !== null || (maxed && claimed) || short;\n const preview = b.IsDamaged ? Math.max(1, level) : Math.min(max, level + 1);\n return (\n <div\n key={b.SlotIndex}\n onClick={() => {\n if (!disabled) void build(b.SlotIndex);\n }}\n style={{\n position: \"absolute\",\n left: i * CARD_STEP,\n bottom: 0,\n width: CARD_W,\n height: CARD_H,\n cursor: disabled ? \"default\" : \"pointer\",\n animation: `idos-board-card-in .35s cubic-bezier(.34,1.56,.64,1) ${0.05 * i}s both`,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 30,\n border: \"7px solid #10172e\",\n background: \"linear-gradient(180deg,#e9f4ff 0%,#bcd9f7 70%,#8fbbe9 100%)\",\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.7), 0 10px 0 rgba(0,0,0,.35)\",\n overflow: \"hidden\",\n filter: maxed && claimed ? \"grayscale(.6)\" : undefined,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n height: 92,\n background: \"linear-gradient(180deg,#3f7fd8,#2a5fb8)\",\n borderTop: \"5px solid #10172e\",\n }}\n />\n <div style={{ position: \"absolute\", left: 10, top: 40, width: 240, height: 240 }}>\n <BuildingArt slot={b.SlotIndex} level={preview} />\n </div>\n {b.IsDamaged ? (\n <div style={{ position: \"absolute\", left: 68, top: 170, width: 123, height: 110 }}>\n <FireArt />\n </div>\n ) : null}\n </div>\n {/* Price (or Claim / Done at the top level) */}\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n top: 6,\n height: 60,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n }}\n >\n {maxed ? (\n <span style={outlinedText(46, claimed ? \"#cfd8e6\" : \"#ffe066\")}>{claimed ? \"Done\" : \"Claim\"}</span>\n ) : price.amount > 0 && price.currencyID ? (\n <>\n <Icon glyph={catalog.iconOf({ kind: \"currency\", id: price.currencyID, amount: 0 })} size={56} />\n <span style={outlinedText(48, short ? \"#ff7b7b\" : \"#fff\")}>{formatAmount(price.amount)}</span>\n </>\n ) : (\n <span style={outlinedText(44)}>{b.IsDamaged ? \"Repair\" : \"Build\"}</span>\n )}\n </div>\n {/* A star per level: outlines for every level, filled up to the current one. */}\n <div\n style={{\n position: \"absolute\",\n left: 10,\n right: 10,\n bottom: 22,\n height: 48,\n display: \"flex\",\n justifyContent: \"center\",\n gap: 2,\n }}\n >\n {Array.from({ length: Math.min(6, max) }, (_, k) => (\n <Star key={k} filled={k < level} gold={k === max - 1 && k < level} />\n ))}\n </div>\n </div>\n );\n })}\n </div>\n </Stage>\n );\n}\n\nfunction Star({ filled, gold }: { filled: boolean; gold: boolean }): ReactNode {\n const fill = !filled ? \"rgba(16,23,46,.45)\" : gold ? \"#ffd23a\" : \"#e6ecf5\";\n return (\n <svg viewBox=\"0 0 42 45\" width={38} height={41} aria-hidden>\n <path\n d=\"M21 3 L26.5 15.5 L40 16.8 L29.8 25.8 L32.8 39 L21 32 L9.2 39 L12.2 25.8 L2 16.8 L15.5 15.5 Z\"\n fill={fill}\n stroke=\"#10172e\"\n strokeWidth=\"3.5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n );\n}\n\nfunction HouseGlyph(): ReactNode {\n return (\n <svg width={22} height={22} viewBox=\"0 0 24 24\" aria-hidden>\n <path d=\"M3 11L12 3l9 8\" fill=\"#e8534e\" stroke=\"#fff\" strokeWidth=\"2\" strokeLinejoin=\"round\" />\n <rect x=\"6\" y=\"10\" width=\"12\" height=\"11\" rx=\"1.5\" fill=\"#fff4dc\" stroke=\"#fff\" strokeWidth=\"1.5\" />\n <rect x=\"10.5\" y=\"14\" width=\"3\" height=\"7\" fill=\"#8a5a2b\" />\n </svg>\n );\n}\n"
|
|
82
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n Icon,\n formatAmount,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState, useUserState } from \"../react/hooks\";\nimport { useRunAction } from \"../react/useRunAction\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport { buildCost, resolveStageBuildings } from \"../data/boardConfig\";\nimport { BuildingArt, FireArt, StageBackdrop } from \"./modal/art\";\nimport {\n Stage,\n at,\n easeOutBack,\n outlinedText,\n tween,\n type StageHandle,\n} from \"./modal/stage\";\n\n// The city (the reference's Building_Window, BoardSurvival BoardScene): the stage backdrop with the\n// five buildings standing where they stand on the stage, each at its level, and a row of cards along\n// the bottom — the next level's picture, the price, a star per level. Tapping a card builds; a hammer\n// beat squashes and pops the building on the stage. The board HUD keeps a \"City\" button that opens it.\n\n/** Building positions on the stage (Building_Window/Buildings, SlotIndex 0..4), canvas units. */\nconst STAGE_SLOTS: Array<[number, number]> = [\n [-320, -360],\n [320, -360],\n [-320, 200],\n [320, 200],\n [0, 0],\n];\n/** Cards: 260 × 350, centres 263 apart along the bottom, the row fitted to the width (FitRowToWidth). */\nconst CARD_W = 260;\nconst CARD_H = 350;\nconst CARD_STEP = 263;\n\nconst launcher: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n height: 44,\n padding: \"0 16px\",\n borderRadius: 22,\n border: \"3px solid #fff\",\n background: \"linear-gradient(180deg, #7ad35a 0%, #3e9e35 100%)\",\n boxShadow: \"0 4px 0 #2a6e22, 0 8px 14px rgba(0,0,0,.3)\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 17,\n textShadow: \"0 2px 0 rgba(0,0,0,.3)\",\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n};\n\nexport function BuildPanel(): ReactNode {\n const board = useBoardState();\n const [open, setOpen] = useState(false);\n if ((board?.BuildingStates ?? []).length === 0) return null;\n return (\n <>\n <button type=\"button\" style={launcher} onClick={() => setOpen(true)}>\n <HouseGlyph /> City\n </button>\n {open ? <CityWindow onClose={() => setOpen(false)} /> : null}\n </>\n );\n}\n\nfunction CityWindow({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const { busy, run } = useRunAction();\n const stage = useRef<StageHandle>(null);\n const buildingEls = useRef<Array<HTMLDivElement | null>>([]);\n const [upgrading, setUpgrading] = useState<number | null>(null);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n const stageLevel = board?.StageLevel ?? 1;\n const defs = resolveStageBuildings(client, stageLevel);\n const buildings = board?.BuildingStates ?? [];\n const balance = (id: string): number =>\n state?.InventoryV2?.VirtualCurrencies?.[id]?.Amount ?? 0;\n\n const close = async (): Promise<void> => {\n play(\"close\");\n await stage.current?.fadeOut(0.25);\n onClose();\n };\n\n const build = async (slot: number): Promise<void> => {\n if (busy || upgrading !== null) return;\n setUpgrading(slot);\n play(\"click\");\n let ok = false;\n await run(`Build #${slot}`, async () => {\n const res = await client.gameLoop.boardLoopBuild(slot);\n ok = res.ok;\n if (!res.ok) toast(res.error, \"error\");\n return res;\n });\n if (ok) {\n // The hammer beat (StageBuilding.PlayUpgradeEffect): squash, pop, settle, sparks.\n const el = buildingEls.current[slot];\n play(\"purchase\");\n kit.fx.burst(el);\n await tween(0.1, (t) => {\n if (el) el.style.transform = `scale(${1 - 0.12 * t})`;\n });\n await tween(0.16, (t) => {\n if (el) el.style.transform = `scale(${0.88 + 0.24 * easeOutBack(t)})`;\n });\n await tween(0.12, (t) => {\n if (el) el.style.transform = `scale(${1.12 - 0.12 * t})`;\n });\n stage.current?.shake(5, 0.15, 24);\n }\n setUpgrading(null);\n };\n\n const rowWidth = CARD_STEP * 4 + CARD_W;\n const rowScale = Math.min(1, 1040 / rowWidth);\n\n return (\n <Stage ref={stage} backdrop={<StageBackdrop />} dim=\"rgba(0,0,0,.5)\">\n {/* The stage: the buildings at their levels. */}\n {buildings.map((b) => {\n const pos = STAGE_SLOTS[b.SlotIndex];\n if (!pos) return null;\n const level = b.Level ?? 0;\n return (\n <div\n key={b.SlotIndex}\n ref={(el) => {\n buildingEls.current[b.SlotIndex] = el;\n }}\n style={{ ...at(pos[0], pos[1] + 137, 450, 450) }}\n >\n <BuildingArt\n slot={b.SlotIndex}\n level={b.IsDamaged ? 1 : level}\n damaged={!!b.IsDamaged}\n />\n {b.IsDamaged ? (\n <div\n style={{\n position: \"absolute\",\n left: 140,\n top: 60,\n width: 170,\n height: 200,\n animation: \"idos-board-breathe .5s ease-in-out infinite\",\n }}\n >\n <FireArt />\n </div>\n ) : null}\n </div>\n );\n })}\n\n {/* Title plate + close */}\n <div\n style={{\n ...at(0, 860, 560, 110),\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <div\n style={{\n padding: \"10px 46px\",\n borderRadius: 26,\n background: \"linear-gradient(180deg,#5cc8ff,#1c8fe6)\",\n border: \"7px solid #10172e\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.4), 0 8px 0 #0d5aa8\",\n ...outlinedText(56),\n }}\n >\n CITY\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => void close()}\n aria-label=\"Close\"\n style={{\n ...at(440, 860, 110, 110),\n borderRadius: 30,\n border: \"7px solid #10172e\",\n background: \"linear-gradient(180deg,#ff7a6b,#e83b2e)\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.4), 0 8px 0 #8f1a12\",\n cursor: \"pointer\",\n ...outlinedText(60),\n lineHeight: 1,\n fontFamily: \"inherit\",\n }}\n >\n ✕\n </button>\n\n {/* The row of cards */}\n <div\n style={{\n position: \"absolute\",\n left: 540 - (rowWidth * rowScale) / 2,\n bottom: 180,\n width: rowWidth,\n height: CARD_H,\n transform: `scale(${rowScale})`,\n transformOrigin: \"0 100%\",\n }}\n >\n {buildings.map((b, i) => {\n const def = defs.find((d) => d.SlotIndex === b.SlotIndex);\n const level = b.Level ?? 0;\n const max = def?.MaxLevel ?? 6;\n const maxed = max > 0 && level >= max;\n const price =\n b.IsDamaged || maxed\n ? { amount: 0, currencyID: undefined }\n : buildCost(client, stageLevel, b.SlotIndex, level);\n const short =\n price.amount > 0 &&\n price.currencyID !== undefined &&\n balance(price.currencyID) < price.amount;\n const claimed = !!b.MaxLevelRewardClaimed;\n const disabled =\n busy || upgrading !== null || (maxed && claimed) || short;\n const preview = b.IsDamaged\n ? Math.max(1, level)\n : Math.min(max, level + 1);\n return (\n <div\n key={b.SlotIndex}\n onClick={() => {\n if (!disabled) void build(b.SlotIndex);\n }}\n style={{\n position: \"absolute\",\n left: i * CARD_STEP,\n bottom: 0,\n width: CARD_W,\n height: CARD_H,\n cursor: disabled ? \"default\" : \"pointer\",\n animation: `idos-board-card-in .35s cubic-bezier(.34,1.56,.64,1) ${0.05 * i}s both`,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 30,\n border: \"7px solid #10172e\",\n background:\n \"linear-gradient(180deg,#e9f4ff 0%,#bcd9f7 70%,#8fbbe9 100%)\",\n boxShadow:\n \"inset 0 6px 0 rgba(255,255,255,.7), 0 10px 0 rgba(0,0,0,.35)\",\n overflow: \"hidden\",\n filter: maxed && claimed ? \"grayscale(.6)\" : undefined,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n height: 92,\n background: \"linear-gradient(180deg,#3f7fd8,#2a5fb8)\",\n borderTop: \"5px solid #10172e\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n left: 10,\n top: 40,\n width: 240,\n height: 240,\n }}\n >\n <BuildingArt slot={b.SlotIndex} level={preview} />\n </div>\n {b.IsDamaged ? (\n <div\n style={{\n position: \"absolute\",\n left: 68,\n top: 170,\n width: 123,\n height: 110,\n }}\n >\n <FireArt />\n </div>\n ) : null}\n </div>\n {/* Price (or Claim / Done at the top level) */}\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n top: 6,\n height: 60,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n }}\n >\n {maxed ? (\n <span\n style={outlinedText(46, claimed ? \"#cfd8e6\" : \"#ffe066\")}\n >\n {claimed ? \"Done\" : \"Claim\"}\n </span>\n ) : price.amount > 0 && price.currencyID ? (\n <>\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: price.currencyID,\n amount: 0,\n })}\n size={56}\n />\n <span style={outlinedText(48, short ? \"#ff7b7b\" : \"#fff\")}>\n {formatAmount(price.amount)}\n </span>\n </>\n ) : (\n <span style={outlinedText(44)}>\n {b.IsDamaged ? \"Repair\" : \"Build\"}\n </span>\n )}\n </div>\n {/* A star per level: outlines for every level, filled up to the current one. */}\n <div\n style={{\n position: \"absolute\",\n left: 10,\n right: 10,\n bottom: 22,\n height: 48,\n display: \"flex\",\n justifyContent: \"center\",\n gap: 2,\n }}\n >\n {Array.from({ length: Math.min(6, max) }, (_, k) => (\n <Star\n key={k}\n filled={k < level}\n gold={k === max - 1 && k < level}\n />\n ))}\n </div>\n </div>\n );\n })}\n </div>\n </Stage>\n );\n}\n\nfunction Star({ filled, gold }: { filled: boolean; gold: boolean }): ReactNode {\n const fill = !filled ? \"rgba(16,23,46,.45)\" : gold ? \"#ffd23a\" : \"#e6ecf5\";\n return (\n <svg viewBox=\"0 0 42 45\" width={38} height={41} aria-hidden>\n <path\n d=\"M21 3 L26.5 15.5 L40 16.8 L29.8 25.8 L32.8 39 L21 32 L9.2 39 L12.2 25.8 L2 16.8 L15.5 15.5 Z\"\n fill={fill}\n stroke=\"#10172e\"\n strokeWidth=\"3.5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n );\n}\n\nfunction HouseGlyph(): ReactNode {\n return (\n <svg width={22} height={22} viewBox=\"0 0 24 24\" aria-hidden>\n <path\n d=\"M3 11L12 3l9 8\"\n fill=\"#e8534e\"\n stroke=\"#fff\"\n strokeWidth=\"2\"\n strokeLinejoin=\"round\"\n />\n <rect\n x=\"6\"\n y=\"10\"\n width=\"12\"\n height=\"11\"\n rx=\"1.5\"\n fill=\"#fff4dc\"\n stroke=\"#fff\"\n strokeWidth=\"1.5\"\n />\n <rect x=\"10.5\" y=\"14\" width=\"3\" height=\"7\" fill=\"#8a5a2b\" />\n </svg>\n );\n}\n"
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
"path": "components/InteractionPanel.tsx",
|
|
86
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\
|
|
86
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { BoardPendingInteraction } from \"@idosgames/core\";\nimport { useBoardState } from \"../react/hooks\";\nimport { useRolling } from \"../react/boardBusy\";\nimport { RollControls } from \"./RollControls\";\nimport { BuildPanel } from \"./BuildPanel\";\nimport { SpecialPanel } from \"./SpecialPanel\";\nimport { AttackPanel } from \"./AttackPanel\";\nimport { RaidPanel } from \"./RaidPanel\";\n\n/**\n * Routes the board's pending interaction to its full-screen panel. The panel opens once the token has\n * landed (not when the roll's answer arrives) and stays until its own sequence ends: the server drops\n * the pending as soon as the action is accepted, while the panel is still showing the result. The\n * roll button stays on screen throughout — paused while a panel plays, so AUTO resumes by itself.\n */\nexport function InteractionPanel(): ReactNode {\n const board = useBoardState();\n const rolling = useRolling();\n const live = board?.Pending ?? null;\n const [shown, setShown] = useState<BoardPendingInteraction | null>(null);\n // A panel that closed on a failure leaves its pending alive; it is not reopened in a loop — the\n // next change of the pending (a new roll) brings it back, like the reference's \"first ROLL\".\n const dismissed = useRef<BoardPendingInteraction | null>(null);\n const liveRef = useRef(live);\n liveRef.current = live;\n\n // Open on a new pending after the roll animation; a pending restored on load opens straight away.\n useEffect(() => {\n if (shown || !live || rolling || live === dismissed.current) return;\n if (\n live.Type === \"SPECIAL\" ||\n live.Type === \"ATTACK\" ||\n live.Type === \"RAID\"\n )\n setShown(live);\n }, [live, rolling, shown]);\n\n // While its pending is alive the panel follows it (a raid's layout arrives with a refetch).\n const pending = shown && live && live.Type === shown.Type ? live : shown;\n const done = (): void => {\n dismissed.current = liveRef.current;\n setShown(null);\n };\n // ROLL while a dismissed pending is alive brings its panel back (no die is spent).\n const reopen = (): boolean => {\n const p = liveRef.current;\n if (!p || shown) return false;\n dismissed.current = null;\n setShown(p);\n return true;\n };\n\n const panel =\n pending?.Type === \"SPECIAL\" ? (\n <SpecialPanel key=\"special\" pending={pending} onDone={done} />\n ) : pending?.Type === \"ATTACK\" ? (\n <AttackPanel key=\"attack\" pending={pending} onDone={done} />\n ) : pending?.Type === \"RAID\" ? (\n <RaidPanel key=\"raid\" pending={pending} onDone={done} />\n ) : null;\n\n return (\n <>\n {panel}\n {panel ? null : <BuildPanel />}\n <RollControls paused={panel !== null} onRollIntercept={reopen} />\n </>\n );\n}\n"
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"path": "components/modal/art.tsx",
|
|
90
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\n\n// The art of the board's full-screen panels, drawn in SVG after the reference's sprites (BoardSurvival\n// Content/Sprites: GameModes/Raid, GameModes/Attack, Buildings/Stages, the electric sword). The module\n// ships text only — the seeder skips binary files — so every picture is redrawn here: same shapes,\n// same palette, the same heavy dark outline and glossy highlight.\n\nconst INK = \"#10172e\";\n\n/** The stage backdrop of the attack window (Stages/{stage}/bg): sky, snowy peaks, a village, a meadow, a dirt road. */\nexport function StageBackdrop({ style }: { style?: CSSProperties }): ReactNode {\n return (\n <svg\n viewBox=\"0 0 1080 1920\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", ...style }}\n aria-hidden\n >\n <defs>\n <linearGradient id=\"bd-sky\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#1f86e6\" />\n <stop offset=\"1\" stopColor=\"#8ccbf6\" />\n </linearGradient>\n <linearGradient id=\"bd-field\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#8dc53a\" />\n <stop offset=\"0.5\" stopColor=\"#9bcf2c\" />\n <stop offset=\"1\" stopColor=\"#79b52a\" />\n </linearGradient>\n <linearGradient id=\"bd-road\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#d9a24e\" />\n <stop offset=\"1\" stopColor=\"#c48a3a\" />\n </linearGradient>\n <radialGradient id=\"bd-light\" cx=\"0.5\" cy=\"0.55\" r=\"0.6\">\n <stop offset=\"0\" stopColor=\"#fff9c4\" stopOpacity=\"0.35\" />\n <stop offset=\"1\" stopColor=\"#fff9c4\" stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n <rect width=\"1080\" height=\"700\" fill=\"url(#bd-sky)\" />\n {[\n [130, 110, 1],\n [930, 100, 0.9],\n [800, 200, 0.8],\n [60, 250, 0.6],\n ].map(([x, y, s], i) => (\n <g key={i} transform={`translate(${x} ${y}) scale(${s})`} fill=\"#fff\">\n <ellipse cx=\"0\" cy=\"20\" rx=\"110\" ry=\"34\" />\n <circle cx=\"-40\" cy=\"0\" r=\"42\" />\n <circle cx=\"20\" cy=\"-18\" r=\"56\" />\n <circle cx=\"70\" cy=\"8\" r=\"36\" />\n </g>\n ))}\n {/* Far peaks */}\n <path\n d=\"M0 380 L90 300 L170 340 L290 190 L400 330 L510 210 L640 300 L770 330 L860 290 L990 220 L1080 300 L1080 470 L0 470 Z\"\n fill=\"#5b8fd6\"\n />\n <path\n d=\"M290 190 L250 250 L280 240 L310 270 L330 230 Z M510 210 L470 270 L505 255 L530 280 L555 250 Z M990 220 L950 280 L985 265 L1010 290 L1035 260 Z\"\n fill=\"#fff\"\n />\n <path d=\"M0 430 Q270 360 540 420 T1080 410 L1080 520 L0 520 Z\" fill=\"#4f9a3c\" />\n <path d=\"M0 470 Q300 430 600 470 T1080 460 L1080 560 L0 560 Z\" fill=\"#62ad3e\" />\n {/* Village on the horizon */}\n {[\n [220, 560, \"#e9dcc2\", \"#c4473a\"],\n [370, 520, \"#ece2cf\", \"#c4473a\"],\n [600, 530, \"#ece2cf\", \"#c4473a\"],\n [740, 500, \"#ece2cf\", \"#c4473a\"],\n ].map(([x, y, wall, band], i) => (\n <g key={i} transform={`translate(${x} ${y})`} stroke={INK} strokeWidth=\"5\" strokeLinejoin=\"round\">\n <path d=\"M-70 0 L-70 -50 Q0 -110 70 -50 L70 0 Z\" fill={wall as string} />\n <path d=\"M-70 -40 L70 -40\" stroke={band as string} strokeWidth=\"9\" />\n <rect x=\"-16\" y=\"-36\" width=\"32\" height=\"36\" fill=\"#8a5a2b\" />\n </g>\n ))}\n <g transform=\"translate(870 560)\" stroke={INK} strokeWidth=\"5\" strokeLinejoin=\"round\">\n <rect x=\"-90\" y=\"-70\" width=\"180\" height=\"70\" fill=\"#e8cf9c\" />\n <path d=\"M-110 -70 L-60 -130 L110 -130 L120 -70 Z\" fill=\"#c9442e\" />\n <rect x=\"-15\" y=\"-46\" width=\"30\" height=\"46\" fill=\"#8a5a2b\" />\n </g>\n {[80, 160, 300, 470, 690, 960, 1030].map((x, i) => (\n <g key={i} transform={`translate(${x} ${560 + (i % 2) * 20})`}>\n <circle cx=\"0\" cy=\"-40\" r={50 - (i % 3) * 8} fill=\"#3f8f2e\" stroke={INK} strokeWidth=\"5\" />\n <circle cx=\"-14\" cy=\"-56\" r=\"16\" fill=\"#5bb043\" />\n </g>\n ))}\n {/* Meadow */}\n <rect y=\"590\" width=\"1080\" height=\"1330\" fill=\"url(#bd-field)\" />\n <rect y=\"590\" width=\"1080\" height=\"1330\" fill=\"url(#bd-light)\" />\n <path\n d=\"M0 1520 Q260 1480 540 1530 T1080 1520 L1080 1700 Q800 1740 540 1700 T0 1720 Z\"\n fill=\"url(#bd-road)\"\n stroke=\"#b07a35\"\n strokeWidth=\"4\"\n />\n {[\n [40, 700, 1.1],\n [1030, 830, 0.9],\n [60, 1170, 1],\n [1020, 1180, 1.2],\n [120, 1840, 1.5],\n [960, 1830, 1.6],\n ].map(([x, y, s], i) => (\n <g key={i} transform={`translate(${x} ${y}) scale(${s})`} stroke={INK} strokeWidth=\"4\">\n <circle cx=\"-30\" cy=\"0\" r=\"46\" fill=\"#58a83a\" />\n <circle cx=\"30\" cy=\"-10\" r=\"52\" fill=\"#4c9c33\" />\n <circle cx=\"0\" cy=\"-40\" r=\"40\" fill=\"#63b443\" />\n </g>\n ))}\n {[\n [90, 740],\n [140, 1190],\n [1000, 1490],\n [870, 1500],\n [260, 1880],\n [820, 1840],\n ].map(([x, y], i) => (\n <ellipse key={i} cx={x} cy={y} rx=\"38\" ry=\"24\" fill=\"#9aa1ad\" stroke={INK} strokeWidth=\"4\" />\n ))}\n {Array.from({ length: 40 }, (_, i) => (\n <circle\n key={i}\n cx={(i * 197) % 1080}\n cy={640 + ((i * 331) % 1200)}\n r=\"5\"\n fill={i % 3 === 0 ? \"#fff\" : i % 3 === 1 ? \"#ffd84a\" : \"#ff8fb0\"}\n />\n ))}\n </svg>\n );\n}\n\n/** The raid window's blue alcove (GameModes/Raid/background). */\nexport function RaidBackdrop(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 1080 1920\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <defs>\n <radialGradient id=\"rb-in\" cx=\"0.5\" cy=\"0.5\" r=\"0.7\">\n <stop offset=\"0\" stopColor=\"#2f80ea\" />\n <stop offset=\"1\" stopColor=\"#1b5fc9\" />\n </radialGradient>\n <linearGradient id=\"rb-out\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#0d48a8\" />\n <stop offset=\"1\" stopColor=\"#1557c2\" />\n </linearGradient>\n </defs>\n <rect width=\"1080\" height=\"1920\" fill=\"url(#rb-out)\" />\n <path\n d=\"M10 1750 L10 330 Q10 235 110 235 L970 235 Q1070 235 1070 330 L1070 1750 Z\"\n fill=\"#2a6fd8\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"8\"\n />\n <path\n d=\"M60 1760 L60 360 Q60 285 135 285 L945 285 Q1020 285 1020 360 L1020 1760 Z\"\n fill=\"url(#rb-in)\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"6\"\n />\n <path d=\"M40 245 L1040 245\" stroke=\"#6fb0ff\" strokeWidth=\"6\" opacity=\"0.6\" />\n <rect y=\"1760\" width=\"1080\" height=\"160\" fill=\"#174fb4\" />\n <path d=\"M0 1760 L1080 1760\" stroke=\"#0b3a8c\" strokeWidth=\"8\" />\n <path d=\"M0 1840 L80 1730 L80 1760 Z M1080 1840 L1000 1730 L1000 1760 Z\" fill=\"#2a6fd8\" stroke=\"#0b3a8c\" strokeWidth=\"6\" />\n </svg>\n );\n}\n\n/** Roof colours per building slot — the same as the houses on the island (city.ts). */\nconst ROOFS = [\"#e8534e\", \"#4a90e2\", \"#9c5cff\", \"#f0a020\", \"#42c9c2\", \"#4caf50\"];\n\n/**\n * A building of the attack window (Buildings/Stages/{stage}/{slot}_{level}): an isometric cartoon\n * house that grows a storey every two levels. Level 0 is an empty plot under scaffolding, shown grey\n * like the reference's grayscale material; a damaged one is the level-1 art, scorched.\n */\nexport function BuildingArt({\n slot,\n level,\n damaged,\n}: {\n slot: number;\n level: number;\n damaged?: boolean;\n}): ReactNode {\n const roof = ROOFS[slot % ROOFS.length] ?? \"#e8534e\";\n const empty = level <= 0;\n const storeys = damaged ? 1 : Math.min(3, 1 + Math.floor(Math.max(0, level - 1) / 2));\n const wallH = 70;\n const top = 300 - storeys * wallH;\n return (\n <svg\n viewBox=\"0 0 400 400\"\n style={{\n width: \"100%\",\n height: \"100%\",\n overflow: \"visible\",\n filter: empty ? \"grayscale(1) brightness(1.05)\" : damaged ? \"brightness(.72) saturate(.7)\" : undefined,\n }}\n aria-hidden\n >\n <g stroke={INK} strokeWidth=\"7\" strokeLinejoin=\"round\">\n {/* Plot */}\n <path d=\"M200 250 L360 320 L200 390 L40 320 Z\" fill=\"#e8c88f\" />\n <path d=\"M40 320 L40 336 L200 406 L360 336 L360 320 L200 390 Z\" fill=\"#c9a26a\" />\n <ellipse cx=\"70\" cy=\"300\" rx=\"28\" ry=\"20\" fill=\"#6cbf3d\" />\n <ellipse cx=\"335\" cy=\"305\" rx=\"30\" ry=\"22\" fill=\"#6cbf3d\" />\n {empty ? (\n <>\n <path d=\"M120 300 L120 160 M280 300 L280 160 M120 170 L280 170 M120 230 L280 230\" fill=\"none\" stroke=\"#d98c2b\" strokeWidth=\"12\" />\n <path d=\"M120 300 L120 160 M280 300 L280 160 M120 170 L280 170 M120 230 L280 230\" fill=\"none\" />\n <rect x=\"160\" y=\"280\" width=\"50\" height=\"40\" fill=\"#e09a3a\" />\n <rect x=\"225\" y=\"290\" width=\"40\" height=\"32\" fill=\"#e09a3a\" />\n </>\n ) : (\n <>\n {/* Walls: left face + right face */}\n <path d={`M90 ${top + 40} L200 ${top + 90} L200 340 L90 290 Z`} fill=\"#f4e7cc\" />\n <path d={`M200 ${top + 90} L310 ${top + 40} L310 290 L200 340 Z`} fill=\"#dccaa6\" />\n {Array.from({ length: storeys }, (_, i) => {\n const y = top + 70 + i * wallH;\n return (\n <g key={i} strokeWidth=\"5\">\n <path d={`M118 ${y} L150 ${y + 15} L150 ${y + 45} L118 ${y + 30} Z`} fill=\"#7fd0ff\" />\n <path d={`M232 ${y + 18} L268 ${y + 2} L268 ${y + 32} L232 ${y + 48} Z`} fill=\"#7fd0ff\" />\n </g>\n );\n })}\n <path d=\"M178 330 L178 290 L210 305 L210 344 Z\" fill=\"#8a5a2b\" strokeWidth=\"5\" />\n {/* Roof */}\n <path\n d={`M70 ${top + 48} L200 ${top - 20} L330 ${top + 48} L200 ${top + 108} Z`}\n fill={roof}\n />\n <path d={`M200 ${top - 20} L200 ${top + 108}`} stroke=\"rgba(0,0,0,.25)\" strokeWidth=\"6\" />\n <path d={`M95 ${top + 40} L200 ${top - 8}`} stroke=\"rgba(255,255,255,.55)\" strokeWidth=\"8\" fill=\"none\" />\n {level >= 5 ? (\n <path d={`M200 ${top - 20} L200 ${top - 70} L240 ${top - 58} L200 ${top - 46}`} fill=\"#ffd84a\" strokeWidth=\"5\" />\n ) : null}\n </>\n )}\n {damaged ? (\n <g strokeWidth=\"5\" fill=\"none\" stroke=\"#2a1a10\">\n <path d=\"M130 250 L150 270 L140 290\" />\n <path d=\"M250 240 L270 262 L258 282\" />\n </g>\n ) : null}\n </g>\n </svg>\n );\n}\n\n/** The red crosshair over every target building (GameModes/Attack/aim). */\nexport function AimArt(): ReactNode {\n return (\n <svg viewBox=\"0 0 100 100\" style={{ width: \"100%\", height: \"100%\" }} aria-hidden>\n <g stroke=\"#6a0d0d\" strokeWidth=\"3\" fill=\"#ef2b2b\" strokeLinejoin=\"round\">\n <path d=\"M50 14 A36 36 0 1 1 49.9 14 Z M50 26 A24 24 0 1 0 50.1 26 Z\" fillRule=\"evenodd\" />\n <rect x=\"45.5\" y=\"4\" width=\"9\" height=\"34\" rx=\"4.5\" />\n <rect x=\"45.5\" y=\"62\" width=\"9\" height=\"34\" rx=\"4.5\" />\n <rect x=\"4\" y=\"45.5\" width=\"34\" height=\"9\" rx=\"4.5\" />\n <rect x=\"62\" y=\"45.5\" width=\"34\" height=\"9\" rx=\"4.5\" />\n </g>\n <path d=\"M26 30 A30 30 0 0 1 44 18\" stroke=\"#ff9a9a\" strokeWidth=\"3\" fill=\"none\" strokeLinecap=\"round\" />\n </svg>\n );\n}\n\n/** The electric sword (BetterAttackPanel/ElectricSword): an ice-blue blade wrapped in lightning. */\nexport function SwordArt(): ReactNode {\n return (\n <svg viewBox=\"0 0 200 600\" style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }} aria-hidden>\n <defs>\n <linearGradient id=\"sw-blade\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n <stop offset=\"0\" stopColor=\"#bfe6ff\" />\n <stop offset=\"0.55\" stopColor=\"#ffffff\" />\n <stop offset=\"1\" stopColor=\"#8fd0ff\" />\n </linearGradient>\n <filter id=\"sw-glow\" x=\"-60%\" y=\"-20%\" width=\"220%\" height=\"140%\">\n <feGaussianBlur stdDeviation=\"9\" result=\"b\" />\n <feMerge>\n <feMergeNode in=\"b\" />\n <feMergeNode in=\"SourceGraphic\" />\n </feMerge>\n </filter>\n </defs>\n <g filter=\"url(#sw-glow)\">\n <path d=\"M100 8 L140 70 L132 420 L68 420 L60 70 Z\" fill=\"url(#sw-blade)\" stroke=\"#1f63d6\" strokeWidth=\"7\" strokeLinejoin=\"round\" />\n <path d=\"M100 30 L104 410\" stroke=\"#e6f6ff\" strokeWidth=\"8\" opacity=\"0.9\" />\n <path\n d=\"M56 90 L40 140 L58 170 L38 230 L56 270 L40 330 L58 380\"\n stroke=\"#7cd4ff\"\n strokeWidth=\"5\"\n fill=\"none\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M144 110 L162 160 L142 200 L164 260 L144 300 L160 360 L140 400\"\n stroke=\"#7cd4ff\"\n strokeWidth=\"5\"\n fill=\"none\"\n strokeLinejoin=\"round\"\n />\n </g>\n <g stroke=\"#14306b\" strokeWidth=\"7\" strokeLinejoin=\"round\">\n <rect x=\"30\" y=\"418\" width=\"140\" height=\"34\" rx=\"8\" fill=\"#a9dcff\" />\n <rect x=\"80\" y=\"452\" width=\"40\" height=\"22\" fill=\"#cfeeff\" />\n <rect x=\"74\" y=\"472\" width=\"52\" height=\"92\" rx=\"12\" fill=\"#3e5f9c\" />\n <path d=\"M76 492 L124 492 M76 512 L124 512 M76 532 L124 532 M76 552 L124 552\" stroke=\"#7d9ad0\" strokeWidth=\"5\" />\n <rect x=\"66\" y=\"560\" width=\"68\" height=\"32\" rx=\"16\" fill=\"#2d4a85\" />\n </g>\n </svg>\n );\n}\n\n/** A jagged bolt from the sword to the struck building. */\nexport function LightningArt(): ReactNode {\n return (\n <svg viewBox=\"0 0 100 300\" preserveAspectRatio=\"none\" style={{ width: \"100%\", height: \"100%\" }} aria-hidden>\n <path\n d=\"M58 0 L36 92 L60 98 L30 190 L56 196 L42 300 L80 168 L52 162 L78 74 L52 68 Z\"\n fill=\"#e9f8ff\"\n stroke=\"#4fb6ff\"\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n );\n}\n\n/** The shield that blocks a strike (Abilities/ui_ab_shield). */\nexport function ShieldArt(): ReactNode {\n return (\n <svg viewBox=\"0 0 200 220\" style={{ width: \"100%\", height: \"100%\" }} aria-hidden>\n <g stroke={INK} strokeWidth=\"9\" strokeLinejoin=\"round\">\n <path d=\"M100 10 L180 40 Q182 150 100 210 Q18 150 20 40 Z\" fill=\"#3d8cf0\" />\n <path d=\"M100 32 L160 54 Q160 138 100 186 Q40 138 40 54 Z\" fill=\"#ffd23a\" />\n <path d=\"M100 52 L140 68 Q138 128 100 162 Q62 128 60 68 Z\" fill=\"#3d8cf0\" />\n </g>\n <path d=\"M48 60 Q60 50 86 42\" stroke=\"#fff\" strokeWidth=\"8\" fill=\"none\" strokeLinecap=\"round\" opacity=\"0.7\" />\n </svg>\n );\n}\n\n/** Fire on a hit building (Abilities/ui_ab_fireball). */\nexport function FireArt(): ReactNode {\n return (\n <svg viewBox=\"0 0 200 240\" style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }} aria-hidden>\n <g stroke=\"#7a1f05\" strokeWidth=\"7\" strokeLinejoin=\"round\">\n <path d=\"M100 8 Q150 70 160 120 Q176 200 100 230 Q24 200 40 120 Q56 150 74 150 Q60 90 100 8 Z\" fill=\"#ff5a1f\" />\n <path d=\"M100 70 Q130 120 132 160 Q132 210 100 214 Q66 210 70 166 Q80 180 92 178 Q86 130 100 70 Z\" fill=\"#ffb21f\" />\n <path d=\"M100 140 Q114 170 112 190 Q110 206 100 206 Q88 204 88 190 Q90 170 100 140 Z\" fill=\"#fff3a0\" />\n </g>\n </svg>\n );\n}\n\n/** A closed bank safe of the raid grid: the frame, the door with its gloss, and the tumbler on top. */\nexport function SafeFrameArt({ open }: { open: boolean }): ReactNode {\n return (\n <svg viewBox=\"0 0 200 200\" style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }} aria-hidden>\n <rect x=\"8\" y=\"16\" width=\"184\" height=\"168\" rx=\"30\" fill=\"#b9cdf0\" stroke={INK} strokeWidth=\"7\" />\n <path d=\"M22 40 Q24 26 44 24\" stroke=\"#fff\" strokeWidth=\"7\" fill=\"none\" strokeLinecap=\"round\" />\n <rect\n x=\"26\"\n y=\"34\"\n width=\"148\"\n height=\"132\"\n rx=\"20\"\n fill={open ? \"#13286a\" : \"#2a4fa8\"}\n stroke={INK}\n strokeWidth=\"5\"\n />\n {open ? <rect x=\"34\" y=\"42\" width=\"132\" height=\"116\" rx=\"16\" fill=\"#0f2258\" /> : null}\n </svg>\n );\n}\n\nexport function SafeDoorArt(): ReactNode {\n return (\n <svg viewBox=\"0 0 200 200\" style={{ width: \"100%\", height: \"100%\" }} aria-hidden>\n <rect x=\"8\" y=\"16\" width=\"184\" height=\"168\" rx=\"30\" fill=\"#b9cdf0\" stroke={INK} strokeWidth=\"7\" />\n <rect x=\"26\" y=\"34\" width=\"148\" height=\"132\" rx=\"20\" fill=\"#c6d8f7\" stroke={INK} strokeWidth=\"5\" />\n <ellipse cx=\"48\" cy=\"52\" rx=\"13\" ry=\"8\" fill=\"#fff\" transform=\"rotate(-30 48 52)\" />\n <path d=\"M26 150 Q100 170 174 150\" stroke=\"#9db6e4\" strokeWidth=\"6\" fill=\"none\" />\n </svg>\n );\n}\n\nexport function TumblerArt(): ReactNode {\n return (\n <svg viewBox=\"0 0 200 200\" style={{ width: \"100%\", height: \"100%\" }} aria-hidden>\n <circle cx=\"100\" cy=\"104\" r=\"30\" fill=\"#4a6cb3\" stroke={INK} strokeWidth=\"5\" />\n <g stroke={INK} strokeWidth=\"5\" fill=\"#ffcf2e\" strokeLinejoin=\"round\">\n <rect x=\"91\" y=\"64\" width=\"18\" height=\"40\" rx=\"9\" />\n <rect x=\"91\" y=\"64\" width=\"18\" height=\"40\" rx=\"9\" transform=\"rotate(120 100 104)\" />\n <rect x=\"91\" y=\"64\" width=\"18\" height=\"40\" rx=\"9\" transform=\"rotate(240 100 104)\" />\n <circle cx=\"100\" cy=\"104\" r=\"17\" />\n </g>\n <circle cx=\"100\" cy=\"104\" r=\"8\" fill=\"none\" stroke=\"#c99a10\" strokeWidth=\"3\" />\n </svg>\n );\n}\n\nexport type BarsTone = \"gold\" | \"silver\" | \"bronze\";\nconst BARS: Record<BarsTone, [string, string, string]> = {\n gold: [\"#fff06a\", \"#ffcb1f\", \"#e98a0c\"],\n silver: [\"#ffffff\", \"#d7d9de\", \"#a3a7b0\"],\n bronze: [\"#ffd1a1\", \"#e2924c\", \"#a8571f\"],\n};\n\n/** Three stacked ingots (Raid/gold, silver, bronze) — the symbols of the raid. */\nexport function BarsArt({ tone }: { tone: BarsTone }): ReactNode {\n const [hi, mid, lo] = BARS[tone];\n const bar = (x: number, y: number): ReactNode => (\n <g transform={`translate(${x} ${y})`} stroke={INK} strokeWidth=\"6\" strokeLinejoin=\"round\">\n <path d=\"M0 40 L60 10 L130 30 L70 62 Z\" fill={hi} />\n <path d=\"M0 40 L70 62 L70 92 L4 70 Z\" fill={mid} />\n <path d=\"M70 62 L130 30 L128 60 L70 92 Z\" fill={lo} />\n </g>\n );\n return (\n <svg viewBox=\"0 0 220 180\" style={{ width: \"100%\", height: \"100%\" }} aria-hidden>\n {bar(8, 70)}\n {bar(84, 76)}\n {bar(44, 18)}\n </svg>\n );\n}\n\n/** The special card's treasure chest (Chest_Legendary_OpenEffect). */\nexport function ChestArt(): ReactNode {\n return (\n <svg viewBox=\"0 0 240 220\" style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }} aria-hidden>\n <defs>\n <radialGradient id=\"ch-glow\" cx=\"0.5\" cy=\"0.45\" r=\"0.55\">\n <stop offset=\"0\" stopColor=\"#fff6b0\" stopOpacity=\"0.95\" />\n <stop offset=\"1\" stopColor=\"#fff6b0\" stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n <circle cx=\"120\" cy=\"96\" r=\"120\" fill=\"url(#ch-glow)\" />\n <g stroke={INK} strokeWidth=\"8\" strokeLinejoin=\"round\">\n <path d=\"M30 70 Q120 -6 210 70 L210 100 L30 100 Z\" fill=\"#9c4dff\" />\n <rect x=\"30\" y=\"96\" width=\"180\" height=\"104\" rx=\"12\" fill=\"#7a2fe0\" />\n <path d=\"M30 100 L210 100\" />\n <rect x=\"104\" y=\"40\" width=\"32\" height=\"160\" fill=\"#ffd23a\" />\n <rect x=\"96\" y=\"104\" width=\"48\" height=\"44\" rx=\"8\" fill=\"#ffd23a\" />\n <circle cx=\"120\" cy=\"124\" r=\"8\" fill={INK} />\n </g>\n <path d=\"M46 72 Q90 30 140 34\" stroke=\"#fff\" strokeWidth=\"8\" fill=\"none\" strokeLinecap=\"round\" opacity=\"0.6\" />\n {[[40, 30], [210, 26], [222, 120], [18, 128]].map(([x, y], i) => (\n <path\n key={i}\n d={`M${x} ${(y ?? 0) - 14} L${(x ?? 0) + 5} ${(y ?? 0) - 4} L${(x ?? 0) + 15} ${y} L${(x ?? 0) + 5} ${(y ?? 0) + 4} L${x} ${(y ?? 0) + 14} L${(x ?? 0) - 5} ${(y ?? 0) + 4} L${(x ?? 0) - 15} ${y} L${(x ?? 0) - 5} ${(y ?? 0) - 4} Z`}\n fill=\"#fff8c2\"\n />\n ))}\n </svg>\n );\n}\n"
|
|
90
|
+
"content": "import type { CSSProperties, ReactNode } from \"react\";\n\n// The art of the board's full-screen panels, drawn in SVG after the reference's sprites (BoardSurvival\n// Content/Sprites: GameModes/Raid, GameModes/Attack, Buildings/Stages, the electric sword). The module\n// ships text only — the seeder skips binary files — so every picture is redrawn here: same shapes,\n// same palette, the same heavy dark outline and glossy highlight.\n\nconst INK = \"#10172e\";\n\n/** The stage backdrop of the attack window (Stages/{stage}/bg): sky, snowy peaks, a village, a meadow, a dirt road. */\nexport function StageBackdrop({ style }: { style?: CSSProperties }): ReactNode {\n return (\n <svg\n viewBox=\"0 0 1080 1920\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n ...style,\n }}\n aria-hidden\n >\n <defs>\n <linearGradient id=\"bd-sky\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#1f86e6\" />\n <stop offset=\"1\" stopColor=\"#8ccbf6\" />\n </linearGradient>\n <linearGradient id=\"bd-field\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#8dc53a\" />\n <stop offset=\"0.5\" stopColor=\"#9bcf2c\" />\n <stop offset=\"1\" stopColor=\"#79b52a\" />\n </linearGradient>\n <linearGradient id=\"bd-road\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#d9a24e\" />\n <stop offset=\"1\" stopColor=\"#c48a3a\" />\n </linearGradient>\n <radialGradient id=\"bd-light\" cx=\"0.5\" cy=\"0.55\" r=\"0.6\">\n <stop offset=\"0\" stopColor=\"#fff9c4\" stopOpacity=\"0.35\" />\n <stop offset=\"1\" stopColor=\"#fff9c4\" stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n <rect width=\"1080\" height=\"700\" fill=\"url(#bd-sky)\" />\n {[\n [130, 110, 1],\n [930, 100, 0.9],\n [800, 200, 0.8],\n [60, 250, 0.6],\n ].map(([x, y, s], i) => (\n <g key={i} transform={`translate(${x} ${y}) scale(${s})`} fill=\"#fff\">\n <ellipse cx=\"0\" cy=\"20\" rx=\"110\" ry=\"34\" />\n <circle cx=\"-40\" cy=\"0\" r=\"42\" />\n <circle cx=\"20\" cy=\"-18\" r=\"56\" />\n <circle cx=\"70\" cy=\"8\" r=\"36\" />\n </g>\n ))}\n {/* Far peaks */}\n <path\n d=\"M0 380 L90 300 L170 340 L290 190 L400 330 L510 210 L640 300 L770 330 L860 290 L990 220 L1080 300 L1080 470 L0 470 Z\"\n fill=\"#5b8fd6\"\n />\n <path\n d=\"M290 190 L250 250 L280 240 L310 270 L330 230 Z M510 210 L470 270 L505 255 L530 280 L555 250 Z M990 220 L950 280 L985 265 L1010 290 L1035 260 Z\"\n fill=\"#fff\"\n />\n <path\n d=\"M0 430 Q270 360 540 420 T1080 410 L1080 520 L0 520 Z\"\n fill=\"#4f9a3c\"\n />\n <path\n d=\"M0 470 Q300 430 600 470 T1080 460 L1080 560 L0 560 Z\"\n fill=\"#62ad3e\"\n />\n {/* Village on the horizon */}\n {[\n [220, 560, \"#e9dcc2\", \"#c4473a\"],\n [370, 520, \"#ece2cf\", \"#c4473a\"],\n [600, 530, \"#ece2cf\", \"#c4473a\"],\n [740, 500, \"#ece2cf\", \"#c4473a\"],\n ].map(([x, y, wall, band], i) => (\n <g\n key={i}\n transform={`translate(${x} ${y})`}\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n >\n <path\n d=\"M-70 0 L-70 -50 Q0 -110 70 -50 L70 0 Z\"\n fill={wall as string}\n />\n <path d=\"M-70 -40 L70 -40\" stroke={band as string} strokeWidth=\"9\" />\n <rect x=\"-16\" y=\"-36\" width=\"32\" height=\"36\" fill=\"#8a5a2b\" />\n </g>\n ))}\n <g\n transform=\"translate(870 560)\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n >\n <rect x=\"-90\" y=\"-70\" width=\"180\" height=\"70\" fill=\"#e8cf9c\" />\n <path d=\"M-110 -70 L-60 -130 L110 -130 L120 -70 Z\" fill=\"#c9442e\" />\n <rect x=\"-15\" y=\"-46\" width=\"30\" height=\"46\" fill=\"#8a5a2b\" />\n </g>\n {[80, 160, 300, 470, 690, 960, 1030].map((x, i) => (\n <g key={i} transform={`translate(${x} ${560 + (i % 2) * 20})`}>\n <circle\n cx=\"0\"\n cy=\"-40\"\n r={50 - (i % 3) * 8}\n fill=\"#3f8f2e\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <circle cx=\"-14\" cy=\"-56\" r=\"16\" fill=\"#5bb043\" />\n </g>\n ))}\n {/* Meadow */}\n <rect y=\"590\" width=\"1080\" height=\"1330\" fill=\"url(#bd-field)\" />\n <rect y=\"590\" width=\"1080\" height=\"1330\" fill=\"url(#bd-light)\" />\n <path\n d=\"M0 1520 Q260 1480 540 1530 T1080 1520 L1080 1700 Q800 1740 540 1700 T0 1720 Z\"\n fill=\"url(#bd-road)\"\n stroke=\"#b07a35\"\n strokeWidth=\"4\"\n />\n {[\n [40, 700, 1.1],\n [1030, 830, 0.9],\n [60, 1170, 1],\n [1020, 1180, 1.2],\n [120, 1840, 1.5],\n [960, 1830, 1.6],\n ].map(([x, y, s], i) => (\n <g\n key={i}\n transform={`translate(${x} ${y}) scale(${s})`}\n stroke={INK}\n strokeWidth=\"4\"\n >\n <circle cx=\"-30\" cy=\"0\" r=\"46\" fill=\"#58a83a\" />\n <circle cx=\"30\" cy=\"-10\" r=\"52\" fill=\"#4c9c33\" />\n <circle cx=\"0\" cy=\"-40\" r=\"40\" fill=\"#63b443\" />\n </g>\n ))}\n {[\n [90, 740],\n [140, 1190],\n [1000, 1490],\n [870, 1500],\n [260, 1880],\n [820, 1840],\n ].map(([x, y], i) => (\n <ellipse\n key={i}\n cx={x}\n cy={y}\n rx=\"38\"\n ry=\"24\"\n fill=\"#9aa1ad\"\n stroke={INK}\n strokeWidth=\"4\"\n />\n ))}\n {Array.from({ length: 40 }, (_, i) => (\n <circle\n key={i}\n cx={(i * 197) % 1080}\n cy={640 + ((i * 331) % 1200)}\n r=\"5\"\n fill={i % 3 === 0 ? \"#fff\" : i % 3 === 1 ? \"#ffd84a\" : \"#ff8fb0\"}\n />\n ))}\n </svg>\n );\n}\n\n/** The raid window's blue alcove (GameModes/Raid/background). */\nexport function RaidBackdrop(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 1080 1920\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <defs>\n <radialGradient id=\"rb-in\" cx=\"0.5\" cy=\"0.5\" r=\"0.7\">\n <stop offset=\"0\" stopColor=\"#2f80ea\" />\n <stop offset=\"1\" stopColor=\"#1b5fc9\" />\n </radialGradient>\n <linearGradient id=\"rb-out\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#0d48a8\" />\n <stop offset=\"1\" stopColor=\"#1557c2\" />\n </linearGradient>\n </defs>\n <rect width=\"1080\" height=\"1920\" fill=\"url(#rb-out)\" />\n <path\n d=\"M10 1750 L10 330 Q10 235 110 235 L970 235 Q1070 235 1070 330 L1070 1750 Z\"\n fill=\"#2a6fd8\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"8\"\n />\n <path\n d=\"M60 1760 L60 360 Q60 285 135 285 L945 285 Q1020 285 1020 360 L1020 1760 Z\"\n fill=\"url(#rb-in)\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"6\"\n />\n <path\n d=\"M40 245 L1040 245\"\n stroke=\"#6fb0ff\"\n strokeWidth=\"6\"\n opacity=\"0.6\"\n />\n <rect y=\"1760\" width=\"1080\" height=\"160\" fill=\"#174fb4\" />\n <path d=\"M0 1760 L1080 1760\" stroke=\"#0b3a8c\" strokeWidth=\"8\" />\n <path\n d=\"M0 1840 L80 1730 L80 1760 Z M1080 1840 L1000 1730 L1000 1760 Z\"\n fill=\"#2a6fd8\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"6\"\n />\n </svg>\n );\n}\n\n/** Roof colours per building slot — the same as the houses on the island (city.ts). */\nconst ROOFS = [\n \"#e8534e\",\n \"#4a90e2\",\n \"#9c5cff\",\n \"#f0a020\",\n \"#42c9c2\",\n \"#4caf50\",\n];\n\n/**\n * A building of the attack window (Buildings/Stages/{stage}/{slot}_{level}): an isometric cartoon\n * house that grows a storey every two levels. Level 0 is an empty plot under scaffolding, shown grey\n * like the reference's grayscale material; a damaged one is the level-1 art, scorched.\n */\nexport function BuildingArt({\n slot,\n level,\n damaged,\n}: {\n slot: number;\n level: number;\n damaged?: boolean;\n}): ReactNode {\n const roof = ROOFS[slot % ROOFS.length] ?? \"#e8534e\";\n const empty = level <= 0;\n const storeys = damaged\n ? 1\n : Math.min(3, 1 + Math.floor(Math.max(0, level - 1) / 2));\n const wallH = 70;\n const top = 300 - storeys * wallH;\n return (\n <svg\n viewBox=\"0 0 400 400\"\n style={{\n width: \"100%\",\n height: \"100%\",\n overflow: \"visible\",\n filter: empty\n ? \"grayscale(1) brightness(1.05)\"\n : damaged\n ? \"brightness(.72) saturate(.7)\"\n : undefined,\n }}\n aria-hidden\n >\n <g stroke={INK} strokeWidth=\"7\" strokeLinejoin=\"round\">\n {/* Plot */}\n <path d=\"M200 250 L360 320 L200 390 L40 320 Z\" fill=\"#e8c88f\" />\n <path\n d=\"M40 320 L40 336 L200 406 L360 336 L360 320 L200 390 Z\"\n fill=\"#c9a26a\"\n />\n <ellipse cx=\"70\" cy=\"300\" rx=\"28\" ry=\"20\" fill=\"#6cbf3d\" />\n <ellipse cx=\"335\" cy=\"305\" rx=\"30\" ry=\"22\" fill=\"#6cbf3d\" />\n {empty ? (\n <>\n <path\n d=\"M120 300 L120 160 M280 300 L280 160 M120 170 L280 170 M120 230 L280 230\"\n fill=\"none\"\n stroke=\"#d98c2b\"\n strokeWidth=\"12\"\n />\n <path\n d=\"M120 300 L120 160 M280 300 L280 160 M120 170 L280 170 M120 230 L280 230\"\n fill=\"none\"\n />\n <rect x=\"160\" y=\"280\" width=\"50\" height=\"40\" fill=\"#e09a3a\" />\n <rect x=\"225\" y=\"290\" width=\"40\" height=\"32\" fill=\"#e09a3a\" />\n </>\n ) : (\n <>\n {/* Walls: left face + right face */}\n <path\n d={`M90 ${top + 40} L200 ${top + 90} L200 340 L90 290 Z`}\n fill=\"#f4e7cc\"\n />\n <path\n d={`M200 ${top + 90} L310 ${top + 40} L310 290 L200 340 Z`}\n fill=\"#dccaa6\"\n />\n {Array.from({ length: storeys }, (_, i) => {\n const y = top + 70 + i * wallH;\n return (\n <g key={i} strokeWidth=\"5\">\n <path\n d={`M118 ${y} L150 ${y + 15} L150 ${y + 45} L118 ${y + 30} Z`}\n fill=\"#7fd0ff\"\n />\n <path\n d={`M232 ${y + 18} L268 ${y + 2} L268 ${y + 32} L232 ${y + 48} Z`}\n fill=\"#7fd0ff\"\n />\n </g>\n );\n })}\n <path\n d=\"M178 330 L178 290 L210 305 L210 344 Z\"\n fill=\"#8a5a2b\"\n strokeWidth=\"5\"\n />\n {/* Roof */}\n <path\n d={`M70 ${top + 48} L200 ${top - 20} L330 ${top + 48} L200 ${top + 108} Z`}\n fill={roof}\n />\n <path\n d={`M200 ${top - 20} L200 ${top + 108}`}\n stroke=\"rgba(0,0,0,.25)\"\n strokeWidth=\"6\"\n />\n <path\n d={`M95 ${top + 40} L200 ${top - 8}`}\n stroke=\"rgba(255,255,255,.55)\"\n strokeWidth=\"8\"\n fill=\"none\"\n />\n {level >= 5 ? (\n <path\n d={`M200 ${top - 20} L200 ${top - 70} L240 ${top - 58} L200 ${top - 46}`}\n fill=\"#ffd84a\"\n strokeWidth=\"5\"\n />\n ) : null}\n </>\n )}\n {damaged ? (\n <g strokeWidth=\"5\" fill=\"none\" stroke=\"#2a1a10\">\n <path d=\"M130 250 L150 270 L140 290\" />\n <path d=\"M250 240 L270 262 L258 282\" />\n </g>\n ) : null}\n </g>\n </svg>\n );\n}\n\n/** The red crosshair over every target building (GameModes/Attack/aim). */\nexport function AimArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 100 100\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke=\"#6a0d0d\" strokeWidth=\"3\" fill=\"#ef2b2b\" strokeLinejoin=\"round\">\n <path\n d=\"M50 14 A36 36 0 1 1 49.9 14 Z M50 26 A24 24 0 1 0 50.1 26 Z\"\n fillRule=\"evenodd\"\n />\n <rect x=\"45.5\" y=\"4\" width=\"9\" height=\"34\" rx=\"4.5\" />\n <rect x=\"45.5\" y=\"62\" width=\"9\" height=\"34\" rx=\"4.5\" />\n <rect x=\"4\" y=\"45.5\" width=\"34\" height=\"9\" rx=\"4.5\" />\n <rect x=\"62\" y=\"45.5\" width=\"34\" height=\"9\" rx=\"4.5\" />\n </g>\n <path\n d=\"M26 30 A30 30 0 0 1 44 18\"\n stroke=\"#ff9a9a\"\n strokeWidth=\"3\"\n fill=\"none\"\n strokeLinecap=\"round\"\n />\n </svg>\n );\n}\n\n/** The electric sword (BetterAttackPanel/ElectricSword): an ice-blue blade wrapped in lightning. */\nexport function SwordArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 600\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <defs>\n <linearGradient id=\"sw-blade\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n <stop offset=\"0\" stopColor=\"#bfe6ff\" />\n <stop offset=\"0.55\" stopColor=\"#ffffff\" />\n <stop offset=\"1\" stopColor=\"#8fd0ff\" />\n </linearGradient>\n <filter id=\"sw-glow\" x=\"-60%\" y=\"-20%\" width=\"220%\" height=\"140%\">\n <feGaussianBlur stdDeviation=\"9\" result=\"b\" />\n <feMerge>\n <feMergeNode in=\"b\" />\n <feMergeNode in=\"SourceGraphic\" />\n </feMerge>\n </filter>\n </defs>\n <g filter=\"url(#sw-glow)\">\n <path\n d=\"M100 8 L140 70 L132 420 L68 420 L60 70 Z\"\n fill=\"url(#sw-blade)\"\n stroke=\"#1f63d6\"\n strokeWidth=\"7\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M100 30 L104 410\"\n stroke=\"#e6f6ff\"\n strokeWidth=\"8\"\n opacity=\"0.9\"\n />\n <path\n d=\"M56 90 L40 140 L58 170 L38 230 L56 270 L40 330 L58 380\"\n stroke=\"#7cd4ff\"\n strokeWidth=\"5\"\n fill=\"none\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M144 110 L162 160 L142 200 L164 260 L144 300 L160 360 L140 400\"\n stroke=\"#7cd4ff\"\n strokeWidth=\"5\"\n fill=\"none\"\n strokeLinejoin=\"round\"\n />\n </g>\n <g stroke=\"#14306b\" strokeWidth=\"7\" strokeLinejoin=\"round\">\n <rect x=\"30\" y=\"418\" width=\"140\" height=\"34\" rx=\"8\" fill=\"#a9dcff\" />\n <rect x=\"80\" y=\"452\" width=\"40\" height=\"22\" fill=\"#cfeeff\" />\n <rect x=\"74\" y=\"472\" width=\"52\" height=\"92\" rx=\"12\" fill=\"#3e5f9c\" />\n <path\n d=\"M76 492 L124 492 M76 512 L124 512 M76 532 L124 532 M76 552 L124 552\"\n stroke=\"#7d9ad0\"\n strokeWidth=\"5\"\n />\n <rect x=\"66\" y=\"560\" width=\"68\" height=\"32\" rx=\"16\" fill=\"#2d4a85\" />\n </g>\n </svg>\n );\n}\n\n/** A jagged bolt from the sword to the struck building. */\nexport function LightningArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 100 300\"\n preserveAspectRatio=\"none\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <path\n d=\"M58 0 L36 92 L60 98 L30 190 L56 196 L42 300 L80 168 L52 162 L78 74 L52 68 Z\"\n fill=\"#e9f8ff\"\n stroke=\"#4fb6ff\"\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n );\n}\n\n/** The shield that blocks a strike (Abilities/ui_ab_shield). */\nexport function ShieldArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 220\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke={INK} strokeWidth=\"9\" strokeLinejoin=\"round\">\n <path\n d=\"M100 10 L180 40 Q182 150 100 210 Q18 150 20 40 Z\"\n fill=\"#3d8cf0\"\n />\n <path\n d=\"M100 32 L160 54 Q160 138 100 186 Q40 138 40 54 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M100 52 L140 68 Q138 128 100 162 Q62 128 60 68 Z\"\n fill=\"#3d8cf0\"\n />\n </g>\n <path\n d=\"M48 60 Q60 50 86 42\"\n stroke=\"#fff\"\n strokeWidth=\"8\"\n fill=\"none\"\n strokeLinecap=\"round\"\n opacity=\"0.7\"\n />\n </svg>\n );\n}\n\n/** Fire on a hit building (Abilities/ui_ab_fireball). */\nexport function FireArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 240\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <g stroke=\"#7a1f05\" strokeWidth=\"7\" strokeLinejoin=\"round\">\n <path\n d=\"M100 8 Q150 70 160 120 Q176 200 100 230 Q24 200 40 120 Q56 150 74 150 Q60 90 100 8 Z\"\n fill=\"#ff5a1f\"\n />\n <path\n d=\"M100 70 Q130 120 132 160 Q132 210 100 214 Q66 210 70 166 Q80 180 92 178 Q86 130 100 70 Z\"\n fill=\"#ffb21f\"\n />\n <path\n d=\"M100 140 Q114 170 112 190 Q110 206 100 206 Q88 204 88 190 Q90 170 100 140 Z\"\n fill=\"#fff3a0\"\n />\n </g>\n </svg>\n );\n}\n\n/** A closed bank safe of the raid grid: the frame, the door with its gloss, and the tumbler on top. */\nexport function SafeFrameArt({ open }: { open: boolean }): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <rect\n x=\"8\"\n y=\"16\"\n width=\"184\"\n height=\"168\"\n rx=\"30\"\n fill=\"#b9cdf0\"\n stroke={INK}\n strokeWidth=\"7\"\n />\n <path\n d=\"M22 40 Q24 26 44 24\"\n stroke=\"#fff\"\n strokeWidth=\"7\"\n fill=\"none\"\n strokeLinecap=\"round\"\n />\n <rect\n x=\"26\"\n y=\"34\"\n width=\"148\"\n height=\"132\"\n rx=\"20\"\n fill={open ? \"#13286a\" : \"#2a4fa8\"}\n stroke={INK}\n strokeWidth=\"5\"\n />\n {open ? (\n <rect x=\"34\" y=\"42\" width=\"132\" height=\"116\" rx=\"16\" fill=\"#0f2258\" />\n ) : null}\n </svg>\n );\n}\n\nexport function SafeDoorArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <rect\n x=\"8\"\n y=\"16\"\n width=\"184\"\n height=\"168\"\n rx=\"30\"\n fill=\"#b9cdf0\"\n stroke={INK}\n strokeWidth=\"7\"\n />\n <rect\n x=\"26\"\n y=\"34\"\n width=\"148\"\n height=\"132\"\n rx=\"20\"\n fill=\"#c6d8f7\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <ellipse\n cx=\"48\"\n cy=\"52\"\n rx=\"13\"\n ry=\"8\"\n fill=\"#fff\"\n transform=\"rotate(-30 48 52)\"\n />\n <path\n d=\"M26 150 Q100 170 174 150\"\n stroke=\"#9db6e4\"\n strokeWidth=\"6\"\n fill=\"none\"\n />\n </svg>\n );\n}\n\nexport function TumblerArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <circle\n cx=\"100\"\n cy=\"104\"\n r=\"30\"\n fill=\"#4a6cb3\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <g stroke={INK} strokeWidth=\"5\" fill=\"#ffcf2e\" strokeLinejoin=\"round\">\n <rect x=\"91\" y=\"64\" width=\"18\" height=\"40\" rx=\"9\" />\n <rect\n x=\"91\"\n y=\"64\"\n width=\"18\"\n height=\"40\"\n rx=\"9\"\n transform=\"rotate(120 100 104)\"\n />\n <rect\n x=\"91\"\n y=\"64\"\n width=\"18\"\n height=\"40\"\n rx=\"9\"\n transform=\"rotate(240 100 104)\"\n />\n <circle cx=\"100\" cy=\"104\" r=\"17\" />\n </g>\n <circle\n cx=\"100\"\n cy=\"104\"\n r=\"8\"\n fill=\"none\"\n stroke=\"#c99a10\"\n strokeWidth=\"3\"\n />\n </svg>\n );\n}\n\nexport type BarsTone = \"gold\" | \"silver\" | \"bronze\";\nconst BARS: Record<BarsTone, [string, string, string]> = {\n gold: [\"#fff06a\", \"#ffcb1f\", \"#e98a0c\"],\n silver: [\"#ffffff\", \"#d7d9de\", \"#a3a7b0\"],\n bronze: [\"#ffd1a1\", \"#e2924c\", \"#a8571f\"],\n};\n\n/** Three stacked ingots (Raid/gold, silver, bronze) — the symbols of the raid. */\nexport function BarsArt({ tone }: { tone: BarsTone }): ReactNode {\n const [hi, mid, lo] = BARS[tone];\n const bar = (x: number, y: number): ReactNode => (\n <g\n transform={`translate(${x} ${y})`}\n stroke={INK}\n strokeWidth=\"6\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M0 40 L60 10 L130 30 L70 62 Z\" fill={hi} />\n <path d=\"M0 40 L70 62 L70 92 L4 70 Z\" fill={mid} />\n <path d=\"M70 62 L130 30 L128 60 L70 92 Z\" fill={lo} />\n </g>\n );\n return (\n <svg\n viewBox=\"0 0 220 180\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n {bar(8, 70)}\n {bar(84, 76)}\n {bar(44, 18)}\n </svg>\n );\n}\n\n/** The special card's treasure chest (Chest_Legendary_OpenEffect). */\nexport function ChestArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 240 220\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <defs>\n <radialGradient id=\"ch-glow\" cx=\"0.5\" cy=\"0.45\" r=\"0.55\">\n <stop offset=\"0\" stopColor=\"#fff6b0\" stopOpacity=\"0.95\" />\n <stop offset=\"1\" stopColor=\"#fff6b0\" stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n <circle cx=\"120\" cy=\"96\" r=\"120\" fill=\"url(#ch-glow)\" />\n <g stroke={INK} strokeWidth=\"8\" strokeLinejoin=\"round\">\n <path d=\"M30 70 Q120 -6 210 70 L210 100 L30 100 Z\" fill=\"#9c4dff\" />\n <rect x=\"30\" y=\"96\" width=\"180\" height=\"104\" rx=\"12\" fill=\"#7a2fe0\" />\n <path d=\"M30 100 L210 100\" />\n <rect x=\"104\" y=\"40\" width=\"32\" height=\"160\" fill=\"#ffd23a\" />\n <rect x=\"96\" y=\"104\" width=\"48\" height=\"44\" rx=\"8\" fill=\"#ffd23a\" />\n <circle cx=\"120\" cy=\"124\" r=\"8\" fill={INK} />\n </g>\n <path\n d=\"M46 72 Q90 30 140 34\"\n stroke=\"#fff\"\n strokeWidth=\"8\"\n fill=\"none\"\n strokeLinecap=\"round\"\n opacity=\"0.6\"\n />\n {[\n [40, 30],\n [210, 26],\n [222, 120],\n [18, 128],\n ].map(([x, y], i) => (\n <path\n key={i}\n d={`M${x} ${(y ?? 0) - 14} L${(x ?? 0) + 5} ${(y ?? 0) - 4} L${(x ?? 0) + 15} ${y} L${(x ?? 0) + 5} ${(y ?? 0) + 4} L${x} ${(y ?? 0) + 14} L${(x ?? 0) - 5} ${(y ?? 0) + 4} L${(x ?? 0) - 15} ${y} L${(x ?? 0) - 5} ${(y ?? 0) - 4} Z`}\n fill=\"#fff8c2\"\n />\n ))}\n </svg>\n );\n}\n"
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
"path": "components/modal/stage.tsx",
|
|
94
|
-
"content": "import {\r\n forwardRef,\r\n useCallback,\r\n useEffect,\r\n useImperativeHandle,\r\n useLayoutEffect,\r\n useRef,\r\n useState,\r\n type CSSProperties,\r\n type ReactNode,\r\n} from \"react\";\r\nimport { Icon, KitPortal, formatAmount, useCatalog, type ResourceLine } from \"@idosgames/react/ui\";\r\n\r\n// The stage every full-screen board panel plays on (the reference's Attack/Raid/Special panels):\r\n// a dim that fades in first, then the panel squashes in over it; everything inside is laid out in the\r\n// reference's 1080×1920 canvas units and scaled to the screen like its CanvasScaler, so the numbers\r\n// taken from the scene (positions, sizes, font sizes) are used as they are.\r\n\r\nexport const DESIGN_W = 1080;\r\nexport const DESIGN_H = 1920;\r\n\r\n// ── Timing ──────────────────────────────────────────────────────────────────────────────────────\r\n\r\n/** Next frame — a timer when the tab is hidden (no frames there), so a sequence still finishes. */\r\nfunction nextFrame(cb: () => void): void {\r\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\") setTimeout(cb, 16);\r\n else requestAnimationFrame(cb);\r\n}\r\n\r\n/** Runs `step(t)` for t 0→1 over `seconds` (real time), resolves at the end. */\r\nexport function tween(seconds: number, step: (t: number) => void): Promise<void> {\r\n return new Promise((resolve) => {\r\n if (seconds <= 0) {\r\n step(1);\r\n resolve();\r\n return;\r\n }\r\n const start = performance.now();\r\n const tick = (): void => {\r\n const t = Math.min(1, (performance.now() - start) / (seconds * 1000));\r\n step(t);\r\n if (t >= 1) resolve();\r\n else nextFrame(tick);\r\n };\r\n nextFrame(tick);\r\n });\r\n}\r\n\r\nexport const sleep = (seconds: number): Promise<void> =>\r\n new Promise((r) => setTimeout(r, Math.max(0, seconds * 1000)));\r\n\r\nexport const easeOutBack = (t: number): number => {\r\n const c1 = 1.70158;\r\n const c3 = c1 + 1;\r\n return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;\r\n};\r\nexport const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;\r\nexport const easeInCubic = (t: number): number => t ** 3;\r\n\r\n// ── Stage ───────────────────────────────────────────────────────────────────────────────────────\r\n\r\nexport interface StageHandle {\r\n /** Perlin-like decaying shake of the panel, in canvas units (TriggerShake). */\r\n shake(intensity: number, seconds: number, frequency?: number): void;\r\n /** Scale punch of the whole panel at an impact (BoardPunchZoom). */\r\n punchZoom(intensity: number, seconds: number, springy: boolean): void;\r\n /** Soft full-screen edge tint (EdgeFlashRoutine). */\r\n edgeFlash(color: string, seconds: number): void;\r\n /** Near-opaque full-frame flash at contact (ImpactFlashRoutine). */\r\n impactFlash(color: string, seconds: number): void;\r\n /** Fades the panel out (FadeOut: alpha and scale to zero) and the dim with it. */\r\n fadeOut(seconds?: number): Promise<void>;\r\n}\r\n\r\n/**\r\n * The full-screen stage. `revealed` turns the panel on after the dim has faded in; the backdrop\r\n * fills the screen (cover), `children` sit in the scaled 1080×1920 canvas.\r\n */\r\nexport const Stage = forwardRef<\r\n StageHandle,\r\n {\r\n backdrop?: ReactNode;\r\n children: ReactNode;\r\n /** Dim colour behind the panel (the reference's `Dim` / `Panal_Dim_Black`). */\r\n dim?: string;\r\n /** Fired once the dim has faded in and the panel starts squashing in. */\r\n onRevealed?: () => void;\r\n /** Canvas height: 1920, or taller for a panel laid out for a tall phone (the raid: 2340). */\r\n height?: number;\r\n }\r\n>(function Stage({ backdrop, children, dim = \"rgba(0,0,0,.72)\", onRevealed, height = DESIGN_H }, ref) {\r\n const host = useRef<HTMLDivElement>(null);\r\n const panel = useRef<HTMLDivElement>(null);\r\n const shaker = useRef<HTMLDivElement>(null);\r\n const edge = useRef<HTMLDivElement>(null);\r\n const impact = useRef<HTMLDivElement>(null);\r\n const dimRef = useRef<HTMLDivElement>(null);\r\n const [scale, setScale] = useState(0.4);\r\n const [revealed, setRevealed] = useState(false);\r\n const shakeId = useRef(0);\r\n\r\n useLayoutEffect(() => {\r\n const el = host.current;\r\n if (!el) return;\r\n const fit = (): void => {\r\n const r = el.getBoundingClientRect();\r\n if (r.width > 0 && r.height > 0) setScale(Math.min(r.width / DESIGN_W, r.height / height));\r\n };\r\n fit();\r\n const ro = new ResizeObserver(fit);\r\n ro.observe(el);\r\n return () => ro.disconnect();\r\n }, []);\r\n\r\n // Dim first (0.2 s), then the panel: alpha in with a squash & stretch settle (FadeIn, 0.3 s).\r\n useEffect(() => {\r\n let alive = true;\r\n const d = dimRef.current;\r\n void (async () => {\r\n await tween(0.2, (t) => {\r\n if (d) d.style.opacity = String(t);\r\n });\r\n if (!alive) return;\r\n setRevealed(true);\r\n onRevealed?.();\r\n const p = panel.current;\r\n await tween(0.3, (t) => {\r\n if (!p) return;\r\n const e = easeOutBack(t);\r\n const sx = 1.15 + (1 - 1.15) * e;\r\n const sy = 0.85 + (1 - 0.85) * e;\r\n p.style.opacity = String(t);\r\n p.style.transform = `scale(${sx}, ${sy})`;\r\n });\r\n })();\r\n return () => {\r\n alive = false;\r\n };\r\n }, []);\r\n\r\n useImperativeHandle(\r\n ref,\r\n (): StageHandle => ({\r\n shake(intensity, seconds, frequency = 24) {\r\n const el = shaker.current;\r\n if (!el) return;\r\n const id = ++shakeId.current;\r\n const seed = Math.random() * 100;\r\n void tween(seconds, (t) => {\r\n if (id !== shakeId.current) return;\r\n const decay = 1 - t;\r\n const time = (performance.now() / 1000) * frequency;\r\n const ox = (noise(time, seed) - 0.5) * 2 * intensity * decay;\r\n const oy = (noise(seed, time) - 0.5) * 2 * intensity * decay;\r\n el.style.translate = t >= 1 ? \"0 0\" : `${ox}px ${oy}px`;\r\n });\r\n },\r\n punchZoom(intensity, seconds, springy) {\r\n const el = shaker.current;\r\n if (!el) return;\r\n void tween(seconds, (t) => {\r\n const e = springy ? easeOutBack(t) : easeOutCubic(t);\r\n el.style.scale = String(1 + Math.sin(e * Math.PI) * intensity);\r\n }).then(() => {\r\n el.style.scale = \"1\";\r\n });\r\n },\r\n edgeFlash(color, seconds) {\r\n const el = edge.current;\r\n if (!el) return;\r\n el.style.boxShadow = `inset 0 0 ${180 * scale + 40}px ${60 * scale + 10}px ${color}`;\r\n void tween(seconds, (t) => {\r\n el.style.opacity = String(t < 0.5 ? t * 2 : (1 - t) * 2);\r\n });\r\n },\r\n impactFlash(color, seconds) {\r\n const el = impact.current;\r\n if (!el) return;\r\n el.style.background = color;\r\n void tween(seconds, (t) => {\r\n el.style.opacity = String(1 - t);\r\n });\r\n },\r\n async fadeOut(seconds = 0.3) {\r\n const p = panel.current;\r\n const d = dimRef.current;\r\n await tween(seconds, (t) => {\r\n const k = 1 - t;\r\n if (p) {\r\n p.style.opacity = String(k);\r\n p.style.transform = `scale(${k})`;\r\n }\r\n if (d) d.style.opacity = String(k);\r\n });\r\n },\r\n }),\r\n [scale],\r\n );\r\n\r\n return (\r\n <KitPortal>\r\n <div\r\n ref={host}\r\n style={{\r\n position: \"fixed\",\r\n inset: 0,\r\n zIndex: 40,\r\n overflow: \"hidden\",\r\n pointerEvents: \"auto\",\r\n fontFamily: \"inherit\",\r\n }}\r\n >\r\n <div ref={dimRef} style={{ position: \"absolute\", inset: 0, background: dim, opacity: 0 }} />\r\n <div\r\n ref={panel}\r\n style={{\r\n position: \"absolute\",\r\n inset: 0,\r\n opacity: 0,\r\n visibility: revealed ? \"visible\" : \"hidden\",\r\n transformOrigin: \"50% 50%\",\r\n }}\r\n >\r\n <div ref={shaker} style={{ position: \"absolute\", inset: 0 }}>\r\n {backdrop}\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n left: \"50%\",\r\n top: \"50%\",\r\n width: DESIGN_W,\r\n height,\r\n marginLeft: -DESIGN_W / 2,\r\n marginTop: -height / 2,\r\n transform: `scale(${scale})`,\r\n transformOrigin: \"50% 50%\",\r\n }}\r\n >\r\n {children}\r\n </div>\r\n </div>\r\n <div ref={edge} style={{ position: \"absolute\", inset: 0, opacity: 0, pointerEvents: \"none\" }} />\r\n <div ref={impact} style={{ position: \"absolute\", inset: 0, opacity: 0, pointerEvents: \"none\" }} />\r\n </div>\r\n </div>\r\n </KitPortal>\r\n );\r\n});\r\n\r\n/** Smooth 2-D value noise in 0..1 — stands in for Mathf.PerlinNoise. */\r\nfunction noise(x: number, y: number): number {\r\n const s = Math.sin(x * 1.7 + y * 3.1) * 0.5 + Math.sin(x * 0.63 - y * 2.3 + 1.3) * 0.5;\r\n return 0.5 + s * 0.5;\r\n}\r\n\r\n/** Positions a box in canvas units the Unity way: anchor at the centre, `x` right, `y` UP. */\r\nexport function at(\r\n x: number,\r\n y: number,\r\n w: number,\r\n h: number,\r\n pivotY = 0.5,\r\n canvasH = DESIGN_H,\r\n): CSSProperties {\r\n return {\r\n position: \"absolute\",\r\n left: DESIGN_W / 2 + x - w / 2,\r\n top: canvasH / 2 - y - h * (1 - pivotY),\r\n width: w,\r\n height: h,\r\n };\r\n}\r\n\r\n/** The reference's chunky outlined game text (TMP with outline + drop shadow). */\r\nexport function outlinedText(size: number, color = \"#fff\", stroke = \"#10172e\"): CSSProperties {\r\n const w = Math.max(2, Math.round(size / 14));\r\n return {\r\n color,\r\n fontSize: size,\r\n fontWeight: 900,\r\n lineHeight: 1.05,\r\n WebkitTextStroke: `${w}px ${stroke}`,\r\n paintOrder: \"stroke fill\",\r\n textShadow: `0 ${w + 2}px 0 ${stroke}`,\r\n letterSpacing: 0.5,\r\n };\r\n}\r\n\r\n// ── Target info (avatar + name at the top) ──────────────────────────────────────────────────────\r\n\r\nexport interface TargetInfoHandle {\r\n /** Squash/stretch + wobble when the target is hit (TargetAvatarReaction). */\r\n react(big: boolean): void;\r\n}\r\n\r\nexport const TargetInfo = forwardRef<\r\n TargetInfoHandle,\r\n { name: string; avatarUrl?: string | null; top: number }\r\n>(function TargetInfo({ name, avatarUrl: rawAvatar, top }, ref) {\r\n // Titles made for Unity send a resource key (\"avatar_default\"), not an address — draw the initial then.\r\n const avatarUrl = rawAvatar && /^(https?:|data:|\\/)/.test(rawAvatar) ? rawAvatar : null;\r\n const avatar = useRef<HTMLDivElement>(null);\r\n useImperativeHandle(ref, () => ({\r\n react(big) {\r\n const el = avatar.current;\r\n if (!el) return;\r\n const amp = 0.2 * (big ? 1.4 : 1);\r\n void tween(big ? 0.5 : 0.3, (t) => {\r\n const decay = 1 - t;\r\n const s = 1 + Math.sin(t * Math.PI * 3) * amp * decay;\r\n const r = Math.sin(t * Math.PI * 2) * (big ? 8 : 5) * decay;\r\n el.style.transform = t >= 1 ? \"\" : `scale(${s}) rotate(${r}deg)`;\r\n });\r\n },\r\n }));\r\n const initial = (name.trim()[0] ?? \"?\").toUpperCase();\r\n return (\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n left: DESIGN_W / 2 - 100,\r\n top,\r\n width: 200,\r\n display: \"flex\",\r\n flexDirection: \"column\",\r\n alignItems: \"center\",\r\n }}\r\n >\r\n <div\r\n ref={avatar}\r\n style={{\r\n width: 200,\r\n height: 200,\r\n borderRadius: \"50%\",\r\n border: \"10px solid #fff\",\r\n boxShadow: \"0 0 0 7px #10172e, 0 12px 0 7px rgba(0,0,0,.35)\",\r\n background: avatarUrl ? `center / cover no-repeat url(\"${avatarUrl}\")` : \"linear-gradient(180deg,#7fc4ff,#3a7be0)\",\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n boxSizing: \"border-box\",\r\n }}\r\n >\r\n {avatarUrl ? null : <span style={outlinedText(96)}>{initial}</span>}\r\n </div>\r\n <div\r\n style={{\r\n ...outlinedText(46),\r\n marginTop: 22,\r\n whiteSpace: \"nowrap\",\r\n maxWidth: 700,\r\n overflow: \"hidden\",\r\n textOverflow: \"ellipsis\",\r\n }}\r\n >\r\n {name}\r\n </div>\r\n </div>\r\n );\r\n});\r\n\r\n// ── Result banner (PanelFrame04 + OutTitle tab, bottom of the screen) ───────────────────────────\r\n\r\n/**\r\n * The result banner: a navy plate across the screen with a yellow stripe and a yellow tab holding the\r\n * title (\"HIT!\", \"BIG REWARD!\"), the reward icon and its amount counting up. It is pulled up from\r\n * below the screen like the reference's banner clip.\r\n */\r\nexport function ResultBanner({\r\n title,\r\n line,\r\n amount,\r\n tickSeconds,\r\n bottom,\r\n emptyText,\r\n}: {\r\n title: string;\r\n line: ResourceLine | null;\r\n amount: number;\r\n tickSeconds: number;\r\n /** Distance of the plate's bottom edge from the canvas bottom, in canvas units. */\r\n bottom: number;\r\n emptyText?: string;\r\n}): ReactNode {\r\n const catalog = useCatalog();\r\n const root = useRef<HTMLDivElement>(null);\r\n const titleRef = useRef<HTMLDivElement>(null);\r\n const amountRef = useRef<HTMLSpanElement>(null);\r\n const [shown, setShown] = useState(0);\r\n\r\n useEffect(() => {\r\n let alive = true;\r\n const r = root.current;\r\n const ti = titleRef.current;\r\n void (async () => {\r\n await tween(0.35, (t) => {\r\n if (r) r.style.transform = `translateY(${(1 - easeOutBack(t)) * 420}px)`;\r\n });\r\n await sleep(0.1);\r\n await tween(0.28, (t) => {\r\n if (ti) ti.style.transform = `scale(${easeOutBack(t)})`;\r\n });\r\n if (amount > 0) {\r\n await tween(tickSeconds, (t) => {\r\n if (alive) setShown(Math.round(amount * t));\r\n });\r\n const a = amountRef.current;\r\n await tween(0.2, (t) => {\r\n if (a) a.style.transform = `scale(${1 + Math.sin(t * Math.PI) * 0.25})`;\r\n });\r\n }\r\n })();\r\n return () => {\r\n alive = false;\r\n };\r\n }, []);\r\n\r\n return (\r\n <div\r\n ref={root}\r\n style={{ position: \"absolute\", left: 0, right: 0, bottom, height: 240, transform: \"translateY(420px)\" }}\r\n >\r\n {/* Plate */}\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n inset: 0,\r\n background: \"linear-gradient(180deg,#3a3a54 0%,#36364e 60%,#2b2b40 100%)\",\r\n borderTop: \"7px solid #10172e\",\r\n borderBottom: \"7px solid #10172e\",\r\n boxShadow: \"0 14px 0 rgba(0,0,0,.35)\",\r\n }}\r\n />\r\n {/* Yellow stripe along the top edge */}\r\n <div style={{ position: \"absolute\", left: 0, right: 0, top: 0, height: 33, background: \"#ffce0d\", borderBottom: \"6px solid #10172e\" }} />\r\n {/* Title tab */}\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n left: \"50%\",\r\n top: -118,\r\n width: 800,\r\n height: 150,\r\n marginLeft: -400,\r\n background: \"#ffce0d\",\r\n clipPath: \"polygon(9% 0, 91% 0, 100% 100%, 0 100%)\",\r\n }}\r\n />\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n left: \"50%\",\r\n top: -112,\r\n width: 776,\r\n height: 140,\r\n marginLeft: -388,\r\n background: \"linear-gradient(180deg,#ffe26b 0%,#ffce0d 55%,#f5b800 100%)\",\r\n clipPath: \"polygon(9.4% 0, 90.6% 0, 99.5% 100%, 0.5% 100%)\",\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n }}\r\n >\r\n <div ref={titleRef} style={{ ...outlinedText(92, \"#fff\", \"#7a3b00\"), transform: \"scale(0)\", whiteSpace: \"nowrap\" }}>\r\n {title}\r\n </div>\r\n </div>\r\n {/* Reward */}\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n inset: \"40px 0 0 0\",\r\n display: \"flex\",\r\n alignItems: \"center\",\r\n justifyContent: \"center\",\r\n gap: 24,\r\n }}\r\n >\r\n {line ? (\r\n <>\r\n <span data-board-reward-icon style={{ display: \"inline-grid\" }}>\r\n <Icon glyph={catalog.iconOf(line)} size={135} />\r\n </span>\r\n <span ref={amountRef} style={{ ...outlinedText(140), display: \"inline-block\" }}>\r\n {formatAmount(shown)}\r\n </span>\r\n </>\r\n ) : (\r\n <span style={outlinedText(64)}>{emptyText ?? \"\"}</span>\r\n )}\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\n/** Imperative restart of a CSS animation on an element (Animator.Play from frame 0). */\r\nexport function useReplay(): [(el: HTMLElement | null, animation: string) => void] {\r\n const replay = useCallback((el: HTMLElement | null, animation: string) => {\r\n if (!el) return;\r\n el.style.animation = \"none\";\r\n void el.offsetWidth;\r\n el.style.animation = animation;\r\n }, []);\r\n return [replay];\r\n}\r\n"
|
|
94
|
+
"content": "import {\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useLayoutEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n Icon,\n KitPortal,\n formatAmount,\n useCatalog,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// The stage every full-screen board panel plays on (the reference's Attack/Raid/Special panels):\n// a dim that fades in first, then the panel squashes in over it; everything inside is laid out in the\n// reference's 1080×1920 canvas units and scaled to the screen like its CanvasScaler, so the numbers\n// taken from the scene (positions, sizes, font sizes) are used as they are.\n\nexport const DESIGN_W = 1080;\nexport const DESIGN_H = 1920;\n\n// ── Timing ──────────────────────────────────────────────────────────────────────────────────────\n\n/** Next frame — a timer when the tab is hidden (no frames there), so a sequence still finishes. */\nfunction nextFrame(cb: () => void): void {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\")\n setTimeout(cb, 16);\n else requestAnimationFrame(cb);\n}\n\n/** Runs `step(t)` for t 0→1 over `seconds` (real time), resolves at the end. */\nexport function tween(\n seconds: number,\n step: (t: number) => void,\n): Promise<void> {\n return new Promise((resolve) => {\n if (seconds <= 0) {\n step(1);\n resolve();\n return;\n }\n const start = performance.now();\n const tick = (): void => {\n const t = Math.min(1, (performance.now() - start) / (seconds * 1000));\n step(t);\n if (t >= 1) resolve();\n else nextFrame(tick);\n };\n nextFrame(tick);\n });\n}\n\nexport const sleep = (seconds: number): Promise<void> =>\n new Promise((r) => setTimeout(r, Math.max(0, seconds * 1000)));\n\nexport const easeOutBack = (t: number): number => {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;\n};\nexport const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;\nexport const easeInCubic = (t: number): number => t ** 3;\n\n// ── Stage ───────────────────────────────────────────────────────────────────────────────────────\n\nexport interface StageHandle {\n /** Perlin-like decaying shake of the panel, in canvas units (TriggerShake). */\n shake(intensity: number, seconds: number, frequency?: number): void;\n /** Scale punch of the whole panel at an impact (BoardPunchZoom). */\n punchZoom(intensity: number, seconds: number, springy: boolean): void;\n /** Soft full-screen edge tint (EdgeFlashRoutine). */\n edgeFlash(color: string, seconds: number): void;\n /** Near-opaque full-frame flash at contact (ImpactFlashRoutine). */\n impactFlash(color: string, seconds: number): void;\n /** Fades the panel out (FadeOut: alpha and scale to zero) and the dim with it. */\n fadeOut(seconds?: number): Promise<void>;\n}\n\n/**\n * The full-screen stage. `revealed` turns the panel on after the dim has faded in; the backdrop\n * fills the screen (cover), `children` sit in the scaled 1080×1920 canvas.\n */\nexport const Stage = forwardRef<\n StageHandle,\n {\n backdrop?: ReactNode;\n children: ReactNode;\n /** Dim colour behind the panel (the reference's `Dim` / `Panal_Dim_Black`). */\n dim?: string;\n /** Fired once the dim has faded in and the panel starts squashing in. */\n onRevealed?: () => void;\n /** Canvas height: 1920, or taller for a panel laid out for a tall phone (the raid: 2340). */\n height?: number;\n }\n>(function Stage(\n {\n backdrop,\n children,\n dim = \"rgba(0,0,0,.72)\",\n onRevealed,\n height = DESIGN_H,\n },\n ref,\n) {\n const host = useRef<HTMLDivElement>(null);\n const panel = useRef<HTMLDivElement>(null);\n const shaker = useRef<HTMLDivElement>(null);\n const edge = useRef<HTMLDivElement>(null);\n const impact = useRef<HTMLDivElement>(null);\n const dimRef = useRef<HTMLDivElement>(null);\n const [scale, setScale] = useState(0.4);\n const [revealed, setRevealed] = useState(false);\n const shakeId = useRef(0);\n\n useLayoutEffect(() => {\n const el = host.current;\n if (!el) return;\n const fit = (): void => {\n const r = el.getBoundingClientRect();\n if (r.width > 0 && r.height > 0)\n setScale(Math.min(r.width / DESIGN_W, r.height / height));\n };\n fit();\n const ro = new ResizeObserver(fit);\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // Dim first (0.2 s), then the panel: alpha in with a squash & stretch settle (FadeIn, 0.3 s).\n useEffect(() => {\n let alive = true;\n const d = dimRef.current;\n void (async () => {\n await tween(0.2, (t) => {\n if (d) d.style.opacity = String(t);\n });\n if (!alive) return;\n setRevealed(true);\n onRevealed?.();\n const p = panel.current;\n await tween(0.3, (t) => {\n if (!p) return;\n const e = easeOutBack(t);\n const sx = 1.15 + (1 - 1.15) * e;\n const sy = 0.85 + (1 - 0.85) * e;\n p.style.opacity = String(t);\n p.style.transform = `scale(${sx}, ${sy})`;\n });\n })();\n return () => {\n alive = false;\n };\n }, []);\n\n useImperativeHandle(\n ref,\n (): StageHandle => ({\n shake(intensity, seconds, frequency = 24) {\n const el = shaker.current;\n if (!el) return;\n const id = ++shakeId.current;\n const seed = Math.random() * 100;\n void tween(seconds, (t) => {\n if (id !== shakeId.current) return;\n const decay = 1 - t;\n const time = (performance.now() / 1000) * frequency;\n const ox = (noise(time, seed) - 0.5) * 2 * intensity * decay;\n const oy = (noise(seed, time) - 0.5) * 2 * intensity * decay;\n el.style.translate = t >= 1 ? \"0 0\" : `${ox}px ${oy}px`;\n });\n },\n punchZoom(intensity, seconds, springy) {\n const el = shaker.current;\n if (!el) return;\n void tween(seconds, (t) => {\n const e = springy ? easeOutBack(t) : easeOutCubic(t);\n el.style.scale = String(1 + Math.sin(e * Math.PI) * intensity);\n }).then(() => {\n el.style.scale = \"1\";\n });\n },\n edgeFlash(color, seconds) {\n const el = edge.current;\n if (!el) return;\n el.style.boxShadow = `inset 0 0 ${180 * scale + 40}px ${60 * scale + 10}px ${color}`;\n void tween(seconds, (t) => {\n el.style.opacity = String(t < 0.5 ? t * 2 : (1 - t) * 2);\n });\n },\n impactFlash(color, seconds) {\n const el = impact.current;\n if (!el) return;\n el.style.background = color;\n void tween(seconds, (t) => {\n el.style.opacity = String(1 - t);\n });\n },\n async fadeOut(seconds = 0.3) {\n const p = panel.current;\n const d = dimRef.current;\n await tween(seconds, (t) => {\n const k = 1 - t;\n if (p) {\n p.style.opacity = String(k);\n p.style.transform = `scale(${k})`;\n }\n if (d) d.style.opacity = String(k);\n });\n },\n }),\n [scale],\n );\n\n return (\n <KitPortal>\n <div\n ref={host}\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n fontFamily: \"inherit\",\n }}\n >\n <div\n ref={dimRef}\n style={{\n position: \"absolute\",\n inset: 0,\n background: dim,\n opacity: 0,\n }}\n />\n <div\n ref={panel}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n visibility: revealed ? \"visible\" : \"hidden\",\n transformOrigin: \"50% 50%\",\n }}\n >\n <div ref={shaker} style={{ position: \"absolute\", inset: 0 }}>\n {backdrop}\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: \"50%\",\n width: DESIGN_W,\n height,\n marginLeft: -DESIGN_W / 2,\n marginTop: -height / 2,\n transform: `scale(${scale})`,\n transformOrigin: \"50% 50%\",\n }}\n >\n {children}\n </div>\n </div>\n <div\n ref={edge}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n pointerEvents: \"none\",\n }}\n />\n <div\n ref={impact}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n pointerEvents: \"none\",\n }}\n />\n </div>\n </div>\n </KitPortal>\n );\n});\n\n/** Smooth 2-D value noise in 0..1 — stands in for Mathf.PerlinNoise. */\nfunction noise(x: number, y: number): number {\n const s =\n Math.sin(x * 1.7 + y * 3.1) * 0.5 +\n Math.sin(x * 0.63 - y * 2.3 + 1.3) * 0.5;\n return 0.5 + s * 0.5;\n}\n\n/** Positions a box in canvas units the Unity way: anchor at the centre, `x` right, `y` UP. */\nexport function at(\n x: number,\n y: number,\n w: number,\n h: number,\n pivotY = 0.5,\n canvasH = DESIGN_H,\n): CSSProperties {\n return {\n position: \"absolute\",\n left: DESIGN_W / 2 + x - w / 2,\n top: canvasH / 2 - y - h * (1 - pivotY),\n width: w,\n height: h,\n };\n}\n\n/** The reference's chunky outlined game text (TMP with outline + drop shadow). */\nexport function outlinedText(\n size: number,\n color = \"#fff\",\n stroke = \"#10172e\",\n): CSSProperties {\n const w = Math.max(2, Math.round(size / 14));\n return {\n color,\n fontSize: size,\n fontWeight: 900,\n lineHeight: 1.05,\n WebkitTextStroke: `${w}px ${stroke}`,\n paintOrder: \"stroke fill\",\n textShadow: `0 ${w + 2}px 0 ${stroke}`,\n letterSpacing: 0.5,\n };\n}\n\n// ── Target info (avatar + name at the top) ──────────────────────────────────────────────────────\n\nexport interface TargetInfoHandle {\n /** Squash/stretch + wobble when the target is hit (TargetAvatarReaction). */\n react(big: boolean): void;\n}\n\nexport const TargetInfo = forwardRef<\n TargetInfoHandle,\n { name: string; avatarUrl?: string | null; top: number }\n>(function TargetInfo({ name, avatarUrl: rawAvatar, top }, ref) {\n // Titles made for Unity send a resource key (\"avatar_default\"), not an address — draw the initial then.\n const avatarUrl =\n rawAvatar && /^(https?:|data:|\\/)/.test(rawAvatar) ? rawAvatar : null;\n const avatar = useRef<HTMLDivElement>(null);\n useImperativeHandle(ref, () => ({\n react(big) {\n const el = avatar.current;\n if (!el) return;\n const amp = 0.2 * (big ? 1.4 : 1);\n void tween(big ? 0.5 : 0.3, (t) => {\n const decay = 1 - t;\n const s = 1 + Math.sin(t * Math.PI * 3) * amp * decay;\n const r = Math.sin(t * Math.PI * 2) * (big ? 8 : 5) * decay;\n el.style.transform = t >= 1 ? \"\" : `scale(${s}) rotate(${r}deg)`;\n });\n },\n }));\n const initial = (name.trim()[0] ?? \"?\").toUpperCase();\n return (\n <div\n style={{\n position: \"absolute\",\n left: DESIGN_W / 2 - 100,\n top,\n width: 200,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n }}\n >\n <div\n ref={avatar}\n style={{\n width: 200,\n height: 200,\n borderRadius: \"50%\",\n border: \"10px solid #fff\",\n boxShadow: \"0 0 0 7px #10172e, 0 12px 0 7px rgba(0,0,0,.35)\",\n background: avatarUrl\n ? `center / cover no-repeat url(\"${avatarUrl}\")`\n : \"linear-gradient(180deg,#7fc4ff,#3a7be0)\",\n display: \"grid\",\n placeItems: \"center\",\n boxSizing: \"border-box\",\n }}\n >\n {avatarUrl ? null : <span style={outlinedText(96)}>{initial}</span>}\n </div>\n <div\n style={{\n ...outlinedText(46),\n marginTop: 22,\n whiteSpace: \"nowrap\",\n maxWidth: 700,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {name}\n </div>\n </div>\n );\n});\n\n// ── Result banner (PanelFrame04 + OutTitle tab, bottom of the screen) ───────────────────────────\n\n/**\n * The result banner: a navy plate across the screen with a yellow stripe and a yellow tab holding the\n * title (\"HIT!\", \"BIG REWARD!\"), the reward icon and its amount counting up. It is pulled up from\n * below the screen like the reference's banner clip.\n */\nexport function ResultBanner({\n title,\n line,\n amount,\n tickSeconds,\n bottom,\n emptyText,\n}: {\n title: string;\n line: ResourceLine | null;\n amount: number;\n tickSeconds: number;\n /** Distance of the plate's bottom edge from the canvas bottom, in canvas units. */\n bottom: number;\n emptyText?: string;\n}): ReactNode {\n const catalog = useCatalog();\n const root = useRef<HTMLDivElement>(null);\n const titleRef = useRef<HTMLDivElement>(null);\n const amountRef = useRef<HTMLSpanElement>(null);\n const [shown, setShown] = useState(0);\n\n useEffect(() => {\n let alive = true;\n const r = root.current;\n const ti = titleRef.current;\n void (async () => {\n await tween(0.35, (t) => {\n if (r)\n r.style.transform = `translateY(${(1 - easeOutBack(t)) * 420}px)`;\n });\n await sleep(0.1);\n await tween(0.28, (t) => {\n if (ti) ti.style.transform = `scale(${easeOutBack(t)})`;\n });\n if (amount > 0) {\n await tween(tickSeconds, (t) => {\n if (alive) setShown(Math.round(amount * t));\n });\n const a = amountRef.current;\n await tween(0.2, (t) => {\n if (a)\n a.style.transform = `scale(${1 + Math.sin(t * Math.PI) * 0.25})`;\n });\n }\n })();\n return () => {\n alive = false;\n };\n }, []);\n\n return (\n <div\n ref={root}\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom,\n height: 240,\n transform: \"translateY(420px)\",\n }}\n >\n {/* Plate */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background:\n \"linear-gradient(180deg,#3a3a54 0%,#36364e 60%,#2b2b40 100%)\",\n borderTop: \"7px solid #10172e\",\n borderBottom: \"7px solid #10172e\",\n boxShadow: \"0 14px 0 rgba(0,0,0,.35)\",\n }}\n />\n {/* Yellow stripe along the top edge */}\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n top: 0,\n height: 33,\n background: \"#ffce0d\",\n borderBottom: \"6px solid #10172e\",\n }}\n />\n {/* Title tab */}\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -118,\n width: 800,\n height: 150,\n marginLeft: -400,\n background: \"#ffce0d\",\n clipPath: \"polygon(9% 0, 91% 0, 100% 100%, 0 100%)\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -112,\n width: 776,\n height: 140,\n marginLeft: -388,\n background:\n \"linear-gradient(180deg,#ffe26b 0%,#ffce0d 55%,#f5b800 100%)\",\n clipPath: \"polygon(9.4% 0, 90.6% 0, 99.5% 100%, 0.5% 100%)\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <div\n ref={titleRef}\n style={{\n ...outlinedText(92, \"#fff\", \"#7a3b00\"),\n transform: \"scale(0)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {title}\n </div>\n </div>\n {/* Reward */}\n <div\n style={{\n position: \"absolute\",\n inset: \"40px 0 0 0\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n {line ? (\n <>\n <span data-board-reward-icon style={{ display: \"inline-grid\" }}>\n <Icon glyph={catalog.iconOf(line)} size={135} />\n </span>\n <span\n ref={amountRef}\n style={{ ...outlinedText(140), display: \"inline-block\" }}\n >\n {formatAmount(shown)}\n </span>\n </>\n ) : (\n <span style={outlinedText(64)}>{emptyText ?? \"\"}</span>\n )}\n </div>\n </div>\n );\n}\n\n/** Imperative restart of a CSS animation on an element (Animator.Play from frame 0). */\nexport function useReplay(): [\n (el: HTMLElement | null, animation: string) => void,\n] {\n const replay = useCallback((el: HTMLElement | null, animation: string) => {\n if (!el) return;\n el.style.animation = \"none\";\n void el.offsetWidth;\n el.style.animation = animation;\n }, []);\n return [replay];\n}\n"
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"path": "components/panelStyles.ts",
|
|
@@ -99,19 +99,19 @@
|
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"path": "components/RaidPanel.tsx",
|
|
102
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\r\nimport type { BoardPendingInteraction, ResourceGrant } from \"@idosgames/core\";\r\nimport {\r\n Icon,\r\n formatAmount,\r\n grantLines,\r\n useCatalog,\r\n useSound,\r\n useToast,\r\n useUiKit,\r\n type ResourceLine,\r\n} from \"@idosgames/react/ui\";\r\nimport { useIDosGamesClient } from \"../react/context\";\r\nimport { readBoardConfig } from \"../data/boardConfig\";\r\nimport { setPanelBusy } from \"../react/boardBusy\";\r\nimport { BarsArt, RaidBackdrop, SafeDoorArt, SafeFrameArt, TumblerArt, type BarsTone } from \"./modal/art\";\r\nimport {\r\n ResultBanner,\r\n Stage,\r\n TargetInfo,\r\n at,\r\n easeOutBack,\r\n easeOutCubic,\r\n outlinedText,\r\n sleep,\r\n tween,\r\n type StageHandle,\r\n type TargetInfoHandle,\r\n} from \"./modal/stage\";\r\n\r\n// The raid (the reference's BetterRaidPanel + BetterRaidCell, BoardSurvival BoardScene): a blue vault\r\n// with the target on top, a legend of the three prizes (three frames each, lit one by one as the\r\n// player finds them, with the amount each prize pays), and twelve safes. Tapping a safe unscrews its\r\n// tumbler, flings the door off and pops the ingot out; three of a kind ends the raid — the rest of the\r\n// safes open, the winning ones glow, and the result banner rises with the loot counting up.\r\n\r\n/** The raid canvas is laid out for a tall phone: the reference's grid runs below 1920. */\r\nconst H = 2340;\r\nconst CELL = 330;\r\n/** Cell centres: GridLayoutGroup 3 columns, cell 330, spacing 50 × 0, upper-centre, grid at (0, -230). */\r\nconst CELLS: Array<[number, number]> = Array.from({ length: 12 }, (_, i) => {\r\n const col = i % 3;\r\n const row = Math.floor(i / 3);\r\n return [(col - 1) * 380, 270 - CELL / 2 - row * CELL];\r\n});\r\n\r\ntype Tier = 1 | 2 | 3;\r\nconst TONE: Record<Tier, BarsTone> = { 1: \"bronze\", 2: \"silver\", 3: \"gold\" };\r\nconst OUTCOME: Record<Tier, \"Small\" | \"Medium\" | \"Big\"> = { 1: \"Small\", 2: \"Medium\", 3: \"Big\" };\r\nconst LABEL: Record<Tier, string> = { 1: \"SMALL REWARD\", 2: \"MEDIUM REWARD\", 3: \"LARGE REWARD\" };\r\nconst RESULT: Record<Tier, string> = { 1: \"SMALL REWARD!\", 2: \"MEDIUM REWARD!\", 3: \"BIG REWARD!\" };\r\n\r\n/** Legend rows: frame size, frame centres x, row centre y (relative to the legend centre), text y. */\r\nconst LEGEND_Y = 530;\r\nconst ROWS: Record<Tier, { size: number; xs: number[]; y: number; textY: number; bg: string; light: string }> = {\r\n 3: { size: 150, xs: [-446.25, -281.25, -116.25], y: 202.5 - 75, textY: 127.5, bg: \"#ffd850\", light: \"#fffb4e\" },\r\n 2: { size: 125, xs: [-394.25, -261.25, -128.25], y: 202.5 - 160 - 62.5, textY: -20, bg: \"#00c0ff\", light: \"#35fbff\" },\r\n 1: { size: 110, xs: [-361.25, -241.25, -121.25], y: 202.5 - 295 - 55, textY: -147.5, bg: \"#53d804\", light: \"#b2f11f\" },\r\n};\r\n\r\nfunction symbolOf(v: unknown): Tier | 0 {\r\n const n = typeof v === \"string\" ? ({ Small: 1, Medium: 2, Big: 3 } as Record<string, number>)[v] ?? Number(v) : Number(v);\r\n return n === 1 || n === 2 || n === 3 ? n : 0;\r\n}\r\n\r\ninterface Preview {\r\n line: ResourceLine | null;\r\n}\r\n\r\nexport function RaidPanel({ pending, onDone }: { pending: BoardPendingInteraction; onDone: () => void }): ReactNode {\r\n const client = useIDosGamesClient();\r\n const kit = useUiKit();\r\n const play = useSound();\r\n const toast = useToast();\r\n const catalog = useCatalog();\r\n const stage = useRef<StageHandle>(null);\r\n const target = useRef<TargetInfoHandle>(null);\r\n const cellEls = useRef<Array<HTMLDivElement | null>>([]);\r\n const frameEls = useRef<Record<string, HTMLDivElement | null>>({});\r\n\r\n const fast = readBoardConfig(client).RaidMode === \"Fast\" || (pending.RaidLayout ?? []).some((c, i) => symbolOf(c?.Symbol) > 0 && !(pending.OpenedIndices ?? []).includes(i));\r\n const layout = (pending.RaidLayout ?? []).map((c) => symbolOf(c?.Symbol));\r\n const [opened, setOpened] = useState<Record<number, Tier | 0>>(() => {\r\n const o: Record<number, Tier | 0> = {};\r\n for (const i of pending.OpenedIndices ?? []) o[i] = layout[i] ?? 0;\r\n return o;\r\n });\r\n /** Opened indices, updated synchronously on every tap (rapid taps must not see a stale list). */\r\n const openedList = useRef<number[]>([...(pending.OpenedIndices ?? [])]);\r\n const [revealing, setRevealing] = useState<Set<number>>(new Set());\r\n const [finished, setFinished] = useState<Tier | null>(null);\r\n const [glow, setGlow] = useState<Set<number>>(new Set());\r\n const [nearMiss, setNearMiss] = useState<Tier | null>(null);\r\n const [result, setResult] = useState<{ tier: Tier | null; line: ResourceLine | null; amount: number; lines: ResourceLine[] } | null>(null);\r\n const [intro, setIntro] = useState(true);\r\n const busy = useRef(false);\r\n\r\n useEffect(() => {\r\n setPanelBusy(true);\r\n return () => setPanelBusy(false);\r\n }, []);\r\n\r\n // Prize preview per tier: \"how much a small/medium/large raid pays\" (RaidRewardPreview).\r\n const previewSrc = (pending as BoardPendingInteraction & {\r\n RaidRewardPreview?: { AttackerGainByOutcome?: Record<string, { Entries?: Array<{ Amount?: number | null } & Record<string, unknown>> | null }> | null };\r\n }).RaidRewardPreview?.AttackerGainByOutcome;\r\n const preview: Record<Tier, Preview> = { 1: { line: null }, 2: { line: null }, 3: { line: null } };\r\n for (const t of [1, 2, 3] as Tier[]) {\r\n const bundle = previewSrc?.[OUTCOME[t]];\r\n const lines = grantLines({ Standard: bundle } as ResourceGrant).filter((l) => l.amount > 0);\r\n preview[t] = { line: lines[0] ?? null };\r\n }\r\n\r\n // The legend counts only the safes the player opened — not the rest revealed at the end.\r\n const counts: Record<Tier, number> = { 1: 0, 2: 0, 3: 0 };\r\n for (const idx of openedList.current) {\r\n const v = opened[idx];\r\n if (v) counts[v]++;\r\n }\r\n\r\n // Staggered scale-in of the closed safes (0.05 s apart, 0.3 s EaseOutBack).\r\n useEffect(() => {\r\n void (async () => {\r\n await sleep(0.55);\r\n const runs = CELLS.map((_, i) => {\r\n const el = cellEls.current[i];\r\n if (!el || opened[i] !== undefined) return Promise.resolve();\r\n return sleep(0.05 * i).then(() =>\r\n tween(0.3, (t) => {\r\n el.style.transform = `scale(${easeOutBack(t)})`;\r\n }),\r\n );\r\n });\r\n await Promise.all(runs);\r\n setIntro(false);\r\n })();\r\n }, []);\r\n\r\n const revealCell = async (i: number, tier: Tier | 0): Promise<void> => {\r\n const el = cellEls.current[i];\r\n const door = el?.querySelector<HTMLElement>(\"[data-door]\");\r\n const tumbler = el?.querySelector<HTMLElement>(\"[data-tumbler]\");\r\n play(\"click\");\r\n // Unscrew the tumbler: two turns, EaseOutCubic, 0.45 s.\r\n await tween(0.45, (t) => {\r\n if (tumbler) tumbler.style.transform = `rotate(${-720 * easeOutCubic(t)}deg)`;\r\n });\r\n // Anticipation bump against the fly-out, then the door and tumbler fly down 260 and fade.\r\n await tween(0.08, (t) => {\r\n const y = -18 * easeOutBack(t);\r\n if (door) door.style.translate = `0 ${y}px`;\r\n if (tumbler) tumbler.style.translate = `0 ${y}px`;\r\n });\r\n play(\"close\");\r\n await tween(0.35, (t) => {\r\n const y = -18 + 278 * easeOutCubic(t);\r\n for (const d of [door, tumbler]) {\r\n if (!d) continue;\r\n d.style.translate = `0 ${y}px`;\r\n d.style.opacity = String(1 - t);\r\n }\r\n });\r\n setOpened((o) => ({ ...o, [i]: tier }));\r\n play(tier === 3 ? \"reward\" : \"coin\");\r\n await sleep(0.02); // let the opened safe render its ingot\r\n const reward = el?.querySelector<HTMLElement>(\"[data-reward]\");\r\n await tween(0.3, (t) => {\r\n if (reward) reward.style.transform = `scale(${easeOutBack(t)})`;\r\n });\r\n setRevealing((r) => {\r\n const next = new Set(r);\r\n next.delete(i);\r\n return next;\r\n });\r\n };\r\n\r\n const litFrame = (tier: Tier, index: number): void => {\r\n const f = frameEls.current[`${tier}:${index}`];\r\n void tween(0.4, (t) => {\r\n if (f) f.style.transform = `scale(${1 + Math.sin(t * Math.PI) * 0.3})`;\r\n });\r\n if (index === 1) setNearMiss(tier);\r\n };\r\n\r\n const finish = async (tier: Tier | null, grant: ResourceGrant | null | undefined, fullLayout: Array<Tier | 0>): Promise<void> => {\r\n // Open the rest at once, light the winning set, hold a beat.\r\n setOpened((o) => {\r\n const next = { ...o };\r\n fullLayout.forEach((s, i) => {\r\n if (next[i] === undefined) next[i] = s;\r\n });\r\n return next;\r\n });\r\n setNearMiss(null);\r\n if (tier) setGlow(new Set(fullLayout.map((s, i) => (s === tier ? i : -1)).filter((i) => i >= 0)));\r\n await sleep(0.8);\r\n\r\n const lines = grantLines(grant).filter((l) => l.amount > 0);\r\n const first = lines[0] ?? null;\r\n kit.fx.reserve(lines);\r\n setResult({ tier, line: first, amount: first?.amount ?? 0, lines });\r\n stage.current?.shake(10, 0.2, 22);\r\n stage.current?.edgeFlash(\"rgba(255,255,255,.2)\", 0.4);\r\n play(\"reward\");\r\n await sleep(0.35 + 0.1 + 0.28);\r\n if (first) kit.fx.burst(document.querySelector(\"[data-board-reward-icon]\"));\r\n await sleep((first ? 1.2 : 0) + 2.0);\r\n\r\n const icon = document.querySelector(\"[data-board-reward-icon]\")?.getBoundingClientRect() ?? null;\r\n const fade = stage.current?.fadeOut();\r\n kit.fx.fly(lines, () => icon);\r\n await fade;\r\n onDone();\r\n };\r\n\r\n const tap = async (i: number): Promise<void> => {\r\n if (intro || finished || openedList.current.includes(i)) return;\r\n stage.current?.shake(4, 0.12, 26);\r\n\r\n if (fast && layout.length > 0) {\r\n const tier = layout[i] ?? 0;\r\n openedList.current.push(i);\r\n const openedNow = [...openedList.current];\r\n const tally: Record<Tier, number> = { 1: 0, 2: 0, 3: 0 };\r\n for (const idx of openedNow) {\r\n const s = layout[idx] ?? 0;\r\n if (s) tally[s]++;\r\n }\r\n const won = ([1, 2, 3] as Tier[]).find((t) => tally[t] >= 3) ?? null;\r\n if (won) setFinished(won);\r\n setRevealing((r) => new Set(r).add(i));\r\n await revealCell(i, tier);\r\n target.current?.react(tier === 3);\r\n if (tier) litFrame(tier, tally[tier] - 1);\r\n if (!won) return;\r\n const res = await client.gameLoop.boardLoopRaidFast(openedNow);\r\n if (!res.ok) {\r\n toast(res.error, \"error\");\r\n await stage.current?.fadeOut();\r\n onDone();\r\n return;\r\n }\r\n const grant = res.data.Operation?.Grant ?? res.data.DualResult?.FromResult?.Grant;\r\n await finish(won, grant, layout);\r\n return;\r\n }\r\n\r\n // Sequential: every dig is a server round-trip; the grid waits for it.\r\n if (busy.current) return;\r\n busy.current = true;\r\n openedList.current.push(i);\r\n setRevealing((r) => new Set(r).add(i));\r\n const res = await client.gameLoop.boardLoopRaid(i);\r\n busy.current = false;\r\n if (!res.ok) {\r\n toast(res.error, \"error\");\r\n await stage.current?.fadeOut();\r\n onDone();\r\n return;\r\n }\r\n const tier = symbolOf(res.data.FoundSymbol);\r\n await revealCell(i, tier);\r\n target.current?.react(tier === 3);\r\n if (tier) litFrame(tier, counts[tier]);\r\n const status = res.data.Status ?? \"\";\r\n if (status.startsWith(\"FINISHED\") || (res.data.AttemptsLeft ?? 1) <= 0) {\r\n const outcome = symbolOf(res.data.Outcome);\r\n setFinished(outcome || 1);\r\n const full = (res.data.RaidLayout ?? []).map((c) => symbolOf(c?.Symbol));\r\n const grant = res.data.Operation?.Grant ?? res.data.DualResult?.FromResult?.Grant;\r\n await finish(outcome || null, grant, full.length ? full : layout);\r\n }\r\n };\r\n\r\n return (\r\n <Stage ref={stage} backdrop={<RaidBackdrop />} height={H} dim=\"rgba(0,0,0,.6)\">\r\n <TargetInfo ref={target} name={pending.TargetPublicData?.Username ?? \"???\"} avatarUrl={pending.TargetPublicData?.AvatarUrl} top={40} />\r\n\r\n {/* Prize legend */}\r\n {([3, 2, 1] as Tier[]).map((tier) => {\r\n const row = ROWS[tier];\r\n const won = result?.tier === tier ? result : null;\r\n const p = preview[tier];\r\n const text = won && won.line ? `x${formatAmount(won.amount)}` : p.line ? `x${formatAmount(p.line.amount)}` : LABEL[tier];\r\n const line = won?.line ?? p.line;\r\n return (\r\n <div key={tier}>\r\n {row.xs.map((x, k) => {\r\n const lit = counts[tier] > k;\r\n const pulse = nearMiss === tier && !lit;\r\n return (\r\n <div\r\n key={k}\r\n ref={(el) => {\r\n frameEls.current[`${tier}:${k}`] = el;\r\n }}\r\n style={{\r\n ...at(x, LEGEND_Y + row.y, row.size, row.size, 0.5, H),\r\n borderRadius: row.size * 0.2,\r\n background: `radial-gradient(circle at 50% 40%, ${row.light} 0%, ${row.bg} 70%)`,\r\n border: \"6px solid #10172e\",\r\n boxShadow: pulse\r\n ? \"0 0 0 6px #ff9a00, 0 0 34px 10px rgba(255,154,0,.8)\"\r\n : \"inset 0 6px 0 rgba(255,255,255,.45), 0 6px 0 rgba(0,0,0,.35)\",\r\n animation: pulse ? \"idos-board-heartbeat .66s ease-in-out infinite\" : undefined,\r\n boxSizing: \"border-box\",\r\n overflow: \"hidden\",\r\n }}\r\n >\r\n <div style={{ position: \"absolute\", inset: \"8%\" }}>\r\n <BarsArt tone={TONE[tier]} />\r\n </div>\r\n {lit ? null : <div style={{ position: \"absolute\", inset: 0, background: \"rgba(0,0,0,.78)\" }} />}\r\n </div>\r\n );\r\n })}\r\n <div style={{ ...at(13.75, LEGEND_Y + row.textY, 110, 110, 0.5, H), display: \"grid\", placeItems: \"center\" }}>\r\n {line ? <Icon glyph={catalog.iconOf(line)} size={100} /> : null}\r\n </div>\r\n <div\r\n style={{\r\n ...at(295, LEGEND_Y + row.textY, 452, 110, 0.5, H),\r\n display: \"flex\",\r\n alignItems: \"center\",\r\n ...outlinedText(won ? 62 : 54, won ? \"#ffe066\" : \"#fff\"),\r\n whiteSpace: \"nowrap\",\r\n }}\r\n >\r\n {text}\r\n </div>\r\n </div>\r\n );\r\n })}\r\n\r\n {/* Safes */}\r\n {CELLS.map(([x, y], i) => {\r\n const state = opened[i];\r\n const isOpen = state !== undefined;\r\n const tier = isOpen ? state : 0;\r\n return (\r\n <div\r\n key={i}\r\n ref={(el) => {\r\n cellEls.current[i] = el;\r\n }}\r\n onClick={() => void tap(i)}\r\n style={{\r\n ...at(x, y, CELL, CELL, 0.5, H),\r\n transform: isOpen ? undefined : \"scale(0)\",\r\n cursor: isOpen || finished ? \"default\" : \"pointer\",\r\n }}\r\n >\r\n <SafeFrameArt open={isOpen} />\r\n {glow.has(i) ? (\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n inset: 40,\r\n borderRadius: 30,\r\n boxShadow: \"0 0 50px 22px rgba(255,236,120,.9)\",\r\n animation: \"idos-board-breathe 1s ease-in-out infinite\",\r\n }}\r\n />\r\n ) : null}\r\n {isOpen && tier ? (\r\n <div data-reward style={{ position: \"absolute\", inset: 60, transform: revealing.has(i) ? \"scale(0)\" : undefined }}>\r\n <BarsArt tone={TONE[tier]} />\r\n </div>\r\n ) : null}\r\n {isOpen ? null : (\r\n <>\r\n <div data-door style={{ position: \"absolute\", inset: 0 }}>\r\n <SafeDoorArt />\r\n </div>\r\n <div data-tumbler style={{ position: \"absolute\", inset: 0 }}>\r\n <TumblerArt />\r\n </div>\r\n </>\r\n )}\r\n </div>\r\n );\r\n })}\r\n\r\n {result ? (\r\n <ResultBanner\r\n title={result.tier ? RESULT[result.tier] : \"REWARD\"}\r\n line={result.line}\r\n amount={result.amount}\r\n tickSeconds={1.2}\r\n bottom={190}\r\n emptyText=\"Nothing was stolen\"\r\n />\r\n ) : null}\r\n </Stage>\r\n );\r\n}\r\n"
|
|
102
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { BoardPendingInteraction, ResourceGrant } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { readBoardConfig } from \"../data/boardConfig\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport {\n BarsArt,\n RaidBackdrop,\n SafeDoorArt,\n SafeFrameArt,\n TumblerArt,\n type BarsTone,\n} from \"./modal/art\";\nimport {\n ResultBanner,\n Stage,\n TargetInfo,\n at,\n easeOutBack,\n easeOutCubic,\n outlinedText,\n sleep,\n tween,\n type StageHandle,\n type TargetInfoHandle,\n} from \"./modal/stage\";\n\n// The raid (the reference's BetterRaidPanel + BetterRaidCell, BoardSurvival BoardScene): a blue vault\n// with the target on top, a legend of the three prizes (three frames each, lit one by one as the\n// player finds them, with the amount each prize pays), and twelve safes. Tapping a safe unscrews its\n// tumbler, flings the door off and pops the ingot out; three of a kind ends the raid — the rest of the\n// safes open, the winning ones glow, and the result banner rises with the loot counting up.\n\n/** The raid canvas is laid out for a tall phone: the reference's grid runs below 1920. */\nconst H = 2340;\nconst CELL = 330;\n/** Cell centres: GridLayoutGroup 3 columns, cell 330, spacing 50 × 0, upper-centre, grid at (0, -230). */\nconst CELLS: Array<[number, number]> = Array.from({ length: 12 }, (_, i) => {\n const col = i % 3;\n const row = Math.floor(i / 3);\n return [(col - 1) * 380, 270 - CELL / 2 - row * CELL];\n});\n\ntype Tier = 1 | 2 | 3;\nconst TONE: Record<Tier, BarsTone> = { 1: \"bronze\", 2: \"silver\", 3: \"gold\" };\nconst OUTCOME: Record<Tier, \"Small\" | \"Medium\" | \"Big\"> = {\n 1: \"Small\",\n 2: \"Medium\",\n 3: \"Big\",\n};\nconst LABEL: Record<Tier, string> = {\n 1: \"SMALL REWARD\",\n 2: \"MEDIUM REWARD\",\n 3: \"LARGE REWARD\",\n};\nconst RESULT: Record<Tier, string> = {\n 1: \"SMALL REWARD!\",\n 2: \"MEDIUM REWARD!\",\n 3: \"BIG REWARD!\",\n};\n\n/** Legend rows: frame size, frame centres x, row centre y (relative to the legend centre), text y. */\nconst LEGEND_Y = 530;\nconst ROWS: Record<\n Tier,\n {\n size: number;\n xs: number[];\n y: number;\n textY: number;\n bg: string;\n light: string;\n }\n> = {\n 3: {\n size: 150,\n xs: [-446.25, -281.25, -116.25],\n y: 202.5 - 75,\n textY: 127.5,\n bg: \"#ffd850\",\n light: \"#fffb4e\",\n },\n 2: {\n size: 125,\n xs: [-394.25, -261.25, -128.25],\n y: 202.5 - 160 - 62.5,\n textY: -20,\n bg: \"#00c0ff\",\n light: \"#35fbff\",\n },\n 1: {\n size: 110,\n xs: [-361.25, -241.25, -121.25],\n y: 202.5 - 295 - 55,\n textY: -147.5,\n bg: \"#53d804\",\n light: \"#b2f11f\",\n },\n};\n\nfunction symbolOf(v: unknown): Tier | 0 {\n const n =\n typeof v === \"string\"\n ? (({ Small: 1, Medium: 2, Big: 3 } as Record<string, number>)[v] ??\n Number(v))\n : Number(v);\n return n === 1 || n === 2 || n === 3 ? n : 0;\n}\n\ninterface Preview {\n line: ResourceLine | null;\n}\n\nexport function RaidPanel({\n pending,\n onDone,\n}: {\n pending: BoardPendingInteraction;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const catalog = useCatalog();\n const stage = useRef<StageHandle>(null);\n const target = useRef<TargetInfoHandle>(null);\n const cellEls = useRef<Array<HTMLDivElement | null>>([]);\n const frameEls = useRef<Record<string, HTMLDivElement | null>>({});\n\n const fast =\n readBoardConfig(client).RaidMode === \"Fast\" ||\n (pending.RaidLayout ?? []).some(\n (c, i) =>\n symbolOf(c?.Symbol) > 0 && !(pending.OpenedIndices ?? []).includes(i),\n );\n const layout = (pending.RaidLayout ?? []).map((c) => symbolOf(c?.Symbol));\n const [opened, setOpened] = useState<Record<number, Tier | 0>>(() => {\n const o: Record<number, Tier | 0> = {};\n for (const i of pending.OpenedIndices ?? []) o[i] = layout[i] ?? 0;\n return o;\n });\n /** Opened indices, updated synchronously on every tap (rapid taps must not see a stale list). */\n const openedList = useRef<number[]>([...(pending.OpenedIndices ?? [])]);\n const [revealing, setRevealing] = useState<Set<number>>(new Set());\n const [finished, setFinished] = useState<Tier | null>(null);\n const [glow, setGlow] = useState<Set<number>>(new Set());\n const [nearMiss, setNearMiss] = useState<Tier | null>(null);\n const [result, setResult] = useState<{\n tier: Tier | null;\n line: ResourceLine | null;\n amount: number;\n lines: ResourceLine[];\n } | null>(null);\n const [intro, setIntro] = useState(true);\n const busy = useRef(false);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n // Prize preview per tier: \"how much a small/medium/large raid pays\" (RaidRewardPreview).\n const previewSrc = (\n pending as BoardPendingInteraction & {\n RaidRewardPreview?: {\n AttackerGainByOutcome?: Record<\n string,\n {\n Entries?: Array<\n { Amount?: number | null } & Record<string, unknown>\n > | null;\n }\n > | null;\n };\n }\n ).RaidRewardPreview?.AttackerGainByOutcome;\n const preview: Record<Tier, Preview> = {\n 1: { line: null },\n 2: { line: null },\n 3: { line: null },\n };\n for (const t of [1, 2, 3] as Tier[]) {\n const bundle = previewSrc?.[OUTCOME[t]];\n const lines = grantLines({ Standard: bundle } as ResourceGrant).filter(\n (l) => l.amount > 0,\n );\n preview[t] = { line: lines[0] ?? null };\n }\n\n // The legend counts only the safes the player opened — not the rest revealed at the end.\n const counts: Record<Tier, number> = { 1: 0, 2: 0, 3: 0 };\n for (const idx of openedList.current) {\n const v = opened[idx];\n if (v) counts[v]++;\n }\n\n // Staggered scale-in of the closed safes (0.05 s apart, 0.3 s EaseOutBack).\n useEffect(() => {\n void (async () => {\n await sleep(0.55);\n const runs = CELLS.map((_, i) => {\n const el = cellEls.current[i];\n if (!el || opened[i] !== undefined) return Promise.resolve();\n return sleep(0.05 * i).then(() =>\n tween(0.3, (t) => {\n el.style.transform = `scale(${easeOutBack(t)})`;\n }),\n );\n });\n await Promise.all(runs);\n setIntro(false);\n })();\n }, []);\n\n const revealCell = async (i: number, tier: Tier | 0): Promise<void> => {\n const el = cellEls.current[i];\n const door = el?.querySelector<HTMLElement>(\"[data-door]\");\n const tumbler = el?.querySelector<HTMLElement>(\"[data-tumbler]\");\n play(\"click\");\n // Unscrew the tumbler: two turns, EaseOutCubic, 0.45 s.\n await tween(0.45, (t) => {\n if (tumbler)\n tumbler.style.transform = `rotate(${-720 * easeOutCubic(t)}deg)`;\n });\n // Anticipation bump against the fly-out, then the door and tumbler fly down 260 and fade.\n await tween(0.08, (t) => {\n const y = -18 * easeOutBack(t);\n if (door) door.style.translate = `0 ${y}px`;\n if (tumbler) tumbler.style.translate = `0 ${y}px`;\n });\n play(\"close\");\n await tween(0.35, (t) => {\n const y = -18 + 278 * easeOutCubic(t);\n for (const d of [door, tumbler]) {\n if (!d) continue;\n d.style.translate = `0 ${y}px`;\n d.style.opacity = String(1 - t);\n }\n });\n setOpened((o) => ({ ...o, [i]: tier }));\n play(tier === 3 ? \"reward\" : \"coin\");\n await sleep(0.02); // let the opened safe render its ingot\n const reward = el?.querySelector<HTMLElement>(\"[data-reward]\");\n await tween(0.3, (t) => {\n if (reward) reward.style.transform = `scale(${easeOutBack(t)})`;\n });\n setRevealing((r) => {\n const next = new Set(r);\n next.delete(i);\n return next;\n });\n };\n\n const litFrame = (tier: Tier, index: number): void => {\n const f = frameEls.current[`${tier}:${index}`];\n void tween(0.4, (t) => {\n if (f) f.style.transform = `scale(${1 + Math.sin(t * Math.PI) * 0.3})`;\n });\n if (index === 1) setNearMiss(tier);\n };\n\n const finish = async (\n tier: Tier | null,\n grant: ResourceGrant | null | undefined,\n fullLayout: Array<Tier | 0>,\n ): Promise<void> => {\n // Open the rest at once, light the winning set, hold a beat.\n setOpened((o) => {\n const next = { ...o };\n fullLayout.forEach((s, i) => {\n if (next[i] === undefined) next[i] = s;\n });\n return next;\n });\n setNearMiss(null);\n if (tier)\n setGlow(\n new Set(\n fullLayout.map((s, i) => (s === tier ? i : -1)).filter((i) => i >= 0),\n ),\n );\n await sleep(0.8);\n\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n const first = lines[0] ?? null;\n kit.fx.reserve(lines);\n setResult({ tier, line: first, amount: first?.amount ?? 0, lines });\n stage.current?.shake(10, 0.2, 22);\n stage.current?.edgeFlash(\"rgba(255,255,255,.2)\", 0.4);\n play(\"reward\");\n await sleep(0.35 + 0.1 + 0.28);\n if (first) kit.fx.burst(document.querySelector(\"[data-board-reward-icon]\"));\n await sleep((first ? 1.2 : 0) + 2.0);\n\n const icon =\n document\n .querySelector(\"[data-board-reward-icon]\")\n ?.getBoundingClientRect() ?? null;\n const fade = stage.current?.fadeOut();\n kit.fx.fly(lines, () => icon);\n await fade;\n onDone();\n };\n\n const tap = async (i: number): Promise<void> => {\n if (intro || finished || openedList.current.includes(i)) return;\n stage.current?.shake(4, 0.12, 26);\n\n if (fast && layout.length > 0) {\n const tier = layout[i] ?? 0;\n openedList.current.push(i);\n const openedNow = [...openedList.current];\n const tally: Record<Tier, number> = { 1: 0, 2: 0, 3: 0 };\n for (const idx of openedNow) {\n const s = layout[idx] ?? 0;\n if (s) tally[s]++;\n }\n const won = ([1, 2, 3] as Tier[]).find((t) => tally[t] >= 3) ?? null;\n if (won) setFinished(won);\n setRevealing((r) => new Set(r).add(i));\n await revealCell(i, tier);\n target.current?.react(tier === 3);\n if (tier) litFrame(tier, tally[tier] - 1);\n if (!won) return;\n const res = await client.gameLoop.boardLoopRaidFast(openedNow);\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const grant =\n res.data.Operation?.Grant ?? res.data.DualResult?.FromResult?.Grant;\n await finish(won, grant, layout);\n return;\n }\n\n // Sequential: every dig is a server round-trip; the grid waits for it.\n if (busy.current) return;\n busy.current = true;\n openedList.current.push(i);\n setRevealing((r) => new Set(r).add(i));\n const res = await client.gameLoop.boardLoopRaid(i);\n busy.current = false;\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const tier = symbolOf(res.data.FoundSymbol);\n await revealCell(i, tier);\n target.current?.react(tier === 3);\n if (tier) litFrame(tier, counts[tier]);\n const status = res.data.Status ?? \"\";\n if (status.startsWith(\"FINISHED\") || (res.data.AttemptsLeft ?? 1) <= 0) {\n const outcome = symbolOf(res.data.Outcome);\n setFinished(outcome || 1);\n const full = (res.data.RaidLayout ?? []).map((c) => symbolOf(c?.Symbol));\n const grant =\n res.data.Operation?.Grant ?? res.data.DualResult?.FromResult?.Grant;\n await finish(outcome || null, grant, full.length ? full : layout);\n }\n };\n\n return (\n <Stage\n ref={stage}\n backdrop={<RaidBackdrop />}\n height={H}\n dim=\"rgba(0,0,0,.6)\"\n >\n <TargetInfo\n ref={target}\n name={pending.TargetPublicData?.Username ?? \"???\"}\n avatarUrl={pending.TargetPublicData?.AvatarUrl}\n top={40}\n />\n\n {/* Prize legend */}\n {([3, 2, 1] as Tier[]).map((tier) => {\n const row = ROWS[tier];\n const won = result?.tier === tier ? result : null;\n const p = preview[tier];\n const text =\n won && won.line\n ? `x${formatAmount(won.amount)}`\n : p.line\n ? `x${formatAmount(p.line.amount)}`\n : LABEL[tier];\n const line = won?.line ?? p.line;\n return (\n <div key={tier}>\n {row.xs.map((x, k) => {\n const lit = counts[tier] > k;\n const pulse = nearMiss === tier && !lit;\n return (\n <div\n key={k}\n ref={(el) => {\n frameEls.current[`${tier}:${k}`] = el;\n }}\n style={{\n ...at(x, LEGEND_Y + row.y, row.size, row.size, 0.5, H),\n borderRadius: row.size * 0.2,\n background: `radial-gradient(circle at 50% 40%, ${row.light} 0%, ${row.bg} 70%)`,\n border: \"6px solid #10172e\",\n boxShadow: pulse\n ? \"0 0 0 6px #ff9a00, 0 0 34px 10px rgba(255,154,0,.8)\"\n : \"inset 0 6px 0 rgba(255,255,255,.45), 0 6px 0 rgba(0,0,0,.35)\",\n animation: pulse\n ? \"idos-board-heartbeat .66s ease-in-out infinite\"\n : undefined,\n boxSizing: \"border-box\",\n overflow: \"hidden\",\n }}\n >\n <div style={{ position: \"absolute\", inset: \"8%\" }}>\n <BarsArt tone={TONE[tier]} />\n </div>\n {lit ? null : (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"rgba(0,0,0,.78)\",\n }}\n />\n )}\n </div>\n );\n })}\n <div\n style={{\n ...at(13.75, LEGEND_Y + row.textY, 110, 110, 0.5, H),\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {line ? <Icon glyph={catalog.iconOf(line)} size={100} /> : null}\n </div>\n <div\n style={{\n ...at(295, LEGEND_Y + row.textY, 452, 110, 0.5, H),\n display: \"flex\",\n alignItems: \"center\",\n ...outlinedText(won ? 62 : 54, won ? \"#ffe066\" : \"#fff\"),\n whiteSpace: \"nowrap\",\n }}\n >\n {text}\n </div>\n </div>\n );\n })}\n\n {/* Safes */}\n {CELLS.map(([x, y], i) => {\n const state = opened[i];\n const isOpen = state !== undefined;\n const tier = isOpen ? state : 0;\n return (\n <div\n key={i}\n ref={(el) => {\n cellEls.current[i] = el;\n }}\n onClick={() => void tap(i)}\n style={{\n ...at(x, y, CELL, CELL, 0.5, H),\n transform: isOpen ? undefined : \"scale(0)\",\n cursor: isOpen || finished ? \"default\" : \"pointer\",\n }}\n >\n <SafeFrameArt open={isOpen} />\n {glow.has(i) ? (\n <div\n style={{\n position: \"absolute\",\n inset: 40,\n borderRadius: 30,\n boxShadow: \"0 0 50px 22px rgba(255,236,120,.9)\",\n animation: \"idos-board-breathe 1s ease-in-out infinite\",\n }}\n />\n ) : null}\n {isOpen && tier ? (\n <div\n data-reward\n style={{\n position: \"absolute\",\n inset: 60,\n transform: revealing.has(i) ? \"scale(0)\" : undefined,\n }}\n >\n <BarsArt tone={TONE[tier]} />\n </div>\n ) : null}\n {isOpen ? null : (\n <>\n <div data-door style={{ position: \"absolute\", inset: 0 }}>\n <SafeDoorArt />\n </div>\n <div data-tumbler style={{ position: \"absolute\", inset: 0 }}>\n <TumblerArt />\n </div>\n </>\n )}\n </div>\n );\n })}\n\n {result ? (\n <ResultBanner\n title={result.tier ? RESULT[result.tier] : \"REWARD\"}\n line={result.line}\n amount={result.amount}\n tickSeconds={1.2}\n bottom={190}\n emptyText=\"Nothing was stolen\"\n />\n ) : null}\n </Stage>\n );\n}\n"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
"path": "components/RollButton.tsx",
|
|
106
|
-
"content": "import type { CSSProperties, PointerEvent, ReactNode } from \"react\";\n\n// The big round ROLL button and its AUTO state (the reference's RollButtonAutoRollVisual, Monopoly GO\n// style). It only draws: the parent owns the input and the auto-roll logic and passes the state in.\n\nconst SIZE = 112;\nconst RING_R = 52;\nconst RING_LEN = 2 * Math.PI * RING_R;\n\n// RollButtonAutoRollVisual colours.\nconst NORMAL = [\"#4f8cff\", \"#2a5fd6\"] as const;\nconst AUTO = [\"#38e1ff\", \"#16a9d6\"] as const;\nconst CHARGE = \"#ffb21e\";\n\nexport interface RollButtonProps {\n auto: boolean;\n /** Auto is on but waits (a pending action, out of dice). */\n paused: boolean;\n busy: boolean;\n disabled: boolean;\n /** Hold-to-auto progress, 0..1. */\n charge: number;\n pressed: boolean;\n onPointerDown: (e: PointerEvent<HTMLButtonElement>) => void;\n onPointerUp: (e: PointerEvent<HTMLButtonElement>) => void;\n onPointerLeave: (e: PointerEvent<HTMLButtonElement>) => void;\n}\n\nexport function RollButton(props: RollButtonProps): ReactNode {\n const { auto, paused, busy, disabled, charge, pressed } = props;\n const [top, deep] = auto ? AUTO : NORMAL;\n const button: CSSProperties = {\n position: \"relative\",\n width: SIZE,\n height: SIZE,\n borderRadius: \"50%\",\n border: \"4px solid #fff\",\n padding: 0,\n background: `radial-gradient(circle at 50% 30%, ${top} 0%, ${deep} 78%)`,\n boxShadow: `0 6px 0 ${auto ? \"#0d7a9c\" : \"#1a3f99\"}, 0 12px 22px rgba(0,0,0,.35)`,\n cursor: disabled ? \"default\" : \"pointer\",\n opacity: disabled ? 0.55 : paused ? 0.7 : 1,\n transform: pressed ? \"translateY(4px) scale(.95)\" : \"none\",\n transition: \"transform 90ms ease-out, background 200ms, opacity 200ms\",\n animation
|
|
106
|
+
"content": "import type { CSSProperties, PointerEvent, ReactNode } from \"react\";\n\n// The big round ROLL button and its AUTO state (the reference's RollButtonAutoRollVisual, Monopoly GO\n// style). It only draws: the parent owns the input and the auto-roll logic and passes the state in.\n\nconst SIZE = 112;\nconst RING_R = 52;\nconst RING_LEN = 2 * Math.PI * RING_R;\n\n// RollButtonAutoRollVisual colours.\nconst NORMAL = [\"#4f8cff\", \"#2a5fd6\"] as const;\nconst AUTO = [\"#38e1ff\", \"#16a9d6\"] as const;\nconst CHARGE = \"#ffb21e\";\n\nexport interface RollButtonProps {\n auto: boolean;\n /** Auto is on but waits (a pending action, out of dice). */\n paused: boolean;\n busy: boolean;\n disabled: boolean;\n /** Hold-to-auto progress, 0..1. */\n charge: number;\n pressed: boolean;\n onPointerDown: (e: PointerEvent<HTMLButtonElement>) => void;\n onPointerUp: (e: PointerEvent<HTMLButtonElement>) => void;\n onPointerLeave: (e: PointerEvent<HTMLButtonElement>) => void;\n}\n\nexport function RollButton(props: RollButtonProps): ReactNode {\n const { auto, paused, busy, disabled, charge, pressed } = props;\n const [top, deep] = auto ? AUTO : NORMAL;\n const button: CSSProperties = {\n position: \"relative\",\n width: SIZE,\n height: SIZE,\n borderRadius: \"50%\",\n border: \"4px solid #fff\",\n padding: 0,\n background: `radial-gradient(circle at 50% 30%, ${top} 0%, ${deep} 78%)`,\n boxShadow: `0 6px 0 ${auto ? \"#0d7a9c\" : \"#1a3f99\"}, 0 12px 22px rgba(0,0,0,.35)`,\n cursor: disabled ? \"default\" : \"pointer\",\n opacity: disabled ? 0.55 : paused ? 0.7 : 1,\n transform: pressed ? \"translateY(4px) scale(.95)\" : \"none\",\n transition: \"transform 90ms ease-out, background 200ms, opacity 200ms\",\n animation:\n !pressed && !disabled && !busy && !auto\n ? \"idos-board-breathe 1.8s ease-in-out infinite\"\n : undefined,\n pointerEvents: \"auto\",\n touchAction: \"none\",\n userSelect: \"none\",\n WebkitUserSelect: \"none\",\n overflow: \"visible\",\n fontFamily: \"inherit\",\n };\n return (\n <button\n type=\"button\"\n style={button}\n disabled={disabled}\n onPointerDown={props.onPointerDown}\n onPointerUp={props.onPointerUp}\n onPointerLeave={props.onPointerLeave}\n onContextMenu={(e) => e.preventDefault()}\n aria-label={auto ? \"Auto roll on — tap to stop\" : \"Roll — hold for auto\"}\n >\n {/* Rotating sheen while auto rolls. */}\n {auto ? (\n <span\n style={{\n position: \"absolute\",\n inset: 4,\n borderRadius: \"50%\",\n background:\n \"conic-gradient(from 0deg, transparent 0 70%, rgba(255,255,255,.55) 80%, transparent 90%)\",\n animation: paused\n ? undefined\n : \"idos-board-spin 1.6s linear infinite\",\n }}\n />\n ) : null}\n {/* Gloss. */}\n <span\n style={{\n position: \"absolute\",\n left: \"16%\",\n right: \"16%\",\n top: 6,\n height: \"38%\",\n borderRadius: \"50%\",\n background:\n \"linear-gradient(180deg, rgba(255,255,255,.55), rgba(255,255,255,0))\",\n pointerEvents: \"none\",\n }}\n />\n {/* Hold-to-auto charge ring. */}\n <svg\n width={SIZE}\n height={SIZE}\n viewBox={`0 0 ${SIZE} ${SIZE}`}\n style={{\n position: \"absolute\",\n left: -4,\n top: -4,\n transform: \"rotate(-90deg)\",\n pointerEvents: \"none\",\n }}\n >\n {charge > 0 ? (\n <circle\n cx={SIZE / 2}\n cy={SIZE / 2}\n r={RING_R}\n fill=\"none\"\n stroke={CHARGE}\n strokeWidth={7}\n strokeLinecap=\"round\"\n strokeDasharray={RING_LEN}\n strokeDashoffset={RING_LEN * (1 - charge)}\n style={{ filter: `drop-shadow(0 0 6px ${CHARGE})` }}\n />\n ) : null}\n </svg>\n <span\n style={{\n position: \"relative\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n gap: 2,\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 24,\n letterSpacing: 1,\n textShadow: \"0 2px 0 rgba(0,0,0,.35), 0 0 2px rgba(0,0,0,.4)\",\n pointerEvents: \"none\",\n }}\n >\n <DiceGlyph />\n {busy && !auto ? \"…\" : auto ? \"AUTO\" : \"ROLL\"}\n </span>\n </button>\n );\n}\n\nfunction DiceGlyph(): ReactNode {\n return (\n <svg width={34} height={22} viewBox=\"0 0 34 22\" aria-hidden>\n <rect\n x=\"1\"\n y=\"3\"\n width=\"17\"\n height=\"17\"\n rx=\"4\"\n fill=\"#fff\"\n transform=\"rotate(-10 9.5 11.5)\"\n />\n <circle cx=\"6\" cy=\"9\" r=\"1.8\" fill=\"#1b1b24\" />\n <circle cx=\"12\" cy=\"14\" r=\"1.8\" fill=\"#1b1b24\" />\n <rect\n x=\"15\"\n y=\"2\"\n width=\"17\"\n height=\"17\"\n rx=\"4\"\n fill=\"#fff\"\n transform=\"rotate(12 23.5 10.5)\"\n />\n <circle cx=\"20\" cy=\"6\" r=\"1.8\" fill=\"#1b1b24\" />\n <circle cx=\"24\" cy=\"10.5\" r=\"1.8\" fill=\"#d0302b\" />\n <circle cx=\"28\" cy=\"15\" r=\"1.8\" fill=\"#1b1b24\" />\n </svg>\n );\n}\n"
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"path": "components/RollControls.tsx",
|
|
110
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type PointerEvent,\n type ReactNode,\n} from \"react\";\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { useBoardState, useUserState } from \"../react/hooks\";\nimport { useStatus } from \"../react/status\";\nimport {\n buildStepPath,\n readBoardConfig,\n resolveLandingCategory,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { RollButton } from \"./RollButton\";\nimport { setRolling, usePanelBusy } from \"../react/boardBusy\";\n\n// The roll: one tap rolls once; holding the button charges a ring and switches to AUTO, which keeps\n// rolling while there are dice and nothing waits for the player (RollMultiplierController). The\n// multiplier cycles through the title's AllowedRollMultipliers and steps down by itself when the dice\n// run short of it, with a flash and a shake so the player notices.\n\nconst HOLD_MS = 600;\nconst AUTO_GAP_MS = 350;\nconst FLOAT_MS = 1600;\n\ninterface Float {\n id: number;\n line: ResourceLine;\n offset: number;\n}\n\nconst wrap: CSSProperties = {\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 14,\n pointerEvents: \"none\",\n};\n\nconst sideChip: CSSProperties = {\n pointerEvents: \"auto\",\n minWidth: 64,\n height: 44,\n borderRadius: 22,\n border: \"3px solid #fff\",\n background: \"linear-gradient(180deg, #ffd85a 0%, #f0a020 100%)\",\n boxShadow: \"0 4px 0 #b06a08, 0 8px 14px rgba(0,0,0,.3)\",\n color: \"#5a2d00\",\n fontWeight: 900,\n fontSize: 20,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n display: \"inline-flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n};\n\nconst diceChip: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n minWidth: 64,\n height: 40,\n padding: \"0 12px 0 6px\",\n borderRadius: 20,\n background: \"rgba(10, 30, 80, .72)\",\n border: \"2px solid rgba(255,255,255,.85)\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 17,\n boxShadow: \"0 6px 12px rgba(0,0,0,.25)\",\n};\n\nexport function RollControls({\n paused: pausedByPanel = false,\n onRollIntercept,\n}: {\n paused?: boolean;\n /** Called before a roll; `true` = it was used for something else (a pending panel reopened). */\n onRollIntercept?: () => boolean;\n}): ReactNode {\n const panelBusy = usePanelBusy();\n const paused = pausedByPanel || panelBusy;\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const board = useBoardState();\n const state = useUserState();\n const { setStatus } = useStatus();\n const catalog = useCatalog();\n const play = useSound();\n\n const cfg = readBoardConfig(client);\n const multipliers = (cfg.AllowedRollMultipliers?.length ? cfg.AllowedRollMultipliers : [1])\n .slice()\n .sort((a, b) => a - b);\n const rollCurrency = cfg.RollCurrencyID;\n const dice = rollCurrency\n ? (state?.InventoryV2?.VirtualCurrencies?.[rollCurrency]?.Amount ?? 0)\n : Number.POSITIVE_INFINITY;\n\n const [mult, setMult] = useState<number>(multipliers[0] ?? 1);\n const [busy, setBusy] = useState(false);\n const [auto, setAuto] = useState(false);\n const [charge, setCharge] = useState(0);\n const [pressed, setPressed] = useState(false);\n const [flash, setFlash] = useState(0);\n const [floats, setFloats] = useState<Float[]>([]);\n const busyRef = useRef(false);\n const hold = useRef<{ start: number; frame: number; enabledAuto: boolean } | null>(null);\n const floatId = useRef(0);\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n\n // Not enough dice for the chosen multiplier: step down to the biggest one the dice cover.\n useEffect(() => {\n if (!rollCurrency || dice >= mult) return;\n const fit = [...multipliers].reverse().find((m) => m <= Math.max(1, dice)) ?? 1;\n if (fit !== mult) {\n setMult(fit);\n setFlash((n) => n + 1);\n }\n }, [dice, mult, multipliers, rollCurrency]);\n\n const showRewards = useCallback(\n (grant: ResourceGrant | null | undefined): void => {\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n if (lines.length === 0) return;\n play(\"coin\");\n const born = lines.map((line, i) => ({ id: ++floatId.current, line, offset: i }));\n setFloats((f) => [...f, ...born]);\n setTimeout(() => {\n const ids = new Set(born.map((b) => b.id));\n setFloats((f) => f.filter((x) => !ids.has(x.id)));\n }, FLOAT_MS + born.length * 120);\n },\n [play],\n );\n\n const roll = useCallback(async (): Promise<void> => {\n if (busyRef.current) return;\n if (onRollIntercept?.()) return;\n busyRef.current = true;\n setBusy(true);\n setRolling(true);\n try {\n const from = client.data.user.state?.GameLoop?.Board?.Position ?? 0;\n const result = await client.gameLoop.boardLoopRoll(mult);\n if (!result.ok) {\n setAuto(false);\n setStatus(`Roll failed: ${result.error}`, \"error\");\n return;\n }\n const data = result.data;\n const path = buildStepPath(data.OldPosition ?? from, data.NewPosition, tileCount);\n await controller.rollDice(data.Steps ?? path.length, data.DiceValues);\n await controller.moveToken(path, { landing: resolveLandingCategory(data) });\n showRewards(data.Operation?.Grant);\n setStatus(\n `Rolled ${data.Steps ?? path.length} → tile ${data.NewPosition} (${data.LandedTileType ?? \"?\"})`,\n \"success\",\n );\n } finally {\n busyRef.current = false;\n setBusy(false);\n setRolling(false);\n }\n }, [client, controller, mult, tileCount, setStatus, showRewards, onRollIntercept]);\n\n // AUTO: the next roll as soon as the last one has played out — paused while the player has to act.\n useEffect(() => {\n if (!auto || busy || paused) return;\n if (dice < 1) {\n setAuto(false);\n return;\n }\n const t = setTimeout(() => void roll(), AUTO_GAP_MS);\n return () => clearTimeout(t);\n }, [auto, busy, paused, dice, roll]);\n\n const stopHold = (): void => {\n if (hold.current) cancelAnimationFrame(hold.current.frame);\n hold.current = null;\n setCharge(0);\n setPressed(false);\n };\n\n const onPointerDown = (e: PointerEvent<HTMLButtonElement>): void => {\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n try {\n e.currentTarget.setPointerCapture(e.pointerId);\n } catch {\n // The pointer is already gone (or synthetic) — the hold still works without capture.\n }\n setPressed(true);\n if (auto) {\n hold.current = { start: performance.now(), frame: 0, enabledAuto: false };\n return;\n }\n const h = { start: performance.now(), frame: 0, enabledAuto: false };\n hold.current = h;\n // AUTO switches on by a timer: frames only draw the ring, and a throttled tab has none.\n const timer = setTimeout(() => {\n if (hold.current !== h || h.enabledAuto) return;\n h.enabledAuto = true;\n setAuto(true);\n setCharge(0);\n play(\"claim\");\n }, HOLD_MS);\n const tick = (): void => {\n if (hold.current !== h || h.enabledAuto) {\n clearTimeout(timer);\n return;\n }\n setCharge(Math.min(1, (performance.now() - h.start) / HOLD_MS));\n h.frame = requestAnimationFrame(tick);\n };\n h.frame = requestAnimationFrame(tick);\n };\n\n const onPointerUp = (): void => {\n const h = hold.current;\n stopHold();\n if (!h) return;\n if (auto && !h.enabledAuto) {\n setAuto(false); // a tap while auto rolls stops it\n return;\n }\n if (h.enabledAuto) return;\n if (!auto && performance.now() - h.start >= HOLD_MS) {\n // Held long enough even if the timer was throttled (background tab): that is a hold.\n setAuto(true);\n play(\"claim\");\n return;\n }\n if (!busy && !paused) {\n play(\"click\");\n void roll();\n }\n };\n\n const cycleMultiplier = (): void => {\n const affordable = multipliers.filter((m) => !rollCurrency || m <= Math.max(1, dice));\n const i = affordable.indexOf(mult);\n setMult(affordable[(i + 1) % affordable.length] ?? 1);\n play(\"tab\");\n };\n\n const noDice = rollCurrency !== undefined && dice < 1;\n\n return (\n <div style={wrap}>\n {rollCurrency ? (\n <div style={diceChip} data-resource-counter={rollCurrency}>\n <Icon glyph={catalog.iconOf({ kind: \"currency\", id: rollCurrency, amount: 0 })} size={26} />\n {formatAmount(dice)}\n </div>\n ) : (\n <div style={{ minWidth: 64 }} />\n )}\n\n <div style={{ position: \"relative\" }}>\n <RollButton\n auto={auto}\n paused={auto && (paused || busy)}\n busy={busy}\n disabled={noDice || (!auto && paused)}\n charge={charge}\n pressed={pressed}\n onPointerDown={onPointerDown}\n onPointerUp={onPointerUp}\n onPointerLeave={() => hold.current && !hold.current.enabledAuto && stopHold()}\n />\n {floats.map((f) => (\n <div\n key={f.id}\n style={{\n position: \"absolute\",\n left: \"50%\",\n bottom: \"100%\",\n marginBottom: 8 + f.offset * 34,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: \"4px 12px 4px 6px\",\n borderRadius: 18,\n background: \"rgba(10, 30, 80, .78)\",\n border: \"2px solid #ffd85a\",\n color: \"#ffe27a\",\n fontWeight: 900,\n fontSize: 18,\n whiteSpace: \"nowrap\",\n pointerEvents: \"none\",\n animation: `idos-board-float ${FLOAT_MS}ms ease-out ${f.offset * 120}ms both`,\n }}\n >\n <Icon glyph={catalog.iconOf(f.line)} size={24} />+{formatAmount(f.line.amount)}\n </div>\n ))}\n </div>\n\n {multipliers.length > 1 ? (\n <button\n key={flash}\n type=\"button\"\n style={{\n ...sideChip,\n animation: flash > 0 ? \"idos-board-shake .45s ease-out\" : undefined,\n }}\n onClick={cycleMultiplier}\n disabled={busy && !auto}\n aria-label=\"Roll multiplier\"\n >\n <span style={{ animation: flash > 0 ? \"idos-board-flash .6s ease-out\" : undefined }}>×{mult}</span>\n </button>\n ) : (\n <div style={{ minWidth: 64 }} />\n )}\n </div>\n );\n}\n"
|
|
110
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type PointerEvent,\n type ReactNode,\n} from \"react\";\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { useBoardState, useUserState } from \"../react/hooks\";\nimport { useStatus } from \"../react/status\";\nimport {\n buildStepPath,\n readBoardConfig,\n resolveLandingCategory,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { RollButton } from \"./RollButton\";\nimport { setRolling, usePanelBusy } from \"../react/boardBusy\";\n\n// The roll: one tap rolls once; holding the button charges a ring and switches to AUTO, which keeps\n// rolling while there are dice and nothing waits for the player (RollMultiplierController). The\n// multiplier cycles through the title's AllowedRollMultipliers and steps down by itself when the dice\n// run short of it, with a flash and a shake so the player notices.\n\nconst HOLD_MS = 600;\nconst AUTO_GAP_MS = 350;\nconst FLOAT_MS = 1600;\n\ninterface Float {\n id: number;\n line: ResourceLine;\n offset: number;\n}\n\nconst wrap: CSSProperties = {\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 14,\n pointerEvents: \"none\",\n};\n\nconst sideChip: CSSProperties = {\n pointerEvents: \"auto\",\n minWidth: 64,\n height: 44,\n borderRadius: 22,\n border: \"3px solid #fff\",\n background: \"linear-gradient(180deg, #ffd85a 0%, #f0a020 100%)\",\n boxShadow: \"0 4px 0 #b06a08, 0 8px 14px rgba(0,0,0,.3)\",\n color: \"#5a2d00\",\n fontWeight: 900,\n fontSize: 20,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n display: \"inline-flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n};\n\nconst diceChip: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n minWidth: 64,\n height: 40,\n padding: \"0 12px 0 6px\",\n borderRadius: 20,\n background: \"rgba(10, 30, 80, .72)\",\n border: \"2px solid rgba(255,255,255,.85)\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 17,\n boxShadow: \"0 6px 12px rgba(0,0,0,.25)\",\n};\n\nexport function RollControls({\n paused: pausedByPanel = false,\n onRollIntercept,\n}: {\n paused?: boolean;\n /** Called before a roll; `true` = it was used for something else (a pending panel reopened). */\n onRollIntercept?: () => boolean;\n}): ReactNode {\n const panelBusy = usePanelBusy();\n const paused = pausedByPanel || panelBusy;\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const board = useBoardState();\n const state = useUserState();\n const { setStatus } = useStatus();\n const catalog = useCatalog();\n const play = useSound();\n\n const cfg = readBoardConfig(client);\n const multipliers = (\n cfg.AllowedRollMultipliers?.length ? cfg.AllowedRollMultipliers : [1]\n )\n .slice()\n .sort((a, b) => a - b);\n const rollCurrency = cfg.RollCurrencyID;\n const dice = rollCurrency\n ? (state?.InventoryV2?.VirtualCurrencies?.[rollCurrency]?.Amount ?? 0)\n : Number.POSITIVE_INFINITY;\n\n const [mult, setMult] = useState<number>(multipliers[0] ?? 1);\n const [busy, setBusy] = useState(false);\n const [auto, setAuto] = useState(false);\n const [charge, setCharge] = useState(0);\n const [pressed, setPressed] = useState(false);\n const [flash, setFlash] = useState(0);\n const [floats, setFloats] = useState<Float[]>([]);\n const busyRef = useRef(false);\n const hold = useRef<{\n start: number;\n frame: number;\n enabledAuto: boolean;\n } | null>(null);\n const floatId = useRef(0);\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n\n // Not enough dice for the chosen multiplier: step down to the biggest one the dice cover.\n useEffect(() => {\n if (!rollCurrency || dice >= mult) return;\n const fit =\n [...multipliers].reverse().find((m) => m <= Math.max(1, dice)) ?? 1;\n if (fit !== mult) {\n setMult(fit);\n setFlash((n) => n + 1);\n }\n }, [dice, mult, multipliers, rollCurrency]);\n\n const showRewards = useCallback(\n (grant: ResourceGrant | null | undefined): void => {\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n if (lines.length === 0) return;\n play(\"coin\");\n const born = lines.map((line, i) => ({\n id: ++floatId.current,\n line,\n offset: i,\n }));\n setFloats((f) => [...f, ...born]);\n setTimeout(\n () => {\n const ids = new Set(born.map((b) => b.id));\n setFloats((f) => f.filter((x) => !ids.has(x.id)));\n },\n FLOAT_MS + born.length * 120,\n );\n },\n [play],\n );\n\n const roll = useCallback(async (): Promise<void> => {\n if (busyRef.current) return;\n if (onRollIntercept?.()) return;\n busyRef.current = true;\n setBusy(true);\n setRolling(true);\n try {\n const from = client.data.user.state?.GameLoop?.Board?.Position ?? 0;\n const result = await client.gameLoop.boardLoopRoll(mult);\n if (!result.ok) {\n setAuto(false);\n setStatus(`Roll failed: ${result.error}`, \"error\");\n return;\n }\n const data = result.data;\n const path = buildStepPath(\n data.OldPosition ?? from,\n data.NewPosition,\n tileCount,\n );\n await controller.rollDice(data.Steps ?? path.length, data.DiceValues);\n await controller.moveToken(path, {\n landing: resolveLandingCategory(data),\n });\n showRewards(data.Operation?.Grant);\n setStatus(\n `Rolled ${data.Steps ?? path.length} → tile ${data.NewPosition} (${data.LandedTileType ?? \"?\"})`,\n \"success\",\n );\n } finally {\n busyRef.current = false;\n setBusy(false);\n setRolling(false);\n }\n }, [\n client,\n controller,\n mult,\n tileCount,\n setStatus,\n showRewards,\n onRollIntercept,\n ]);\n\n // AUTO: the next roll as soon as the last one has played out — paused while the player has to act.\n useEffect(() => {\n if (!auto || busy || paused) return;\n if (dice < 1) {\n setAuto(false);\n return;\n }\n const t = setTimeout(() => void roll(), AUTO_GAP_MS);\n return () => clearTimeout(t);\n }, [auto, busy, paused, dice, roll]);\n\n const stopHold = (): void => {\n if (hold.current) cancelAnimationFrame(hold.current.frame);\n hold.current = null;\n setCharge(0);\n setPressed(false);\n };\n\n const onPointerDown = (e: PointerEvent<HTMLButtonElement>): void => {\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n try {\n e.currentTarget.setPointerCapture(e.pointerId);\n } catch {\n // The pointer is already gone (or synthetic) — the hold still works without capture.\n }\n setPressed(true);\n if (auto) {\n hold.current = { start: performance.now(), frame: 0, enabledAuto: false };\n return;\n }\n const h = { start: performance.now(), frame: 0, enabledAuto: false };\n hold.current = h;\n // AUTO switches on by a timer: frames only draw the ring, and a throttled tab has none.\n const timer = setTimeout(() => {\n if (hold.current !== h || h.enabledAuto) return;\n h.enabledAuto = true;\n setAuto(true);\n setCharge(0);\n play(\"claim\");\n }, HOLD_MS);\n const tick = (): void => {\n if (hold.current !== h || h.enabledAuto) {\n clearTimeout(timer);\n return;\n }\n setCharge(Math.min(1, (performance.now() - h.start) / HOLD_MS));\n h.frame = requestAnimationFrame(tick);\n };\n h.frame = requestAnimationFrame(tick);\n };\n\n const onPointerUp = (): void => {\n const h = hold.current;\n stopHold();\n if (!h) return;\n if (auto && !h.enabledAuto) {\n setAuto(false); // a tap while auto rolls stops it\n return;\n }\n if (h.enabledAuto) return;\n if (!auto && performance.now() - h.start >= HOLD_MS) {\n // Held long enough even if the timer was throttled (background tab): that is a hold.\n setAuto(true);\n play(\"claim\");\n return;\n }\n if (!busy && !paused) {\n play(\"click\");\n void roll();\n }\n };\n\n const cycleMultiplier = (): void => {\n const affordable = multipliers.filter(\n (m) => !rollCurrency || m <= Math.max(1, dice),\n );\n const i = affordable.indexOf(mult);\n setMult(affordable[(i + 1) % affordable.length] ?? 1);\n play(\"tab\");\n };\n\n const noDice = rollCurrency !== undefined && dice < 1;\n\n return (\n <div style={wrap}>\n {rollCurrency ? (\n <div style={diceChip} data-resource-counter={rollCurrency}>\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: rollCurrency,\n amount: 0,\n })}\n size={26}\n />\n {formatAmount(dice)}\n </div>\n ) : (\n <div style={{ minWidth: 64 }} />\n )}\n\n <div style={{ position: \"relative\" }}>\n <RollButton\n auto={auto}\n paused={auto && (paused || busy)}\n busy={busy}\n disabled={noDice || (!auto && paused)}\n charge={charge}\n pressed={pressed}\n onPointerDown={onPointerDown}\n onPointerUp={onPointerUp}\n onPointerLeave={() =>\n hold.current && !hold.current.enabledAuto && stopHold()\n }\n />\n {floats.map((f) => (\n <div\n key={f.id}\n style={{\n position: \"absolute\",\n left: \"50%\",\n bottom: \"100%\",\n marginBottom: 8 + f.offset * 34,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: \"4px 12px 4px 6px\",\n borderRadius: 18,\n background: \"rgba(10, 30, 80, .78)\",\n border: \"2px solid #ffd85a\",\n color: \"#ffe27a\",\n fontWeight: 900,\n fontSize: 18,\n whiteSpace: \"nowrap\",\n pointerEvents: \"none\",\n animation: `idos-board-float ${FLOAT_MS}ms ease-out ${f.offset * 120}ms both`,\n }}\n >\n <Icon glyph={catalog.iconOf(f.line)} size={24} />+\n {formatAmount(f.line.amount)}\n </div>\n ))}\n </div>\n\n {multipliers.length > 1 ? (\n <button\n key={flash}\n type=\"button\"\n style={{\n ...sideChip,\n animation: flash > 0 ? \"idos-board-shake .45s ease-out\" : undefined,\n }}\n onClick={cycleMultiplier}\n disabled={busy && !auto}\n aria-label=\"Roll multiplier\"\n >\n <span\n style={{\n animation:\n flash > 0 ? \"idos-board-flash .6s ease-out\" : undefined,\n }}\n >\n ×{mult}\n </span>\n </button>\n ) : (\n <div style={{ minWidth: 64 }} />\n )}\n </div>\n );\n}\n"
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"path": "components/SpecialPanel.tsx",
|
|
114
|
-
"content": "import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\r\nimport type { BoardPendingInteraction } from \"@idosgames/core\";\r\nimport { Icon, formatAmount, grantLines, useCatalog, useSound, useToast, useUiKit, type ResourceLine } from \"@idosgames/react/ui\";\r\nimport { useIDosGamesClient } from \"../react/context\";\r\nimport { setPanelBusy } from \"../react/boardBusy\";\r\nimport { useBoardState } from \"../react/hooks\";\r\nimport { resolveSpecialChoices } from \"../data/boardConfig\";\r\nimport type { SpecialChoice } from \"../data/interactions\";\r\nimport { ChestArt } from \"./modal/art\";\r\nimport { Stage, at, easeOutBack, outlinedText, tween, type StageHandle } from \"./modal/stage\";\r\n\r\n// The special tile (the reference's SpecialPanel, BoardSurvival BoardScene): the board goes dark,\r\n// a plum ribbon names the mode, one big green card offers the long run (PLAY) and \"Skip\" below takes\r\n// the instant prize. The web board has no run of its own, so a Timed choice whose payout is measured\r\n// by run statistics is not offered here — the card then takes the instant prize. After a Timed choice\r\n// the victory screen (Play_Result_Victory) shows the rewards with Claim and Claim ×3.\r\n\r\nconst FALLBACK: SpecialChoice[] = [\r\n { ChoiceID: \"Instant\", Mode: \"Instant\" },\r\n { ChoiceID: \"Timed\", Mode: \"Timed\" },\r\n];\r\n\r\nexport function SpecialPanel({ pending, onDone }: { pending: BoardPendingInteraction; onDone: () => void }): ReactNode {\r\n const client = useIDosGamesClient();\r\n const kit = useUiKit();\r\n const play = useSound();\r\n const toast = useToast();\r\n const catalog = useCatalog();\r\n const stage = useRef<StageHandle>(null);\r\n const card = useRef<HTMLDivElement>(null);\r\n const [busy, setBusy] = useState(false);\r\n\r\n useEffect(() => {\r\n setPanelBusy(true);\r\n return () => setPanelBusy(false);\r\n }, []);\r\n\r\n const special = pending.Special;\r\n const timedChosen = special?.ChosenMode === \"Timed\" || special?.ChosenMode === 1;\r\n const board = useBoardState();\r\n const stashed = special?.Choices as SpecialChoice[] | null | undefined;\r\n const configured = resolveSpecialChoices(client, board?.StageLevel ?? 1, special?.ModeID) as SpecialChoice[];\r\n const choices = stashed?.length ? stashed : configured.length ? configured : FALLBACK;\r\n const instant = choices.find((c) => c.Mode === \"Instant\");\r\n const timedRaw = choices.find((c) => c.Mode === \"Timed\");\r\n // A run-stat payout needs the reference's survival run — not on the web board.\r\n const timed = timedRaw && !(timedRaw as { RunStats?: unknown }).RunStats ? timedRaw : undefined;\r\n const [phase, setPhase] = useState<\"choose\" | \"victory\">(timedChosen ? \"victory\" : \"choose\");\r\n\r\n const linesOf = (c: SpecialChoice | undefined): ResourceLine[] =>\r\n grantLines(c?.Reward?.Operation?.Grant as Parameters<typeof grantLines>[0]).filter((l) => l.amount > 0);\r\n\r\n const close = async (lines: ResourceLine[], from: Element | null): Promise<void> => {\r\n const rect = from?.getBoundingClientRect() ?? null;\r\n kit.fx.reserve(lines);\r\n const fade = stage.current?.fadeOut();\r\n kit.fx.fly(lines, () => rect);\r\n await fade;\r\n onDone();\r\n };\r\n\r\n const choose = async (choice: SpecialChoice | undefined): Promise<void> => {\r\n if (!choice || busy) return;\r\n setBusy(true);\r\n play(\"click\");\r\n const res = await client.gameLoop.boardSpecialChoose(choice.ChoiceID ?? choice.Mode ?? \"Instant\");\r\n if (!res.ok) {\r\n // The reference's FailAndClose: say why and close — the pending, if alive, comes back on ROLL.\r\n toast(res.error, \"error\");\r\n await stage.current?.fadeOut();\r\n onDone();\r\n return;\r\n }\r\n if (choice.Mode === \"Timed\") {\r\n setBusy(false);\r\n setPhase(\"victory\");\r\n return;\r\n }\r\n play(\"reward\");\r\n const lines = grantLines(res.data.Operation?.Grant).filter((l) => l.amount > 0);\r\n await close(lines.length ? lines : linesOf(choice), card.current);\r\n };\r\n\r\n const claim = async (withAd: boolean): Promise<void> => {\r\n if (busy) return;\r\n setBusy(true);\r\n play(\"claim\");\r\n if (withAd) {\r\n const applied = await client.gameLoop.boardSpecialApplyMultiplier();\r\n if (!applied.ok) {\r\n setBusy(false);\r\n toast(applied.error, \"error\");\r\n return;\r\n }\r\n }\r\n const res = await client.gameLoop.boardSpecialClaim();\r\n if (!res.ok) {\r\n toast(res.error, \"error\");\r\n await stage.current?.fadeOut();\r\n onDone();\r\n return;\r\n }\r\n const lines = grantLines((res.data as { Operation?: { Grant?: Parameters<typeof grantLines>[0] } }).Operation?.Grant).filter((l) => l.amount > 0);\r\n await close(lines, document.querySelector(\"[data-board-victory-rewards]\"));\r\n };\r\n\r\n // The card: Timed (PLAY) when the web board can play it, else the instant prize.\r\n const cardChoice = timed ?? instant;\r\n const cardIsTimed = cardChoice === timed && timed !== undefined;\r\n\r\n return (\r\n <Stage ref={stage} dim=\"rgba(0,0,0,.72)\">\r\n {phase === \"choose\" ? (\r\n <>\r\n <Ribbon y={500} w={900} h={150} tone=\"plum\" text={special?.ModeID && special.ModeID !== \"default\" ? special.ModeID : \"SPECIAL\"} size={65} />\r\n <div\r\n ref={card}\r\n onClick={() => void choose(cardChoice)}\r\n style={{\r\n ...at(0, 0, 483, 630),\r\n cursor: busy ? \"default\" : \"pointer\",\r\n animation: \"idos-board-card-in .45s cubic-bezier(.34,1.56,.64,1) both, idos-board-breathe 2.4s ease-in-out .5s infinite\",\r\n }}\r\n >\r\n <GreenCard>\r\n <div style={{ ...abs(0, 85 - 192, 384, 384, 483, 630) }}>\r\n <ChestArt />\r\n </div>\r\n <div\r\n style={{\r\n ...abs(0, -130 - 55, 423, 110, 483, 630),\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n textAlign: \"center\",\r\n ...outlinedText(42),\r\n }}\r\n >\r\n {cardIsTimed ? \"Bigger reward — play to multiply it.\" : \"Claim your reward instantly.\"}\r\n </div>\r\n <div style={{ ...abs(0, -245 - 62, 412, 124, 483, 630), display: \"grid\", placeItems: \"center\", ...outlinedText(70, \"#fff36a\", \"#145a2c\") }}>\r\n {cardIsTimed ? \"PLAY\" : \"CLAIM\"}\r\n </div>\r\n </GreenCard>\r\n </div>\r\n {cardIsTimed && instant ? (\r\n <div\r\n onClick={() => void choose(instant)}\r\n style={{ ...at(0, -500, 300, 100), display: \"grid\", placeItems: \"center\", cursor: \"pointer\", ...outlinedText(58) }}\r\n >\r\n Skip\r\n </div>\r\n ) : null}\r\n </>\r\n ) : (\r\n <Victory\r\n lines={linesOf(timedRaw)}\r\n busy={busy}\r\n onClaim={() => void claim(false)}\r\n onClaimX3={() => void claim(true)}\r\n iconOf={catalog.iconOf}\r\n />\r\n )}\r\n </Stage>\r\n );\r\n}\r\n\r\n/** Position inside a parent box of `pw × ph` the Unity way (centre anchor, y up). */\r\nfunction abs(x: number, y: number, w: number, h: number, pw: number, ph: number): CSSProperties {\r\n return { position: \"absolute\", left: pw / 2 + x - w / 2, top: ph / 2 - y - h, width: w, height: h };\r\n}\r\n\r\n/** CardFrame03 (green): outer frame, lighter inner panel shifted up, a soft top light. */\r\nfunction GreenCard({ children }: { children: ReactNode }): ReactNode {\r\n return (\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n inset: 0,\r\n borderRadius: 46,\r\n background: \"linear-gradient(180deg,#34d77c 0%,#2ac870 60%,#20a95b 100%)\",\r\n border: \"8px solid #10172e\",\r\n boxShadow: \"inset 0 8px 0 rgba(255,255,255,.45), 0 14px 0 rgba(0,0,0,.4)\",\r\n boxSizing: \"border-box\",\r\n }}\r\n >\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n left: 26,\r\n right: 26,\r\n top: 26,\r\n bottom: 120,\r\n borderRadius: 30,\r\n background: \"radial-gradient(circle at 50% 35%, #6af0a4 0%, #38e283 60%, #2fc673 100%)\",\r\n boxShadow: \"inset 0 -6px 0 rgba(0,0,0,.12)\",\r\n }}\r\n />\r\n {children}\r\n </div>\r\n );\r\n}\r\n\r\n/** Title_Ribbon01 (plum / sky): a raised middle plate with folded ends. */\r\nfunction Ribbon({ y, w, h, tone, text, size }: { y: number; w: number; h: number; tone: \"plum\" | \"sky\"; text: string; size: number }): ReactNode {\r\n const [face, fold, light] = tone === \"plum\" ? [\"#8a1fff\", \"#5a10b8\", \"#c27cff\"] : [\"#2fa8ff\", \"#1b6fc4\", \"#8fd4ff\"];\r\n return (\r\n <div style={{ ...at(0, y, w, h), animation: \"idos-board-card-in .4s cubic-bezier(.34,1.56,.64,1) both\" }}>\r\n <svg viewBox=\"0 0 300 150\" preserveAspectRatio=\"none\" style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }} aria-hidden>\r\n <g stroke=\"#10172e\" strokeWidth=\"6\" strokeLinejoin=\"round\">\r\n <path d=\"M4 40 L70 40 L70 120 L4 120 L28 80 Z\" fill={face} />\r\n <path d=\"M296 40 L230 40 L230 120 L296 120 L272 80 Z\" fill={face} />\r\n <path d=\"M60 50 L80 50 L80 130 L60 120 Z M240 50 L220 50 L220 130 L240 120 Z\" fill={fold} />\r\n <rect x=\"70\" y=\"14\" width=\"160\" height=\"112\" rx=\"14\" fill={face} />\r\n </g>\r\n <rect x=\"80\" y=\"22\" width=\"140\" height=\"16\" rx=\"8\" fill={light} />\r\n </svg>\r\n <div style={{ position: \"absolute\", inset: \"0 15% 12% 15%\", display: \"grid\", placeItems: \"center\", ...outlinedText(size), whiteSpace: \"nowrap\" }}>\r\n {text}\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\n/** Play_Result_Victory: rotating rays, the winged badge, a sky ribbon, REWARDS, item frames, Claim / Claim ×3. */\r\nfunction Victory({\r\n lines,\r\n busy,\r\n onClaim,\r\n onClaimX3,\r\n iconOf,\r\n}: {\r\n lines: ResourceLine[];\r\n busy: boolean;\r\n onClaim: () => void;\r\n onClaimX3: () => void;\r\n iconOf: (line: ResourceLine) => string;\r\n}): ReactNode {\r\n const badge = useRef<HTMLDivElement>(null);\r\n useEffect(() => {\r\n const b = badge.current;\r\n void tween(0.5, (t) => {\r\n if (b) b.style.transform = `scale(${easeOutBack(t)})`;\r\n });\r\n }, []);\r\n return (\r\n <>\r\n <div style={{ ...at(0, 437, 620, 620), animation: \"idos-board-spin 6s linear infinite\", pointerEvents: \"none\" }}>\r\n <svg viewBox=\"0 0 200 200\" style={{ width: \"100%\", height: \"100%\" }} aria-hidden>\r\n {Array.from({ length: 12 }, (_, i) => (\r\n <path key={i} d=\"M100 100 L92 0 L108 0 Z\" fill=\"rgba(255,240,150,.45)\" transform={`rotate(${i * 30} 100 100)`} />\r\n ))}\r\n </svg>\r\n </div>\r\n <div ref={badge} style={{ ...at(0, 533.8, 536, 449), transform: \"scale(0)\" }}>\r\n <svg viewBox=\"0 0 536 449\" style={{ width: \"100%\", height: \"100%\" }} aria-hidden>\r\n <g stroke=\"#10172e\" strokeWidth=\"9\" strokeLinejoin=\"round\">\r\n <path d=\"M268 360 L60 250 Q20 160 70 90 Q150 160 268 170 Z\" fill=\"#ffd23a\" />\r\n <path d=\"M268 360 L476 250 Q516 160 466 90 Q386 160 268 170 Z\" fill=\"#ffd23a\" />\r\n <path d=\"M268 30 L380 90 L370 280 L268 400 L166 280 L156 90 Z\" fill=\"#2fa8ff\" />\r\n <path d=\"M268 70 L340 112 L334 262 L268 346 L202 262 L196 112 Z\" fill=\"#8fd4ff\" />\r\n <path d=\"M268 110 L282 250 L268 300 L254 250 Z\" fill=\"#fff\" />\r\n <rect x=\"226\" y=\"246\" width=\"84\" height=\"18\" rx=\"6\" fill=\"#ffd23a\" />\r\n </g>\r\n </svg>\r\n </div>\r\n <Ribbon y={291} w={690} h={143} tone=\"sky\" text=\"VICTORY!\" size={67} />\r\n <div style={{ ...at(0, 11, 650, 67), display: \"flex\", alignItems: \"center\", justifyContent: \"center\", gap: 24 }}>\r\n <div style={{ width: 174, height: 10, borderRadius: 5, background: \"linear-gradient(90deg, transparent, #ffe066)\" }} />\r\n <div style={outlinedText(40)}>REWARDS</div>\r\n <div style={{ width: 174, height: 10, borderRadius: 5, background: \"linear-gradient(90deg, #ffe066, transparent)\" }} />\r\n </div>\r\n <div data-board-victory-rewards style={{ ...at(0, -150, 915, 190), display: \"flex\", justifyContent: \"center\", gap: 24 }}>\r\n {(lines.length ? lines : []).map((l, i) => (\r\n <div\r\n key={i}\r\n style={{\r\n width: 190,\r\n height: 190,\r\n borderRadius: 34,\r\n background: \"radial-gradient(circle at 50% 40%, #35fbff 0%, #0b69d6 75%)\",\r\n border: \"7px solid #1d3fb1\",\r\n boxShadow: \"0 0 0 5px #10172e, 0 10px 0 rgba(0,0,0,.35)\",\r\n position: \"relative\",\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n animation: `idos-board-card-in .4s cubic-bezier(.34,1.56,.64,1) ${0.3 + i * 0.1}s both`,\r\n }}\r\n >\r\n <Icon glyph={iconOf(l)} size={130} />\r\n <div style={{ position: \"absolute\", bottom: 6, ...outlinedText(38) }}>{formatAmount(l.amount)}</div>\r\n </div>\r\n ))}\r\n </div>\r\n <GameButton y={-644} w={350} h={130} tone=\"sky\" disabled={busy} onClick={onClaim}>\r\n Claim\r\n </GameButton>\r\n <GameButton y={-810} w={370} h={160} tone=\"green\" disabled={busy} onClick={onClaimX3}>\r\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 14 }}>\r\n <svg viewBox=\"0 0 40 40\" width={70} height={70} aria-hidden>\r\n <rect x=\"3\" y=\"7\" width=\"34\" height=\"26\" rx=\"6\" fill=\"#ff4d4d\" stroke=\"#10172e\" strokeWidth=\"3\" />\r\n <path d=\"M16 13 L27 20 L16 27 Z\" fill=\"#fff\" />\r\n </svg>\r\n Claim x3\r\n </span>\r\n </GameButton>\r\n </>\r\n );\r\n}\r\n\r\n/** Button01 (sky / green): glossy pill with a dark outline and a bottom lip. */\r\nfunction GameButton({\r\n y,\r\n w,\r\n h,\r\n tone,\r\n disabled,\r\n onClick,\r\n children,\r\n}: {\r\n y: number;\r\n w: number;\r\n h: number;\r\n tone: \"sky\" | \"green\";\r\n disabled?: boolean;\r\n onClick: () => void;\r\n children: ReactNode;\r\n}): ReactNode {\r\n const [top, bottom, lip] = tone === \"sky\" ? [\"#5cc8ff\", \"#1c8fe6\", \"#0d5aa8\"] : [\"#7be34a\", \"#33b31d\", \"#1f7a10\"];\r\n return (\r\n <button\r\n type=\"button\"\r\n disabled={disabled}\r\n onClick={onClick}\r\n style={{\r\n ...at(0, y, w, h),\r\n borderRadius: h / 2.6,\r\n border: \"7px solid #10172e\",\r\n background: `linear-gradient(180deg, ${top} 0%, ${bottom} 100%)`,\r\n boxShadow: `inset 0 8px 0 rgba(255,255,255,.4), 0 10px 0 ${lip}, 0 16px 0 rgba(0,0,0,.3)`,\r\n cursor: disabled ? \"default\" : \"pointer\",\r\n opacity: disabled ? 0.7 : 1,\r\n fontFamily: \"inherit\",\r\n ...outlinedText(48),\r\n }}\r\n >\r\n {children}\r\n </button>\r\n );\r\n}\r\n"
|
|
114
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { BoardPendingInteraction } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport { useBoardState } from \"../react/hooks\";\nimport { resolveSpecialChoices } from \"../data/boardConfig\";\nimport type { SpecialChoice } from \"../data/interactions\";\nimport { ChestArt } from \"./modal/art\";\nimport {\n Stage,\n at,\n easeOutBack,\n outlinedText,\n tween,\n type StageHandle,\n} from \"./modal/stage\";\n\n// The special tile (the reference's SpecialPanel, BoardSurvival BoardScene): the board goes dark,\n// a plum ribbon names the mode, one big green card offers the long run (PLAY) and \"Skip\" below takes\n// the instant prize. The web board has no run of its own, so a Timed choice whose payout is measured\n// by run statistics is not offered here — the card then takes the instant prize. After a Timed choice\n// the victory screen (Play_Result_Victory) shows the rewards with Claim and Claim ×3.\n\nconst FALLBACK: SpecialChoice[] = [\n { ChoiceID: \"Instant\", Mode: \"Instant\" },\n { ChoiceID: \"Timed\", Mode: \"Timed\" },\n];\n\nexport function SpecialPanel({\n pending,\n onDone,\n}: {\n pending: BoardPendingInteraction;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const catalog = useCatalog();\n const stage = useRef<StageHandle>(null);\n const card = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n const special = pending.Special;\n const timedChosen =\n special?.ChosenMode === \"Timed\" || special?.ChosenMode === 1;\n const board = useBoardState();\n const stashed = special?.Choices as SpecialChoice[] | null | undefined;\n const configured = resolveSpecialChoices(\n client,\n board?.StageLevel ?? 1,\n special?.ModeID,\n ) as SpecialChoice[];\n const choices = stashed?.length\n ? stashed\n : configured.length\n ? configured\n : FALLBACK;\n const instant = choices.find((c) => c.Mode === \"Instant\");\n const timedRaw = choices.find((c) => c.Mode === \"Timed\");\n // A run-stat payout needs the reference's survival run — not on the web board.\n const timed =\n timedRaw && !(timedRaw as { RunStats?: unknown }).RunStats\n ? timedRaw\n : undefined;\n const [phase, setPhase] = useState<\"choose\" | \"victory\">(\n timedChosen ? \"victory\" : \"choose\",\n );\n\n const linesOf = (c: SpecialChoice | undefined): ResourceLine[] =>\n grantLines(\n c?.Reward?.Operation?.Grant as Parameters<typeof grantLines>[0],\n ).filter((l) => l.amount > 0);\n\n const close = async (\n lines: ResourceLine[],\n from: Element | null,\n ): Promise<void> => {\n const rect = from?.getBoundingClientRect() ?? null;\n kit.fx.reserve(lines);\n const fade = stage.current?.fadeOut();\n kit.fx.fly(lines, () => rect);\n await fade;\n onDone();\n };\n\n const choose = async (choice: SpecialChoice | undefined): Promise<void> => {\n if (!choice || busy) return;\n setBusy(true);\n play(\"click\");\n const res = await client.gameLoop.boardSpecialChoose(\n choice.ChoiceID ?? choice.Mode ?? \"Instant\",\n );\n if (!res.ok) {\n // The reference's FailAndClose: say why and close — the pending, if alive, comes back on ROLL.\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n if (choice.Mode === \"Timed\") {\n setBusy(false);\n setPhase(\"victory\");\n return;\n }\n play(\"reward\");\n const lines = grantLines(res.data.Operation?.Grant).filter(\n (l) => l.amount > 0,\n );\n await close(lines.length ? lines : linesOf(choice), card.current);\n };\n\n const claim = async (withAd: boolean): Promise<void> => {\n if (busy) return;\n setBusy(true);\n play(\"claim\");\n if (withAd) {\n const applied = await client.gameLoop.boardSpecialApplyMultiplier();\n if (!applied.ok) {\n setBusy(false);\n toast(applied.error, \"error\");\n return;\n }\n }\n const res = await client.gameLoop.boardSpecialClaim();\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const lines = grantLines(\n (res.data as { Operation?: { Grant?: Parameters<typeof grantLines>[0] } })\n .Operation?.Grant,\n ).filter((l) => l.amount > 0);\n await close(lines, document.querySelector(\"[data-board-victory-rewards]\"));\n };\n\n // The card: Timed (PLAY) when the web board can play it, else the instant prize.\n const cardChoice = timed ?? instant;\n const cardIsTimed = cardChoice === timed && timed !== undefined;\n\n return (\n <Stage ref={stage} dim=\"rgba(0,0,0,.72)\">\n {phase === \"choose\" ? (\n <>\n <Ribbon\n y={500}\n w={900}\n h={150}\n tone=\"plum\"\n text={\n special?.ModeID && special.ModeID !== \"default\"\n ? special.ModeID\n : \"SPECIAL\"\n }\n size={65}\n />\n <div\n ref={card}\n onClick={() => void choose(cardChoice)}\n style={{\n ...at(0, 0, 483, 630),\n cursor: busy ? \"default\" : \"pointer\",\n animation:\n \"idos-board-card-in .45s cubic-bezier(.34,1.56,.64,1) both, idos-board-breathe 2.4s ease-in-out .5s infinite\",\n }}\n >\n <GreenCard>\n <div style={{ ...abs(0, 85 - 192, 384, 384, 483, 630) }}>\n <ChestArt />\n </div>\n <div\n style={{\n ...abs(0, -130 - 55, 423, 110, 483, 630),\n display: \"grid\",\n placeItems: \"center\",\n textAlign: \"center\",\n ...outlinedText(42),\n }}\n >\n {cardIsTimed\n ? \"Bigger reward — play to multiply it.\"\n : \"Claim your reward instantly.\"}\n </div>\n <div\n style={{\n ...abs(0, -245 - 62, 412, 124, 483, 630),\n display: \"grid\",\n placeItems: \"center\",\n ...outlinedText(70, \"#fff36a\", \"#145a2c\"),\n }}\n >\n {cardIsTimed ? \"PLAY\" : \"CLAIM\"}\n </div>\n </GreenCard>\n </div>\n {cardIsTimed && instant ? (\n <div\n onClick={() => void choose(instant)}\n style={{\n ...at(0, -500, 300, 100),\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n ...outlinedText(58),\n }}\n >\n Skip\n </div>\n ) : null}\n </>\n ) : (\n <Victory\n lines={linesOf(timedRaw)}\n busy={busy}\n onClaim={() => void claim(false)}\n onClaimX3={() => void claim(true)}\n iconOf={catalog.iconOf}\n />\n )}\n </Stage>\n );\n}\n\n/** Position inside a parent box of `pw × ph` the Unity way (centre anchor, y up). */\nfunction abs(\n x: number,\n y: number,\n w: number,\n h: number,\n pw: number,\n ph: number,\n): CSSProperties {\n return {\n position: \"absolute\",\n left: pw / 2 + x - w / 2,\n top: ph / 2 - y - h,\n width: w,\n height: h,\n };\n}\n\n/** CardFrame03 (green): outer frame, lighter inner panel shifted up, a soft top light. */\nfunction GreenCard({ children }: { children: ReactNode }): ReactNode {\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 46,\n background:\n \"linear-gradient(180deg,#34d77c 0%,#2ac870 60%,#20a95b 100%)\",\n border: \"8px solid #10172e\",\n boxShadow:\n \"inset 0 8px 0 rgba(255,255,255,.45), 0 14px 0 rgba(0,0,0,.4)\",\n boxSizing: \"border-box\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 26,\n right: 26,\n top: 26,\n bottom: 120,\n borderRadius: 30,\n background:\n \"radial-gradient(circle at 50% 35%, #6af0a4 0%, #38e283 60%, #2fc673 100%)\",\n boxShadow: \"inset 0 -6px 0 rgba(0,0,0,.12)\",\n }}\n />\n {children}\n </div>\n );\n}\n\n/** Title_Ribbon01 (plum / sky): a raised middle plate with folded ends. */\nfunction Ribbon({\n y,\n w,\n h,\n tone,\n text,\n size,\n}: {\n y: number;\n w: number;\n h: number;\n tone: \"plum\" | \"sky\";\n text: string;\n size: number;\n}): ReactNode {\n const [face, fold, light] =\n tone === \"plum\"\n ? [\"#8a1fff\", \"#5a10b8\", \"#c27cff\"]\n : [\"#2fa8ff\", \"#1b6fc4\", \"#8fd4ff\"];\n return (\n <div\n style={{\n ...at(0, y, w, h),\n animation: \"idos-board-card-in .4s cubic-bezier(.34,1.56,.64,1) both\",\n }}\n >\n <svg\n viewBox=\"0 0 300 150\"\n preserveAspectRatio=\"none\"\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n }}\n aria-hidden\n >\n <g stroke=\"#10172e\" strokeWidth=\"6\" strokeLinejoin=\"round\">\n <path d=\"M4 40 L70 40 L70 120 L4 120 L28 80 Z\" fill={face} />\n <path d=\"M296 40 L230 40 L230 120 L296 120 L272 80 Z\" fill={face} />\n <path\n d=\"M60 50 L80 50 L80 130 L60 120 Z M240 50 L220 50 L220 130 L240 120 Z\"\n fill={fold}\n />\n <rect x=\"70\" y=\"14\" width=\"160\" height=\"112\" rx=\"14\" fill={face} />\n </g>\n <rect x=\"80\" y=\"22\" width=\"140\" height=\"16\" rx=\"8\" fill={light} />\n </svg>\n <div\n style={{\n position: \"absolute\",\n inset: \"0 15% 12% 15%\",\n display: \"grid\",\n placeItems: \"center\",\n ...outlinedText(size),\n whiteSpace: \"nowrap\",\n }}\n >\n {text}\n </div>\n </div>\n );\n}\n\n/** Play_Result_Victory: rotating rays, the winged badge, a sky ribbon, REWARDS, item frames, Claim / Claim ×3. */\nfunction Victory({\n lines,\n busy,\n onClaim,\n onClaimX3,\n iconOf,\n}: {\n lines: ResourceLine[];\n busy: boolean;\n onClaim: () => void;\n onClaimX3: () => void;\n iconOf: (line: ResourceLine) => string;\n}): ReactNode {\n const badge = useRef<HTMLDivElement>(null);\n useEffect(() => {\n const b = badge.current;\n void tween(0.5, (t) => {\n if (b) b.style.transform = `scale(${easeOutBack(t)})`;\n });\n }, []);\n return (\n <>\n <div\n style={{\n ...at(0, 437, 620, 620),\n animation: \"idos-board-spin 6s linear infinite\",\n pointerEvents: \"none\",\n }}\n >\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n {Array.from({ length: 12 }, (_, i) => (\n <path\n key={i}\n d=\"M100 100 L92 0 L108 0 Z\"\n fill=\"rgba(255,240,150,.45)\"\n transform={`rotate(${i * 30} 100 100)`}\n />\n ))}\n </svg>\n </div>\n <div\n ref={badge}\n style={{ ...at(0, 533.8, 536, 449), transform: \"scale(0)\" }}\n >\n <svg\n viewBox=\"0 0 536 449\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke=\"#10172e\" strokeWidth=\"9\" strokeLinejoin=\"round\">\n <path\n d=\"M268 360 L60 250 Q20 160 70 90 Q150 160 268 170 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M268 360 L476 250 Q516 160 466 90 Q386 160 268 170 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M268 30 L380 90 L370 280 L268 400 L166 280 L156 90 Z\"\n fill=\"#2fa8ff\"\n />\n <path\n d=\"M268 70 L340 112 L334 262 L268 346 L202 262 L196 112 Z\"\n fill=\"#8fd4ff\"\n />\n <path d=\"M268 110 L282 250 L268 300 L254 250 Z\" fill=\"#fff\" />\n <rect\n x=\"226\"\n y=\"246\"\n width=\"84\"\n height=\"18\"\n rx=\"6\"\n fill=\"#ffd23a\"\n />\n </g>\n </svg>\n </div>\n <Ribbon y={291} w={690} h={143} tone=\"sky\" text=\"VICTORY!\" size={67} />\n <div\n style={{\n ...at(0, 11, 650, 67),\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n <div\n style={{\n width: 174,\n height: 10,\n borderRadius: 5,\n background: \"linear-gradient(90deg, transparent, #ffe066)\",\n }}\n />\n <div style={outlinedText(40)}>REWARDS</div>\n <div\n style={{\n width: 174,\n height: 10,\n borderRadius: 5,\n background: \"linear-gradient(90deg, #ffe066, transparent)\",\n }}\n />\n </div>\n <div\n data-board-victory-rewards\n style={{\n ...at(0, -150, 915, 190),\n display: \"flex\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n {(lines.length ? lines : []).map((l, i) => (\n <div\n key={i}\n style={{\n width: 190,\n height: 190,\n borderRadius: 34,\n background:\n \"radial-gradient(circle at 50% 40%, #35fbff 0%, #0b69d6 75%)\",\n border: \"7px solid #1d3fb1\",\n boxShadow: \"0 0 0 5px #10172e, 0 10px 0 rgba(0,0,0,.35)\",\n position: \"relative\",\n display: \"grid\",\n placeItems: \"center\",\n animation: `idos-board-card-in .4s cubic-bezier(.34,1.56,.64,1) ${0.3 + i * 0.1}s both`,\n }}\n >\n <Icon glyph={iconOf(l)} size={130} />\n <div\n style={{ position: \"absolute\", bottom: 6, ...outlinedText(38) }}\n >\n {formatAmount(l.amount)}\n </div>\n </div>\n ))}\n </div>\n <GameButton\n y={-644}\n w={350}\n h={130}\n tone=\"sky\"\n disabled={busy}\n onClick={onClaim}\n >\n Claim\n </GameButton>\n <GameButton\n y={-810}\n w={370}\n h={160}\n tone=\"green\"\n disabled={busy}\n onClick={onClaimX3}\n >\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 14 }}>\n <svg viewBox=\"0 0 40 40\" width={70} height={70} aria-hidden>\n <rect\n x=\"3\"\n y=\"7\"\n width=\"34\"\n height=\"26\"\n rx=\"6\"\n fill=\"#ff4d4d\"\n stroke=\"#10172e\"\n strokeWidth=\"3\"\n />\n <path d=\"M16 13 L27 20 L16 27 Z\" fill=\"#fff\" />\n </svg>\n Claim x3\n </span>\n </GameButton>\n </>\n );\n}\n\n/** Button01 (sky / green): glossy pill with a dark outline and a bottom lip. */\nfunction GameButton({\n y,\n w,\n h,\n tone,\n disabled,\n onClick,\n children,\n}: {\n y: number;\n w: number;\n h: number;\n tone: \"sky\" | \"green\";\n disabled?: boolean;\n onClick: () => void;\n children: ReactNode;\n}): ReactNode {\n const [top, bottom, lip] =\n tone === \"sky\"\n ? [\"#5cc8ff\", \"#1c8fe6\", \"#0d5aa8\"]\n : [\"#7be34a\", \"#33b31d\", \"#1f7a10\"];\n return (\n <button\n type=\"button\"\n disabled={disabled}\n onClick={onClick}\n style={{\n ...at(0, y, w, h),\n borderRadius: h / 2.6,\n border: \"7px solid #10172e\",\n background: `linear-gradient(180deg, ${top} 0%, ${bottom} 100%)`,\n boxShadow: `inset 0 8px 0 rgba(255,255,255,.4), 0 10px 0 ${lip}, 0 16px 0 rgba(0,0,0,.3)`,\n cursor: disabled ? \"default\" : \"pointer\",\n opacity: disabled ? 0.7 : 1,\n fontFamily: \"inherit\",\n ...outlinedText(48),\n }}\n >\n {children}\n </button>\n );\n}\n"
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
"path": "components/StatusBar.tsx",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
"path": "data/boardConfig.ts",
|
|
130
|
-
"content": "import {\
|
|
130
|
+
"content": "import {\n CurveInterpolation,\n CurveShape,\n evaluateCurve,\n roundAmount,\n type BoardLoopDefinition,\n type IDosGamesClient,\n} from \"@idosgames/core\";\n\ntype BoardStageDef = NonNullable<BoardLoopDefinition[\"StagesByLevel\"]>[string];\nexport type BuildingDefinition = NonNullable<\n BoardStageDef[\"Buildings\"]\n>[number];\n\n// Lightweight, hand-written views over the (opaque) server `BoardDefinition` config section.\n// Shapes mirror the live backend (titleID URLV9SUP): a 40-tile loop template + per-stage data.\n\nexport interface BoardTileDef {\n Index: number;\n Type: string;\n SpecialModeID?: string;\n ChanceTableID?: string;\n}\n\nexport interface BoardTemplate {\n Tiles?: Record<string, BoardTileDef>;\n}\n\nexport interface StageBuilding {\n SlotIndex: number;\n Name?: string;\n MaxLevel?: number;\n}\n\nexport interface StageDefinition {\n Name?: string;\n BoardTemplateID?: string;\n Buildings?: StageBuilding[];\n}\n\nexport interface BoardConfig {\n RollCurrencyID?: string;\n ShieldCurrencyID?: string;\n SoftCurrencyID?: string;\n RaidMode?: string;\n AllowedRollMultipliers?: number[];\n BoardTemplatesByID?: Record<string, BoardTemplate>;\n StagesByLevel?: Record<string, StageDefinition>;\n}\n\nexport function readBoardConfig(client: IDosGamesClient): BoardConfig {\n return client.data.config.getSection<BoardConfig>(\"BoardDefinition\") ?? {};\n}\n\nexport interface TileInfo {\n index: number;\n type: string;\n}\n\nfunction stageForLevel(\n config: BoardConfig,\n stageLevel: number,\n): StageDefinition | undefined {\n const stages = config.StagesByLevel ?? {};\n return stages[String(stageLevel)] ?? stages[\"1\"] ?? Object.values(stages)[0];\n}\n\n/** The loop tiles (ordered by index) for a stage, resolved through its BoardTemplateID. */\nexport function resolveTiles(\n config: BoardConfig,\n stageLevel: number,\n): TileInfo[] {\n const stage = stageForLevel(config, stageLevel);\n const templates = config.BoardTemplatesByID ?? {};\n const tplID = stage?.BoardTemplateID ?? Object.keys(templates)[0];\n const tpl = tplID ? templates[tplID] : undefined;\n const tiles = tpl?.Tiles ?? {};\n return Object.values(tiles)\n .map((t) => ({ index: t.Index, type: t.Type }))\n .sort((a, b) => a.index - b.index);\n}\n\nexport function resolveStageName(\n config: BoardConfig,\n stageLevel: number,\n): string | undefined {\n return stageForLevel(config, stageLevel)?.Name;\n}\n\n/** The definition the build price comes from (core's model; this module's own view omits economy). */\nfunction economyConfig(client: IDosGamesClient): BoardLoopDefinition {\n return (\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\") ?? {}\n );\n}\n\n/**\n * Buildings of a stage by the server's layer order: Override.Buildings → inline Buildings →\n * the stage template's Buildings. Reading only the inline list showed nothing on a title whose\n * buildings live in a template.\n */\nexport function resolveStageBuildings(\n client: IDosGamesClient,\n stageLevel: number,\n): BuildingDefinition[] {\n const def = economyConfig(client);\n const stages = def.StagesByLevel ?? {};\n const stage = stages[String(stageLevel)] ?? stages[\"1\"];\n const template = stage?.StageTemplateID\n ? def.StageTemplatesByID?.[stage.StageTemplateID]\n : undefined;\n for (const layer of [\n stage?.Override?.Buildings,\n stage?.Buildings,\n template?.Buildings,\n ])\n if (layer && layer.length > 0) return layer;\n return [];\n}\n\n/**\n * Price preview of building `slot` from `level` to the next one — the client mirror of the server's\n * procedural economy (Unity: BuildCostCalculator / EconomyMath / BoardStageResolver):\n * `round(Unit(N) × CostMatrix[slot][level])`. Unit(N) is the table curve over the stages' UnitValue\n * anchors. 0 = no price is configured (the server decides; the button stays enabled).\n */\nexport function buildCost(\n client: IDosGamesClient,\n stageLevel: number,\n slot: number,\n level: number,\n): { amount: number; currencyID?: string } {\n const def = economyConfig(client);\n const currencyID = def.SoftCurrencyID ?? undefined;\n const stages = def.StagesByLevel ?? {};\n const stage = stages[String(stageLevel)];\n\n let matrix = stage?.Override?.EconomyOverride?.CostMatrix ?? null;\n if (!matrix || matrix.length === 0) {\n const templates = def.ProceduralEconomy?.CostMatrixTemplatesByID ?? {};\n const find = (id: string): number[][] | null => {\n const key = Object.keys(templates).find(\n (k) => k.toLowerCase() === id.toLowerCase(),\n );\n const m = key ? templates[key] : null;\n return m && m.length > 0 ? m : null;\n };\n matrix =\n find(stage?.CostMatrixTemplateID?.trim() || \"Universal\") ??\n find(\"Universal\");\n }\n const factor = matrix?.[slot]?.[level];\n if (factor === undefined) return { amount: 0, currencyID };\n\n // Unit(N): anchors (level, UnitValue) as an absolute table, geometric between points, tail growth past the last.\n const anchors = Object.entries(stages)\n .map(([key, s]) => ({ AtStep: Number(key), Value: s.UnitValue ?? 0 }))\n .filter((a) => Number.isInteger(a.AtStep) && a.AtStep > 0 && a.Value > 0)\n .sort((a, b) => a.AtStep - b.AtStep);\n let unit = 1;\n const first = anchors[0];\n const last = anchors[anchors.length - 1];\n if (first && last) {\n const lastOverride =\n stages[String(last.AtStep)]?.Override?.EconomyOverride?.PerBoardGrowth ??\n 0;\n const growth =\n lastOverride > 0\n ? lastOverride\n : (def.ProceduralEconomy?.PerBoardGrowth ?? 0);\n unit = roundAmount(\n evaluateCurve(\n {\n Shape: CurveShape.TableAbsolute,\n Points: anchors,\n Interpolation: CurveInterpolation.Geometric,\n TailGrowthRate: growth - 1,\n MinResult: 1,\n },\n first.Value,\n Math.max(1, stageLevel),\n 1,\n ),\n );\n }\n return { amount: roundAmount(unit * factor), currencyID };\n}\n\n/**\n * The choices of a special mode as the stage configures them (StageOperations.SpecialModesByID,\n * override over template). The pending the server restores carries only the ModeID, so a special\n * offer reopened after a reload reads its choices here.\n */\nexport function resolveSpecialChoices(\n client: IDosGamesClient,\n stageLevel: number,\n modeID: string | null | undefined,\n): Array<Record<string, unknown>> {\n if (!modeID) return [];\n const def = economyConfig(client);\n const stages = def.StagesByLevel ?? {};\n const stage = stages[String(stageLevel)] ?? stages[\"1\"];\n const template = stage?.StageTemplateID\n ? def.StageTemplatesByID?.[stage.StageTemplateID]\n : undefined;\n const mode =\n stage?.Override?.StageOperations?.SpecialModesByID?.[modeID] ??\n template?.StageOperations?.SpecialModesByID?.[modeID];\n return (mode?.Choices ?? []) as Array<Record<string, unknown>>;\n}\n\n/**\n * How far the current stage is built (Unity StageProgressView): the summed building levels against\n * their summed maximum (6 per building when the config names none), and how many stages the config\n * lists (null — open-ended, the procedural stages go on).\n */\nexport function stageProgress(\n client: IDosGamesClient,\n stageLevel: number,\n states: ReadonlyArray<{ Level?: number | null }>,\n): { built: number; max: number; stageCount: number | null } {\n const defs = resolveStageBuildings(client, stageLevel);\n const count = Math.max(defs.length, states.length);\n let built = 0;\n let max = 0;\n for (let i = 0; i < count; i++) {\n const cap = Number(defs[i]?.MaxLevel ?? 6) || 6;\n max += cap;\n built += Math.min(cap, Number(states[i]?.Level ?? 0));\n }\n const listed = Object.keys(economyConfig(client).StagesByLevel ?? {}).length;\n return { built, max, stageCount: listed > 1 ? listed : null };\n}\n\nexport function resolveBuildingCount(\n config: BoardConfig,\n stageLevel: number,\n): number {\n return stageForLevel(config, stageLevel)?.Buildings?.length ?? 0;\n}\n\n/** How the board reacts to a landing: quiet, a reward burst, or a \"strong\" tile that shakes it. */\nexport type LandingCategory =\n \"Reward\" | \"Attack\" | \"Raid\" | \"Special\" | \"Empty\" | \"Unknown\";\n\n/**\n * The landing category of a roll, from its response — the pending action first (the server decided\n * it), then a special offer, then the tile type. `LandedTileType` is not always a tile type name: the\n * server may send a sentinel such as \"ShieldRefund\", which lands quietly.\n */\nexport function resolveLandingCategory(roll: {\n ActionRequired?: string | null;\n SpecialModeOffer?: unknown;\n LandedTileType?: string | null;\n}): LandingCategory {\n const action = roll.ActionRequired?.toUpperCase();\n if (action === \"ATTACK\") return \"Attack\";\n if (action === \"RAID\") return \"Raid\";\n if (roll.SpecialModeOffer) return \"Special\";\n const type = roll.LandedTileType ?? \"\";\n if (/^Reward\\d*$/i.test(type) || /^CommunityChest$/i.test(type))\n return \"Reward\";\n if (/^Attack$/i.test(type)) return \"Attack\";\n if (/^Raid$/i.test(type)) return \"Raid\";\n if (/^Special$/i.test(type)) return \"Special\";\n return type ? \"Empty\" : \"Unknown\";\n}\n\n/** Steps the token visits going from `from` to `to` around an n-tile loop (exclusive of `from`). */\nexport function buildStepPath(from: number, to: number, n: number): number[] {\n if (n <= 0) return [];\n const steps = (((to - from) % n) + n) % n;\n const path: number[] = [];\n for (let i = 1; i <= steps; i++) path.push((from + i) % n);\n return path;\n}\n"
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"path": "data/interactions.ts",
|
|
@@ -143,47 +143,47 @@
|
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
"path": "game/board.ts",
|
|
146
|
-
"content": "import * as THREE from \"three\";\r\nimport type { TileInfo } from \"../data/boardConfig\";\r\nimport {\r\n BOARD_PLATE,\r\n TILE_BLUE,\r\n TILE_CREAM,\r\n TILE_FULL,\r\n TILE_GAP,\r\n TILE_HEIGHT,\r\n TILE_PURPLE,\r\n TILE_SIDE,\r\n TILE_TOP,\r\n} from \"./palette\";\r\nimport { TILE_FLOOR, plateGeometry, tileGeometry } from \"./tileMesh\";\r\nimport { TileIconCache, hasTileIcon } from \"./tileIcons\";\r\nimport { clamp01, easeOutBack, easeOutQuad, punch } from \"./tween\";\r\n\r\n// The loop of tiles on its plate. Layout, colours and every tile motion are the reference's\r\n// (BoardScene: Board/Tile_N; BoardGameManager press/bounce; BoardTile.PulseTile).\r\n\r\n/** Distance from a corner tile's centre to the first side tile's centre. */\r\nconst CORNER_TO_SIDE = TILE_FULL / 2 + TILE_GAP + TILE_SIDE / 2;\r\n/** Distance between two side tiles' centres. */\r\nconst SIDE_PITCH = TILE_SIDE + TILE_GAP;\r\n/** How far the plate reaches past the outer edge of the tiles. */\r\nconst PLATE_LIP = 0.06;\r\nconst PLATE_THICKNESS = 0.144;\r\n\r\n// BoardGameManager: tileSquashDownDuration / tileSquashUpDuration / tileSquashDepth.\r\nconst PRESS_DOWN_S = 0.06;\r\nconst PRESS_UP_S = 0.25;\r\nexport const PRESS_DEPTH = 0.025;\r\n\r\n// BoardTile: pulseHopHeight / pulseDuration / pulseScalePunch / pulseTiltDegrees.\r\nconst PULSE_HOP = 0.11;\r\nconst PULSE_S = 0.32;\r\nconst PULSE_SCALE = 0.16;\r\nconst PULSE_TILT_DEG = 9;\r\n\r\nconst HIGHLIGHT_S = 0.85;\r\n\r\n/** Icon size: corners carry a bigger sticker (sprite scale 0.2 vs 0.15 in the scene). */\r\nconst ICON_CORNER = 0.2;\r\nconst ICON_SIDE = 0.155;\r\n/** Stickers are turned to read upright from the camera (it looks along +X −Z). */\r\nconst ICON_YAW = -Math.PI / 4;\r\n\r\ninterface TileView {\r\n group: THREE.Group;\r\n material: THREE.MeshStandardMaterial;\r\n color: THREE.Color;\r\n baseY: number;\r\n /** Press: 0 = up, 1 = pressed by PRESS_DEPTH. */\r\n press: number;\r\n pressFrom: number;\r\n pressTo: number;\r\n pressT: number;\r\n pressDuration: number;\r\n bounceBack: boolean;\r\n pulse: {\r\n t: number;\r\n duration: number;\r\n amplitude: number;\r\n vibrato: number;\r\n tilt: THREE.Vector2;\r\n } | null;\r\n highlightT: number;\r\n}\r\n\r\nexport interface BoardLayout {\r\n /** Distance between the centres of two opposite corner tiles. */\r\n side: number;\r\n /** Half the plate's size — the board's footprint. */\r\n halfExtent: number;\r\n}\r\n\r\nexport class Board {\r\n readonly group = new THREE.Group();\r\n private readonly icons = new TileIconCache();\r\n private readonly geometries: THREE.BufferGeometry[] = [];\r\n private readonly sharedMaterials: THREE.Material[] = [];\r\n private tiles: TileView[] = [];\r\n private origins: THREE.Vector3[] = [];\r\n private headings: number[] = [];\r\n private layoutInfo: BoardLayout = { side: 0, halfExtent: 0 };\r\n private overlayTexture: THREE.Texture | null = null;\r\n\r\n get layout(): BoardLayout {\r\n return this.layoutInfo;\r\n }\r\n\r\n get count(): number {\r\n return this.tiles.length;\r\n }\r\n\r\n /** Where the pawn stands on tile `index` (top of the tile, not pressed). */\r\n origin(index: number): THREE.Vector3 | undefined {\r\n return this.origins[index];\r\n }\r\n\r\n /** World Y of the tile's top right now — follows a press, so the pawn sinks with its tile. */\r\n surfaceY(index: number): number {\r\n const tile = this.tiles[index];\r\n return tile ? tile.group.position.y + TILE_HEIGHT : TILE_TOP;\r\n }\r\n\r\n normalize(index: number): number {\r\n const n = this.tiles.length;\r\n return n > 0 ? ((index % n) + n) % n : 0;\r\n }\r\n\r\n build(tiles: TileInfo[]): void {\r\n this.clear();\r\n const count = tiles.length;\r\n // No tiles configured yet: an empty plate of the reference's 40-tile size, so the island and\r\n // the camera still frame something sensible.\r\n const perSide = count > 0 ? Math.max(1, Math.round(count / 4)) : 10;\r\n const side =\r\n perSide >= 2\r\n ? CORNER_TO_SIDE * 2 + (perSide - 2) * SIDE_PITCH\r\n : TILE_FULL + TILE_GAP;\r\n const c = side / 2;\r\n const halfExtent = c + TILE_FULL / 2 + PLATE_LIP;\r\n this.layoutInfo = { side, halfExtent };\r\n\r\n // The plate under the whole board, with soft contact shadows of the tiles painted on it.\r\n const plateGeo = plateGeometry(halfExtent * 2, PLATE_THICKNESS);\r\n const plateMat = new THREE.MeshStandardMaterial({\r\n color: BOARD_PLATE,\r\n roughness: 0.75,\r\n metalness: 0,\r\n vertexColors: true,\r\n });\r\n const plate = new THREE.Mesh(plateGeo, plateMat);\r\n plate.receiveShadow = true;\r\n plate.castShadow = true;\r\n this.group.add(plate);\r\n this.geometries.push(plateGeo);\r\n this.sharedMaterials.push(plateMat);\r\n\r\n const cornerGeo = tileGeometry(TILE_FULL, TILE_FULL);\r\n const sideGeo = tileGeometry(TILE_FULL, TILE_SIDE);\r\n const cornerIcon = new THREE.PlaneGeometry(ICON_CORNER, ICON_CORNER).rotateX(-Math.PI / 2);\r\n const sideIcon = new THREE.PlaneGeometry(ICON_SIDE, ICON_SIDE).rotateX(-Math.PI / 2);\r\n this.geometries.push(cornerGeo, sideGeo, cornerIcon, sideIcon);\r\n\r\n // Side starts and directions, walking the loop the way the reference's Tile_1 → Tile_40 do.\r\n const starts = [\r\n new THREE.Vector3(-c, 0, c),\r\n new THREE.Vector3(-c, 0, -c),\r\n new THREE.Vector3(c, 0, -c),\r\n new THREE.Vector3(c, 0, c),\r\n ];\r\n const dirs = [\r\n new THREE.Vector3(0, 0, -1),\r\n new THREE.Vector3(1, 0, 0),\r\n new THREE.Vector3(0, 0, 1),\r\n new THREE.Vector3(-1, 0, 0),\r\n ];\r\n const footprints: { x: number; z: number; w: number; d: number }[] = [];\r\n\r\n for (const info of tiles) {\r\n const i = info.index;\r\n const sideIndex = Math.floor(i / perSide) % 4;\r\n const k = i % perSide;\r\n const corner = k === 0;\r\n const along = corner ? 0 : CORNER_TO_SIDE + (k - 1) * SIDE_PITCH;\r\n const start = starts[sideIndex] ?? new THREE.Vector3();\r\n const dir = dirs[sideIndex] ?? new THREE.Vector3(0, 0, -1);\r\n const pos = start.clone().addScaledVector(dir, along);\r\n\r\n // Tile3 on the corners, Tile1 in the middle of each side, Tile2 everywhere else.\r\n const colorHex = corner\r\n ? TILE_PURPLE\r\n : k === Math.floor(perSide / 2)\r\n ? TILE_BLUE\r\n : TILE_CREAM;\r\n const material = new THREE.MeshStandardMaterial({\r\n color: colorHex,\r\n roughness: 0.5,\r\n metalness: 0.13,\r\n vertexColors: true,\r\n });\r\n const mesh = new THREE.Mesh(corner ? cornerGeo : sideGeo, material);\r\n mesh.castShadow = true;\r\n mesh.receiveShadow = true;\r\n\r\n const group = new THREE.Group();\r\n const heading = dir.x !== 0 ? Math.PI / 2 : 0;\r\n group.rotation.y = heading;\r\n const baseY = TILE_TOP - TILE_HEIGHT;\r\n group.position.set(pos.x, baseY, pos.z);\r\n group.add(mesh);\r\n\r\n const texture = hasTileIcon(info.type) ? this.icons.get(info.type) : null;\r\n if (texture) {\r\n const iconMat = new THREE.MeshStandardMaterial({\r\n map: texture,\r\n transparent: true,\r\n depthWrite: false,\r\n roughness: 0.6,\r\n polygonOffset: true,\r\n polygonOffsetFactor: -2,\r\n polygonOffsetUnits: -2,\r\n });\r\n const icon = new THREE.Mesh(corner ? cornerIcon : sideIcon, iconMat);\r\n icon.position.y = TILE_FLOOR + 0.0015;\r\n icon.rotation.y = ICON_YAW - heading;\r\n icon.receiveShadow = true;\r\n group.add(icon);\r\n }\r\n\r\n this.group.add(group);\r\n this.tiles[i] = {\r\n group,\r\n material,\r\n color: new THREE.Color(colorHex),\r\n baseY,\r\n press: 0,\r\n pressFrom: 0,\r\n pressTo: 0,\r\n pressT: 1,\r\n pressDuration: 0,\r\n bounceBack: false,\r\n pulse: null,\r\n highlightT: 1,\r\n };\r\n this.origins[i] = new THREE.Vector3(pos.x, TILE_TOP, pos.z);\r\n this.headings[i] = heading;\r\n const w = heading ? (corner ? TILE_FULL : TILE_SIDE) : TILE_FULL;\r\n const d = heading ? TILE_FULL : corner ? TILE_FULL : TILE_SIDE;\r\n footprints.push({ x: pos.x, z: pos.z, w, d });\r\n }\r\n\r\n this.addContactShadows(halfExtent, footprints);\r\n }\r\n\r\n /** The tiles' soft contact shadow on the plate — the AO the reference bakes into the seams. */\r\n private addContactShadows(\r\n halfExtent: number,\r\n footprints: { x: number; z: number; w: number; d: number }[],\r\n ): void {\r\n const px = 512;\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = px;\r\n canvas.height = px;\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx) return;\r\n const scale = px / (halfExtent * 2);\r\n ctx.fillStyle = \"rgba(60, 25, 0, 0.55)\";\r\n ctx.shadowColor = \"rgba(60, 25, 0, 0.9)\";\r\n ctx.shadowBlur = 9;\r\n for (const f of footprints) {\r\n const w = (f.w + 0.02) * scale;\r\n const d = (f.d + 0.02) * scale;\r\n const cx = (f.x + halfExtent) * scale;\r\n const cy = (f.z + halfExtent) * scale;\r\n ctx.beginPath();\r\n // roundRect is missing in Safari < 16 — a plain rectangle blurs to the same soft shadow.\r\n if (typeof ctx.roundRect === \"function\")\r\n ctx.roundRect(cx - w / 2, cy - d / 2, w, d, Math.min(w, d) * 0.22);\r\n else ctx.rect(cx - w / 2, cy - d / 2, w, d);\r\n ctx.fill();\r\n }\r\n const texture = new THREE.CanvasTexture(canvas);\r\n const geometry = new THREE.PlaneGeometry(halfExtent * 2, halfExtent * 2).rotateX(-Math.PI / 2);\r\n const material = new THREE.MeshBasicMaterial({\r\n map: texture,\r\n transparent: true,\r\n depthWrite: false,\r\n opacity: 0.55,\r\n polygonOffset: true,\r\n polygonOffsetFactor: -1,\r\n polygonOffsetUnits: -1,\r\n });\r\n const overlay = new THREE.Mesh(geometry, material);\r\n overlay.position.y = 0.0008;\r\n overlay.renderOrder = 1;\r\n this.group.add(overlay);\r\n this.geometries.push(geometry);\r\n this.sharedMaterials.push(material);\r\n this.overlayTexture = texture;\r\n }\r\n\r\n // ---- tile motions ----\r\n\r\n /** The pawn landed and stays: sink the tile and hold it there. */\r\n press(index: number, animated = true): void {\r\n const tile = this.tiles[index];\r\n if (!tile) return;\r\n this.startPress(tile, 1, animated ? PRESS_DOWN_S : 0, false);\r\n }\r\n\r\n /** The pawn left: let the tile spring back up. */\r\n release(index: number): void {\r\n const tile = this.tiles[index];\r\n if (!tile || tile.pressTo === 0) return;\r\n this.startPress(tile, 0, PRESS_UP_S, false);\r\n }\r\n\r\n /** The pawn hopped over: a quick dip and spring back. */\r\n bounce(index: number): void {\r\n const tile = this.tiles[index];\r\n if (!tile) return;\r\n this.startPress(tile, 1, PRESS_DOWN_S, true);\r\n }\r\n\r\n private startPress(tile: TileView, to: number, duration: number, bounceBack: boolean): void {\r\n tile.pressFrom = tile.press;\r\n tile.pressTo = to;\r\n tile.pressT = duration > 0 ? 0 : 1;\r\n tile.pressDuration = duration;\r\n tile.bounceBack = bounceBack;\r\n if (duration <= 0) tile.press = to;\r\n }\r\n\r\n /**\r\n * The neighbour wave: a hop UP with a scale punch and a random tilt (BoardTile.PulseTile).\r\n * Restarting a pulse mid-way starts from rest — the punch shape is 0 at both ends, so it never drifts.\r\n */\r\n pulse(index: number, amplitude = 1, durationScale = 1, vibrato = 4): void {\r\n const tile = this.tiles[index];\r\n if (!tile) return;\r\n tile.pulse = {\r\n t: 0,\r\n duration: PULSE_S * durationScale,\r\n amplitude,\r\n vibrato,\r\n tilt: new THREE.Vector2(Math.random() * 2 - 1, Math.random() * 2 - 1),\r\n };\r\n }\r\n\r\n /** Glow the tile the pawn landed on. */\r\n highlight(index: number): void {\r\n const tile = this.tiles[index];\r\n if (tile) tile.highlightT = 0;\r\n }\r\n\r\n update(dt: number): void {\r\n for (const tile of this.tiles) {\r\n if (!tile) continue;\r\n\r\n if (tile.pressT < 1) {\r\n tile.pressT = clamp01(tile.pressT + dt / Math.max(1e-4, tile.pressDuration));\r\n const e = tile.pressTo > tile.pressFrom ? easeOutQuad(tile.pressT) : easeOutBack(tile.pressT, 2.2);\r\n tile.press = tile.pressFrom + (tile.pressTo - tile.pressFrom) * e;\r\n if (tile.pressT >= 1 && tile.bounceBack) this.startPress(tile, 0, PRESS_UP_S, false);\r\n }\r\n\r\n let hop = 0;\r\n let grow = 0;\r\n let tiltX = 0;\r\n let tiltZ = 0;\r\n if (tile.pulse) {\r\n const p = tile.pulse;\r\n p.t += dt / p.duration;\r\n if (p.t >= 1) {\r\n tile.pulse = null;\r\n } else {\r\n hop = punch(p.t, p.vibrato, 0.7) * PULSE_HOP * p.amplitude;\r\n grow = punch(p.t, p.vibrato, 0.6) * PULSE_SCALE * p.amplitude;\r\n const tilt = punch(p.t, Math.max(1, p.vibrato - 1), 0.5) *\r\n THREE.MathUtils.degToRad(PULSE_TILT_DEG) * p.amplitude;\r\n tiltX = p.tilt.x * tilt;\r\n tiltZ = p.tilt.y * tilt;\r\n }\r\n }\r\n\r\n const g = tile.group;\r\n g.position.y = tile.baseY - tile.press * PRESS_DEPTH + Math.max(0, hop);\r\n g.scale.setScalar(1 + grow);\r\n g.rotation.x = tiltX;\r\n g.rotation.z = tiltZ;\r\n\r\n if (tile.highlightT < 1) {\r\n tile.highlightT = Math.min(1, tile.highlightT + dt / HIGHLIGHT_S);\r\n const glow = Math.sin(tile.highlightT * Math.PI);\r\n tile.material.emissive.copy(tile.color).lerp(new THREE.Color(0xffffff), 0.35);\r\n tile.material.emissiveIntensity = glow * 0.55;\r\n if (tile.highlightT >= 1) tile.material.emissiveIntensity = 0;\r\n }\r\n }\r\n }\r\n\r\n /** Every tile at rest — what a reload or a long jump starts from. */\r\n resetMotions(): void {\r\n for (const tile of this.tiles) {\r\n if (!tile) continue;\r\n tile.press = 0;\r\n tile.pressTo = 0;\r\n tile.pressT = 1;\r\n tile.pulse = null;\r\n }\r\n }\r\n\r\n private clear(): void {\r\n this.group.traverse((obj) => {\r\n if (obj instanceof THREE.Mesh) {\r\n const m = obj.material as THREE.Material | THREE.Material[];\r\n if (Array.isArray(m)) m.forEach((x) => x.dispose());\r\n else m.dispose();\r\n }\r\n });\r\n this.group.clear();\r\n this.geometries.forEach((g) => g.dispose());\r\n this.geometries.length = 0;\r\n this.sharedMaterials.length = 0;\r\n this.overlayTexture?.dispose();\r\n this.overlayTexture = null;\r\n this.tiles = [];\r\n this.origins = [];\r\n this.headings = [];\r\n }\r\n\r\n dispose(): void {\r\n this.clear();\r\n this.icons.dispose();\r\n }\r\n}\r\n"
|
|
146
|
+
"content": "import * as THREE from \"three\";\nimport type { TileInfo } from \"../data/boardConfig\";\nimport {\n BOARD_PLATE,\n TILE_BLUE,\n TILE_CREAM,\n TILE_FULL,\n TILE_GAP,\n TILE_HEIGHT,\n TILE_PURPLE,\n TILE_SIDE,\n TILE_TOP,\n} from \"./palette\";\nimport { TILE_FLOOR, plateGeometry, tileGeometry } from \"./tileMesh\";\nimport { TileIconCache, hasTileIcon } from \"./tileIcons\";\nimport { clamp01, easeOutBack, easeOutQuad, punch } from \"./tween\";\n\n// The loop of tiles on its plate. Layout, colours and every tile motion are the reference's\n// (BoardScene: Board/Tile_N; BoardGameManager press/bounce; BoardTile.PulseTile).\n\n/** Distance from a corner tile's centre to the first side tile's centre. */\nconst CORNER_TO_SIDE = TILE_FULL / 2 + TILE_GAP + TILE_SIDE / 2;\n/** Distance between two side tiles' centres. */\nconst SIDE_PITCH = TILE_SIDE + TILE_GAP;\n/** How far the plate reaches past the outer edge of the tiles. */\nconst PLATE_LIP = 0.06;\nconst PLATE_THICKNESS = 0.144;\n\n// BoardGameManager: tileSquashDownDuration / tileSquashUpDuration / tileSquashDepth.\nconst PRESS_DOWN_S = 0.06;\nconst PRESS_UP_S = 0.25;\nexport const PRESS_DEPTH = 0.025;\n\n// BoardTile: pulseHopHeight / pulseDuration / pulseScalePunch / pulseTiltDegrees.\nconst PULSE_HOP = 0.11;\nconst PULSE_S = 0.32;\nconst PULSE_SCALE = 0.16;\nconst PULSE_TILT_DEG = 9;\n\nconst HIGHLIGHT_S = 0.85;\n\n/** Icon size: corners carry a bigger sticker (sprite scale 0.2 vs 0.15 in the scene). */\nconst ICON_CORNER = 0.2;\nconst ICON_SIDE = 0.155;\n/** Stickers are turned to read upright from the camera (it looks along +X −Z). */\nconst ICON_YAW = -Math.PI / 4;\n\ninterface TileView {\n group: THREE.Group;\n material: THREE.MeshStandardMaterial;\n color: THREE.Color;\n baseY: number;\n /** Press: 0 = up, 1 = pressed by PRESS_DEPTH. */\n press: number;\n pressFrom: number;\n pressTo: number;\n pressT: number;\n pressDuration: number;\n bounceBack: boolean;\n pulse: {\n t: number;\n duration: number;\n amplitude: number;\n vibrato: number;\n tilt: THREE.Vector2;\n } | null;\n highlightT: number;\n}\n\nexport interface BoardLayout {\n /** Distance between the centres of two opposite corner tiles. */\n side: number;\n /** Half the plate's size — the board's footprint. */\n halfExtent: number;\n}\n\nexport class Board {\n readonly group = new THREE.Group();\n private readonly icons = new TileIconCache();\n private readonly geometries: THREE.BufferGeometry[] = [];\n private readonly sharedMaterials: THREE.Material[] = [];\n private tiles: TileView[] = [];\n private origins: THREE.Vector3[] = [];\n private headings: number[] = [];\n private layoutInfo: BoardLayout = { side: 0, halfExtent: 0 };\n private overlayTexture: THREE.Texture | null = null;\n\n get layout(): BoardLayout {\n return this.layoutInfo;\n }\n\n get count(): number {\n return this.tiles.length;\n }\n\n /** Where the pawn stands on tile `index` (top of the tile, not pressed). */\n origin(index: number): THREE.Vector3 | undefined {\n return this.origins[index];\n }\n\n /** World Y of the tile's top right now — follows a press, so the pawn sinks with its tile. */\n surfaceY(index: number): number {\n const tile = this.tiles[index];\n return tile ? tile.group.position.y + TILE_HEIGHT : TILE_TOP;\n }\n\n normalize(index: number): number {\n const n = this.tiles.length;\n return n > 0 ? ((index % n) + n) % n : 0;\n }\n\n build(tiles: TileInfo[]): void {\n this.clear();\n const count = tiles.length;\n // No tiles configured yet: an empty plate of the reference's 40-tile size, so the island and\n // the camera still frame something sensible.\n const perSide = count > 0 ? Math.max(1, Math.round(count / 4)) : 10;\n const side =\n perSide >= 2\n ? CORNER_TO_SIDE * 2 + (perSide - 2) * SIDE_PITCH\n : TILE_FULL + TILE_GAP;\n const c = side / 2;\n const halfExtent = c + TILE_FULL / 2 + PLATE_LIP;\n this.layoutInfo = { side, halfExtent };\n\n // The plate under the whole board, with soft contact shadows of the tiles painted on it.\n const plateGeo = plateGeometry(halfExtent * 2, PLATE_THICKNESS);\n const plateMat = new THREE.MeshStandardMaterial({\n color: BOARD_PLATE,\n roughness: 0.75,\n metalness: 0,\n vertexColors: true,\n });\n const plate = new THREE.Mesh(plateGeo, plateMat);\n plate.receiveShadow = true;\n plate.castShadow = true;\n this.group.add(plate);\n this.geometries.push(plateGeo);\n this.sharedMaterials.push(plateMat);\n\n const cornerGeo = tileGeometry(TILE_FULL, TILE_FULL);\n const sideGeo = tileGeometry(TILE_FULL, TILE_SIDE);\n const cornerIcon = new THREE.PlaneGeometry(\n ICON_CORNER,\n ICON_CORNER,\n ).rotateX(-Math.PI / 2);\n const sideIcon = new THREE.PlaneGeometry(ICON_SIDE, ICON_SIDE).rotateX(\n -Math.PI / 2,\n );\n this.geometries.push(cornerGeo, sideGeo, cornerIcon, sideIcon);\n\n // Side starts and directions, walking the loop the way the reference's Tile_1 → Tile_40 do.\n const starts = [\n new THREE.Vector3(-c, 0, c),\n new THREE.Vector3(-c, 0, -c),\n new THREE.Vector3(c, 0, -c),\n new THREE.Vector3(c, 0, c),\n ];\n const dirs = [\n new THREE.Vector3(0, 0, -1),\n new THREE.Vector3(1, 0, 0),\n new THREE.Vector3(0, 0, 1),\n new THREE.Vector3(-1, 0, 0),\n ];\n const footprints: { x: number; z: number; w: number; d: number }[] = [];\n\n for (const info of tiles) {\n const i = info.index;\n const sideIndex = Math.floor(i / perSide) % 4;\n const k = i % perSide;\n const corner = k === 0;\n const along = corner ? 0 : CORNER_TO_SIDE + (k - 1) * SIDE_PITCH;\n const start = starts[sideIndex] ?? new THREE.Vector3();\n const dir = dirs[sideIndex] ?? new THREE.Vector3(0, 0, -1);\n const pos = start.clone().addScaledVector(dir, along);\n\n // Tile3 on the corners, Tile1 in the middle of each side, Tile2 everywhere else.\n const colorHex = corner\n ? TILE_PURPLE\n : k === Math.floor(perSide / 2)\n ? TILE_BLUE\n : TILE_CREAM;\n const material = new THREE.MeshStandardMaterial({\n color: colorHex,\n roughness: 0.5,\n metalness: 0.13,\n vertexColors: true,\n });\n const mesh = new THREE.Mesh(corner ? cornerGeo : sideGeo, material);\n mesh.castShadow = true;\n mesh.receiveShadow = true;\n\n const group = new THREE.Group();\n const heading = dir.x !== 0 ? Math.PI / 2 : 0;\n group.rotation.y = heading;\n const baseY = TILE_TOP - TILE_HEIGHT;\n group.position.set(pos.x, baseY, pos.z);\n group.add(mesh);\n\n const texture = hasTileIcon(info.type) ? this.icons.get(info.type) : null;\n if (texture) {\n const iconMat = new THREE.MeshStandardMaterial({\n map: texture,\n transparent: true,\n depthWrite: false,\n roughness: 0.6,\n polygonOffset: true,\n polygonOffsetFactor: -2,\n polygonOffsetUnits: -2,\n });\n const icon = new THREE.Mesh(corner ? cornerIcon : sideIcon, iconMat);\n icon.position.y = TILE_FLOOR + 0.0015;\n icon.rotation.y = ICON_YAW - heading;\n icon.receiveShadow = true;\n group.add(icon);\n }\n\n this.group.add(group);\n this.tiles[i] = {\n group,\n material,\n color: new THREE.Color(colorHex),\n baseY,\n press: 0,\n pressFrom: 0,\n pressTo: 0,\n pressT: 1,\n pressDuration: 0,\n bounceBack: false,\n pulse: null,\n highlightT: 1,\n };\n this.origins[i] = new THREE.Vector3(pos.x, TILE_TOP, pos.z);\n this.headings[i] = heading;\n const w = heading ? (corner ? TILE_FULL : TILE_SIDE) : TILE_FULL;\n const d = heading ? TILE_FULL : corner ? TILE_FULL : TILE_SIDE;\n footprints.push({ x: pos.x, z: pos.z, w, d });\n }\n\n this.addContactShadows(halfExtent, footprints);\n }\n\n /** The tiles' soft contact shadow on the plate — the AO the reference bakes into the seams. */\n private addContactShadows(\n halfExtent: number,\n footprints: { x: number; z: number; w: number; d: number }[],\n ): void {\n const px = 512;\n const canvas = document.createElement(\"canvas\");\n canvas.width = px;\n canvas.height = px;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n const scale = px / (halfExtent * 2);\n ctx.fillStyle = \"rgba(60, 25, 0, 0.55)\";\n ctx.shadowColor = \"rgba(60, 25, 0, 0.9)\";\n ctx.shadowBlur = 9;\n for (const f of footprints) {\n const w = (f.w + 0.02) * scale;\n const d = (f.d + 0.02) * scale;\n const cx = (f.x + halfExtent) * scale;\n const cy = (f.z + halfExtent) * scale;\n ctx.beginPath();\n // roundRect is missing in Safari < 16 — a plain rectangle blurs to the same soft shadow.\n if (typeof ctx.roundRect === \"function\")\n ctx.roundRect(cx - w / 2, cy - d / 2, w, d, Math.min(w, d) * 0.22);\n else ctx.rect(cx - w / 2, cy - d / 2, w, d);\n ctx.fill();\n }\n const texture = new THREE.CanvasTexture(canvas);\n const geometry = new THREE.PlaneGeometry(\n halfExtent * 2,\n halfExtent * 2,\n ).rotateX(-Math.PI / 2);\n const material = new THREE.MeshBasicMaterial({\n map: texture,\n transparent: true,\n depthWrite: false,\n opacity: 0.55,\n polygonOffset: true,\n polygonOffsetFactor: -1,\n polygonOffsetUnits: -1,\n });\n const overlay = new THREE.Mesh(geometry, material);\n overlay.position.y = 0.0008;\n overlay.renderOrder = 1;\n this.group.add(overlay);\n this.geometries.push(geometry);\n this.sharedMaterials.push(material);\n this.overlayTexture = texture;\n }\n\n // ---- tile motions ----\n\n /** The pawn landed and stays: sink the tile and hold it there. */\n press(index: number, animated = true): void {\n const tile = this.tiles[index];\n if (!tile) return;\n this.startPress(tile, 1, animated ? PRESS_DOWN_S : 0, false);\n }\n\n /** The pawn left: let the tile spring back up. */\n release(index: number): void {\n const tile = this.tiles[index];\n if (!tile || tile.pressTo === 0) return;\n this.startPress(tile, 0, PRESS_UP_S, false);\n }\n\n /** The pawn hopped over: a quick dip and spring back. */\n bounce(index: number): void {\n const tile = this.tiles[index];\n if (!tile) return;\n this.startPress(tile, 1, PRESS_DOWN_S, true);\n }\n\n private startPress(\n tile: TileView,\n to: number,\n duration: number,\n bounceBack: boolean,\n ): void {\n tile.pressFrom = tile.press;\n tile.pressTo = to;\n tile.pressT = duration > 0 ? 0 : 1;\n tile.pressDuration = duration;\n tile.bounceBack = bounceBack;\n if (duration <= 0) tile.press = to;\n }\n\n /**\n * The neighbour wave: a hop UP with a scale punch and a random tilt (BoardTile.PulseTile).\n * Restarting a pulse mid-way starts from rest — the punch shape is 0 at both ends, so it never drifts.\n */\n pulse(index: number, amplitude = 1, durationScale = 1, vibrato = 4): void {\n const tile = this.tiles[index];\n if (!tile) return;\n tile.pulse = {\n t: 0,\n duration: PULSE_S * durationScale,\n amplitude,\n vibrato,\n tilt: new THREE.Vector2(Math.random() * 2 - 1, Math.random() * 2 - 1),\n };\n }\n\n /** Glow the tile the pawn landed on. */\n highlight(index: number): void {\n const tile = this.tiles[index];\n if (tile) tile.highlightT = 0;\n }\n\n update(dt: number): void {\n for (const tile of this.tiles) {\n if (!tile) continue;\n\n if (tile.pressT < 1) {\n tile.pressT = clamp01(\n tile.pressT + dt / Math.max(1e-4, tile.pressDuration),\n );\n const e =\n tile.pressTo > tile.pressFrom\n ? easeOutQuad(tile.pressT)\n : easeOutBack(tile.pressT, 2.2);\n tile.press = tile.pressFrom + (tile.pressTo - tile.pressFrom) * e;\n if (tile.pressT >= 1 && tile.bounceBack)\n this.startPress(tile, 0, PRESS_UP_S, false);\n }\n\n let hop = 0;\n let grow = 0;\n let tiltX = 0;\n let tiltZ = 0;\n if (tile.pulse) {\n const p = tile.pulse;\n p.t += dt / p.duration;\n if (p.t >= 1) {\n tile.pulse = null;\n } else {\n hop = punch(p.t, p.vibrato, 0.7) * PULSE_HOP * p.amplitude;\n grow = punch(p.t, p.vibrato, 0.6) * PULSE_SCALE * p.amplitude;\n const tilt =\n punch(p.t, Math.max(1, p.vibrato - 1), 0.5) *\n THREE.MathUtils.degToRad(PULSE_TILT_DEG) *\n p.amplitude;\n tiltX = p.tilt.x * tilt;\n tiltZ = p.tilt.y * tilt;\n }\n }\n\n const g = tile.group;\n g.position.y = tile.baseY - tile.press * PRESS_DEPTH + Math.max(0, hop);\n g.scale.setScalar(1 + grow);\n g.rotation.x = tiltX;\n g.rotation.z = tiltZ;\n\n if (tile.highlightT < 1) {\n tile.highlightT = Math.min(1, tile.highlightT + dt / HIGHLIGHT_S);\n const glow = Math.sin(tile.highlightT * Math.PI);\n tile.material.emissive\n .copy(tile.color)\n .lerp(new THREE.Color(0xffffff), 0.35);\n tile.material.emissiveIntensity = glow * 0.55;\n if (tile.highlightT >= 1) tile.material.emissiveIntensity = 0;\n }\n }\n }\n\n /** Every tile at rest — what a reload or a long jump starts from. */\n resetMotions(): void {\n for (const tile of this.tiles) {\n if (!tile) continue;\n tile.press = 0;\n tile.pressTo = 0;\n tile.pressT = 1;\n tile.pulse = null;\n }\n }\n\n private clear(): void {\n this.group.traverse((obj) => {\n if (obj instanceof THREE.Mesh) {\n const m = obj.material as THREE.Material | THREE.Material[];\n if (Array.isArray(m)) m.forEach((x) => x.dispose());\n else m.dispose();\n }\n });\n this.group.clear();\n this.geometries.forEach((g) => g.dispose());\n this.geometries.length = 0;\n this.sharedMaterials.length = 0;\n this.overlayTexture?.dispose();\n this.overlayTexture = null;\n this.tiles = [];\n this.origins = [];\n this.headings = [];\n }\n\n dispose(): void {\n this.clear();\n this.icons.dispose();\n }\n}\n"
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
"path": "game/BoardController.ts",
|
|
150
|
-
"content": "import * as THREE from \"three\";\r\nimport type { LandingCategory, TileInfo } from \"../data/boardConfig\";\r\nimport { Board } from \"./board\";\r\nimport { CameraRig } from \"./cameraRig\";\r\nimport { City } from \"./city\";\r\nimport { Dice } from \"./dice\";\r\nimport { Island } from \"./island\";\r\nimport { Juice, isStrong } from \"./juice\";\r\nimport { TILE_TOP } from \"./palette\";\r\nimport { Particles } from \"./particles\";\r\nimport { Props } from \"./props\";\r\nimport { createStage, type Stage } from \"./stage\";\r\nimport { Token } from \"./token\";\r\nimport { Ticker, easeInQuad, easeOutQuad, lerp } from \"./tween\";\r\n\r\n// BoardGameManager's movement: step rhythm, hop height, the suspense hop on strong tiles.\r\nconst STEP_S = 0.3;\r\nconst HOP_HEIGHT = 0.3;\r\nconst SUSPENSE_DURATION = 1.7;\r\nconst SUSPENSE_HEIGHT = 1.6;\r\nconst SUSPENSE_RISE = 0.32;\r\nconst SUSPENSE_HANG = 0.34;\r\nconst SUSPENSE_SLOWMO = 0.35;\r\nconst SUSPENSE_SQUASH = 1.7;\r\nconst TURN_SPEED = THREE.MathUtils.degToRad(720);\r\nconst TRAIL_MIN_STEPS = 3;\r\n\r\n/** Height input of the hop's sine for the suspense hop: fast rise, flat hang at the peak, fast drop. */\r\nfunction suspenseSine(t: number): number {\r\n const drop = SUSPENSE_RISE + SUSPENSE_HANG;\r\n if (t <= SUSPENSE_RISE) return lerp(0, Math.PI / 2, easeOutQuad(t / SUSPENSE_RISE));\r\n if (t <= drop) return Math.PI / 2;\r\n return lerp(Math.PI / 2, Math.PI, easeInQuad((t - drop) / (1 - drop)));\r\n}\r\n\r\n/** Horizontal progress of the suspense hop: most of the way before and after the hang, barely during. */\r\nfunction suspenseAcross(t: number): number {\r\n const drop = SUSPENSE_RISE + SUSPENSE_HANG;\r\n if (t <= SUSPENSE_RISE) return lerp(0, 0.55, easeInQuad(t / SUSPENSE_RISE));\r\n if (t <= drop) return lerp(0.55, 0.7, (t - SUSPENSE_RISE) / SUSPENSE_HANG);\r\n return lerp(0.7, 1, easeOutQuad((t - drop) / (1 - drop)));\r\n}\r\n\r\nexport interface MoveOptions {\r\n /** How the board reacts to the final landing (see `resolveLandingCategory`). */\r\n landing?: LandingCategory;\r\n}\r\n\r\n/**\r\n * Three.js renderer for the board — the look of the Unity reference scene: a loop of rounded tiles\r\n * on a plate, on a low-poly island in the sea, lit by a soft-shadowed sun; the player's city beside\r\n * it; a pawn that hops with squash & stretch; dice tossed onto the board; dust, sparks, a camera that\r\n * follows, pans and shakes. Engine-only — it knows nothing about the SDK.\r\n */\r\nexport class BoardController {\r\n private readonly stage: Stage;\r\n private readonly rig: CameraRig;\r\n private readonly ticker = new Ticker();\r\n private readonly island = new Island();\r\n private readonly props = new Props();\r\n private readonly board = new Board();\r\n private readonly city = new City();\r\n private readonly token = new Token();\r\n private readonly particles = new Particles();\r\n private readonly dice: Dice;\r\n private readonly juice: Juice;\r\n\r\n private position = 0;\r\n private hopping = false;\r\n private facing = 0;\r\n /** Board time runs slower during the suspense hop's hang — every animation with it. */\r\n private timeScale = 1;\r\n private levelsShown = false;\r\n private frame = 0;\r\n private last = 0;\r\n private disposed = false;\r\n private running = true;\r\n private resizeObserver?: ResizeObserver;\r\n\r\n private timer: ReturnType<typeof setTimeout> | undefined;\r\n\r\n /**\r\n * Next tick. A hidden tab gets no frames at all, so a roll that was mid-animation when the player\r\n * switched away would never land — and the pending panel and AUTO wait for it. Hidden, the board\r\n * keeps ticking on a timer (without drawing) until the tab is back.\r\n */\r\n private schedule(): void {\r\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\") {\r\n this.timer = setTimeout(() => this.loop(performance.now()), 50);\r\n } else {\r\n this.frame = requestAnimationFrame(this.loop);\r\n }\r\n }\r\n\r\n /** The tab hid with a frame already requested: that frame never comes — switch to the timer now. */\r\n private readonly onVisibility = (): void => {\r\n if (this.disposed || !this.running) return;\r\n this.unschedule();\r\n this.schedule();\r\n };\r\n\r\n private unschedule(): void {\r\n cancelAnimationFrame(this.frame);\r\n if (this.timer !== undefined) clearTimeout(this.timer);\r\n this.timer = undefined;\r\n }\r\n\r\n private readonly loop = (now: number): void => {\r\n if (this.disposed || !this.running) return;\r\n this.schedule();\r\n const hidden = typeof document !== \"undefined\" && document.visibilityState === \"hidden\";\r\n // A throttled hidden timer fires about once a second: let time catch up instead of crawling.\r\n const dt = Math.min(hidden ? 1 : 0.05, Math.max(0, (now - (this.last || now)) / 1000));\r\n this.last = now;\r\n this.tick(dt);\r\n if (!hidden) this.stage.renderer.render(this.stage.scene, this.rig.camera);\r\n };\r\n\r\n constructor(private readonly host: HTMLElement) {\r\n this.stage = createStage(host);\r\n this.rig = new CameraRig(this.stage.renderer.domElement, this.stage.scene.fog as THREE.Fog | null);\r\n this.dice = new Dice(this.ticker);\r\n this.juice = new Juice(this.board, this.rig, this.particles, this.ticker);\r\n this.city.onUpgrade = (at, height) => this.particles.upgrade(at, height);\r\n\r\n this.stage.scene.add(\r\n this.island.group,\r\n this.props.group,\r\n this.board.group,\r\n this.city.group,\r\n this.token.root,\r\n this.token.shadow,\r\n this.token.trail,\r\n this.dice.group,\r\n this.particles.group,\r\n );\r\n this.token.root.position.set(0, TILE_TOP, 0);\r\n\r\n this.resize();\r\n this.resizeObserver = new ResizeObserver(() => this.resize());\r\n this.resizeObserver.observe(host);\r\n document.addEventListener(\"visibilitychange\", this.onVisibility);\r\n this.schedule();\r\n }\r\n\r\n private tick(dt: number): void {\r\n const worldDt = dt * this.timeScale;\r\n this.ticker.update(worldDt);\r\n this.board.update(worldDt);\r\n\r\n if (!this.hopping) {\r\n // Standing: ride the tile under the pawn (press, wave).\r\n this.token.root.position.y = this.board.surfaceY(this.position);\r\n }\r\n const yaw = this.token.root.rotation.y;\r\n const diff = Math.atan2(Math.sin(this.facing - yaw), Math.cos(this.facing - yaw));\r\n const turn = TURN_SPEED * worldDt;\r\n this.token.root.rotation.y = Math.abs(diff) <= turn ? this.facing : yaw + Math.sign(diff) * turn;\r\n this.token.update(worldDt, TILE_TOP);\r\n\r\n this.city.update(worldDt);\r\n this.particles.update(worldDt);\r\n this.island.update(dt);\r\n this.props.update(dt);\r\n this.rig.follow(this.token.root.position);\r\n this.rig.update(dt);\r\n }\r\n\r\n // ---- board ----\r\n\r\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\r\n this.board.build(tiles);\r\n const { side, halfExtent } = this.board.layout;\r\n this.island.build(halfExtent);\r\n this.city.build(buildingCount, halfExtent, side);\r\n this.props.build(side / 2, this.city.spots, 0.3);\r\n this.levelsShown = false;\r\n this.stage.fitShadow(new THREE.Vector3(0, 0, -0.15), Island.grassHalf(halfExtent) + 0.35);\r\n this.rig.setBounds(halfExtent);\r\n this.position = 0;\r\n this.setPosition(0);\r\n // Nothing to stand on until the title has tiles.\r\n this.token.root.visible = this.board.count > 0;\r\n this.token.shadow.visible = this.board.count > 0;\r\n this.rig.snap(this.board.origin(0) ?? new THREE.Vector3(0, TILE_TOP, 0));\r\n }\r\n\r\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\r\n * (the Mode Router invariant: only the active mode runs its RAF). */\r\n setRunning(running: boolean): void {\r\n const shouldRun = running && !this.disposed;\r\n if (shouldRun === this.running) return;\r\n this.running = shouldRun;\r\n // Never leave the board in slow motion behind a suspended mode.\r\n this.timeScale = 1;\r\n if (shouldRun) {\r\n this.last = 0;\r\n this.schedule();\r\n } else this.unschedule();\r\n }\r\n\r\n /** Snap the token to a tile with no animation; the tile under it stays pressed. */\r\n setPosition(index: number): void {\r\n const p = this.board.origin(index);\r\n if (!p) return;\r\n this.board.release(this.position);\r\n this.position = index;\r\n this.board.press(index, false);\r\n this.token.root.position.set(p.x, this.board.surfaceY(index), p.z);\r\n this.faceAlong(index);\r\n this.token.root.rotation.y = this.facing;\r\n this.rig.follow(this.token.root.position);\r\n }\r\n\r\n /** Face the way the pawn will walk from `index` — not where it came from, on a corner. */\r\n private faceAlong(index: number): void {\r\n const here = this.board.origin(index);\r\n const next = this.board.origin(this.board.normalize(index + 1));\r\n if (here && next) this.facing = Math.atan2(next.x - here.x, next.z - here.z);\r\n }\r\n\r\n /**\r\n * What the scene currently shows. Read by the module's agent surface: a rendered board is a single\r\n * canvas, so this is the only way the AI Coder's agent can tell where the token stands and whether\r\n * the board was loaded at all.\r\n */\r\n describeScene(): {\r\n running: boolean;\r\n tileCount: number;\r\n buildingCount: number;\r\n token: { x: number; z: number };\r\n } {\r\n return {\r\n running: this.running,\r\n tileCount: this.board.count,\r\n buildingCount: this.city.count,\r\n token: {\r\n x: Math.round(this.token.root.position.x * 100) / 100,\r\n z: Math.round(this.token.root.position.z * 100) / 100,\r\n },\r\n };\r\n }\r\n\r\n // ---- dice ----\r\n\r\n /**\r\n * Bring the camera back to the pawn and toss the dice beside it. `values` are the per-die faces the\r\n * server rolled (`DiceValues`); without them the total is split over two dice. Resolves once they\r\n * settle; they shrink away on their own a moment later.\r\n */\r\n rollDice(total: number, values?: number[] | null): Promise<void> {\r\n this.rig.recenter();\r\n const at = this.board.origin(this.position) ?? new THREE.Vector3();\r\n return this.dice.roll(values ?? null, total, at, new THREE.Vector3(0, 0, 0));\r\n }\r\n\r\n // ---- token ----\r\n\r\n /** Animate the token through the given tile indices, one hop at a time. */\r\n async moveToken(path: number[], options: MoveOptions = {}): Promise<void> {\r\n if (path.length === 0 || this.board.count === 0) return;\r\n const landing = options.landing ?? \"Unknown\";\r\n this.board.release(this.position);\r\n this.token.emitting = path.length >= TRAIL_MIN_STEPS;\r\n this.hopping = true;\r\n try {\r\n for (let i = 0; i < path.length; i++) {\r\n const next = path[i];\r\n if (next === undefined || !this.board.origin(next)) continue;\r\n const last = i === path.length - 1;\r\n // Rhythm: the first hops a little quicker, slowing towards the end; the last one higher\r\n // and longer — \"here it lands\".\r\n const indexT = path.length > 1 ? i / (path.length - 1) : 1;\r\n let duration = lerp(STEP_S * 0.85, STEP_S * 1.3, indexT);\r\n let height = 1;\r\n let suspense = false;\r\n if (last) {\r\n duration = STEP_S * 1.5;\r\n height = 1.3;\r\n if (isStrong(landing)) {\r\n // Monopoly GO's trick: on a special tile the pawn flies higher and hangs at the top.\r\n duration = STEP_S * SUSPENSE_DURATION;\r\n height = SUSPENSE_HEIGHT;\r\n suspense = true;\r\n }\r\n }\r\n await this.hop(next, duration, height, suspense, last, landing);\r\n }\r\n } finally {\r\n this.timeScale = 1;\r\n this.token.emitting = false;\r\n this.token.lift = 0;\r\n this.token.spin = 0;\r\n this.hopping = false;\r\n }\r\n this.faceAlong(this.position);\r\n }\r\n\r\n private async hop(\r\n to: number,\r\n duration: number,\r\n heightScale: number,\r\n suspense: boolean,\r\n last: boolean,\r\n landing: LandingCategory,\r\n ): Promise<void> {\r\n const target = this.board.origin(to);\r\n if (!target) return;\r\n const from = this.token.root.position.clone();\r\n const dx = target.x - from.x;\r\n const dz = target.z - from.z;\r\n if (dx * dx + dz * dz > 1e-6) this.facing = Math.atan2(dx, dz);\r\n const squash = suspense ? SUSPENSE_SQUASH : 1;\r\n this.token.beginHop(squash);\r\n\r\n await this.ticker.run(duration, (t) => {\r\n if (suspense) {\r\n const hang = t > SUSPENSE_RISE && t <= SUSPENSE_RISE + SUSPENSE_HANG;\r\n this.timeScale = hang ? SUSPENSE_SLOWMO : 1;\r\n }\r\n const across = suspense ? suspenseAcross(t) : t;\r\n const sine = suspense ? suspenseSine(t) : t * Math.PI;\r\n this.token.root.position.set(\r\n lerp(from.x, target.x, across),\r\n lerp(from.y, TILE_TOP, across),\r\n lerp(from.z, target.z, across),\r\n );\r\n this.token.lift = Math.sin(sine) * HOP_HEIGHT * heightScale;\r\n this.token.setHopProgress(t);\r\n // Whole turns over the flight, so the landing needs no snap back.\r\n this.token.spin = suspense ? Math.PI * 2 * t : 0;\r\n });\r\n\r\n this.timeScale = 1;\r\n this.position = to;\r\n this.token.root.position.set(target.x, TILE_TOP, target.z);\r\n this.token.lift = 0;\r\n this.token.spin = 0;\r\n this.token.land(squash);\r\n\r\n if (last) {\r\n // Contact — the moment for the juice, before the tile sinks.\r\n this.juice.landed(landing, to, target);\r\n this.board.press(to);\r\n this.board.highlight(to);\r\n } else {\r\n this.board.bounce(to);\r\n }\r\n }\r\n\r\n /** Pulse a tile to mark where the token landed. */\r\n highlightTile(index: number): void {\r\n this.board.highlight(index);\r\n }\r\n\r\n /** Grow the city's houses to each building's level. */\r\n setBuildingLevels(levels: number[]): void {\r\n this.city.setLevels(levels, !this.levelsShown);\r\n this.levelsShown = true;\r\n }\r\n\r\n private resize(): void {\r\n const w = this.host.clientWidth || 640;\r\n const h = this.host.clientHeight || 640;\r\n this.stage.renderer.setSize(w, h, false);\r\n this.rig.resize(w, h);\r\n }\r\n\r\n /**\r\n * One still frame of the board, for the platform's share-a-shot button.\r\n *\r\n * ⚠ Renders and reads in the SAME turn. The renderer is created without\r\n * `preserveDrawingBuffer`, so by the time anyone else touches this canvas its drawing buffer is\r\n * already cleared and `toBlob` hands back a black rectangle without failing.\r\n */\r\n async capture(): Promise<Blob | null> {\r\n if (this.disposed) return null;\r\n this.stage.renderer.render(this.stage.scene, this.rig.camera);\r\n return await new Promise<Blob | null>((resolve) => {\r\n this.stage.renderer.domElement.toBlob(resolve, \"image/png\");\r\n });\r\n }\r\n\r\n destroy(): void {\r\n this.disposed = true;\r\n this.unschedule();\r\n document.removeEventListener(\"visibilitychange\", this.onVisibility);\r\n this.resizeObserver?.disconnect();\r\n // Finish every running animation, so nothing awaiting one is left hanging.\r\n this.ticker.cancelAll();\r\n this.rig.dispose();\r\n this.island.dispose();\r\n this.props.dispose();\r\n this.board.dispose();\r\n this.city.dispose();\r\n this.token.dispose();\r\n this.dice.dispose();\r\n this.particles.dispose();\r\n this.stage.dispose();\r\n }\r\n}\r\n"
|
|
150
|
+
"content": "import * as THREE from \"three\";\nimport type { LandingCategory, TileInfo } from \"../data/boardConfig\";\nimport { Board } from \"./board\";\nimport { CameraRig } from \"./cameraRig\";\nimport { City } from \"./city\";\nimport { Dice } from \"./dice\";\nimport { Island } from \"./island\";\nimport { Juice, isStrong } from \"./juice\";\nimport { TILE_TOP } from \"./palette\";\nimport { Particles } from \"./particles\";\nimport { Props } from \"./props\";\nimport { createStage, type Stage } from \"./stage\";\nimport { Token } from \"./token\";\nimport { Ticker, easeInQuad, easeOutQuad, lerp } from \"./tween\";\n\n// BoardGameManager's movement: step rhythm, hop height, the suspense hop on strong tiles.\nconst STEP_S = 0.3;\nconst HOP_HEIGHT = 0.3;\nconst SUSPENSE_DURATION = 1.7;\nconst SUSPENSE_HEIGHT = 1.6;\nconst SUSPENSE_RISE = 0.32;\nconst SUSPENSE_HANG = 0.34;\nconst SUSPENSE_SLOWMO = 0.35;\nconst SUSPENSE_SQUASH = 1.7;\nconst TURN_SPEED = THREE.MathUtils.degToRad(720);\nconst TRAIL_MIN_STEPS = 3;\n\n/** Height input of the hop's sine for the suspense hop: fast rise, flat hang at the peak, fast drop. */\nfunction suspenseSine(t: number): number {\n const drop = SUSPENSE_RISE + SUSPENSE_HANG;\n if (t <= SUSPENSE_RISE)\n return lerp(0, Math.PI / 2, easeOutQuad(t / SUSPENSE_RISE));\n if (t <= drop) return Math.PI / 2;\n return lerp(Math.PI / 2, Math.PI, easeInQuad((t - drop) / (1 - drop)));\n}\n\n/** Horizontal progress of the suspense hop: most of the way before and after the hang, barely during. */\nfunction suspenseAcross(t: number): number {\n const drop = SUSPENSE_RISE + SUSPENSE_HANG;\n if (t <= SUSPENSE_RISE) return lerp(0, 0.55, easeInQuad(t / SUSPENSE_RISE));\n if (t <= drop) return lerp(0.55, 0.7, (t - SUSPENSE_RISE) / SUSPENSE_HANG);\n return lerp(0.7, 1, easeOutQuad((t - drop) / (1 - drop)));\n}\n\nexport interface MoveOptions {\n /** How the board reacts to the final landing (see `resolveLandingCategory`). */\n landing?: LandingCategory;\n}\n\n/**\n * Three.js renderer for the board — the look of the Unity reference scene: a loop of rounded tiles\n * on a plate, on a low-poly island in the sea, lit by a soft-shadowed sun; the player's city beside\n * it; a pawn that hops with squash & stretch; dice tossed onto the board; dust, sparks, a camera that\n * follows, pans and shakes. Engine-only — it knows nothing about the SDK.\n */\nexport class BoardController {\n private readonly stage: Stage;\n private readonly rig: CameraRig;\n private readonly ticker = new Ticker();\n private readonly island = new Island();\n private readonly props = new Props();\n private readonly board = new Board();\n private readonly city = new City();\n private readonly token = new Token();\n private readonly particles = new Particles();\n private readonly dice: Dice;\n private readonly juice: Juice;\n\n private position = 0;\n private hopping = false;\n private facing = 0;\n /** Board time runs slower during the suspense hop's hang — every animation with it. */\n private timeScale = 1;\n private levelsShown = false;\n private frame = 0;\n private last = 0;\n private disposed = false;\n private running = true;\n private resizeObserver?: ResizeObserver;\n\n private timer: ReturnType<typeof setTimeout> | undefined;\n\n /**\n * Next tick. A hidden tab gets no frames at all, so a roll that was mid-animation when the player\n * switched away would never land — and the pending panel and AUTO wait for it. Hidden, the board\n * keeps ticking on a timer (without drawing) until the tab is back.\n */\n private schedule(): void {\n if (\n typeof document !== \"undefined\" &&\n document.visibilityState === \"hidden\"\n ) {\n this.timer = setTimeout(() => this.loop(performance.now()), 50);\n } else {\n this.frame = requestAnimationFrame(this.loop);\n }\n }\n\n /** The tab hid with a frame already requested: that frame never comes — switch to the timer now. */\n private readonly onVisibility = (): void => {\n if (this.disposed || !this.running) return;\n this.unschedule();\n this.schedule();\n };\n\n private unschedule(): void {\n cancelAnimationFrame(this.frame);\n if (this.timer !== undefined) clearTimeout(this.timer);\n this.timer = undefined;\n }\n\n private readonly loop = (now: number): void => {\n if (this.disposed || !this.running) return;\n this.schedule();\n const hidden =\n typeof document !== \"undefined\" && document.visibilityState === \"hidden\";\n // A throttled hidden timer fires about once a second: let time catch up instead of crawling.\n const dt = Math.min(\n hidden ? 1 : 0.05,\n Math.max(0, (now - (this.last || now)) / 1000),\n );\n this.last = now;\n this.tick(dt);\n if (!hidden) this.stage.renderer.render(this.stage.scene, this.rig.camera);\n };\n\n constructor(private readonly host: HTMLElement) {\n this.stage = createStage(host);\n this.rig = new CameraRig(\n this.stage.renderer.domElement,\n this.stage.scene.fog as THREE.Fog | null,\n );\n this.dice = new Dice(this.ticker);\n this.juice = new Juice(this.board, this.rig, this.particles, this.ticker);\n this.city.onUpgrade = (at, height) => this.particles.upgrade(at, height);\n\n this.stage.scene.add(\n this.island.group,\n this.props.group,\n this.board.group,\n this.city.group,\n this.token.root,\n this.token.shadow,\n this.token.trail,\n this.dice.group,\n this.particles.group,\n );\n this.token.root.position.set(0, TILE_TOP, 0);\n\n this.resize();\n this.resizeObserver = new ResizeObserver(() => this.resize());\n this.resizeObserver.observe(host);\n document.addEventListener(\"visibilitychange\", this.onVisibility);\n this.schedule();\n }\n\n private tick(dt: number): void {\n const worldDt = dt * this.timeScale;\n this.ticker.update(worldDt);\n this.board.update(worldDt);\n\n if (!this.hopping) {\n // Standing: ride the tile under the pawn (press, wave).\n this.token.root.position.y = this.board.surfaceY(this.position);\n }\n const yaw = this.token.root.rotation.y;\n const diff = Math.atan2(\n Math.sin(this.facing - yaw),\n Math.cos(this.facing - yaw),\n );\n const turn = TURN_SPEED * worldDt;\n this.token.root.rotation.y =\n Math.abs(diff) <= turn ? this.facing : yaw + Math.sign(diff) * turn;\n this.token.update(worldDt, TILE_TOP);\n\n this.city.update(worldDt);\n this.particles.update(worldDt);\n this.island.update(dt);\n this.props.update(dt);\n this.rig.follow(this.token.root.position);\n this.rig.update(dt);\n }\n\n // ---- board ----\n\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\n this.board.build(tiles);\n const { side, halfExtent } = this.board.layout;\n this.island.build(halfExtent);\n this.city.build(buildingCount, halfExtent, side);\n this.props.build(side / 2, this.city.spots, 0.3);\n this.levelsShown = false;\n this.stage.fitShadow(\n new THREE.Vector3(0, 0, -0.15),\n Island.grassHalf(halfExtent) + 0.35,\n );\n this.rig.setBounds(halfExtent);\n this.position = 0;\n this.setPosition(0);\n // Nothing to stand on until the title has tiles.\n this.token.root.visible = this.board.count > 0;\n this.token.shadow.visible = this.board.count > 0;\n this.rig.snap(this.board.origin(0) ?? new THREE.Vector3(0, TILE_TOP, 0));\n }\n\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs its RAF). */\n setRunning(running: boolean): void {\n const shouldRun = running && !this.disposed;\n if (shouldRun === this.running) return;\n this.running = shouldRun;\n // Never leave the board in slow motion behind a suspended mode.\n this.timeScale = 1;\n if (shouldRun) {\n this.last = 0;\n this.schedule();\n } else this.unschedule();\n }\n\n /** Snap the token to a tile with no animation; the tile under it stays pressed. */\n setPosition(index: number): void {\n const p = this.board.origin(index);\n if (!p) return;\n this.board.release(this.position);\n this.position = index;\n this.board.press(index, false);\n this.token.root.position.set(p.x, this.board.surfaceY(index), p.z);\n this.faceAlong(index);\n this.token.root.rotation.y = this.facing;\n this.rig.follow(this.token.root.position);\n }\n\n /** Face the way the pawn will walk from `index` — not where it came from, on a corner. */\n private faceAlong(index: number): void {\n const here = this.board.origin(index);\n const next = this.board.origin(this.board.normalize(index + 1));\n if (here && next)\n this.facing = Math.atan2(next.x - here.x, next.z - here.z);\n }\n\n /**\n * What the scene currently shows. Read by the module's agent surface: a rendered board is a single\n * canvas, so this is the only way the AI Coder's agent can tell where the token stands and whether\n * the board was loaded at all.\n */\n describeScene(): {\n running: boolean;\n tileCount: number;\n buildingCount: number;\n token: { x: number; z: number };\n } {\n return {\n running: this.running,\n tileCount: this.board.count,\n buildingCount: this.city.count,\n token: {\n x: Math.round(this.token.root.position.x * 100) / 100,\n z: Math.round(this.token.root.position.z * 100) / 100,\n },\n };\n }\n\n // ---- dice ----\n\n /**\n * Bring the camera back to the pawn and toss the dice beside it. `values` are the per-die faces the\n * server rolled (`DiceValues`); without them the total is split over two dice. Resolves once they\n * settle; they shrink away on their own a moment later.\n */\n rollDice(total: number, values?: number[] | null): Promise<void> {\n this.rig.recenter();\n const at = this.board.origin(this.position) ?? new THREE.Vector3();\n return this.dice.roll(\n values ?? null,\n total,\n at,\n new THREE.Vector3(0, 0, 0),\n );\n }\n\n // ---- token ----\n\n /** Animate the token through the given tile indices, one hop at a time. */\n async moveToken(path: number[], options: MoveOptions = {}): Promise<void> {\n if (path.length === 0 || this.board.count === 0) return;\n const landing = options.landing ?? \"Unknown\";\n this.board.release(this.position);\n this.token.emitting = path.length >= TRAIL_MIN_STEPS;\n this.hopping = true;\n try {\n for (let i = 0; i < path.length; i++) {\n const next = path[i];\n if (next === undefined || !this.board.origin(next)) continue;\n const last = i === path.length - 1;\n // Rhythm: the first hops a little quicker, slowing towards the end; the last one higher\n // and longer — \"here it lands\".\n const indexT = path.length > 1 ? i / (path.length - 1) : 1;\n let duration = lerp(STEP_S * 0.85, STEP_S * 1.3, indexT);\n let height = 1;\n let suspense = false;\n if (last) {\n duration = STEP_S * 1.5;\n height = 1.3;\n if (isStrong(landing)) {\n // Monopoly GO's trick: on a special tile the pawn flies higher and hangs at the top.\n duration = STEP_S * SUSPENSE_DURATION;\n height = SUSPENSE_HEIGHT;\n suspense = true;\n }\n }\n await this.hop(next, duration, height, suspense, last, landing);\n }\n } finally {\n this.timeScale = 1;\n this.token.emitting = false;\n this.token.lift = 0;\n this.token.spin = 0;\n this.hopping = false;\n }\n this.faceAlong(this.position);\n }\n\n private async hop(\n to: number,\n duration: number,\n heightScale: number,\n suspense: boolean,\n last: boolean,\n landing: LandingCategory,\n ): Promise<void> {\n const target = this.board.origin(to);\n if (!target) return;\n const from = this.token.root.position.clone();\n const dx = target.x - from.x;\n const dz = target.z - from.z;\n if (dx * dx + dz * dz > 1e-6) this.facing = Math.atan2(dx, dz);\n const squash = suspense ? SUSPENSE_SQUASH : 1;\n this.token.beginHop(squash);\n\n await this.ticker.run(duration, (t) => {\n if (suspense) {\n const hang = t > SUSPENSE_RISE && t <= SUSPENSE_RISE + SUSPENSE_HANG;\n this.timeScale = hang ? SUSPENSE_SLOWMO : 1;\n }\n const across = suspense ? suspenseAcross(t) : t;\n const sine = suspense ? suspenseSine(t) : t * Math.PI;\n this.token.root.position.set(\n lerp(from.x, target.x, across),\n lerp(from.y, TILE_TOP, across),\n lerp(from.z, target.z, across),\n );\n this.token.lift = Math.sin(sine) * HOP_HEIGHT * heightScale;\n this.token.setHopProgress(t);\n // Whole turns over the flight, so the landing needs no snap back.\n this.token.spin = suspense ? Math.PI * 2 * t : 0;\n });\n\n this.timeScale = 1;\n this.position = to;\n this.token.root.position.set(target.x, TILE_TOP, target.z);\n this.token.lift = 0;\n this.token.spin = 0;\n this.token.land(squash);\n\n if (last) {\n // Contact — the moment for the juice, before the tile sinks.\n this.juice.landed(landing, to, target);\n this.board.press(to);\n this.board.highlight(to);\n } else {\n this.board.bounce(to);\n }\n }\n\n /** Pulse a tile to mark where the token landed. */\n highlightTile(index: number): void {\n this.board.highlight(index);\n }\n\n /** Grow the city's houses to each building's level. */\n setBuildingLevels(levels: number[]): void {\n this.city.setLevels(levels, !this.levelsShown);\n this.levelsShown = true;\n }\n\n private resize(): void {\n const w = this.host.clientWidth || 640;\n const h = this.host.clientHeight || 640;\n this.stage.renderer.setSize(w, h, false);\n this.rig.resize(w, h);\n }\n\n /**\n * One still frame of the board, for the platform's share-a-shot button.\n *\n * ⚠ Renders and reads in the SAME turn. The renderer is created without\n * `preserveDrawingBuffer`, so by the time anyone else touches this canvas its drawing buffer is\n * already cleared and `toBlob` hands back a black rectangle without failing.\n */\n async capture(): Promise<Blob | null> {\n if (this.disposed) return null;\n this.stage.renderer.render(this.stage.scene, this.rig.camera);\n return await new Promise<Blob | null>((resolve) => {\n this.stage.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n destroy(): void {\n this.disposed = true;\n this.unschedule();\n document.removeEventListener(\"visibilitychange\", this.onVisibility);\n this.resizeObserver?.disconnect();\n // Finish every running animation, so nothing awaiting one is left hanging.\n this.ticker.cancelAll();\n this.rig.dispose();\n this.island.dispose();\n this.props.dispose();\n this.board.dispose();\n this.city.dispose();\n this.token.dispose();\n this.dice.dispose();\n this.particles.dispose();\n this.stage.dispose();\n }\n}\n"
|
|
151
151
|
},
|
|
152
152
|
{
|
|
153
153
|
"path": "game/cameraRig.ts",
|
|
154
|
-
"content": "import * as THREE from \"three\";\nimport { easeOutElastic, easeOutQuad } from \"./tween\";\n\n// The reference's camera (BoardCameraRig + BoardCameraJuice): a narrow-lens (FOV 20) view at 40° down\n// and 45° round, 15 units back, that follows the pawn with a dead zone, pans under a finger with\n// inertia and a rubber band at the edges, snaps back to the pawn when a roll starts, and shakes and\n// kicks its FOV on the strong landings.\n\nconst FOV = 20;\nconst DISTANCE = 15;\nconst PITCH = THREE.MathUtils.degToRad(40);\nconst YAW = THREE.MathUtils.degToRad(45);\nconst PLANE_Y = 0.075;\n\nconst FOLLOW_SMOOTH = 0.18;\nconst RECENTER_SMOOTH = 0.25;\nconst DEAD_ZONE_U = 0.35;\nconst DEAD_ZONE_V = 0.5;\n/** Keep the pawn a little above the middle of the screen — the roll button sits below it. */\nconst FOLLOW_LEAD_V = 0.2;\nconst RUBBER = 0.4;\nconst RUBBER_RETURN = 0.12;\nconst INERTIA_FRICTION = 5.5;\n\nconst KICK_OUT_S = 0.08;\nconst KICK_BACK_S = 0.35;\nconst SHAKE_S = 0.45;\n\n/** Unity's SmoothDamp, per component. */\nfunction smoothDamp(\n current: THREE.Vector3,\n target: THREE.Vector3,\n velocity: THREE.Vector3,\n smoothTime: number,\n dt: number,\n): void {\n const st = Math.max(1e-4, smoothTime);\n const omega = 2 / st;\n const x = omega * dt;\n const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);\n for (const axis of [\"x\", \"z\"] as const) {\n const change = current[axis] - target[axis];\n const temp = (velocity[axis] + omega * change) * dt;\n velocity[axis] = (velocity[axis] - omega * temp) * exp;\n current[axis] = target[axis] + (change + temp) * exp;\n }\n}\n\n/** Past the edge the view follows the finger less and less, never more than RUBBER. */\nfunction rubber(over: number): number {\n const sign = Math.sign(over);\n const x = Math.abs(over);\n return sign * (1 - 1 / ((x * 0.55) / RUBBER + 1)) * RUBBER;\n}\n\nexport class CameraRig {\n readonly camera = new THREE.PerspectiveCamera(FOV, 1, 0.3, 200);\n /** Screen right, on the board plane. */\n private readonly axisU: THREE.Vector3;\n /** Screen up, projected on the board plane (away from the camera). */\n private readonly axisV: THREE.Vector3;\n private readonly back: THREE.Vector3;\n\n private readonly raw = new THREE.Vector3(0, PLANE_Y, 0);\n private readonly shown = new THREE.Vector3(0, PLANE_Y, 0);\n private readonly velocity = new THREE.Vector3();\n private readonly target = new THREE.Vector3();\n private readonly desired = new THREE.Vector3();\n private mode: \"follow\" | \"free\" | \"recenter\" = \"follow\";\n private bound = 1.5;\n private distance = DISTANCE;\n private needWidth = 2.8;\n private aspect = 1;\n\n private dragging = false;\n private pointerId = -1;\n private lastX = 0;\n private lastY = 0;\n private lastMoveAt = 0;\n private readonly fling = new THREE.Vector3();\n private heightPx = 640;\n\n private kickAmount = 0;\n private kickT = 1;\n private shakeAmp = 0;\n private shakeT = 1;\n private time = 0;\n\n constructor(\n private readonly element: HTMLElement,\n private readonly fog: THREE.Fog | null,\n ) {\n // Unity forward for euler (40, 45, 0), with Z flipped (Unity is left-handed).\n const forward = new THREE.Vector3(\n Math.sin(YAW) * Math.cos(PITCH),\n -Math.sin(PITCH),\n -Math.cos(YAW) * Math.cos(PITCH),\n ).normalize();\n this.back = forward.clone().negate();\n this.axisV = new THREE.Vector3(forward.x, 0, forward.z).normalize();\n this.axisU = new THREE.Vector3().crossVectors(this.axisV, new THREE.Vector3(0, 1, 0)).normalize();\n\n element.addEventListener(\"pointerdown\", this.onDown);\n element.addEventListener(\"pointermove\", this.onMove);\n element.addEventListener(\"pointerup\", this.onUp);\n element.addEventListener(\"pointercancel\", this.onUp);\n this.apply();\n }\n\n /** The board's footprint: the view may roam over it (plus the rubber band), no further. */\n setBounds(boardHalf: number): void {\n this.bound = boardHalf + 0.25;\n this.needWidth = boardHalf * 2 * 0.98;\n this.fit();\n }\n\n resize(width: number, height: number): void {\n this.heightPx = Math.max(1, height);\n this.aspect = width / Math.max(1, height);\n this.camera.aspect = this.aspect;\n this.fit();\n }\n\n /** On a narrow portrait screen back off so the board's width fits; never closer than the reference. */\n private fit(): void {\n const halfTan = Math.tan(THREE.MathUtils.degToRad(FOV / 2));\n const forWidth = this.needWidth / (2 * halfTan * this.aspect);\n this.distance = THREE.MathUtils.clamp(forWidth, DISTANCE, DISTANCE * 1.7);\n if (this.fog) {\n this.fog.near = this.distance + 9;\n this.fog.far = this.distance + 34;\n }\n this.camera.updateProjectionMatrix();\n }\n\n /** Where the pawn is — the follow target. */\n follow(position: THREE.Vector3): void {\n this.target.set(position.x, PLANE_Y, position.z).addScaledVector(this.axisV, -FOLLOW_LEAD_V);\n }\n\n /** Jump straight onto the pawn (load). */\n snap(position: THREE.Vector3): void {\n this.follow(position);\n this.raw.copy(this.target);\n this.shown.copy(this.target);\n this.velocity.set(0, 0, 0);\n this.mode = \"follow\";\n this.apply();\n }\n\n /** Back to the pawn — the roll is starting. */\n recenter(): void {\n if (this.dragging) this.endDrag();\n this.fling.set(0, 0, 0);\n this.mode = \"recenter\";\n }\n\n shake(force: number): void {\n this.shakeAmp = Math.max(this.shakeAmp * (1 - this.shakeT), force);\n this.shakeT = 0;\n }\n\n kick(amountDeg: number): void {\n // Finish the previous kick at the base FOV before the next — no drift (BoardCameraJuice).\n this.kickAmount = amountDeg;\n this.kickT = 0;\n }\n\n update(dt: number): void {\n this.time += dt;\n if (this.mode === \"follow\") {\n const d = new THREE.Vector3().subVectors(this.target, this.raw);\n const du = d.dot(this.axisU);\n const dv = d.dot(this.axisV);\n this.desired.copy(this.raw);\n if (Math.abs(du) > DEAD_ZONE_U)\n this.desired.addScaledVector(this.axisU, du - Math.sign(du) * DEAD_ZONE_U);\n if (Math.abs(dv) > DEAD_ZONE_V)\n this.desired.addScaledVector(this.axisV, dv - Math.sign(dv) * DEAD_ZONE_V);\n smoothDamp(this.raw, this.desired, this.velocity, FOLLOW_SMOOTH, dt);\n } else if (this.mode === \"recenter\") {\n smoothDamp(this.raw, this.target, this.velocity, RECENTER_SMOOTH, dt);\n if (this.raw.distanceTo(this.target) < 0.01) this.mode = \"follow\";\n } else if (!this.dragging) {\n // Free: coast on the fling, then spring back inside the bounds.\n this.raw.addScaledVector(this.fling, dt);\n this.fling.multiplyScalar(Math.exp(-INERTIA_FRICTION * dt));\n const clamped = this.clampFlat(this.raw.clone());\n if (clamped.distanceToSquared(this.raw) > 1e-8) {\n this.fling.set(0, 0, 0);\n smoothDamp(this.raw, clamped, this.velocity, RUBBER_RETURN, dt);\n }\n }\n\n // Shown focus: clamped, with the rubber overshoot while the finger pulls past the edge.\n this.shown.copy(this.raw);\n for (const axis of [\"x\", \"z\"] as const) {\n const v = this.raw[axis];\n if (v > this.bound) this.shown[axis] = this.bound + rubber(v - this.bound);\n else if (v < -this.bound) this.shown[axis] = -this.bound + rubber(v + this.bound);\n }\n\n if (this.kickT < 1) {\n this.kickT = Math.min(1, this.kickT + dt / (KICK_OUT_S + KICK_BACK_S));\n }\n if (this.shakeT < 1) this.shakeT = Math.min(1, this.shakeT + dt / SHAKE_S);\n this.apply();\n }\n\n private clampFlat(v: THREE.Vector3): THREE.Vector3 {\n v.x = THREE.MathUtils.clamp(v.x, -this.bound, this.bound);\n v.z = THREE.MathUtils.clamp(v.z, -this.bound, this.bound);\n return v;\n }\n\n private apply(): void {\n // FOV kick: out fast (OutQuad), back with an elastic settle.\n let fov = FOV;\n if (this.kickT < 1) {\n const elapsed = this.kickT * (KICK_OUT_S + KICK_BACK_S);\n fov += elapsed < KICK_OUT_S\n ? this.kickAmount * easeOutQuad(elapsed / KICK_OUT_S)\n : this.kickAmount * (1 - easeOutElastic((elapsed - KICK_OUT_S) / KICK_BACK_S, 0.6));\n }\n if (Math.abs(this.camera.fov - fov) > 1e-4) {\n this.camera.fov = fov;\n this.camera.updateProjectionMatrix();\n }\n\n const pos = this.shown.clone().addScaledVector(this.back, this.distance);\n if (this.shakeT < 1) {\n // Impulse downwards that rattles out (Cinemachine impulse, DefaultVelocity ≈ down).\n const decay = Math.pow(1 - this.shakeT, 2);\n const a = this.shakeAmp * decay;\n const t = this.time * 38;\n pos.addScaledVector(this.axisU, Math.sin(t * 1.3) * a * 0.6);\n pos.y += Math.sin(t) * a;\n }\n this.camera.position.copy(pos);\n this.camera.lookAt(pos.clone().addScaledVector(this.back, -this.distance));\n }\n\n // ---- drag to pan ----\n\n private readonly onDown = (e: PointerEvent): void => {\n if (this.dragging || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n this.dragging = true;\n this.pointerId = e.pointerId;\n this.lastX = e.clientX;\n this.lastY = e.clientY;\n this.lastMoveAt = performance.now();\n this.fling.set(0, 0, 0);\n this.velocity.set(0, 0, 0);\n this.mode = \"free\";\n this.element.setPointerCapture?.(e.pointerId);\n };\n\n private readonly onMove = (e: PointerEvent): void => {\n if (!this.dragging || e.pointerId !== this.pointerId) return;\n const dx = e.clientX - this.lastX;\n const dy = e.clientY - this.lastY;\n this.lastX = e.clientX;\n this.lastY = e.clientY;\n const worldPerPx = (2 * this.distance * Math.tan(THREE.MathUtils.degToRad(FOV / 2))) / this.heightPx;\n const move = new THREE.Vector3()\n .addScaledVector(this.axisU, -dx * worldPerPx)\n .addScaledVector(this.axisV, (dy * worldPerPx) / Math.sin(PITCH));\n this.raw.add(move);\n const now = performance.now();\n const dt = Math.max(1, now - this.lastMoveAt) / 1000;\n this.lastMoveAt = now;\n this.fling.lerp(move.divideScalar(dt), 0.5);\n };\n\n private readonly onUp = (e: PointerEvent): void => {\n if (!this.dragging || e.pointerId !== this.pointerId) return;\n this.endDrag();\n };\n\n private endDrag(): void {\n this.dragging = false;\n if (this.pointerId >= 0) this.element.releasePointerCapture?.(this.pointerId);\n this.pointerId = -1;\n if (performance.now() - this.lastMoveAt > 90) this.fling.set(0, 0, 0);\n }\n\n dispose(): void {\n this.element.removeEventListener(\"pointerdown\", this.onDown);\n this.element.removeEventListener(\"pointermove\", this.onMove);\n this.element.removeEventListener(\"pointerup\", this.onUp);\n this.element.removeEventListener(\"pointercancel\", this.onUp);\n }\n}\n"
|
|
154
|
+
"content": "import * as THREE from \"three\";\nimport { easeOutElastic, easeOutQuad } from \"./tween\";\n\n// The reference's camera (BoardCameraRig + BoardCameraJuice): a narrow-lens (FOV 20) view at 40° down\n// and 45° round, 15 units back, that follows the pawn with a dead zone, pans under a finger with\n// inertia and a rubber band at the edges, snaps back to the pawn when a roll starts, and shakes and\n// kicks its FOV on the strong landings.\n\nconst FOV = 20;\nconst DISTANCE = 15;\nconst PITCH = THREE.MathUtils.degToRad(40);\nconst YAW = THREE.MathUtils.degToRad(45);\nconst PLANE_Y = 0.075;\n\nconst FOLLOW_SMOOTH = 0.18;\nconst RECENTER_SMOOTH = 0.25;\nconst DEAD_ZONE_U = 0.35;\nconst DEAD_ZONE_V = 0.5;\n/** Keep the pawn a little above the middle of the screen — the roll button sits below it. */\nconst FOLLOW_LEAD_V = 0.2;\nconst RUBBER = 0.4;\nconst RUBBER_RETURN = 0.12;\nconst INERTIA_FRICTION = 5.5;\n\nconst KICK_OUT_S = 0.08;\nconst KICK_BACK_S = 0.35;\nconst SHAKE_S = 0.45;\n\n/** Unity's SmoothDamp, per component. */\nfunction smoothDamp(\n current: THREE.Vector3,\n target: THREE.Vector3,\n velocity: THREE.Vector3,\n smoothTime: number,\n dt: number,\n): void {\n const st = Math.max(1e-4, smoothTime);\n const omega = 2 / st;\n const x = omega * dt;\n const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);\n for (const axis of [\"x\", \"z\"] as const) {\n const change = current[axis] - target[axis];\n const temp = (velocity[axis] + omega * change) * dt;\n velocity[axis] = (velocity[axis] - omega * temp) * exp;\n current[axis] = target[axis] + (change + temp) * exp;\n }\n}\n\n/** Past the edge the view follows the finger less and less, never more than RUBBER. */\nfunction rubber(over: number): number {\n const sign = Math.sign(over);\n const x = Math.abs(over);\n return sign * (1 - 1 / ((x * 0.55) / RUBBER + 1)) * RUBBER;\n}\n\nexport class CameraRig {\n readonly camera = new THREE.PerspectiveCamera(FOV, 1, 0.3, 200);\n /** Screen right, on the board plane. */\n private readonly axisU: THREE.Vector3;\n /** Screen up, projected on the board plane (away from the camera). */\n private readonly axisV: THREE.Vector3;\n private readonly back: THREE.Vector3;\n\n private readonly raw = new THREE.Vector3(0, PLANE_Y, 0);\n private readonly shown = new THREE.Vector3(0, PLANE_Y, 0);\n private readonly velocity = new THREE.Vector3();\n private readonly target = new THREE.Vector3();\n private readonly desired = new THREE.Vector3();\n private mode: \"follow\" | \"free\" | \"recenter\" = \"follow\";\n private bound = 1.5;\n private distance = DISTANCE;\n private needWidth = 2.8;\n private aspect = 1;\n\n private dragging = false;\n private pointerId = -1;\n private lastX = 0;\n private lastY = 0;\n private lastMoveAt = 0;\n private readonly fling = new THREE.Vector3();\n private heightPx = 640;\n\n private kickAmount = 0;\n private kickT = 1;\n private shakeAmp = 0;\n private shakeT = 1;\n private time = 0;\n\n constructor(\n private readonly element: HTMLElement,\n private readonly fog: THREE.Fog | null,\n ) {\n // Unity forward for euler (40, 45, 0), with Z flipped (Unity is left-handed).\n const forward = new THREE.Vector3(\n Math.sin(YAW) * Math.cos(PITCH),\n -Math.sin(PITCH),\n -Math.cos(YAW) * Math.cos(PITCH),\n ).normalize();\n this.back = forward.clone().negate();\n this.axisV = new THREE.Vector3(forward.x, 0, forward.z).normalize();\n this.axisU = new THREE.Vector3()\n .crossVectors(this.axisV, new THREE.Vector3(0, 1, 0))\n .normalize();\n\n element.addEventListener(\"pointerdown\", this.onDown);\n element.addEventListener(\"pointermove\", this.onMove);\n element.addEventListener(\"pointerup\", this.onUp);\n element.addEventListener(\"pointercancel\", this.onUp);\n this.apply();\n }\n\n /** The board's footprint: the view may roam over it (plus the rubber band), no further. */\n setBounds(boardHalf: number): void {\n this.bound = boardHalf + 0.25;\n this.needWidth = boardHalf * 2 * 0.98;\n this.fit();\n }\n\n resize(width: number, height: number): void {\n this.heightPx = Math.max(1, height);\n this.aspect = width / Math.max(1, height);\n this.camera.aspect = this.aspect;\n this.fit();\n }\n\n /** On a narrow portrait screen back off so the board's width fits; never closer than the reference. */\n private fit(): void {\n const halfTan = Math.tan(THREE.MathUtils.degToRad(FOV / 2));\n const forWidth = this.needWidth / (2 * halfTan * this.aspect);\n this.distance = THREE.MathUtils.clamp(forWidth, DISTANCE, DISTANCE * 1.7);\n if (this.fog) {\n this.fog.near = this.distance + 9;\n this.fog.far = this.distance + 34;\n }\n this.camera.updateProjectionMatrix();\n }\n\n /** Where the pawn is — the follow target. */\n follow(position: THREE.Vector3): void {\n this.target\n .set(position.x, PLANE_Y, position.z)\n .addScaledVector(this.axisV, -FOLLOW_LEAD_V);\n }\n\n /** Jump straight onto the pawn (load). */\n snap(position: THREE.Vector3): void {\n this.follow(position);\n this.raw.copy(this.target);\n this.shown.copy(this.target);\n this.velocity.set(0, 0, 0);\n this.mode = \"follow\";\n this.apply();\n }\n\n /** Back to the pawn — the roll is starting. */\n recenter(): void {\n if (this.dragging) this.endDrag();\n this.fling.set(0, 0, 0);\n this.mode = \"recenter\";\n }\n\n shake(force: number): void {\n this.shakeAmp = Math.max(this.shakeAmp * (1 - this.shakeT), force);\n this.shakeT = 0;\n }\n\n kick(amountDeg: number): void {\n // Finish the previous kick at the base FOV before the next — no drift (BoardCameraJuice).\n this.kickAmount = amountDeg;\n this.kickT = 0;\n }\n\n update(dt: number): void {\n this.time += dt;\n if (this.mode === \"follow\") {\n const d = new THREE.Vector3().subVectors(this.target, this.raw);\n const du = d.dot(this.axisU);\n const dv = d.dot(this.axisV);\n this.desired.copy(this.raw);\n if (Math.abs(du) > DEAD_ZONE_U)\n this.desired.addScaledVector(\n this.axisU,\n du - Math.sign(du) * DEAD_ZONE_U,\n );\n if (Math.abs(dv) > DEAD_ZONE_V)\n this.desired.addScaledVector(\n this.axisV,\n dv - Math.sign(dv) * DEAD_ZONE_V,\n );\n smoothDamp(this.raw, this.desired, this.velocity, FOLLOW_SMOOTH, dt);\n } else if (this.mode === \"recenter\") {\n smoothDamp(this.raw, this.target, this.velocity, RECENTER_SMOOTH, dt);\n if (this.raw.distanceTo(this.target) < 0.01) this.mode = \"follow\";\n } else if (!this.dragging) {\n // Free: coast on the fling, then spring back inside the bounds.\n this.raw.addScaledVector(this.fling, dt);\n this.fling.multiplyScalar(Math.exp(-INERTIA_FRICTION * dt));\n const clamped = this.clampFlat(this.raw.clone());\n if (clamped.distanceToSquared(this.raw) > 1e-8) {\n this.fling.set(0, 0, 0);\n smoothDamp(this.raw, clamped, this.velocity, RUBBER_RETURN, dt);\n }\n }\n\n // Shown focus: clamped, with the rubber overshoot while the finger pulls past the edge.\n this.shown.copy(this.raw);\n for (const axis of [\"x\", \"z\"] as const) {\n const v = this.raw[axis];\n if (v > this.bound)\n this.shown[axis] = this.bound + rubber(v - this.bound);\n else if (v < -this.bound)\n this.shown[axis] = -this.bound + rubber(v + this.bound);\n }\n\n if (this.kickT < 1) {\n this.kickT = Math.min(1, this.kickT + dt / (KICK_OUT_S + KICK_BACK_S));\n }\n if (this.shakeT < 1) this.shakeT = Math.min(1, this.shakeT + dt / SHAKE_S);\n this.apply();\n }\n\n private clampFlat(v: THREE.Vector3): THREE.Vector3 {\n v.x = THREE.MathUtils.clamp(v.x, -this.bound, this.bound);\n v.z = THREE.MathUtils.clamp(v.z, -this.bound, this.bound);\n return v;\n }\n\n private apply(): void {\n // FOV kick: out fast (OutQuad), back with an elastic settle.\n let fov = FOV;\n if (this.kickT < 1) {\n const elapsed = this.kickT * (KICK_OUT_S + KICK_BACK_S);\n fov +=\n elapsed < KICK_OUT_S\n ? this.kickAmount * easeOutQuad(elapsed / KICK_OUT_S)\n : this.kickAmount *\n (1 - easeOutElastic((elapsed - KICK_OUT_S) / KICK_BACK_S, 0.6));\n }\n if (Math.abs(this.camera.fov - fov) > 1e-4) {\n this.camera.fov = fov;\n this.camera.updateProjectionMatrix();\n }\n\n const pos = this.shown.clone().addScaledVector(this.back, this.distance);\n if (this.shakeT < 1) {\n // Impulse downwards that rattles out (Cinemachine impulse, DefaultVelocity ≈ down).\n const decay = Math.pow(1 - this.shakeT, 2);\n const a = this.shakeAmp * decay;\n const t = this.time * 38;\n pos.addScaledVector(this.axisU, Math.sin(t * 1.3) * a * 0.6);\n pos.y += Math.sin(t) * a;\n }\n this.camera.position.copy(pos);\n this.camera.lookAt(pos.clone().addScaledVector(this.back, -this.distance));\n }\n\n // ---- drag to pan ----\n\n private readonly onDown = (e: PointerEvent): void => {\n if (this.dragging || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n this.dragging = true;\n this.pointerId = e.pointerId;\n this.lastX = e.clientX;\n this.lastY = e.clientY;\n this.lastMoveAt = performance.now();\n this.fling.set(0, 0, 0);\n this.velocity.set(0, 0, 0);\n this.mode = \"free\";\n this.element.setPointerCapture?.(e.pointerId);\n };\n\n private readonly onMove = (e: PointerEvent): void => {\n if (!this.dragging || e.pointerId !== this.pointerId) return;\n const dx = e.clientX - this.lastX;\n const dy = e.clientY - this.lastY;\n this.lastX = e.clientX;\n this.lastY = e.clientY;\n const worldPerPx =\n (2 * this.distance * Math.tan(THREE.MathUtils.degToRad(FOV / 2))) /\n this.heightPx;\n const move = new THREE.Vector3()\n .addScaledVector(this.axisU, -dx * worldPerPx)\n .addScaledVector(this.axisV, (dy * worldPerPx) / Math.sin(PITCH));\n this.raw.add(move);\n const now = performance.now();\n const dt = Math.max(1, now - this.lastMoveAt) / 1000;\n this.lastMoveAt = now;\n this.fling.lerp(move.divideScalar(dt), 0.5);\n };\n\n private readonly onUp = (e: PointerEvent): void => {\n if (!this.dragging || e.pointerId !== this.pointerId) return;\n this.endDrag();\n };\n\n private endDrag(): void {\n this.dragging = false;\n if (this.pointerId >= 0)\n this.element.releasePointerCapture?.(this.pointerId);\n this.pointerId = -1;\n if (performance.now() - this.lastMoveAt > 90) this.fling.set(0, 0, 0);\n }\n\n dispose(): void {\n this.element.removeEventListener(\"pointerdown\", this.onDown);\n this.element.removeEventListener(\"pointermove\", this.onMove);\n this.element.removeEventListener(\"pointerup\", this.onUp);\n this.element.removeEventListener(\"pointercancel\", this.onUp);\n }\n}\n"
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
"path": "game/city.ts",
|
|
158
|
-
"content": "import * as THREE from \"three\";\nimport { GROUND_Y } from \"./palette\";\nimport { clamp01, easeOutBack } from \"./tween\";\n\n// The player's city: one low-poly house per building slot, on the island behind the far side of the\n// board. The reference shows its buildings as 2D art in a window of their own and keeps the middle\n// of the board free for the dice — so the city stands next to the board, and grows a storey per level.\n\nconst ROOFS = [0xe8534e, 0x4a90e2, 0x9c5cff, 0xf0a020, 0x42c9c2, 0x4caf50];\nconst WALLS = [0xfff4dc, 0xf3e9ff, 0xe8f6ff, 0xfff0e0, 0xeefbea, 0xfdf3d0];\nconst WIDTH = 0.17;\nconst STOREY = 0.055;\nconst BASE_H = 0.035;\nconst GROW_S = 0.45;\n\ninterface House {\n root: THREE.Group;\n body: THREE.Mesh;\n roof: THREE.Mesh;\n windows: THREE.Group;\n site: THREE.Group;\n level: number;\n shownHeight: number;\n fromHeight: number;\n growT: number;\n popT: number;\n}\n\nexport class City {\n readonly group = new THREE.Group();\n private readonly geometries: THREE.BufferGeometry[] = [];\n private readonly materials = new Map<number, THREE.MeshStandardMaterial>();\n private houses: House[] = [];\n /** Called when a house levels up — the board puts its sparkle there. */\n onUpgrade: ((at: THREE.Vector3, height: number) => void) | null = null;\n\n private mat(color: number): THREE.MeshStandardMaterial {\n let m = this.materials.get(color);\n if (!m) {\n m = new THREE.MeshStandardMaterial({
|
|
158
|
+
"content": "import * as THREE from \"three\";\nimport { GROUND_Y } from \"./palette\";\nimport { clamp01, easeOutBack } from \"./tween\";\n\n// The player's city: one low-poly house per building slot, on the island behind the far side of the\n// board. The reference shows its buildings as 2D art in a window of their own and keeps the middle\n// of the board free for the dice — so the city stands next to the board, and grows a storey per level.\n\nconst ROOFS = [0xe8534e, 0x4a90e2, 0x9c5cff, 0xf0a020, 0x42c9c2, 0x4caf50];\nconst WALLS = [0xfff4dc, 0xf3e9ff, 0xe8f6ff, 0xfff0e0, 0xeefbea, 0xfdf3d0];\nconst WIDTH = 0.17;\nconst STOREY = 0.055;\nconst BASE_H = 0.035;\nconst GROW_S = 0.45;\n\ninterface House {\n root: THREE.Group;\n body: THREE.Mesh;\n roof: THREE.Mesh;\n windows: THREE.Group;\n site: THREE.Group;\n level: number;\n shownHeight: number;\n fromHeight: number;\n growT: number;\n popT: number;\n}\n\nexport class City {\n readonly group = new THREE.Group();\n private readonly geometries: THREE.BufferGeometry[] = [];\n private readonly materials = new Map<number, THREE.MeshStandardMaterial>();\n private houses: House[] = [];\n /** Called when a house levels up — the board puts its sparkle there. */\n onUpgrade: ((at: THREE.Vector3, height: number) => void) | null = null;\n\n private mat(color: number): THREE.MeshStandardMaterial {\n let m = this.materials.get(color);\n if (!m) {\n m = new THREE.MeshStandardMaterial({\n color,\n roughness: 0.7,\n flatShading: true,\n });\n this.materials.set(color, m);\n }\n return m;\n }\n\n private geo<T extends THREE.BufferGeometry>(g: T): T {\n this.geometries.push(g);\n return g;\n }\n\n /** Where the houses stand, for keeping the island's trees out of them. */\n get spots(): THREE.Vector3[] {\n return this.houses.map((h) => h.root.position.clone());\n }\n\n build(count: number, boardHalf: number, side: number): void {\n this.clear();\n if (count <= 0) return;\n const z = -(boardHalf + 0.42);\n const span = Math.min(side * 0.85, (count - 1) * 0.3);\n const unitBody = this.geo(new THREE.BoxGeometry(WIDTH, 1, WIDTH * 0.9));\n unitBody.translate(0, 0.5, 0);\n const roofGeo = this.geo(new THREE.ConeGeometry(WIDTH * 0.82, 0.09, 4, 1));\n roofGeo.rotateY(Math.PI / 4);\n roofGeo.translate(0, 0.045, 0);\n const baseGeo = this.geo(\n new THREE.BoxGeometry(WIDTH * 1.35, BASE_H, WIDTH * 1.25),\n );\n baseGeo.translate(0, BASE_H / 2, 0);\n const windowGeo = this.geo(new THREE.BoxGeometry(0.026, 0.026, 0.006));\n const doorGeo = this.geo(new THREE.BoxGeometry(0.036, 0.05, 0.006));\n const poleGeo = this.geo(new THREE.CylinderGeometry(0.004, 0.004, 0.09, 4));\n poleGeo.translate(0, 0.045, 0);\n\n for (let i = 0; i < count; i++) {\n const root = new THREE.Group();\n const x = count > 1 ? -span / 2 + (span * i) / (count - 1) : 0;\n root.position.set(x, GROUND_Y, z + (i % 2 ? -0.08 : 0));\n root.rotation.y = i % 2 ? 0.12 : -0.12;\n\n const base = new THREE.Mesh(baseGeo, this.mat(0xd9cbb0));\n const body = new THREE.Mesh(\n unitBody,\n this.mat(WALLS[i % WALLS.length] ?? 0xffffff),\n );\n body.position.y = BASE_H;\n const roof = new THREE.Mesh(\n roofGeo,\n this.mat(ROOFS[i % ROOFS.length] ?? 0xe8534e),\n );\n const windows = new THREE.Group();\n const door = new THREE.Mesh(doorGeo, this.mat(0x8a5a2b));\n door.position.set(0, BASE_H + 0.025, (WIDTH * 0.9) / 2 + 0.003);\n windows.add(door);\n\n // Level 0: an empty plot with a few stakes, ready to build on.\n const site = new THREE.Group();\n for (const [px, pz] of [\n [-1, -1],\n [1, -1],\n [-1, 1],\n [1, 1],\n ] as const) {\n const pole = new THREE.Mesh(poleGeo, this.mat(0xc79a5b));\n pole.position.set(px * WIDTH * 0.55, BASE_H, pz * WIDTH * 0.5);\n site.add(pole);\n }\n\n root.add(base, body, roof, windows, site);\n root.traverse((o) => {\n if (o instanceof THREE.Mesh) {\n o.castShadow = true;\n o.receiveShadow = true;\n }\n });\n this.group.add(root);\n const house: House = {\n root,\n body,\n roof,\n windows,\n site,\n level: 0,\n shownHeight: 0,\n fromHeight: 0,\n growT: 1,\n popT: 1,\n };\n this.houses.push(house);\n this.layoutHouse(house, 0, windowGeo);\n }\n this.windowGeo = windowGeo;\n }\n\n private windowGeo: THREE.BufferGeometry | null = null;\n\n /** Rebuild the windows for a house's storeys (front and side faces). */\n private layoutHouse(\n house: House,\n level: number,\n windowGeo: THREE.BufferGeometry,\n ): void {\n while (house.windows.children.length > 1) {\n const last = house.windows.children[house.windows.children.length - 1];\n if (last) house.windows.remove(last);\n }\n const glass = this.mat(0x5a7fb5);\n for (let s = 1; s < level; s++) {\n const y = BASE_H + s * STOREY + STOREY * 0.45;\n for (const dx of [-0.045, 0.045]) {\n const w = new THREE.Mesh(windowGeo, glass);\n w.position.set(dx, y, (WIDTH * 0.9) / 2 + 0.003);\n w.castShadow = false;\n house.windows.add(w);\n const side = new THREE.Mesh(windowGeo, glass);\n side.rotation.y = -Math.PI / 2;\n side.position.set(-WIDTH / 2 - 0.003, y, dx);\n side.castShadow = false;\n house.windows.add(side);\n }\n }\n }\n\n /** Grow each house to its level; a level-up pops the house and sparkles (unless `instant`). */\n setLevels(levels: number[], instant: boolean): void {\n this.houses.forEach((house, i) => {\n const level = Math.max(0, Math.floor(levels[i] ?? 0));\n if (level === house.level && house.growT >= 1) {\n this.applyHeight(house, this.heightFor(level));\n return;\n }\n const grew = level > house.level;\n house.fromHeight = house.shownHeight;\n house.level = level;\n if (this.windowGeo) this.layoutHouse(house, level, this.windowGeo);\n if (instant) {\n house.growT = 1;\n this.applyHeight(house, this.heightFor(level));\n return;\n }\n house.growT = 0;\n if (grew) {\n house.popT = 0;\n const top = house.root.position.clone();\n this.onUpgrade?.(top, this.heightFor(level));\n }\n });\n }\n\n private heightFor(level: number): number {\n return level <= 0 ? 0 : STOREY * (level + 0.6);\n }\n\n private applyHeight(house: House, height: number): void {\n house.shownHeight = height;\n const built = height > 0.001;\n house.body.visible = built;\n house.roof.visible = built;\n house.windows.visible = built;\n house.site.visible = !built;\n house.body.scale.y = Math.max(0.001, height);\n house.roof.position.y = BASE_H + height;\n }\n\n update(dt: number): void {\n for (const house of this.houses) {\n if (house.growT < 1) {\n house.growT = clamp01(house.growT + dt / GROW_S);\n const target = this.heightFor(house.level);\n const e = easeOutBack(house.growT, 2);\n this.applyHeight(\n house,\n house.fromHeight + (target - house.fromHeight) * e,\n );\n }\n if (house.popT < 1) {\n house.popT = clamp01(house.popT + dt / 0.35);\n const s = 1 + Math.sin(house.popT * Math.PI) * 0.18;\n house.root.scale.set(s, 1 + Math.sin(house.popT * Math.PI) * 0.08, s);\n }\n }\n }\n\n get count(): number {\n return this.houses.length;\n }\n\n private clear(): void {\n this.group.clear();\n this.geometries.forEach((g) => g.dispose());\n this.geometries.length = 0;\n this.houses = [];\n this.windowGeo = null;\n }\n\n dispose(): void {\n this.clear();\n this.materials.forEach((m) => m.dispose());\n this.materials.clear();\n }\n}\n"
|
|
159
159
|
},
|
|
160
160
|
{
|
|
161
161
|
"path": "game/dice.ts",
|
|
162
|
-
"content": "import * as THREE from \"three\";\r\nimport { RoundedBoxGeometry } from \"three/addons/geometries/RoundedBoxGeometry.js\";\r\nimport { blobTexture } from \"./token\";\r\nimport { Ticker, bounceOut, clamp01, easeOutBack, easeOutCubic, lerp } from \"./tween\";\r\n\r\n// The dice (the reference's DiceAnimator): they pop in, are tossed onto the board towards the pawn,\r\n// tumble and bounce, settle on exactly the faces the server rolled, show them, and shrink away.\r\n// The reference throws them with physics and re-maps the faces; here the toss is scripted, so the\r\n// result face is exact by construction and no physics engine ships with the template.\r\n\r\nconst SIZE = 0.11;\r\nconst REST_Y = SIZE / 2 + 0.001; // lying on the plate (its top is y = 0)\r\nconst TOSS_S = 0.95;\r\nconst DROP = 0.42;\r\nconst SHOW_S = 1.0; // showResultDuration\r\nconst HIDE_S = 0.2; // scaleOutDuration\r\nconst EPIC_STEPS = 10; // epicRollStepThreshold\r\nconst EPIC_MULT = 1.4; // epicAmplitudeMultiplier\r\n\r\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z; opposite faces sum to 7.\r\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\r\n// Rotation that brings each value's face to the top (+Y).\r\nconst REST_ROTATION: Record<number, [number, number, number]> = {\r\n 1: [-Math.PI / 2, 0, 0],\r\n 2: [0, 0, Math.PI / 2],\r\n 3: [0, 0, 0],\r\n 4: [Math.PI, 0, 0],\r\n 5: [0, 0, -Math.PI / 2],\r\n 6: [Math.PI / 2, 0, 0],\r\n};\r\n\r\nconst PIP_LAYOUTS: Record<number, string[]> = {\r\n 1: [\"C\"],\r\n 2: [\"TL\", \"BR\"],\r\n 3: [\"TL\", \"C\", \"BR\"],\r\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\r\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\r\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\r\n};\r\nconst PIP_COORDS: Record<string, [number, number]> = {\r\n TL: [0.27, 0.27],\r\n TR: [0.73, 0.27],\r\n ML: [0.27, 0.5],\r\n C: [0.5, 0.5],\r\n MR: [0.73, 0.5],\r\n BL: [0.27, 0.73],\r\n BR: [0.73, 0.73],\r\n};\r\n\r\ninterface Die {\r\n mesh: THREE.Mesh;\r\n shadow: THREE.Mesh;\r\n}\r\n\r\n/** Split a total into two d6 faces when the server sent no per-die breakdown (scripted steps). */\r\nfunction splitTotal(total: number): number[] {\r\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\r\n const min1 = Math.max(1, clamped - 6);\r\n const max1 = Math.min(6, clamped - 1);\r\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\r\n return [v1, clamped - v1];\r\n}\r\n\r\nexport class Dice {\r\n readonly group = new THREE.Group();\r\n private readonly geometry = new RoundedBoxGeometry(SIZE, SIZE, SIZE, 3, SIZE * 0.17);\r\n private readonly shadowGeo = new THREE.PlaneGeometry(SIZE * 2.6, SIZE * 2.6).rotateX(-Math.PI / 2);\r\n private readonly shadowTex = blobTexture();\r\n private readonly textures: THREE.CanvasTexture[] = [];\r\n private readonly materials: THREE.MeshStandardMaterial[] = [];\r\n private readonly dice: Die[] = [];\r\n private rollId = 0;\r\n\r\n constructor(private readonly ticker: Ticker) {\r\n const byValue: Record<number, THREE.CanvasTexture> = {};\r\n for (let v = 1; v <= 6; v++) {\r\n const t = new THREE.CanvasTexture(this.pipCanvas(v));\r\n t.colorSpace = THREE.SRGBColorSpace;\r\n t.anisotropy = 4;\r\n this.textures.push(t);\r\n byValue[v] = t;\r\n }\r\n for (const value of FACE_VALUES) {\r\n this.materials.push(\r\n new THREE.MeshStandardMaterial({ map: byValue[value] ?? null, roughness: 0.25, metalness: 0 }),\r\n );\r\n }\r\n this.group.visible = false;\r\n }\r\n\r\n private pipCanvas(value: number): HTMLCanvasElement {\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = 128;\r\n canvas.height = 128;\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx) return canvas;\r\n const s = canvas.width;\r\n const g = ctx.createRadialGradient(s * 0.4, s * 0.35, s * 0.1, s / 2, s / 2, s * 0.75);\r\n g.addColorStop(0, \"#ffffff\");\r\n g.addColorStop(1, \"#e9e6f2\");\r\n ctx.fillStyle = g;\r\n ctx.fillRect(0, 0, s, s);\r\n for (const slot of PIP_LAYOUTS[value] ?? []) {\r\n const coord = PIP_COORDS[slot];\r\n if (!coord) continue;\r\n // Sunken pip: a dark dot with a lighter rim, as on the reference's dice texture.\r\n ctx.fillStyle = value === 1 ? \"#d0302b\" : \"#1b1b24\";\r\n ctx.beginPath();\r\n ctx.arc(coord[0] * s, coord[1] * s, s * (value === 1 ? 0.13 : 0.095), 0, Math.PI * 2);\r\n ctx.fill();\r\n ctx.strokeStyle = \"rgba(255,255,255,0.6)\";\r\n ctx.lineWidth = 2;\r\n ctx.beginPath();\r\n ctx.arc(coord[0] * s, coord[1] * s + 1.5, s * (value === 1 ? 0.13 : 0.095), 0.15 * Math.PI, 0.85 * Math.PI);\r\n ctx.stroke();\r\n }\r\n return canvas;\r\n }\r\n\r\n private ensure(count: number): void {\r\n while (this.dice.length < count) {\r\n const mesh = new THREE.Mesh(this.geometry, this.materials);\r\n mesh.castShadow = true;\r\n mesh.receiveShadow = true;\r\n const shadow = new THREE.Mesh(\r\n this.shadowGeo,\r\n new THREE.MeshBasicMaterial({ map: this.shadowTex, transparent: true, depthWrite: false }),\r\n );\r\n shadow.renderOrder = 2;\r\n this.group.add(mesh, shadow);\r\n this.dice.push({ mesh, shadow });\r\n }\r\n this.dice.forEach((d, i) => {\r\n d.mesh.visible = i < count;\r\n d.shadow.visible = i < count;\r\n });\r\n }\r\n\r\n /**\r\n * Toss the dice next to the pawn (`token`) towards it from the board's inside (`center`), land them\r\n * on `values` (one die per value; with none, `total` is split over two d6), and resolve once they\r\n * have settled. They stay visible for a moment and then shrink away on their own.\r\n */\r\n roll(values: number[] | null, total: number, token: THREE.Vector3, center: THREE.Vector3): Promise<void> {\r\n const faces = (values && values.length > 0 ? values : splitTotal(total))\r\n .slice(0, 3)\r\n .map((v) => Math.max(1, Math.min(6, Math.round(v))));\r\n const id = ++this.rollId;\r\n const epic = total >= EPIC_STEPS ? EPIC_MULT : 1;\r\n this.ensure(faces.length);\r\n this.group.visible = true;\r\n\r\n const inward = new THREE.Vector3().subVectors(center, token).setY(0);\r\n if (inward.lengthSq() < 1e-6) inward.set(1, 0, 0);\r\n inward.normalize();\r\n const across = new THREE.Vector3(-inward.z, 0, inward.x);\r\n\r\n const toss = faces.map((value, i) => {\r\n const lane = (i - (faces.length - 1) / 2) * 0.16;\r\n const land = token.clone()\r\n .addScaledVector(inward, 0.4 + Math.random() * 0.06)\r\n .addScaledVector(across, lane);\r\n const start = land.clone().addScaledVector(inward, 0.55).addScaledVector(across, (Math.random() - 0.5) * 0.1);\r\n const r = REST_ROTATION[value] ?? [0, 0, 0];\r\n const yaw = Math.random() * Math.PI * 2;\r\n const turns = (n: number): number => Math.PI * 2 * n;\r\n const from = new THREE.Euler(Math.random() * 6, Math.random() * 6, Math.random() * 6);\r\n const to = new THREE.Euler(r[0] + turns(2 + i) * epic, r[1], r[2] + turns(1 + i) * epic);\r\n return { start, land, from, to, rest: new THREE.Euler(r[0], 0, r[2]), yaw };\r\n });\r\n\r\n const yawQ = new THREE.Quaternion();\r\n const up = new THREE.Vector3(0, 1, 0);\r\n const eulerQ = new THREE.Quaternion();\r\n const e = new THREE.Euler();\r\n\r\n return this.ticker\r\n .run(TOSS_S, (t) => {\r\n if (id !== this.rollId) return;\r\n const pop = t < 0.18 ? easeOutBack(clamp01(t / 0.18), 3) : 1;\r\n const move = easeOutCubic(t);\r\n const height = DROP * epic * (1 - bounceOut(t));\r\n const spin = easeOutCubic(t);\r\n toss.forEach((d, i) => {\r\n const die = this.dice[i];\r\n if (!die) return;\r\n die.mesh.scale.setScalar(Math.max(0.001, pop));\r\n die.mesh.position.set(\r\n lerp(d.start.x, d.land.x, move),\r\n REST_Y + height,\r\n lerp(d.start.z, d.land.z, move),\r\n );\r\n e.set(lerp(d.from.x, d.to.x, spin), lerp(d.from.y, d.to.y, spin), lerp(d.from.z, d.to.z, spin));\r\n eulerQ.setFromEuler(e);\r\n yawQ.setFromAxisAngle(up, d.yaw * spin);\r\n die.mesh.quaternion.copy(yawQ).multiply(eulerQ);\r\n die.shadow.position.set(die.mesh.position.x, 0.002, die.mesh.position.z);\r\n const f = clamp01(height / 0.4);\r\n die.shadow.scale.setScalar(1 - f * 0.5);\r\n (die.shadow.material as THREE.MeshBasicMaterial).opacity = 0.85 - f * 0.55;\r\n });\r\n })\r\n .then(() => {\r\n if (id !== this.rollId) return;\r\n toss.forEach((d, i) => {\r\n const die = this.dice[i];\r\n if (!die) return;\r\n eulerQ.setFromEuler(d.rest);\r\n yawQ.setFromAxisAngle(up, d.yaw);\r\n die.mesh.quaternion.copy(yawQ).multiply(eulerQ);\r\n die.mesh.position.set(d.land.x, REST_Y, d.land.z);\r\n });\r\n // Settled: a small \"here's your number\" pop, then hide on their own.\r\n void this.ticker.run(0.16, (t) => {\r\n if (id !== this.rollId) return;\r\n const s = 1 + Math.sin(t * Math.PI) * 0.15;\r\n this.dice.forEach((d) => d.mesh.scale.setScalar(s));\r\n });\r\n void this.ticker.wait(SHOW_S).then(() => this.hide(id));\r\n return this.ticker.wait(0.18);\r\n });\r\n }\r\n\r\n private hide(id: number): void {\r\n if (id !== this.rollId) return;\r\n void this.ticker\r\n .run(HIDE_S, (t) => {\r\n if (id !== this.rollId) return;\r\n const s = Math.max(0.001, 1 - easeOutCubic(t));\r\n this.dice.forEach((d) => {\r\n d.mesh.scale.setScalar(s);\r\n (d.shadow.material as THREE.MeshBasicMaterial).opacity = 0.85 * s;\r\n });\r\n })\r\n .then(() => {\r\n if (id === this.rollId) this.group.visible = false;\r\n });\r\n }\r\n\r\n /** What the scene currently shows (for the agent surface). */\r\n get visible(): boolean {\r\n return this.group.visible;\r\n }\r\n\r\n dispose(): void {\r\n this.geometry.dispose();\r\n this.shadowGeo.dispose();\r\n this.shadowTex.dispose();\r\n this.textures.forEach((t) => t.dispose());\r\n this.materials.forEach((m) => m.dispose());\r\n this.dice.forEach((d) => (d.shadow.material as THREE.Material).dispose());\r\n }\r\n}\r\n"
|
|
162
|
+
"content": "import * as THREE from \"three\";\nimport { RoundedBoxGeometry } from \"three/addons/geometries/RoundedBoxGeometry.js\";\nimport { blobTexture } from \"./token\";\nimport {\n Ticker,\n bounceOut,\n clamp01,\n easeOutBack,\n easeOutCubic,\n lerp,\n} from \"./tween\";\n\n// The dice (the reference's DiceAnimator): they pop in, are tossed onto the board towards the pawn,\n// tumble and bounce, settle on exactly the faces the server rolled, show them, and shrink away.\n// The reference throws them with physics and re-maps the faces; here the toss is scripted, so the\n// result face is exact by construction and no physics engine ships with the template.\n\nconst SIZE = 0.11;\nconst REST_Y = SIZE / 2 + 0.001; // lying on the plate (its top is y = 0)\nconst TOSS_S = 0.95;\nconst DROP = 0.42;\nconst SHOW_S = 1.0; // showResultDuration\nconst HIDE_S = 0.2; // scaleOutDuration\nconst EPIC_STEPS = 10; // epicRollStepThreshold\nconst EPIC_MULT = 1.4; // epicAmplitudeMultiplier\n\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z; opposite faces sum to 7.\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\n// Rotation that brings each value's face to the top (+Y).\nconst REST_ROTATION: Record<number, [number, number, number]> = {\n 1: [-Math.PI / 2, 0, 0],\n 2: [0, 0, Math.PI / 2],\n 3: [0, 0, 0],\n 4: [Math.PI, 0, 0],\n 5: [0, 0, -Math.PI / 2],\n 6: [Math.PI / 2, 0, 0],\n};\n\nconst PIP_LAYOUTS: Record<number, string[]> = {\n 1: [\"C\"],\n 2: [\"TL\", \"BR\"],\n 3: [\"TL\", \"C\", \"BR\"],\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\n};\nconst PIP_COORDS: Record<string, [number, number]> = {\n TL: [0.27, 0.27],\n TR: [0.73, 0.27],\n ML: [0.27, 0.5],\n C: [0.5, 0.5],\n MR: [0.73, 0.5],\n BL: [0.27, 0.73],\n BR: [0.73, 0.73],\n};\n\ninterface Die {\n mesh: THREE.Mesh;\n shadow: THREE.Mesh;\n}\n\n/** Split a total into two d6 faces when the server sent no per-die breakdown (scripted steps). */\nfunction splitTotal(total: number): number[] {\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\n const min1 = Math.max(1, clamped - 6);\n const max1 = Math.min(6, clamped - 1);\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\n return [v1, clamped - v1];\n}\n\nexport class Dice {\n readonly group = new THREE.Group();\n private readonly geometry = new RoundedBoxGeometry(\n SIZE,\n SIZE,\n SIZE,\n 3,\n SIZE * 0.17,\n );\n private readonly shadowGeo = new THREE.PlaneGeometry(\n SIZE * 2.6,\n SIZE * 2.6,\n ).rotateX(-Math.PI / 2);\n private readonly shadowTex = blobTexture();\n private readonly textures: THREE.CanvasTexture[] = [];\n private readonly materials: THREE.MeshStandardMaterial[] = [];\n private readonly dice: Die[] = [];\n private rollId = 0;\n\n constructor(private readonly ticker: Ticker) {\n const byValue: Record<number, THREE.CanvasTexture> = {};\n for (let v = 1; v <= 6; v++) {\n const t = new THREE.CanvasTexture(this.pipCanvas(v));\n t.colorSpace = THREE.SRGBColorSpace;\n t.anisotropy = 4;\n this.textures.push(t);\n byValue[v] = t;\n }\n for (const value of FACE_VALUES) {\n this.materials.push(\n new THREE.MeshStandardMaterial({\n map: byValue[value] ?? null,\n roughness: 0.25,\n metalness: 0,\n }),\n );\n }\n this.group.visible = false;\n }\n\n private pipCanvas(value: number): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = 128;\n canvas.height = 128;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return canvas;\n const s = canvas.width;\n const g = ctx.createRadialGradient(\n s * 0.4,\n s * 0.35,\n s * 0.1,\n s / 2,\n s / 2,\n s * 0.75,\n );\n g.addColorStop(0, \"#ffffff\");\n g.addColorStop(1, \"#e9e6f2\");\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, s, s);\n for (const slot of PIP_LAYOUTS[value] ?? []) {\n const coord = PIP_COORDS[slot];\n if (!coord) continue;\n // Sunken pip: a dark dot with a lighter rim, as on the reference's dice texture.\n ctx.fillStyle = value === 1 ? \"#d0302b\" : \"#1b1b24\";\n ctx.beginPath();\n ctx.arc(\n coord[0] * s,\n coord[1] * s,\n s * (value === 1 ? 0.13 : 0.095),\n 0,\n Math.PI * 2,\n );\n ctx.fill();\n ctx.strokeStyle = \"rgba(255,255,255,0.6)\";\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.arc(\n coord[0] * s,\n coord[1] * s + 1.5,\n s * (value === 1 ? 0.13 : 0.095),\n 0.15 * Math.PI,\n 0.85 * Math.PI,\n );\n ctx.stroke();\n }\n return canvas;\n }\n\n private ensure(count: number): void {\n while (this.dice.length < count) {\n const mesh = new THREE.Mesh(this.geometry, this.materials);\n mesh.castShadow = true;\n mesh.receiveShadow = true;\n const shadow = new THREE.Mesh(\n this.shadowGeo,\n new THREE.MeshBasicMaterial({\n map: this.shadowTex,\n transparent: true,\n depthWrite: false,\n }),\n );\n shadow.renderOrder = 2;\n this.group.add(mesh, shadow);\n this.dice.push({ mesh, shadow });\n }\n this.dice.forEach((d, i) => {\n d.mesh.visible = i < count;\n d.shadow.visible = i < count;\n });\n }\n\n /**\n * Toss the dice next to the pawn (`token`) towards it from the board's inside (`center`), land them\n * on `values` (one die per value; with none, `total` is split over two d6), and resolve once they\n * have settled. They stay visible for a moment and then shrink away on their own.\n */\n roll(\n values: number[] | null,\n total: number,\n token: THREE.Vector3,\n center: THREE.Vector3,\n ): Promise<void> {\n const faces = (values && values.length > 0 ? values : splitTotal(total))\n .slice(0, 3)\n .map((v) => Math.max(1, Math.min(6, Math.round(v))));\n const id = ++this.rollId;\n const epic = total >= EPIC_STEPS ? EPIC_MULT : 1;\n this.ensure(faces.length);\n this.group.visible = true;\n\n const inward = new THREE.Vector3().subVectors(center, token).setY(0);\n if (inward.lengthSq() < 1e-6) inward.set(1, 0, 0);\n inward.normalize();\n const across = new THREE.Vector3(-inward.z, 0, inward.x);\n\n const toss = faces.map((value, i) => {\n const lane = (i - (faces.length - 1) / 2) * 0.16;\n const land = token\n .clone()\n .addScaledVector(inward, 0.4 + Math.random() * 0.06)\n .addScaledVector(across, lane);\n const start = land\n .clone()\n .addScaledVector(inward, 0.55)\n .addScaledVector(across, (Math.random() - 0.5) * 0.1);\n const r = REST_ROTATION[value] ?? [0, 0, 0];\n const yaw = Math.random() * Math.PI * 2;\n const turns = (n: number): number => Math.PI * 2 * n;\n const from = new THREE.Euler(\n Math.random() * 6,\n Math.random() * 6,\n Math.random() * 6,\n );\n const to = new THREE.Euler(\n r[0] + turns(2 + i) * epic,\n r[1],\n r[2] + turns(1 + i) * epic,\n );\n return {\n start,\n land,\n from,\n to,\n rest: new THREE.Euler(r[0], 0, r[2]),\n yaw,\n };\n });\n\n const yawQ = new THREE.Quaternion();\n const up = new THREE.Vector3(0, 1, 0);\n const eulerQ = new THREE.Quaternion();\n const e = new THREE.Euler();\n\n return this.ticker\n .run(TOSS_S, (t) => {\n if (id !== this.rollId) return;\n const pop = t < 0.18 ? easeOutBack(clamp01(t / 0.18), 3) : 1;\n const move = easeOutCubic(t);\n const height = DROP * epic * (1 - bounceOut(t));\n const spin = easeOutCubic(t);\n toss.forEach((d, i) => {\n const die = this.dice[i];\n if (!die) return;\n die.mesh.scale.setScalar(Math.max(0.001, pop));\n die.mesh.position.set(\n lerp(d.start.x, d.land.x, move),\n REST_Y + height,\n lerp(d.start.z, d.land.z, move),\n );\n e.set(\n lerp(d.from.x, d.to.x, spin),\n lerp(d.from.y, d.to.y, spin),\n lerp(d.from.z, d.to.z, spin),\n );\n eulerQ.setFromEuler(e);\n yawQ.setFromAxisAngle(up, d.yaw * spin);\n die.mesh.quaternion.copy(yawQ).multiply(eulerQ);\n die.shadow.position.set(\n die.mesh.position.x,\n 0.002,\n die.mesh.position.z,\n );\n const f = clamp01(height / 0.4);\n die.shadow.scale.setScalar(1 - f * 0.5);\n (die.shadow.material as THREE.MeshBasicMaterial).opacity =\n 0.85 - f * 0.55;\n });\n })\n .then(() => {\n if (id !== this.rollId) return;\n toss.forEach((d, i) => {\n const die = this.dice[i];\n if (!die) return;\n eulerQ.setFromEuler(d.rest);\n yawQ.setFromAxisAngle(up, d.yaw);\n die.mesh.quaternion.copy(yawQ).multiply(eulerQ);\n die.mesh.position.set(d.land.x, REST_Y, d.land.z);\n });\n // Settled: a small \"here's your number\" pop, then hide on their own.\n void this.ticker.run(0.16, (t) => {\n if (id !== this.rollId) return;\n const s = 1 + Math.sin(t * Math.PI) * 0.15;\n this.dice.forEach((d) => d.mesh.scale.setScalar(s));\n });\n void this.ticker.wait(SHOW_S).then(() => this.hide(id));\n return this.ticker.wait(0.18);\n });\n }\n\n private hide(id: number): void {\n if (id !== this.rollId) return;\n void this.ticker\n .run(HIDE_S, (t) => {\n if (id !== this.rollId) return;\n const s = Math.max(0.001, 1 - easeOutCubic(t));\n this.dice.forEach((d) => {\n d.mesh.scale.setScalar(s);\n (d.shadow.material as THREE.MeshBasicMaterial).opacity = 0.85 * s;\n });\n })\n .then(() => {\n if (id === this.rollId) this.group.visible = false;\n });\n }\n\n /** What the scene currently shows (for the agent surface). */\n get visible(): boolean {\n return this.group.visible;\n }\n\n dispose(): void {\n this.geometry.dispose();\n this.shadowGeo.dispose();\n this.shadowTex.dispose();\n this.textures.forEach((t) => t.dispose());\n this.materials.forEach((m) => m.dispose());\n this.dice.forEach((d) => (d.shadow.material as THREE.Material).dispose());\n }\n}\n"
|
|
163
163
|
},
|
|
164
164
|
{
|
|
165
165
|
"path": "game/island.ts",
|
|
166
|
-
"content": "import * as THREE from \"three\";\
|
|
166
|
+
"content": "import * as THREE from \"three\";\nimport { FOAM, GRASS, GRASS_DARK, GROUND_Y, SAND, SEA } from \"./palette\";\nimport { slabGeometry } from \"./tileMesh\";\n\n// The island the board stands on and the sea around it (the reference's \"Island sea001\" and its\n// grass plane): a grass plateau, a sandy beach stepping down to the water, a ring of surf, and a sea\n// to the horizon whose ripples drift slowly.\n\n/** How far the grass reaches past the board plate. Props live in this strip. */\nexport const GRASS_MARGIN = 1.35;\nconst BEACH_WIDTH = 0.28;\nconst SEA_Y = -0.2;\n\nfunction rippleTexture(): THREE.CanvasTexture {\n const px = 256;\n const canvas = document.createElement(\"canvas\");\n canvas.width = px;\n canvas.height = px;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.fillStyle = `#${new THREE.Color(SEA).getHexString()}`;\n ctx.fillRect(0, 0, px, px);\n // Soft lighter strokes: the \"sparkle\" bands a casual sea is drawn with. Seeded, so tiling is stable.\n let seed = 7;\n const rand = (): number => {\n seed = (seed * 16807) % 2147483647;\n return seed / 2147483647;\n };\n ctx.lineCap = \"round\";\n for (let i = 0; i < 26; i++) {\n const x = rand() * px;\n const y = rand() * px;\n const len = 14 + rand() * 30;\n ctx.strokeStyle = `rgba(255,255,255,${0.1 + rand() * 0.16})`;\n ctx.lineWidth = 2 + rand() * 2.5;\n for (const [dx, dy] of [\n [0, 0],\n [-px, 0],\n [px, 0],\n [0, -px],\n [0, px],\n ] as const) {\n ctx.beginPath();\n ctx.moveTo(x + dx, y + dy);\n ctx.quadraticCurveTo(\n x + dx + len / 2,\n y + dy - 3,\n x + dx + len,\n y + dy,\n );\n ctx.stroke();\n }\n }\n }\n const texture = new THREE.CanvasTexture(canvas);\n texture.wrapS = THREE.RepeatWrapping;\n texture.wrapT = THREE.RepeatWrapping;\n texture.colorSpace = THREE.SRGBColorSpace;\n texture.anisotropy = 4;\n return texture;\n}\n\n/** A soft white rounded-rectangle ring — the surf line along the beach. */\nfunction foamTexture(): THREE.CanvasTexture {\n const px = 256;\n const canvas = document.createElement(\"canvas\");\n canvas.width = px;\n canvas.height = px;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n const inset = 34;\n ctx.strokeStyle = \"rgba(255,255,255,0.95)\";\n ctx.shadowColor = \"rgba(255,255,255,1)\";\n ctx.shadowBlur = 14;\n ctx.lineWidth = 10;\n ctx.beginPath();\n const w = px - inset * 2;\n if (typeof ctx.roundRect === \"function\")\n ctx.roundRect(inset, inset, w, w, 46);\n else ctx.rect(inset, inset, w, w);\n ctx.stroke();\n }\n const texture = new THREE.CanvasTexture(canvas);\n texture.colorSpace = THREE.SRGBColorSpace;\n return texture;\n}\n\nexport class Island {\n readonly group = new THREE.Group();\n private readonly disposables: { dispose(): void }[] = [];\n private seaTexture: THREE.CanvasTexture | null = null;\n private foam: THREE.Mesh | null = null;\n private time = 0;\n\n /** Grass half-size for a board whose plate has half-size `boardHalf`. */\n static grassHalf(boardHalf: number): number {\n return boardHalf + GRASS_MARGIN;\n }\n\n build(boardHalf: number): void {\n this.clear();\n const grassHalf = Island.grassHalf(boardHalf);\n const beachHalf = grassHalf + BEACH_WIDTH;\n\n // Sea to the horizon.\n const seaTex = rippleTexture();\n seaTex.repeat.set(26, 26);\n this.seaTexture = seaTex;\n const seaGeo = new THREE.PlaneGeometry(160, 160).rotateX(-Math.PI / 2);\n const seaMat = new THREE.MeshStandardMaterial({\n map: seaTex,\n roughness: 0.32,\n metalness: 0.05,\n });\n const sea = new THREE.Mesh(seaGeo, seaMat);\n sea.position.y = SEA_Y;\n sea.receiveShadow = true;\n\n // Shallow water: a lighter band hugging the island.\n const shallowGeo = slabGeometry(\n beachHalf * 2 + 0.9,\n beachHalf * 2 + 0.9,\n 0.9,\n SEA_Y - 0.002,\n 0.004,\n 0.001,\n );\n const shallowMat = new THREE.MeshStandardMaterial({\n color: new THREE.Color(SEA).lerp(new THREE.Color(0x9fe6ff), 0.45),\n transparent: true,\n opacity: 0.55,\n roughness: 0.3,\n depthWrite: false,\n });\n const shallow = new THREE.Mesh(shallowGeo, shallowMat);\n shallow.position.y = 0.006;\n shallow.renderOrder = 1;\n\n // Surf ring around the beach.\n const foamTex = foamTexture();\n const foamGeo = new THREE.PlaneGeometry(\n beachHalf * 2 * 1.3,\n beachHalf * 2 * 1.3,\n ).rotateX(-Math.PI / 2);\n const foamMat = new THREE.MeshBasicMaterial({\n map: foamTex,\n color: FOAM,\n transparent: true,\n opacity: 0.6,\n depthWrite: false,\n });\n const foam = new THREE.Mesh(foamGeo, foamMat);\n foam.position.y = SEA_Y + 0.012;\n foam.renderOrder = 2;\n this.foam = foam;\n\n // Beach and grass plateau.\n const beachGeo = slabGeometry(\n beachHalf * 2,\n beachHalf * 2,\n 0.75,\n SEA_Y - 0.12,\n GROUND_Y - 0.035 - (SEA_Y - 0.12),\n 0.05,\n );\n const beachMat = new THREE.MeshStandardMaterial({\n color: SAND,\n roughness: 0.95,\n flatShading: true,\n });\n const beach = new THREE.Mesh(beachGeo, beachMat);\n beach.receiveShadow = true;\n\n const grassGeo = slabGeometry(\n grassHalf * 2,\n grassHalf * 2,\n 0.6,\n GROUND_Y - 0.2,\n 0.2,\n 0.03,\n );\n const grassMat = new THREE.MeshStandardMaterial({\n color: GRASS,\n roughness: 0.92,\n });\n const grass = new THREE.Mesh(grassGeo, grassMat);\n grass.receiveShadow = true;\n\n // Darker grass patches, so the plateau doesn't read as one flat colour.\n const patchMat = new THREE.MeshStandardMaterial({\n color: GRASS_DARK,\n roughness: 0.95,\n transparent: true,\n opacity: 0.35,\n depthWrite: false,\n });\n const patchGeo = new THREE.CircleGeometry(1, 18).rotateX(-Math.PI / 2);\n let seed = 11;\n const rand = (): number => {\n seed = (seed * 16807) % 2147483647;\n return seed / 2147483647;\n };\n for (let i = 0; i < 14; i++) {\n const patch = new THREE.Mesh(patchGeo, patchMat);\n const angle = rand() * Math.PI * 2;\n const r = boardHalf + 0.25 + rand() * (GRASS_MARGIN - 0.5);\n patch.position.set(\n Math.cos(angle) * r,\n GROUND_Y + 0.001,\n Math.sin(angle) * r,\n );\n patch.scale.set(0.18 + rand() * 0.3, 1, 0.12 + rand() * 0.22);\n patch.rotation.y = rand() * Math.PI;\n patch.receiveShadow = true;\n this.group.add(patch);\n }\n\n this.group.add(sea, shallow, foam, beach, grass);\n this.disposables.push(\n seaGeo,\n seaMat,\n seaTex,\n shallowGeo,\n shallowMat,\n foamGeo,\n foamMat,\n foamTex,\n beachGeo,\n beachMat,\n grassGeo,\n grassMat,\n patchMat,\n patchGeo,\n );\n }\n\n update(dt: number): void {\n this.time += dt;\n if (this.seaTexture) {\n this.seaTexture.offset.x = (this.time * 0.006) % 1;\n this.seaTexture.offset.y = (Math.sin(this.time * 0.25) * 0.01) % 1;\n }\n if (this.foam) {\n const s = 1 + Math.sin(this.time * 1.1) * 0.012;\n this.foam.scale.set(s, 1, s);\n (this.foam.material as THREE.MeshBasicMaterial).opacity =\n 0.45 + Math.sin(this.time * 1.1 + 1) * 0.15;\n }\n }\n\n private clear(): void {\n this.disposables.forEach((d) => d.dispose());\n this.disposables.length = 0;\n this.group.clear();\n this.seaTexture = null;\n this.foam = null;\n }\n\n dispose(): void {\n this.clear();\n }\n}\n"
|
|
167
167
|
},
|
|
168
168
|
{
|
|
169
169
|
"path": "game/juice.ts",
|
|
170
|
-
"content": "import type * as THREE from \"three\";\nimport type { LandingCategory } from \"../data/boardConfig\";\nimport type { Board } from \"./board\";\nimport type { CameraRig } from \"./cameraRig\";\nimport type { Particles } from \"./particles\";\nimport type { Ticker } from \"./tween\";\n\n// What the board does when the pawn lands — the reference's BoardJuiceDirector, tuned by the numbers\n// of its BoardJuiceConfig asset. Dust on every landing; a burst on a reward; on the three \"strong\"\n// tiles (Attack, Raid, Special) a camera shake with an FOV kick and a wave through the neighbours.\n// Quiet tiles shake nothing.\n\nexport const BOARD_JUICE = {\n enableCameraShake: true,\n enableParticles: true,\n /** Shake force per strong category. */\n force: { Attack: 0.07, Raid: 0.07, Special: 0.07 },\n /** Force → degrees of FOV kick. */\n fovKickScale: 60,\n waveRadius: 4,\n waveAmplitudeScale: 0.8,\n waveStepDelay: 0.1,\n waveForce: { Attack: 2, Raid: 2, Special: 2 },\n};\n\nexport function isStrong(category: LandingCategory): category is \"Attack\" | \"Raid\" | \"Special\" {\n return category === \"Attack\" || category === \"Raid\" || category === \"Special\";\n}\n\nexport class Juice {\n constructor(\n private readonly board: Board,\n private readonly rig: CameraRig,\n private readonly particles: Particles,\n private readonly ticker: Ticker,\n ) {}\n\n landed(category: LandingCategory, index: number, at: THREE.Vector3): void {\n if (BOARD_JUICE.enableParticles) this.particles.dust(at);\n if (category === \"Reward\") {\n if (BOARD_JUICE.enableParticles) this.particles.rewardBurst(at);\n return;\n }\n if (!isStrong(category)) return;\n const force = BOARD_JUICE.force[category];\n if (BOARD_JUICE.enableCameraShake) {\n this.rig.shake(force);\n this.rig.kick(force * BOARD_JUICE.fovKickScale);\n }\n void this.wave(index
|
|
170
|
+
"content": "import type * as THREE from \"three\";\nimport type { LandingCategory } from \"../data/boardConfig\";\nimport type { Board } from \"./board\";\nimport type { CameraRig } from \"./cameraRig\";\nimport type { Particles } from \"./particles\";\nimport type { Ticker } from \"./tween\";\n\n// What the board does when the pawn lands — the reference's BoardJuiceDirector, tuned by the numbers\n// of its BoardJuiceConfig asset. Dust on every landing; a burst on a reward; on the three \"strong\"\n// tiles (Attack, Raid, Special) a camera shake with an FOV kick and a wave through the neighbours.\n// Quiet tiles shake nothing.\n\nexport const BOARD_JUICE = {\n enableCameraShake: true,\n enableParticles: true,\n /** Shake force per strong category. */\n force: { Attack: 0.07, Raid: 0.07, Special: 0.07 },\n /** Force → degrees of FOV kick. */\n fovKickScale: 60,\n waveRadius: 4,\n waveAmplitudeScale: 0.8,\n waveStepDelay: 0.1,\n waveForce: { Attack: 2, Raid: 2, Special: 2 },\n};\n\nexport function isStrong(\n category: LandingCategory,\n): category is \"Attack\" | \"Raid\" | \"Special\" {\n return category === \"Attack\" || category === \"Raid\" || category === \"Special\";\n}\n\nexport class Juice {\n constructor(\n private readonly board: Board,\n private readonly rig: CameraRig,\n private readonly particles: Particles,\n private readonly ticker: Ticker,\n ) {}\n\n landed(category: LandingCategory, index: number, at: THREE.Vector3): void {\n if (BOARD_JUICE.enableParticles) this.particles.dust(at);\n if (category === \"Reward\") {\n if (BOARD_JUICE.enableParticles) this.particles.rewardBurst(at);\n return;\n }\n if (!isStrong(category)) return;\n const force = BOARD_JUICE.force[category];\n if (BOARD_JUICE.enableCameraShake) {\n this.rig.shake(force);\n this.rig.kick(force * BOARD_JUICE.fovKickScale);\n }\n void this.wave(\n index,\n BOARD_JUICE.waveRadius,\n BOARD_JUICE.waveForce[category],\n );\n }\n\n /**\n * The wave runs out from the landing tile both ways round the loop: nearer tiles snap harder and\n * shorter, farther ones softer and longer — energy dying out, not one rhythm for all.\n */\n private async wave(\n center: number,\n radius: number,\n force: number,\n ): Promise<void> {\n const count = this.board.count;\n if (count <= 1) return;\n const stepDelay = BOARD_JUICE.waveStepDelay / Math.max(force, 0.05);\n for (let step = 1; step <= radius; step++) {\n await this.ticker.wait(stepDelay);\n const amplitude = (BOARD_JUICE.waveAmplitudeScale * force) / step;\n const durationScale =\n 0.75 + (1.6 - 0.75) * ((step - 1) / Math.max(radius - 1, 1));\n const vibrato = Math.max(2, 5 - step);\n for (const raw of [center + step, center - step]) {\n const index = this.board.normalize(raw);\n if (index !== center)\n this.board.pulse(index, amplitude, durationScale, vibrato);\n }\n }\n }\n}\n"
|
|
171
171
|
},
|
|
172
172
|
{
|
|
173
173
|
"path": "game/palette.ts",
|
|
174
|
-
"content": "// Colours and the few sizes every part of the board shares. Taken 1:1 from the Unity reference scene\
|
|
174
|
+
"content": "// Colours and the few sizes every part of the board shares. Taken 1:1 from the Unity reference scene\n// (Board_Survival / IDosGamesTemplates/BoardGame: BoardScene.unity, its materials and the\n// TextureBoard.png palette atlas), so re-skinning the board is editing this one file.\n//\n// The world is in the reference's units — one corner tile is 0.25 — which is why the camera, light\n// and animation numbers elsewhere can be the reference's numbers unchanged.\n\n/** Tile1.mat — the blue tile in the middle of every side. */\nexport const TILE_BLUE = 0x0073ff;\n/** Tile2.mat — the cream tile most of the loop is made of. */\nexport const TILE_CREAM = 0xfeffd5;\n/** Tile3.mat — the purple corner tile. */\nexport const TILE_PURPLE = 0xc767e9;\n\n/** Background Material B — the warm plate the tiles stand on. */\nexport const BOARD_PLATE = 0xe9a63c;\n/** Background Material A — the grass of the island. */\nexport const GRASS = 0x5fb362;\nexport const GRASS_DARK = 0x3f9a4c;\nexport const SAND = 0xe6c48a;\nexport const SEA = 0x3f8fc9;\nexport const FOAM = 0xd8f1ff;\n\n/** Piece.mat — the pawn. */\nexport const PAWN = 0xc2ddff;\n\n/** Directional light (colour of the reference's sun) and the flat ambient of the baked scene. */\nexport const SUN_COLOR = 0xfff9eb;\nexport const SKY_AMBIENT = 0xaec9ff;\nexport const GROUND_BOUNCE = 0x52c37a;\n\n// Atlas colours the low-poly props are painted with (TextureBoard.png).\nexport const LEAF = 0x63c95b;\nexport const LEAF_DARK = 0x3ba64f;\nexport const LEAF_LIGHT = 0x86d24a;\nexport const TRUNK = 0x8a5a2b;\nexport const TRUNK_DARK = 0x6b3f16;\nexport const STONE = 0xb7bcc0;\nexport const STONE_DARK = 0x8e959b;\nexport const MUSHROOM_CAP = 0xe8534e;\nexport const FLOWER = 0xffe14d;\nexport const CACTUS = 0x47b05a;\n\n// ---- sizes (reference units) ----\n\n/** Full size of a corner tile (and the depth of a side tile). */\nexport const TILE_FULL = 0.253;\n/** Length of a side tile along its side — the corner size scaled by 0.75, as in the scene. */\nexport const TILE_SIDE = 0.19;\n/** Gap between neighbouring tiles. */\nexport const TILE_GAP = 0.0575;\n/** Height of a tile slab. */\nexport const TILE_HEIGHT = 0.066;\n/** Y of the tile tops — where the pawn stands. */\nexport const TILE_TOP = 0.07;\n/** Y of the grass. */\nexport const GROUND_Y = -0.07;\n"
|
|
175
175
|
},
|
|
176
176
|
{
|
|
177
177
|
"path": "game/particles.ts",
|
|
178
|
-
"content": "import * as THREE from \"three\";\n\n// The board's particle effects, pooled sprites with no engine of their own: the dust puff under every\n// landing (SmokeVFX_2), the golden burst on a reward tile (RewardBurstVFX: 18 sparks, gravity 0.9),\n// and the sparkle of a building upgrade (UpgradeEffect).\n\ninterface Particle {\n sprite: THREE.Sprite;\n material: THREE.SpriteMaterial;\n velocity: THREE.Vector3;\n gravity: number;\n drag: number;\n life: number;\n age: number;\n size0: number;\n size1: number;\n spin: number;\n fadeIn: number;\n active: boolean;\n}\n\nfunction softCircle(): THREE.CanvasTexture {\n const px = 64;\n const canvas = document.createElement(\"canvas\");\n canvas.width = px;\n canvas.height = px;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n const g = ctx.createRadialGradient(px / 2
|
|
178
|
+
"content": "import * as THREE from \"three\";\n\n// The board's particle effects, pooled sprites with no engine of their own: the dust puff under every\n// landing (SmokeVFX_2), the golden burst on a reward tile (RewardBurstVFX: 18 sparks, gravity 0.9),\n// and the sparkle of a building upgrade (UpgradeEffect).\n\ninterface Particle {\n sprite: THREE.Sprite;\n material: THREE.SpriteMaterial;\n velocity: THREE.Vector3;\n gravity: number;\n drag: number;\n life: number;\n age: number;\n size0: number;\n size1: number;\n spin: number;\n fadeIn: number;\n active: boolean;\n}\n\nfunction softCircle(): THREE.CanvasTexture {\n const px = 64;\n const canvas = document.createElement(\"canvas\");\n canvas.width = px;\n canvas.height = px;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n const g = ctx.createRadialGradient(\n px / 2,\n px / 2,\n 0,\n px / 2,\n px / 2,\n px / 2,\n );\n g.addColorStop(0, \"rgba(255,255,255,1)\");\n g.addColorStop(0.45, \"rgba(255,255,255,0.75)\");\n g.addColorStop(1, \"rgba(255,255,255,0)\");\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, px, px);\n }\n const t = new THREE.CanvasTexture(canvas);\n t.colorSpace = THREE.SRGBColorSpace;\n return t;\n}\n\nfunction sparkle(): THREE.CanvasTexture {\n const px = 64;\n const canvas = document.createElement(\"canvas\");\n canvas.width = px;\n canvas.height = px;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n const c = px / 2;\n const glow = ctx.createRadialGradient(c, c, 0, c, c, c);\n glow.addColorStop(0, \"rgba(255,255,255,0.9)\");\n glow.addColorStop(0.25, \"rgba(255,255,255,0.35)\");\n glow.addColorStop(1, \"rgba(255,255,255,0)\");\n ctx.fillStyle = glow;\n ctx.fillRect(0, 0, px, px);\n ctx.fillStyle = \"#fff\";\n ctx.beginPath();\n for (let i = 0; i < 8; i++) {\n const r = i % 2 === 0 ? c * 0.95 : c * 0.18;\n const a = (i / 8) * Math.PI * 2 - Math.PI / 2;\n const x = c + Math.cos(a) * r;\n const y = c + Math.sin(a) * r;\n if (i === 0) ctx.moveTo(x, y);\n else ctx.lineTo(x, y);\n }\n ctx.closePath();\n ctx.fill();\n }\n const t = new THREE.CanvasTexture(canvas);\n t.colorSpace = THREE.SRGBColorSpace;\n return t;\n}\n\nexport class Particles {\n readonly group = new THREE.Group();\n private readonly pool: Particle[] = [];\n private readonly circleTex = softCircle();\n private readonly sparkTex = sparkle();\n\n private take(texture: THREE.Texture, additive: boolean): Particle {\n let p = this.pool.find((x) => !x.active);\n if (!p) {\n const material = new THREE.SpriteMaterial({\n transparent: true,\n depthWrite: false,\n });\n const sprite = new THREE.Sprite(material);\n sprite.renderOrder = 5;\n this.group.add(sprite);\n p = {\n sprite,\n material,\n velocity: new THREE.Vector3(),\n gravity: 0,\n drag: 0,\n life: 1,\n age: 0,\n size0: 0.1,\n size1: 0.1,\n spin: 0,\n fadeIn: 0,\n active: false,\n };\n this.pool.push(p);\n }\n p.material.map = texture;\n p.material.blending = additive\n ? THREE.AdditiveBlending\n : THREE.NormalBlending;\n p.material.needsUpdate = true;\n p.active = true;\n p.age = 0;\n p.sprite.visible = true;\n return p;\n }\n\n /** Puff of dust where the pawn lands. */\n dust(at: THREE.Vector3): void {\n const count = 8;\n for (let i = 0; i < count; i++) {\n const p = this.take(this.circleTex, false);\n const a = (i / count) * Math.PI * 2 + Math.random() * 0.4;\n p.sprite.position.set(\n at.x + Math.cos(a) * 0.03,\n at.y + 0.015,\n at.z + Math.sin(a) * 0.03,\n );\n const speed = 0.32 + Math.random() * 0.18;\n p.velocity.set(\n Math.cos(a) * speed,\n 0.1 + Math.random() * 0.12,\n Math.sin(a) * speed,\n );\n p.gravity = 0;\n p.drag = 4.2;\n p.life = 0.5 + Math.random() * 0.2;\n p.size0 = 0.05;\n p.size1 = 0.13 + Math.random() * 0.04;\n p.spin = 0;\n p.fadeIn = 0.05;\n p.material.color.setRGB(1, 0.97, 0.9);\n p.material.opacity = 0.8;\n }\n }\n\n /** Golden sparks fountaining out of a reward tile. */\n rewardBurst(at: THREE.Vector3): void {\n for (let i = 0; i < 18; i++) {\n const p = this.take(this.sparkTex, true);\n p.sprite.position.set(at.x, at.y + 0.04, at.z);\n const a = Math.random() * Math.PI * 2;\n const out = 0.25 + Math.random() * 0.45;\n p.velocity.set(\n Math.cos(a) * out,\n 0.9 + Math.random() * 0.6,\n Math.sin(a) * out,\n );\n p.gravity = 2.6;\n p.drag = 0.6;\n p.life = 0.8 + Math.random() * 0.35;\n p.size0 = 0.09;\n p.size1 = 0.03;\n p.spin = (Math.random() - 0.5) * 8;\n p.fadeIn = 0;\n p.material.color.setRGB(1, 0.78 + Math.random() * 0.17, 0.25);\n p.material.opacity = 1;\n }\n // A short flash at the centre.\n const flash = this.take(this.circleTex, true);\n flash.sprite.position.set(at.x, at.y + 0.05, at.z);\n flash.velocity.set(0, 0, 0);\n flash.gravity = 0;\n flash.drag = 0;\n flash.life = 0.28;\n flash.size0 = 0.12;\n flash.size1 = 0.42;\n flash.spin = 0;\n flash.fadeIn = 0;\n flash.material.color.setRGB(1, 0.92, 0.55);\n flash.material.opacity = 0.9;\n }\n\n /** Sparkle ring and rising stars when a building levels up. */\n upgrade(at: THREE.Vector3, height: number): void {\n for (let i = 0; i < 16; i++) {\n const p = this.take(this.sparkTex, true);\n const a = (i / 16) * Math.PI * 2;\n p.sprite.position.set(\n at.x + Math.cos(a) * 0.09,\n at.y + 0.02,\n at.z + Math.sin(a) * 0.09,\n );\n p.velocity.set(\n Math.cos(a) * 0.12,\n 0.45 + Math.random() * 0.45 + height,\n Math.sin(a) * 0.12,\n );\n p.gravity = 0.3;\n p.drag = 1.5;\n p.life = 0.75 + Math.random() * 0.4;\n p.size0 = 0.07;\n p.size1 = 0.02;\n p.spin = (Math.random() - 0.5) * 6;\n p.fadeIn = 0;\n p.material.color.setRGB(0.7 + Math.random() * 0.3, 0.95, 1);\n p.material.opacity = 1;\n }\n }\n\n update(dt: number): void {\n for (const p of this.pool) {\n if (!p.active) continue;\n p.age += dt;\n const t = p.age / p.life;\n if (t >= 1) {\n p.active = false;\n p.sprite.visible = false;\n continue;\n }\n const damp = Math.exp(-p.drag * dt);\n p.velocity.multiplyScalar(damp);\n p.velocity.y -= p.gravity * dt;\n p.sprite.position.addScaledVector(p.velocity, dt);\n const size = p.size0 + (p.size1 - p.size0) * (1 - (1 - t) * (1 - t));\n p.sprite.scale.set(size, size, size);\n p.material.rotation += p.spin * dt;\n const fadeIn = p.fadeIn > 0 ? Math.min(1, p.age / p.fadeIn) : 1;\n const fadeOut = 1 - t * t;\n p.sprite.material.opacity =\n fadeIn *\n fadeOut *\n (p.material.blending === THREE.AdditiveBlending ? 1 : 0.8);\n }\n }\n\n dispose(): void {\n for (const p of this.pool) p.material.dispose();\n this.pool.length = 0;\n this.group.clear();\n this.circleTex.dispose();\n this.sparkTex.dispose();\n }\n}\n"
|
|
179
179
|
},
|
|
180
180
|
{
|
|
181
181
|
"path": "game/props.ts",
|
|
182
|
-
"content": "import * as THREE from \"three\";\nimport {\n CACTUS,\n FLOWER,\n GROUND_Y,\n LEAF,\n LEAF_DARK,\n LEAF_LIGHT,\n MUSHROOM_CAP,\n STONE,\n STONE_DARK,\n TRUNK,\n TRUNK_DARK,\n} from \"./palette\";\n\n// The island's decoration: the 21 trees, bushes, plants and stones of the reference scene, at the\n// places the scene puts them. The scene's are a paid low-poly pack, so these are built from primitives\n// in the same hyper-casual style — flat shading, the board's palette, everything casting a shadow.\n\ntype Kind =\n | \"tree\"\n | \"cubeTree\"\n | \"palm\"\n | \"stump\"\n | \"stem\"\n | \"bush\"\n | \"plant\"\n | \"swamp\"\n | \"mushroom\"\n | \"cactus\"\n | \"flower\"\n | \"stone\";\n\n/** [kind, x, z] in the reference's coordinates (tile centres span 0 … 2.53 on both axes). */\nconst LAYOUT: ReadonlyArray<readonly [Kind, number, number]> = [\n [\"stone\", 0.835, 3.48],\n [\"plant\", 3.126, 2.461],\n [\"flower\", 1.277, 2.953],\n [\"cubeTree\", 2.15, 3.262],\n [\"stem\", 3.622, -0.329],\n [\"stone\", -0.732, 1.923],\n [\"palm\", 2.644, -0.721],\n [\"tree\", 3.367, 3.073],\n [\"swamp\", -0.785, 0.558],\n [\"mushroom\", -0.489, -0.407],\n [\"bush\", -0.607, 1.217],\n [\"plant\", 3.267, 1.351],\n [\"plant\", -0.548, 2.529],\n [\"stump\", 3.063, 3.559],\n [\"tree\", -0.28, 3.018],\n [\"tree\", 3.372, 2.091],\n [\"cactus\", 1.183, -0.922],\n [\"plant\", -0.409, 0.374],\n [\"tree\", 3.753, 1.082],\n [\"bush\", 0.533, -0.665],\n [\"cubeTree\", 1.827, -0.484],\n];\n\n/** Reference board: corner tile centres at 0 and 2.53. */\nconst REF_SIDE = 2.53;\n\n/**\n * Reference coordinate → ours. Inside the board's span it stretches with the board; outside, the\n * distance past the edge is kept, so a smaller board keeps its trees on the grass and off the tiles.\n * Z flips sign: Unity is left-handed.\n */\nfunction mapAxis(u: number, half: number): number {\n if (u < 0) return -half + u;\n if (u > REF_SIDE) return half + (u - REF_SIDE);\n return -half + (u / REF_SIDE) * half * 2;\n}\n\nclass Materials {\n private readonly cache = new Map<number, THREE.MeshStandardMaterial>();\n get(color: number): THREE.MeshStandardMaterial {\n let m = this.cache.get(color);\n if (!m) {\n m = new THREE.MeshStandardMaterial({ color, roughness: 0.85, flatShading: true });\n this.cache.set(color, m);\n }\n return m;\n }\n dispose(): void {\n this.cache.forEach((m) => m.dispose());\n this.cache.clear();\n }\n}\n\n/** Deterministic randomness per prop — the island looks the same on every load. */\nfunction rng(seed: number): () => number {\n let s = (seed * 2654435761) % 2147483647 || 1;\n return () => {\n s = (s * 16807) % 2147483647;\n return s / 2147483647;\n };\n}\n\n/** Squash a geometry's vertices a little so primitives read as hand-made low-poly. */\nfunction jitter(geometry: THREE.BufferGeometry, amount: number, rand: () => number): THREE.BufferGeometry {\n const pos = geometry.getAttribute(\"position\");\n const seen = new Map<string, THREE.Vector3>();\n for (let i = 0; i < pos.count; i++) {\n const key = `${pos.getX(i).toFixed(4)},${pos.getY(i).toFixed(4)},${pos.getZ(i).toFixed(4)}`;\n let offset = seen.get(key);\n if (!offset) {\n offset = new THREE.Vector3((rand() - 0.5) * amount, (rand() - 0.5) * amount, (rand() - 0.5) * amount);\n seen.set(key, offset);\n }\n pos.setXYZ(i, pos.getX(i) + offset.x, pos.getY(i) + offset.y, pos.getZ(i) + offset.z);\n }\n geometry.computeVertexNormals();\n return geometry;\n}\n\nexport class Props {\n readonly group = new THREE.Group();\n private readonly materials = new Materials();\n private readonly geometries: THREE.BufferGeometry[] = [];\n private sway: { obj: THREE.Object3D; phase: number; amount: number }[] = [];\n private time = 0;\n\n /**\n * Place the props around a board whose corner tiles sit at ±`half`. Spots within `keepOut` of a\n * point in `avoid` (the city) are skipped.\n */\n build(half: number, avoid: THREE.Vector3[] = [], keepOut = 0.3): void {\n this.clear();\n LAYOUT.forEach(([kind, ux, uz], i) => {\n const x = mapAxis(ux, half);\n const z = -mapAxis(uz, half);\n if (avoid.some((p) => Math.hypot(p.x - x, p.z - z) < keepOut)) return;\n const rand = rng(i + 1);\n const prop = this.make(kind, rand);\n prop.position.set(x, GROUND_Y, z);\n prop.rotation.y = rand() * Math.PI * 2;\n const s = 0.9 + rand() * 0.25;\n prop.scale.setScalar(s);\n prop.traverse((o) => {\n if (o instanceof THREE.Mesh) {\n o.castShadow = true;\n o.receiveShadow = true;\n }\n });\n this.group.add(prop);\n });\n }\n\n private geo<T extends THREE.BufferGeometry>(g: T): T {\n this.geometries.push(g);\n return g;\n }\n\n private mesh(g: THREE.BufferGeometry, color: number): THREE.Mesh {\n return new THREE.Mesh(this.geo(g), this.materials.get(color));\n }\n\n private make(kind: Kind, rand: () => number): THREE.Group {\n const g = new THREE.Group();\n switch (kind) {\n case \"tree\": {\n const h = 0.16 + rand() * 0.06;\n const trunk = this.mesh(new THREE.CylinderGeometry(0.018, 0.026, h, 6), TRUNK);\n trunk.position.y = h / 2;\n g.add(trunk);\n const crown = new THREE.Group();\n const blobs = 3;\n for (let b = 0; b < blobs; b++) {\n const r = 0.075 - b * 0.012 + rand() * 0.012;\n const blob = this.mesh(\n jitter(new THREE.IcosahedronGeometry(r, 0), r * 0.25, rand),\n b === 0 ? LEAF_DARK : b === 1 ? LEAF : LEAF_LIGHT,\n );\n blob.position.set((rand() - 0.5) * 0.05, h + b * 0.055, (rand() - 0.5) * 0.05);\n crown.add(blob);\n }\n g.add(crown);\n this.sway.push({ obj: crown, phase: rand() * 6, amount: 0.035 });\n break;\n }\n case \"cubeTree\": {\n const h = 0.12 + rand() * 0.05;\n const trunk = this.mesh(new THREE.BoxGeometry(0.03, h, 0.03), TRUNK);\n trunk.position.y = h / 2;\n const crown = this.mesh(new THREE.BoxGeometry(0.13, 0.13, 0.13), LEAF);\n crown.position.y = h + 0.05;\n crown.rotation.set(rand() * 0.3, rand() * Math.PI, rand() * 0.3);\n const top = this.mesh(new THREE.BoxGeometry(0.08, 0.08, 0.08), LEAF_LIGHT);\n top.position.y = h + 0.14;\n top.rotation.y = rand() * Math.PI;\n g.add(trunk, crown, top);\n this.sway.push({ obj: crown, phase: rand() * 6, amount: 0.025 });\n break;\n }\n case \"palm\": {\n const segs = 5;\n let y = 0;\n let x = 0;\n for (let s = 0; s < segs; s++) {\n const seg = this.mesh(new THREE.CylinderGeometry(0.016, 0.021, 0.055, 6), s % 2 ? TRUNK : TRUNK_DARK);\n seg.position.set(x, y + 0.0275, 0);\n seg.rotation.z = -0.12;\n g.add(seg);\n y += 0.052;\n x += 0.008;\n }\n const crown = new THREE.Group();\n crown.position.set(x, y, 0);\n for (let l = 0; l < 6; l++) {\n const leaf = this.mesh(new THREE.ConeGeometry(0.028, 0.16, 4, 1), l % 2 ? LEAF : LEAF_DARK);\n leaf.geometry.translate(0, 0.08, 0);\n leaf.rotation.set(0, (l / 6) * Math.PI * 2, 1.95);\n leaf.scale.set(1, 1, 0.35);\n crown.add(leaf);\n }\n g.add(crown);\n this.sway.push({ obj: crown, phase: rand() * 6, amount: 0.05 });\n break;\n }\n case \"stump\": {\n const stump = this.mesh(new THREE.CylinderGeometry(0.045, 0.055, 0.05, 8), TRUNK);\n stump.position.y = 0.025;\n const top = this.mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.004, 8), 0xd8a868);\n top.position.y = 0.051;\n g.add(stump, top);\n break;\n }\n case \"stem\": {\n for (let s = 0; s < 3; s++) {\n const h = 0.12 + rand() * 0.08;\n const stem = this.mesh(new THREE.CylinderGeometry(0.006, 0.009, h, 5), LEAF_DARK);\n stem.position.set((rand() - 0.5) * 0.05, h / 2, (rand() - 0.5) * 0.05);\n stem.rotation.z = (rand() - 0.5) * 0.4;\n const bud = this.mesh(new THREE.IcosahedronGeometry(0.018, 0), FLOWER);\n bud.position.set(stem.position.x, h, stem.position.z);\n g.add(stem, bud);\n }\n break;\n }\n case \"bush\": {\n for (let b = 0; b < 3; b++) {\n const r = 0.045 + rand() * 0.02;\n const blob = this.mesh(jitter(new THREE.IcosahedronGeometry(r, 0), r * 0.3, rand), b ? LEAF : LEAF_DARK);\n blob.position.set((b - 1) * 0.045, r * 0.75, (rand() - 0.5) * 0.04);\n g.add(blob);\n }\n break;\n }\n case \"plant\":\n case \"swamp\": {\n const blades = kind === \"swamp\" ? 7 : 5;\n for (let b = 0; b < blades; b++) {\n const h = 0.07 + rand() * 0.06;\n const blade = this.mesh(new THREE.ConeGeometry(0.014, h, 4), b % 2 ? LEAF : LEAF_LIGHT);\n blade.geometry.translate(0, h / 2, 0);\n const a = (b / blades) * Math.PI * 2;\n blade.rotation.set(Math.cos(a) * 0.5, 0, Math.sin(a) * 0.5);\n g.add(blade);\n }\n break;\n }\n case \"mushroom\": {\n const stem = this.mesh(new THREE.CylinderGeometry(0.012, 0.016, 0.05, 6), 0xfff1d6);\n stem.position.y = 0.025;\n const cap = this.mesh(new THREE.SphereGeometry(0.04, 8, 4, 0, Math.PI * 2, 0, Math.PI / 2), MUSHROOM_CAP);\n cap.position.y = 0.045;\n g.add(stem, cap);\n for (let d = 0; d < 4; d++) {\n const dot = this.mesh(new THREE.IcosahedronGeometry(0.007, 0), 0xffffff);\n const a = rand() * Math.PI * 2;\n dot.position.set(Math.cos(a) * 0.022, 0.075, Math.sin(a) * 0.022);\n g.add(dot);\n }\n break;\n }\n case \"cactus\": {\n const body = this.mesh(new THREE.CapsuleGeometry(0.025, 0.12, 3, 7), CACTUS);\n body.position.y = 0.085;\n const armL = this.mesh(new THREE.CapsuleGeometry(0.014, 0.04, 3, 6), CACTUS);\n armL.position.set(-0.035, 0.1, 0);\n const armR = this.mesh(new THREE.CapsuleGeometry(0.014, 0.05, 3, 6), CACTUS);\n armR.position.set(0.035, 0.12, 0);\n const flower = this.mesh(new THREE.IcosahedronGeometry(0.012, 0), 0xff7aa8);\n flower.position.y = 0.175;\n g.add(body, armL, armR, flower);\n break;\n }\n case \"flower\": {\n for (let f = 0; f < 5; f++) {\n const h = 0.04 + rand() * 0.04;\n const stem = this.mesh(new THREE.CylinderGeometry(0.004, 0.004, h, 4), LEAF_DARK);\n const px = (rand() - 0.5) * 0.09;\n const pz = (rand() - 0.5) * 0.09;\n stem.position.set(px, h / 2, pz);\n const head = this.mesh(new THREE.IcosahedronGeometry(0.013, 0), f % 2 ? FLOWER : 0xffffff);\n head.position.set(px, h, pz);\n g.add(stem, head);\n }\n break;\n }\n case \"stone\": {\n const big = this.mesh(jitter(new THREE.DodecahedronGeometry(0.065, 0), 0.025, rand), STONE);\n big.position.y = 0.03;\n big.scale.set(1.2, 0.75, 1);\n const small = this.mesh(jitter(new THREE.DodecahedronGeometry(0.035, 0), 0.012, rand), STONE_DARK);\n small.position.set(0.075, 0.015, 0.03);\n g.add(big, small);\n break;\n }\n }\n return g;\n }\n\n /** A breeze through the crowns. */\n update(dt: number): void {\n this.time += dt;\n for (const s of this.sway) {\n s.obj.rotation.z = Math.sin(this.time * 1.3 + s.phase) * s.amount;\n s.obj.rotation.x = Math.sin(this.time * 0.9 + s.phase * 1.7) * s.amount * 0.6;\n }\n }\n\n private clear(): void {\n this.group.clear();\n this.geometries.forEach((g) => g.dispose());\n this.geometries.length = 0;\n this.sway = [];\n }\n\n dispose(): void {\n this.clear();\n this.materials.dispose();\n }\n}\n"
|
|
182
|
+
"content": "import * as THREE from \"three\";\nimport {\n CACTUS,\n FLOWER,\n GROUND_Y,\n LEAF,\n LEAF_DARK,\n LEAF_LIGHT,\n MUSHROOM_CAP,\n STONE,\n STONE_DARK,\n TRUNK,\n TRUNK_DARK,\n} from \"./palette\";\n\n// The island's decoration: the 21 trees, bushes, plants and stones of the reference scene, at the\n// places the scene puts them. The scene's are a paid low-poly pack, so these are built from primitives\n// in the same hyper-casual style — flat shading, the board's palette, everything casting a shadow.\n\ntype Kind =\n | \"tree\"\n | \"cubeTree\"\n | \"palm\"\n | \"stump\"\n | \"stem\"\n | \"bush\"\n | \"plant\"\n | \"swamp\"\n | \"mushroom\"\n | \"cactus\"\n | \"flower\"\n | \"stone\";\n\n/** [kind, x, z] in the reference's coordinates (tile centres span 0 … 2.53 on both axes). */\nconst LAYOUT: ReadonlyArray<readonly [Kind, number, number]> = [\n [\"stone\", 0.835, 3.48],\n [\"plant\", 3.126, 2.461],\n [\"flower\", 1.277, 2.953],\n [\"cubeTree\", 2.15, 3.262],\n [\"stem\", 3.622, -0.329],\n [\"stone\", -0.732, 1.923],\n [\"palm\", 2.644, -0.721],\n [\"tree\", 3.367, 3.073],\n [\"swamp\", -0.785, 0.558],\n [\"mushroom\", -0.489, -0.407],\n [\"bush\", -0.607, 1.217],\n [\"plant\", 3.267, 1.351],\n [\"plant\", -0.548, 2.529],\n [\"stump\", 3.063, 3.559],\n [\"tree\", -0.28, 3.018],\n [\"tree\", 3.372, 2.091],\n [\"cactus\", 1.183, -0.922],\n [\"plant\", -0.409, 0.374],\n [\"tree\", 3.753, 1.082],\n [\"bush\", 0.533, -0.665],\n [\"cubeTree\", 1.827, -0.484],\n];\n\n/** Reference board: corner tile centres at 0 and 2.53. */\nconst REF_SIDE = 2.53;\n\n/**\n * Reference coordinate → ours. Inside the board's span it stretches with the board; outside, the\n * distance past the edge is kept, so a smaller board keeps its trees on the grass and off the tiles.\n * Z flips sign: Unity is left-handed.\n */\nfunction mapAxis(u: number, half: number): number {\n if (u < 0) return -half + u;\n if (u > REF_SIDE) return half + (u - REF_SIDE);\n return -half + (u / REF_SIDE) * half * 2;\n}\n\nclass Materials {\n private readonly cache = new Map<number, THREE.MeshStandardMaterial>();\n get(color: number): THREE.MeshStandardMaterial {\n let m = this.cache.get(color);\n if (!m) {\n m = new THREE.MeshStandardMaterial({\n color,\n roughness: 0.85,\n flatShading: true,\n });\n this.cache.set(color, m);\n }\n return m;\n }\n dispose(): void {\n this.cache.forEach((m) => m.dispose());\n this.cache.clear();\n }\n}\n\n/** Deterministic randomness per prop — the island looks the same on every load. */\nfunction rng(seed: number): () => number {\n let s = (seed * 2654435761) % 2147483647 || 1;\n return () => {\n s = (s * 16807) % 2147483647;\n return s / 2147483647;\n };\n}\n\n/** Squash a geometry's vertices a little so primitives read as hand-made low-poly. */\nfunction jitter(\n geometry: THREE.BufferGeometry,\n amount: number,\n rand: () => number,\n): THREE.BufferGeometry {\n const pos = geometry.getAttribute(\"position\");\n const seen = new Map<string, THREE.Vector3>();\n for (let i = 0; i < pos.count; i++) {\n const key = `${pos.getX(i).toFixed(4)},${pos.getY(i).toFixed(4)},${pos.getZ(i).toFixed(4)}`;\n let offset = seen.get(key);\n if (!offset) {\n offset = new THREE.Vector3(\n (rand() - 0.5) * amount,\n (rand() - 0.5) * amount,\n (rand() - 0.5) * amount,\n );\n seen.set(key, offset);\n }\n pos.setXYZ(\n i,\n pos.getX(i) + offset.x,\n pos.getY(i) + offset.y,\n pos.getZ(i) + offset.z,\n );\n }\n geometry.computeVertexNormals();\n return geometry;\n}\n\nexport class Props {\n readonly group = new THREE.Group();\n private readonly materials = new Materials();\n private readonly geometries: THREE.BufferGeometry[] = [];\n private sway: { obj: THREE.Object3D; phase: number; amount: number }[] = [];\n private time = 0;\n\n /**\n * Place the props around a board whose corner tiles sit at ±`half`. Spots within `keepOut` of a\n * point in `avoid` (the city) are skipped.\n */\n build(half: number, avoid: THREE.Vector3[] = [], keepOut = 0.3): void {\n this.clear();\n LAYOUT.forEach(([kind, ux, uz], i) => {\n const x = mapAxis(ux, half);\n const z = -mapAxis(uz, half);\n if (avoid.some((p) => Math.hypot(p.x - x, p.z - z) < keepOut)) return;\n const rand = rng(i + 1);\n const prop = this.make(kind, rand);\n prop.position.set(x, GROUND_Y, z);\n prop.rotation.y = rand() * Math.PI * 2;\n const s = 0.9 + rand() * 0.25;\n prop.scale.setScalar(s);\n prop.traverse((o) => {\n if (o instanceof THREE.Mesh) {\n o.castShadow = true;\n o.receiveShadow = true;\n }\n });\n this.group.add(prop);\n });\n }\n\n private geo<T extends THREE.BufferGeometry>(g: T): T {\n this.geometries.push(g);\n return g;\n }\n\n private mesh(g: THREE.BufferGeometry, color: number): THREE.Mesh {\n return new THREE.Mesh(this.geo(g), this.materials.get(color));\n }\n\n private make(kind: Kind, rand: () => number): THREE.Group {\n const g = new THREE.Group();\n switch (kind) {\n case \"tree\": {\n const h = 0.16 + rand() * 0.06;\n const trunk = this.mesh(\n new THREE.CylinderGeometry(0.018, 0.026, h, 6),\n TRUNK,\n );\n trunk.position.y = h / 2;\n g.add(trunk);\n const crown = new THREE.Group();\n const blobs = 3;\n for (let b = 0; b < blobs; b++) {\n const r = 0.075 - b * 0.012 + rand() * 0.012;\n const blob = this.mesh(\n jitter(new THREE.IcosahedronGeometry(r, 0), r * 0.25, rand),\n b === 0 ? LEAF_DARK : b === 1 ? LEAF : LEAF_LIGHT,\n );\n blob.position.set(\n (rand() - 0.5) * 0.05,\n h + b * 0.055,\n (rand() - 0.5) * 0.05,\n );\n crown.add(blob);\n }\n g.add(crown);\n this.sway.push({ obj: crown, phase: rand() * 6, amount: 0.035 });\n break;\n }\n case \"cubeTree\": {\n const h = 0.12 + rand() * 0.05;\n const trunk = this.mesh(new THREE.BoxGeometry(0.03, h, 0.03), TRUNK);\n trunk.position.y = h / 2;\n const crown = this.mesh(new THREE.BoxGeometry(0.13, 0.13, 0.13), LEAF);\n crown.position.y = h + 0.05;\n crown.rotation.set(rand() * 0.3, rand() * Math.PI, rand() * 0.3);\n const top = this.mesh(\n new THREE.BoxGeometry(0.08, 0.08, 0.08),\n LEAF_LIGHT,\n );\n top.position.y = h + 0.14;\n top.rotation.y = rand() * Math.PI;\n g.add(trunk, crown, top);\n this.sway.push({ obj: crown, phase: rand() * 6, amount: 0.025 });\n break;\n }\n case \"palm\": {\n const segs = 5;\n let y = 0;\n let x = 0;\n for (let s = 0; s < segs; s++) {\n const seg = this.mesh(\n new THREE.CylinderGeometry(0.016, 0.021, 0.055, 6),\n s % 2 ? TRUNK : TRUNK_DARK,\n );\n seg.position.set(x, y + 0.0275, 0);\n seg.rotation.z = -0.12;\n g.add(seg);\n y += 0.052;\n x += 0.008;\n }\n const crown = new THREE.Group();\n crown.position.set(x, y, 0);\n for (let l = 0; l < 6; l++) {\n const leaf = this.mesh(\n new THREE.ConeGeometry(0.028, 0.16, 4, 1),\n l % 2 ? LEAF : LEAF_DARK,\n );\n leaf.geometry.translate(0, 0.08, 0);\n leaf.rotation.set(0, (l / 6) * Math.PI * 2, 1.95);\n leaf.scale.set(1, 1, 0.35);\n crown.add(leaf);\n }\n g.add(crown);\n this.sway.push({ obj: crown, phase: rand() * 6, amount: 0.05 });\n break;\n }\n case \"stump\": {\n const stump = this.mesh(\n new THREE.CylinderGeometry(0.045, 0.055, 0.05, 8),\n TRUNK,\n );\n stump.position.y = 0.025;\n const top = this.mesh(\n new THREE.CylinderGeometry(0.04, 0.04, 0.004, 8),\n 0xd8a868,\n );\n top.position.y = 0.051;\n g.add(stump, top);\n break;\n }\n case \"stem\": {\n for (let s = 0; s < 3; s++) {\n const h = 0.12 + rand() * 0.08;\n const stem = this.mesh(\n new THREE.CylinderGeometry(0.006, 0.009, h, 5),\n LEAF_DARK,\n );\n stem.position.set(\n (rand() - 0.5) * 0.05,\n h / 2,\n (rand() - 0.5) * 0.05,\n );\n stem.rotation.z = (rand() - 0.5) * 0.4;\n const bud = this.mesh(\n new THREE.IcosahedronGeometry(0.018, 0),\n FLOWER,\n );\n bud.position.set(stem.position.x, h, stem.position.z);\n g.add(stem, bud);\n }\n break;\n }\n case \"bush\": {\n for (let b = 0; b < 3; b++) {\n const r = 0.045 + rand() * 0.02;\n const blob = this.mesh(\n jitter(new THREE.IcosahedronGeometry(r, 0), r * 0.3, rand),\n b ? LEAF : LEAF_DARK,\n );\n blob.position.set((b - 1) * 0.045, r * 0.75, (rand() - 0.5) * 0.04);\n g.add(blob);\n }\n break;\n }\n case \"plant\":\n case \"swamp\": {\n const blades = kind === \"swamp\" ? 7 : 5;\n for (let b = 0; b < blades; b++) {\n const h = 0.07 + rand() * 0.06;\n const blade = this.mesh(\n new THREE.ConeGeometry(0.014, h, 4),\n b % 2 ? LEAF : LEAF_LIGHT,\n );\n blade.geometry.translate(0, h / 2, 0);\n const a = (b / blades) * Math.PI * 2;\n blade.rotation.set(Math.cos(a) * 0.5, 0, Math.sin(a) * 0.5);\n g.add(blade);\n }\n break;\n }\n case \"mushroom\": {\n const stem = this.mesh(\n new THREE.CylinderGeometry(0.012, 0.016, 0.05, 6),\n 0xfff1d6,\n );\n stem.position.y = 0.025;\n const cap = this.mesh(\n new THREE.SphereGeometry(0.04, 8, 4, 0, Math.PI * 2, 0, Math.PI / 2),\n MUSHROOM_CAP,\n );\n cap.position.y = 0.045;\n g.add(stem, cap);\n for (let d = 0; d < 4; d++) {\n const dot = this.mesh(\n new THREE.IcosahedronGeometry(0.007, 0),\n 0xffffff,\n );\n const a = rand() * Math.PI * 2;\n dot.position.set(Math.cos(a) * 0.022, 0.075, Math.sin(a) * 0.022);\n g.add(dot);\n }\n break;\n }\n case \"cactus\": {\n const body = this.mesh(\n new THREE.CapsuleGeometry(0.025, 0.12, 3, 7),\n CACTUS,\n );\n body.position.y = 0.085;\n const armL = this.mesh(\n new THREE.CapsuleGeometry(0.014, 0.04, 3, 6),\n CACTUS,\n );\n armL.position.set(-0.035, 0.1, 0);\n const armR = this.mesh(\n new THREE.CapsuleGeometry(0.014, 0.05, 3, 6),\n CACTUS,\n );\n armR.position.set(0.035, 0.12, 0);\n const flower = this.mesh(\n new THREE.IcosahedronGeometry(0.012, 0),\n 0xff7aa8,\n );\n flower.position.y = 0.175;\n g.add(body, armL, armR, flower);\n break;\n }\n case \"flower\": {\n for (let f = 0; f < 5; f++) {\n const h = 0.04 + rand() * 0.04;\n const stem = this.mesh(\n new THREE.CylinderGeometry(0.004, 0.004, h, 4),\n LEAF_DARK,\n );\n const px = (rand() - 0.5) * 0.09;\n const pz = (rand() - 0.5) * 0.09;\n stem.position.set(px, h / 2, pz);\n const head = this.mesh(\n new THREE.IcosahedronGeometry(0.013, 0),\n f % 2 ? FLOWER : 0xffffff,\n );\n head.position.set(px, h, pz);\n g.add(stem, head);\n }\n break;\n }\n case \"stone\": {\n const big = this.mesh(\n jitter(new THREE.DodecahedronGeometry(0.065, 0), 0.025, rand),\n STONE,\n );\n big.position.y = 0.03;\n big.scale.set(1.2, 0.75, 1);\n const small = this.mesh(\n jitter(new THREE.DodecahedronGeometry(0.035, 0), 0.012, rand),\n STONE_DARK,\n );\n small.position.set(0.075, 0.015, 0.03);\n g.add(big, small);\n break;\n }\n }\n return g;\n }\n\n /** A breeze through the crowns. */\n update(dt: number): void {\n this.time += dt;\n for (const s of this.sway) {\n s.obj.rotation.z = Math.sin(this.time * 1.3 + s.phase) * s.amount;\n s.obj.rotation.x =\n Math.sin(this.time * 0.9 + s.phase * 1.7) * s.amount * 0.6;\n }\n }\n\n private clear(): void {\n this.group.clear();\n this.geometries.forEach((g) => g.dispose());\n this.geometries.length = 0;\n this.sway = [];\n }\n\n dispose(): void {\n this.clear();\n this.materials.dispose();\n }\n}\n"
|
|
183
183
|
},
|
|
184
184
|
{
|
|
185
185
|
"path": "game/stage.ts",
|
|
186
|
-
"content": "import * as THREE from \"three\";\
|
|
186
|
+
"content": "import * as THREE from \"three\";\nimport { GROUND_BOUNCE, SEA, SKY_AMBIENT, SUN_COLOR } from \"./palette\";\n\n// Renderer, scene and light — the look of the reference's baked lighting, done in real time.\n//\n// The reference (URP, mobile profile) bakes its sun into lightmaps with a 10° soft shadow and AO in\n// the tile seams, and lights the pawn and the dice from light probes. Here the sun is a real-time\n// directional light with a soft PCF shadow, and a hemisphere light stands in for the flat sky-blue\n// ambient plus the green bounce off the grass the bake produced.\n\n/** The reference's sun: euler (45, -100, 0). Unity is left-handed, so Z flips sign. */\nfunction sunDirection(): THREE.Vector3 {\n const pitch = THREE.MathUtils.degToRad(45);\n const yaw = THREE.MathUtils.degToRad(-100);\n return new THREE.Vector3(\n Math.sin(yaw) * Math.cos(pitch),\n -Math.sin(pitch),\n -Math.cos(yaw) * Math.cos(pitch),\n ).normalize();\n}\n\nconst isSmallScreen = (): boolean =>\n typeof window !== \"undefined\" &&\n (window.matchMedia?.(\"(pointer: coarse)\").matches ||\n Math.min(window.innerWidth, window.innerHeight) < 700);\n\nexport interface Stage {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n sun: THREE.DirectionalLight;\n /** Fit the sun's shadow box around a world-space area (the island). */\n fitShadow(center: THREE.Vector3, halfSize: number): void;\n dispose(): void;\n}\n\nexport function createStage(host: HTMLElement): Stage {\n const renderer = new THREE.WebGLRenderer({ antialias: true });\n renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n renderer.outputColorSpace = THREE.SRGBColorSpace;\n renderer.toneMapping = THREE.NeutralToneMapping;\n renderer.toneMappingExposure = 1.05;\n renderer.shadowMap.enabled = true;\n renderer.shadowMap.type = THREE.PCFShadowMap;\n // setSize(…, false) leaves the CSS size alone: without these the canvas shows at devicePixelRatio\n // times its size on a phone, and the view looks zoomed into its top-left corner.\n renderer.domElement.style.display = \"block\";\n renderer.domElement.style.width = \"100%\";\n renderer.domElement.style.height = \"100%\";\n renderer.domElement.style.touchAction = \"none\";\n host.appendChild(renderer.domElement);\n\n const scene = new THREE.Scene();\n scene.background = new THREE.Color(SEA);\n // The sea runs to the horizon; the fog melts its far edge into the sky colour instead of a cut.\n scene.fog = new THREE.Fog(\n new THREE.Color(SEA).lerp(new THREE.Color(0xffffff), 0.25),\n 24,\n 46,\n );\n\n const hemi = new THREE.HemisphereLight(SKY_AMBIENT, GROUND_BOUNCE, 1.55);\n scene.add(hemi);\n\n const sun = new THREE.DirectionalLight(SUN_COLOR, 2.1);\n sun.castShadow = true;\n const mapSize = isSmallScreen() ? 1024 : 2048;\n sun.shadow.mapSize.set(mapSize, mapSize);\n // Soft edge of the reference's 10° shadow angle.\n sun.shadow.radius = mapSize > 1024 ? 6 : 4;\n sun.shadow.bias = -0.0004;\n sun.shadow.normalBias = 0.012;\n scene.add(sun, sun.target);\n\n const dir = sunDirection();\n const fitShadow = (center: THREE.Vector3, halfSize: number): void => {\n sun.target.position.copy(center);\n sun.position.copy(center).addScaledVector(dir, -12);\n const cam = sun.shadow.camera;\n cam.left = -halfSize;\n cam.right = halfSize;\n cam.top = halfSize;\n cam.bottom = -halfSize;\n cam.near = 1;\n cam.far = 24;\n cam.updateProjectionMatrix();\n sun.shadow.needsUpdate = true;\n };\n\n return {\n renderer,\n scene,\n sun,\n fitShadow,\n dispose(): void {\n renderer.dispose();\n renderer.domElement.remove();\n },\n };\n}\n"
|
|
187
187
|
},
|
|
188
188
|
{
|
|
189
189
|
"path": "game/tileIcons.ts",
|
|
@@ -191,15 +191,15 @@
|
|
|
191
191
|
},
|
|
192
192
|
{
|
|
193
193
|
"path": "game/tileMesh.ts",
|
|
194
|
-
"content": "import * as THREE from \"three\";\nimport { mergeGeometries } from \"three/addons/utils/BufferGeometryUtils.js\";\nimport { TILE_HEIGHT } from \"./palette\";\n\n// Geometry of the board pieces, built in code. The reference tile (Tile1.fbx) is a rounded slab with\n// a raised rim around a slightly sunken top; the board stands on a rounded plate. Both are extruded\n// rounded rectangles with a bevel, so the edges catch the light the way the modelled ones do.\n\n/** Rounded rectangle in the XY plane, centred; an optional hole `inset` in from the edge. */\nfunction roundedRect(\n width: number,\n depth: number,\n radius: number,\n inset = -1,\n): THREE.Shape {\n const shape = outline(new THREE.Shape(), width, depth, radius);\n if (inset > 0) {\n const hole = outline(\n new THREE.Path(),\n width - inset * 2,\n depth - inset * 2,\n Math.max(0.004, radius - inset),\n );\n shape.holes.push(hole);\n }\n return shape;\n}\n\nfunction outline<T extends THREE.Path>(\n path: T,\n width: number,\n depth: number,\n radius: number,\n): T {\n const x = -width / 2;\n const y = -depth / 2;\n const r = Math.min(radius, width / 2, depth / 2);\n path.moveTo(x + r, y);\n path.lineTo(x + width - r, y);\n path.quadraticCurveTo(x + width, y, x + width, y + r);\n path.lineTo(x + width, y + depth - r);\n path.quadraticCurveTo(x + width, y + depth, x + width - r, y + depth);\n path.lineTo(x + r, y + depth);\n path.quadraticCurveTo(x, y + depth, x, y + depth - r);\n path.lineTo(x, y + r);\n path.quadraticCurveTo(x, y, x + r, y);\n return path;\n}\n\n/**\n * Extrude a shape upwards: the result spans y ∈ [bottom, bottom + height] (bevel included) and keeps\n * the shape's footprint (the bevel is taken out of the outline, not added to it).\n */\nfunction extrudeUp(\n makeShape: (shrink: number) => THREE.Shape,\n bottom: number,\n height: number,\n bevel: number,\n segments = 3,\n): THREE.BufferGeometry {\n const geometry = new THREE.ExtrudeGeometry(makeShape(bevel), {\n depth: Math.max(0.0005, height - bevel * 2),\n bevelEnabled: true,\n bevelThickness: bevel,\n bevelSize: bevel,\n bevelSegments: segments,\n curveSegments: 6,\n });\n // Extrusion runs along +Z from -bevel; lay it down so it rises along +Y.\n geometry.rotateX(-Math.PI / 2);\n geometry.translate(0, bottom + bevel, 0);\n return geometry.index ? geometry.toNonIndexed() : geometry;\n}\n\n/**\n * Vertex shade by height: the lower part of a piece goes darker. Cheap stand-in for the baked AO in\n * the seams between tiles — the reference's lightmap darkens exactly there.\n */\nfunction shadeByHeight(\n geometry: THREE.BufferGeometry,\n yMin: number,\n yMax: number,\n darkest: number,\n): void {\n const pos = geometry.getAttribute(\"position\");\n const colors = new Float32Array(pos.count * 3);\n for (let i = 0; i < pos.count; i++) {\n const t = THREE.MathUtils.clamp((pos.getY(i) - yMin) / (yMax - yMin), 0, 1);\n const shade = darkest + (1 - darkest) * Math.sqrt(t);\n colors[i * 3] = shade;\n colors[i * 3 + 1] = shade;\n colors[i * 3 + 2] = shade;\n }\n geometry.setAttribute(\"color\", new THREE.BufferAttribute(colors, 3));\n}\n\n/** Depth of the sunken top inside the rim. */\nexport const TILE_RECESS = 0.012;\n/** Y of the sunken top above the tile's bottom — where the icon lies. */\nexport const TILE_FLOOR = TILE_HEIGHT - TILE_RECESS;\n\n/**\n * One tile, `width` along X and `depth` along Z, bottom at y = 0 and rim top at TILE_HEIGHT.\n * Merged into one geometry (body + rim) so a tile is one mesh with one material.\n */\nexport function tileGeometry(width: number
|
|
194
|
+
"content": "import * as THREE from \"three\";\nimport { mergeGeometries } from \"three/addons/utils/BufferGeometryUtils.js\";\nimport { TILE_HEIGHT } from \"./palette\";\n\n// Geometry of the board pieces, built in code. The reference tile (Tile1.fbx) is a rounded slab with\n// a raised rim around a slightly sunken top; the board stands on a rounded plate. Both are extruded\n// rounded rectangles with a bevel, so the edges catch the light the way the modelled ones do.\n\n/** Rounded rectangle in the XY plane, centred; an optional hole `inset` in from the edge. */\nfunction roundedRect(\n width: number,\n depth: number,\n radius: number,\n inset = -1,\n): THREE.Shape {\n const shape = outline(new THREE.Shape(), width, depth, radius);\n if (inset > 0) {\n const hole = outline(\n new THREE.Path(),\n width - inset * 2,\n depth - inset * 2,\n Math.max(0.004, radius - inset),\n );\n shape.holes.push(hole);\n }\n return shape;\n}\n\nfunction outline<T extends THREE.Path>(\n path: T,\n width: number,\n depth: number,\n radius: number,\n): T {\n const x = -width / 2;\n const y = -depth / 2;\n const r = Math.min(radius, width / 2, depth / 2);\n path.moveTo(x + r, y);\n path.lineTo(x + width - r, y);\n path.quadraticCurveTo(x + width, y, x + width, y + r);\n path.lineTo(x + width, y + depth - r);\n path.quadraticCurveTo(x + width, y + depth, x + width - r, y + depth);\n path.lineTo(x + r, y + depth);\n path.quadraticCurveTo(x, y + depth, x, y + depth - r);\n path.lineTo(x, y + r);\n path.quadraticCurveTo(x, y, x + r, y);\n return path;\n}\n\n/**\n * Extrude a shape upwards: the result spans y ∈ [bottom, bottom + height] (bevel included) and keeps\n * the shape's footprint (the bevel is taken out of the outline, not added to it).\n */\nfunction extrudeUp(\n makeShape: (shrink: number) => THREE.Shape,\n bottom: number,\n height: number,\n bevel: number,\n segments = 3,\n): THREE.BufferGeometry {\n const geometry = new THREE.ExtrudeGeometry(makeShape(bevel), {\n depth: Math.max(0.0005, height - bevel * 2),\n bevelEnabled: true,\n bevelThickness: bevel,\n bevelSize: bevel,\n bevelSegments: segments,\n curveSegments: 6,\n });\n // Extrusion runs along +Z from -bevel; lay it down so it rises along +Y.\n geometry.rotateX(-Math.PI / 2);\n geometry.translate(0, bottom + bevel, 0);\n return geometry.index ? geometry.toNonIndexed() : geometry;\n}\n\n/**\n * Vertex shade by height: the lower part of a piece goes darker. Cheap stand-in for the baked AO in\n * the seams between tiles — the reference's lightmap darkens exactly there.\n */\nfunction shadeByHeight(\n geometry: THREE.BufferGeometry,\n yMin: number,\n yMax: number,\n darkest: number,\n): void {\n const pos = geometry.getAttribute(\"position\");\n const colors = new Float32Array(pos.count * 3);\n for (let i = 0; i < pos.count; i++) {\n const t = THREE.MathUtils.clamp((pos.getY(i) - yMin) / (yMax - yMin), 0, 1);\n const shade = darkest + (1 - darkest) * Math.sqrt(t);\n colors[i * 3] = shade;\n colors[i * 3 + 1] = shade;\n colors[i * 3 + 2] = shade;\n }\n geometry.setAttribute(\"color\", new THREE.BufferAttribute(colors, 3));\n}\n\n/** Depth of the sunken top inside the rim. */\nexport const TILE_RECESS = 0.012;\n/** Y of the sunken top above the tile's bottom — where the icon lies. */\nexport const TILE_FLOOR = TILE_HEIGHT - TILE_RECESS;\n\n/**\n * One tile, `width` along X and `depth` along Z, bottom at y = 0 and rim top at TILE_HEIGHT.\n * Merged into one geometry (body + rim) so a tile is one mesh with one material.\n */\nexport function tileGeometry(\n width: number,\n depth: number,\n): THREE.BufferGeometry {\n const radius = Math.min(width, depth) * 0.2;\n const body = extrudeUp(\n (s) => roundedRect(width - s * 2, depth - s * 2, radius - s),\n 0,\n TILE_FLOOR,\n 0.008,\n );\n const rimWidth = Math.min(width, depth) * 0.085;\n const rim = extrudeUp(\n (s) => roundedRect(width - s * 2, depth - s * 2, radius - s, rimWidth),\n TILE_FLOOR - 0.006,\n TILE_RECESS + 0.006,\n 0.0035,\n 2,\n );\n const merged = mergeGeometries([body, rim]);\n body.dispose();\n rim.dispose();\n const geometry = merged ?? new THREE.BoxGeometry(width, TILE_HEIGHT, depth);\n shadeByHeight(geometry, 0, TILE_HEIGHT, 0.62);\n geometry.computeBoundingSphere();\n return geometry;\n}\n\n/** The rounded plate the tiles stand on: `size` square, top at y = 0. */\nexport function plateGeometry(\n size: number,\n thickness: number,\n): THREE.BufferGeometry {\n const geometry = extrudeUp(\n (s) => roundedRect(size - s * 2, size - s * 2, 0.12 - s),\n -thickness,\n thickness,\n 0.025,\n 4,\n );\n shadeByHeight(geometry, -thickness, 0, 0.55);\n return geometry;\n}\n\n/** A rounded rectangle lying flat (XZ), centred, for the island layers. */\nexport function slabGeometry(\n width: number,\n depth: number,\n radius: number,\n bottom: number,\n height: number,\n bevel: number,\n): THREE.BufferGeometry {\n return extrudeUp(\n (s) => roundedRect(width - s * 2, depth - s * 2, radius - s),\n bottom,\n height,\n bevel,\n 4,\n );\n}\n"
|
|
195
195
|
},
|
|
196
196
|
{
|
|
197
197
|
"path": "game/token.ts",
|
|
198
|
-
"content": "import * as THREE from \"three\";\nimport { PAWN } from \"./palette\";\nimport { clamp01, curve } from \"./tween\";\n\n// The player's pawn (the reference's Pawn.fbx turned on a lathe) with its squash & stretch\n// (BoardTokenAnimator), a blob shadow on the ground (BlobShadow), and the trail of a long roll.\n\n/** The pawn's silhouette, bottom to neck: [radius, height]. Head is a sphere on top. */\nconst PROFILE: ReadonlyArray<readonly [number, number]> = [\n [0, 0],\n [0.058, 0],\n [0.063, 0.006],\n [0.063, 0.02],\n [0.058, 0.03],\n [0.05, 0.036],\n [0.047, 0.05],\n [0.044, 0.075],\n [0.037, 0.105],\n [0.03, 0.135],\n [0.026, 0.162],\n [0.027, 0.18],\n [0.04, 0.188],\n [0.042, 0.197],\n [0.038, 0.206],\n [0.026, 0.212],\n [0.02, 0.222],\n];\nconst HEAD_Y = 0.255;\nconst HEAD_R = 0.044;\nexport const PAWN_HEIGHT = HEAD_Y + HEAD_R;\n\n// BoardTokenAnimator.\nconst IDLE_AMOUNT = 0.035;\nconst IDLE_SPEED = 2.2;\nconst HOP_STRETCH: ReadonlyArray<readonly [number, number]> = [\n [0, -0.22],\n [0.18, 0.18],\n [0.75, 0.12],\n [1, -0.25],\n];\nconst LANDING_S = 0.18;\nconst LANDING_SQUASH: ReadonlyArray<readonly [number, number]> = [\n [0, -0.25],\n [0.45, 0.1],\n [1, 0],\n];\n\n// BlobShadow: footprint × 3.4, fades over 1.6 × hop height.\nconst BLOB_SIZE = 0.15 * 1.4;\nconst BLOB_FADE_HEIGHT = 0.48;\n\nconst TRAIL_POINTS = 22;\nconst TRAIL_LIFE = 0.32;\nconst TRAIL_WIDTH = 0.05;\n\ntype SquashState = \"idle\" | \"hop\" | \"landing\";\n\nexport function blobTexture(): THREE.CanvasTexture {\n const px = 64;\n const canvas = document.createElement(\"canvas\");\n canvas.width = px;\n canvas.height = px;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n const g = ctx.createRadialGradient(px / 2
|
|
198
|
+
"content": "import * as THREE from \"three\";\nimport { PAWN } from \"./palette\";\nimport { clamp01, curve } from \"./tween\";\n\n// The player's pawn (the reference's Pawn.fbx turned on a lathe) with its squash & stretch\n// (BoardTokenAnimator), a blob shadow on the ground (BlobShadow), and the trail of a long roll.\n\n/** The pawn's silhouette, bottom to neck: [radius, height]. Head is a sphere on top. */\nconst PROFILE: ReadonlyArray<readonly [number, number]> = [\n [0, 0],\n [0.058, 0],\n [0.063, 0.006],\n [0.063, 0.02],\n [0.058, 0.03],\n [0.05, 0.036],\n [0.047, 0.05],\n [0.044, 0.075],\n [0.037, 0.105],\n [0.03, 0.135],\n [0.026, 0.162],\n [0.027, 0.18],\n [0.04, 0.188],\n [0.042, 0.197],\n [0.038, 0.206],\n [0.026, 0.212],\n [0.02, 0.222],\n];\nconst HEAD_Y = 0.255;\nconst HEAD_R = 0.044;\nexport const PAWN_HEIGHT = HEAD_Y + HEAD_R;\n\n// BoardTokenAnimator.\nconst IDLE_AMOUNT = 0.035;\nconst IDLE_SPEED = 2.2;\nconst HOP_STRETCH: ReadonlyArray<readonly [number, number]> = [\n [0, -0.22],\n [0.18, 0.18],\n [0.75, 0.12],\n [1, -0.25],\n];\nconst LANDING_S = 0.18;\nconst LANDING_SQUASH: ReadonlyArray<readonly [number, number]> = [\n [0, -0.25],\n [0.45, 0.1],\n [1, 0],\n];\n\n// BlobShadow: footprint × 3.4, fades over 1.6 × hop height.\nconst BLOB_SIZE = 0.15 * 1.4;\nconst BLOB_FADE_HEIGHT = 0.48;\n\nconst TRAIL_POINTS = 22;\nconst TRAIL_LIFE = 0.32;\nconst TRAIL_WIDTH = 0.05;\n\ntype SquashState = \"idle\" | \"hop\" | \"landing\";\n\nexport function blobTexture(): THREE.CanvasTexture {\n const px = 64;\n const canvas = document.createElement(\"canvas\");\n canvas.width = px;\n canvas.height = px;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n const g = ctx.createRadialGradient(\n px / 2,\n px / 2,\n 0,\n px / 2,\n px / 2,\n px / 2,\n );\n g.addColorStop(0, \"rgba(0,0,0,0.55)\");\n g.addColorStop(0.5, \"rgba(0,0,0,0.3)\");\n g.addColorStop(1, \"rgba(0,0,0,0)\");\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, px, px);\n }\n return new THREE.CanvasTexture(canvas);\n}\n\nexport class Token {\n /** On the tile: position and facing. */\n readonly root = new THREE.Group();\n /** Lifted by the hop. */\n private readonly hop = new THREE.Group();\n /** Squash & stretch and the suspense spin. */\n private readonly body = new THREE.Group();\n private readonly material: THREE.MeshStandardMaterial;\n private readonly geometries: THREE.BufferGeometry[] = [];\n readonly shadow: THREE.Mesh;\n private readonly shadowMat: THREE.MeshBasicMaterial;\n private readonly shadowTex: THREE.CanvasTexture;\n\n private state: SquashState = \"idle\";\n private hopProgress = 0;\n private hopIntensity = 1;\n private landingElapsed = 0;\n private landingIntensity = 1;\n private idleTime = 0;\n\n // Trail.\n readonly trail: THREE.Mesh;\n private readonly trailGeo: THREE.BufferGeometry;\n private readonly trailMat: THREE.MeshBasicMaterial;\n private trailPoints: { p: THREE.Vector3; age: number }[] = [];\n emitting = false;\n\n constructor() {\n this.material = new THREE.MeshStandardMaterial({\n color: PAWN,\n roughness: 0.32,\n metalness: 0.05,\n });\n const lathe = new THREE.LatheGeometry(\n PROFILE.map(([r, y]) => new THREE.Vector2(r, y)),\n 28,\n );\n const head = new THREE.SphereGeometry(HEAD_R, 24, 16);\n head.translate(0, HEAD_Y, 0);\n this.geometries.push(lathe, head);\n for (const g of [lathe, head]) {\n const mesh = new THREE.Mesh(g, this.material);\n mesh.castShadow = true;\n mesh.receiveShadow = true;\n this.body.add(mesh);\n }\n this.hop.add(this.body);\n this.root.add(this.hop);\n\n this.shadowTex = blobTexture();\n this.shadowMat = new THREE.MeshBasicMaterial({\n map: this.shadowTex,\n transparent: true,\n depthWrite: false,\n polygonOffset: true,\n polygonOffsetFactor: -4,\n polygonOffsetUnits: -4,\n });\n const shadowGeo = new THREE.PlaneGeometry(BLOB_SIZE, BLOB_SIZE).rotateX(\n -Math.PI / 2,\n );\n this.geometries.push(shadowGeo);\n this.shadow = new THREE.Mesh(shadowGeo, this.shadowMat);\n this.shadow.renderOrder = 2;\n\n this.trailGeo = new THREE.BufferGeometry();\n this.trailGeo.setAttribute(\n \"position\",\n new THREE.BufferAttribute(new Float32Array(TRAIL_POINTS * 2 * 3), 3),\n );\n this.trailGeo.setAttribute(\n \"color\",\n new THREE.BufferAttribute(new Float32Array(TRAIL_POINTS * 2 * 4), 4),\n );\n const index: number[] = [];\n for (let i = 0; i < TRAIL_POINTS - 1; i++) {\n const a = i * 2;\n index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);\n }\n this.trailGeo.setIndex(index);\n this.trailMat = new THREE.MeshBasicMaterial({\n vertexColors: true,\n transparent: true,\n depthWrite: false,\n blending: THREE.AdditiveBlending,\n side: THREE.DoubleSide,\n });\n this.trail = new THREE.Mesh(this.trailGeo, this.trailMat);\n this.trail.frustumCulled = false;\n this.trail.renderOrder = 4;\n }\n\n /** Height of the hop above the tile. */\n set lift(y: number) {\n this.hop.position.y = y;\n }\n get lift(): number {\n return this.hop.position.y;\n }\n\n /** The suspense hop's spin. */\n set spin(radians: number) {\n this.body.rotation.y = radians;\n }\n\n beginHop(intensity = 1): void {\n this.state = \"hop\";\n this.hopProgress = 0;\n this.hopIntensity = intensity;\n }\n\n setHopProgress(t: number): void {\n if (this.state === \"hop\") this.hopProgress = clamp01(t);\n }\n\n land(intensity = 1): void {\n this.state = \"landing\";\n this.landingElapsed = 0;\n this.landingIntensity = intensity;\n }\n\n /** World position of the pawn's middle — where the trail is drawn from. */\n private readonly mid = new THREE.Vector3();\n\n update(dt: number, groundY: number): void {\n let stretch: number;\n if (this.state === \"hop\") {\n stretch = curve(HOP_STRETCH, this.hopProgress) * this.hopIntensity;\n } else if (this.state === \"landing\") {\n this.landingElapsed += dt;\n stretch =\n curve(LANDING_SQUASH, clamp01(this.landingElapsed / LANDING_S)) *\n this.landingIntensity;\n if (this.landingElapsed >= LANDING_S) {\n this.state = \"idle\";\n this.idleTime = 0;\n }\n } else {\n this.idleTime += dt;\n stretch = Math.sin(this.idleTime * IDLE_SPEED) * IDLE_AMOUNT;\n }\n // Volume-preserving: taller is thinner.\n const height = Math.max(0.05, 1 + stretch);\n const width = 1 / Math.sqrt(height);\n this.body.scale.set(width, height, width);\n\n // Blob shadow on the ground: paler and smaller the higher the hop.\n const fade = clamp01(this.lift / BLOB_FADE_HEIGHT);\n this.shadow.position.set(\n this.root.position.x,\n groundY + 0.001,\n this.root.position.z,\n );\n const s = 1 - fade * 0.45;\n this.shadow.scale.set(s, 1, s);\n this.shadowMat.opacity = 1 - fade * 0.7;\n\n this.updateTrail(dt);\n }\n\n private updateTrail(dt: number): void {\n for (const point of this.trailPoints) point.age += dt;\n this.trailPoints = this.trailPoints.filter((p) => p.age < TRAIL_LIFE);\n if (this.emitting) {\n this.mid.set(0, 0.11, 0);\n this.hop.localToWorld(this.mid);\n const last = this.trailPoints[this.trailPoints.length - 1];\n if (!last || last.p.distanceToSquared(this.mid) > 0.0004)\n this.trailPoints.push({ p: this.mid.clone(), age: 0 });\n if (this.trailPoints.length > TRAIL_POINTS) this.trailPoints.shift();\n }\n\n const pos = this.trailGeo.getAttribute(\"position\") as THREE.BufferAttribute;\n const col = this.trailGeo.getAttribute(\"color\") as THREE.BufferAttribute;\n const n = this.trailPoints.length;\n const up = new THREE.Vector3(0, 1, 0);\n const side = new THREE.Vector3();\n const tangent = new THREE.Vector3();\n for (let i = 0; i < TRAIL_POINTS; i++) {\n const point = this.trailPoints[Math.min(i, n - 1)];\n if (!point || n < 2) {\n pos.setXYZ(i * 2, 0, -10, 0);\n pos.setXYZ(i * 2 + 1, 0, -10, 0);\n col.setXYZW(i * 2, 0, 0, 0, 0);\n col.setXYZW(i * 2 + 1, 0, 0, 0, 0);\n continue;\n }\n const prev =\n this.trailPoints[Math.max(0, Math.min(i, n - 1) - 1)] ?? point;\n const next = this.trailPoints[Math.min(n - 1, i + 1)] ?? point;\n tangent.subVectors(next.p, prev.p);\n side.crossVectors(tangent, up).normalize();\n const along = i / Math.max(1, n - 1);\n const life = 1 - point.age / TRAIL_LIFE;\n const w = TRAIL_WIDTH * along * life;\n pos.setXYZ(\n i * 2,\n point.p.x + side.x * w,\n point.p.y,\n point.p.z + side.z * w,\n );\n pos.setXYZ(\n i * 2 + 1,\n point.p.x - side.x * w,\n point.p.y,\n point.p.z - side.z * w,\n );\n const a = i < n ? along * life * 0.85 : 0;\n col.setXYZW(i * 2, 1, 0.92, 0.6, a);\n col.setXYZW(i * 2 + 1, 1, 0.92, 0.6, a);\n }\n pos.needsUpdate = true;\n col.needsUpdate = true;\n }\n\n dispose(): void {\n this.geometries.forEach((g) => g.dispose());\n this.material.dispose();\n this.shadowMat.dispose();\n this.shadowTex.dispose();\n this.trailGeo.dispose();\n this.trailMat.dispose();\n }\n}\n"
|
|
199
199
|
},
|
|
200
200
|
{
|
|
201
201
|
"path": "game/tween.ts",
|
|
202
|
-
"content": "// The handful of easing curves and timed tasks the board animates with. Not a tween library: every\n// task is ticked by the board's own render loop (`Ticker.update`), so a suspended mode freezes its\n// animations together with its frame loop, and the board's slow motion slows all of them at once.\n\nexport const clamp01 = (t: number): number => (t < 0 ? 0 : t > 1 ? 1 : t);\nexport const lerp = (a: number, b: number, t: number): number
|
|
202
|
+
"content": "// The handful of easing curves and timed tasks the board animates with. Not a tween library: every\n// task is ticked by the board's own render loop (`Ticker.update`), so a suspended mode freezes its\n// animations together with its frame loop, and the board's slow motion slows all of them at once.\n\nexport const clamp01 = (t: number): number => (t < 0 ? 0 : t > 1 ? 1 : t);\nexport const lerp = (a: number, b: number, t: number): number =>\n a + (b - a) * t;\n\nexport const easeOutQuad = (t: number): number => 1 - (1 - t) * (1 - t);\nexport const easeInQuad = (t: number): number => t * t;\nexport const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);\nexport const easeInOutSine = (t: number): number =>\n -(Math.cos(Math.PI * t) - 1) / 2;\nexport const smoothstep = (t: number): number => t * t * (3 - 2 * t);\n\n/** Back-out: overshoots a little and settles — pop-ins. */\nexport function easeOutBack(t: number, s = 1.70158): number {\n const u = t - 1;\n return u * u * ((s + 1) * u + s) + 1;\n}\n\n/** Elastic-out with DOTween's (amplitude, period) parametrisation. */\nexport function easeOutElastic(t: number, period = 0.6): number {\n if (t <= 0) return 0;\n if (t >= 1) return 1;\n const s = (period / (2 * Math.PI)) * Math.asin(1);\n return (\n Math.pow(2, -10 * t) * Math.sin(((t - s) * (2 * Math.PI)) / period) + 1\n );\n}\n\n/** Bounce-out (0 → 1 with decaying bounces) — the dice toss. */\nexport function bounceOut(t: number): number {\n const n1 = 7.5625;\n const d1 = 2.75;\n if (t < 1 / d1) return n1 * t * t;\n if (t < 2 / d1) {\n const u = t - 1.5 / d1;\n return n1 * u * u + 0.75;\n }\n if (t < 2.5 / d1) {\n const u = t - 2.25 / d1;\n return n1 * u * u + 0.9375;\n }\n const u = t - 2.625 / d1;\n return n1 * u * u + 0.984375;\n}\n\n/**\n * DOTween's punch, shaped the same way: `vibrato` swings that die out over the duration, the\n * swings back past the rest point weakened by `elasticity`. 0 at both ends, so a punch interrupted\n * and restarted never leaves the object displaced.\n */\nexport function punch(t: number, vibrato: number, elasticity: number): number {\n if (t <= 0 || t >= 1) return 0;\n const wave = Math.sin(t * Math.PI * Math.max(1, vibrato));\n const decay = 1 - t;\n return (wave < 0 ? wave * elasticity : wave) * decay;\n}\n\n/** Piecewise-linear curve through (time, value) keys — Unity's AnimationCurve, linear tangents. */\nexport function curve(\n keys: ReadonlyArray<readonly [number, number]>,\n t: number,\n): number {\n const first = keys[0];\n if (!first) return 0;\n if (t <= first[0]) return first[1];\n for (let i = 1; i < keys.length; i++) {\n const a = keys[i - 1];\n const b = keys[i];\n if (!a || !b) break;\n if (t <= b[0]) {\n const u = (t - a[0]) / Math.max(1e-6, b[0] - a[0]);\n // smoothstep between keys: the reference's curves are auto-tangent, not linear.\n return lerp(a[1], b[1], smoothstep(clamp01(u)));\n }\n }\n return keys[keys.length - 1]?.[1] ?? 0;\n}\n\ninterface Task {\n elapsed: number;\n duration: number;\n step: (t: number) => void;\n resolve: () => void;\n}\n\n/**\n * Timed tasks driven by the board's frame loop. `run(duration, step)` calls `step(t)` with t going\n * 0 → 1 over `duration` seconds of BOARD time, then resolves. `cancelAll` (on destroy) finishes every\n * task at once, so nothing that awaits an animation is left hanging.\n */\nexport class Ticker {\n private tasks: Task[] = [];\n\n run(duration: number, step: (t: number) => void): Promise<void> {\n return new Promise<void>((resolve) => {\n if (duration <= 0) {\n step(1);\n resolve();\n return;\n }\n step(0);\n this.tasks.push({ elapsed: 0, duration, step, resolve });\n });\n }\n\n wait(seconds: number): Promise<void> {\n return this.run(seconds, () => {});\n }\n\n update(dt: number): void {\n if (this.tasks.length === 0) return;\n // Snapshot: a step may start a new task — it lands in the fresh list and starts next frame.\n const current = this.tasks;\n this.tasks = [];\n const live: Task[] = [];\n const done: Task[] = [];\n for (const task of current) {\n task.elapsed += dt;\n const t = clamp01(task.elapsed / task.duration);\n task.step(t);\n (t >= 1 ? done : live).push(task);\n }\n this.tasks = live.concat(this.tasks);\n for (const task of done) task.resolve();\n }\n\n cancelAll(): void {\n const tasks = this.tasks;\n this.tasks = [];\n for (const task of tasks) {\n task.step(1);\n task.resolve();\n }\n }\n}\n"
|
|
203
203
|
},
|
|
204
204
|
{
|
|
205
205
|
"path": "index.ts",
|
|
@@ -207,11 +207,11 @@
|
|
|
207
207
|
},
|
|
208
208
|
{
|
|
209
209
|
"path": "module.ts",
|
|
210
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\
|
|
210
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\nimport { createNewsStore, type BoardChrome } from \"./chrome\";\nimport { idleCharacterUpgraded } from \"./events\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n\n // Shared chrome: draw our own copy only while no other module (the base: the lobby draws them) took the role.\n const chrome: BoardChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n // The board lives inside the lobby (`inLobby` below): the lobby's frame is around it and there\n // is no round \"back\" button over its corner to make room for.\n lobbyButton: false,\n };\n\n // Optional listen: news from Idle RPG, if it is installed. Subscribed here, not in a panel —\n // the board's panel is unmounted while the player is in the Idle RPG mode, exactly when it fires.\n const news = createNewsStore();\n ctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`⚔️ Idle RPG hero reached Lv ${level}`),\n );\n\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box, chrome, news),\n });\n // Lives inside the lobby, like the reference: balances, wallet and profile on top, the systems'\n // tabs below, and the app starts straight on the board after sign-in.\n ctx.registerRoute({\n id: \"board-game\",\n label: \"Board\",\n icon: \"🎲\",\n inLobby: true,\n });\n\n // Поверхность для агента. Здесь она НАМЕРЕННО тонкая: игровой интерфейс этого модуля —\n // обычный React в DOM, агент его и так читает деревом и нажимает кнопки как человек.\n // Дублировать эти же действия в actions значило бы завести второй способ играть, который\n // разъедется с настоящим. Из canvas'а агенту не видно только состояние сцены — его и отдаём.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n news: news.get(),\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
|
|
211
211
|
},
|
|
212
212
|
{
|
|
213
213
|
"path": "react/boardBusy.ts",
|
|
214
|
-
"content": "import { useSyncExternalStore } from \"react\";\n\n// Board-wide \"something is still playing\" flags (the reference's AttackPanel/RaidPanel/SpecialPanel\n// IsBusy + the roll in flight). The server clears a pending the moment the action is accepted, while\n// its panel is still showing the result and the reward flight — AUTO must wait for the panel, not for\n// the pending, or the next roll starts on top of it.\n\nlet rolling = false;\nlet panelBusy = false;\nconst listeners = new Set<() => void>();\n\nconst emit = (): void => {\n for (const l of [...listeners]) l();\n};\n\n/** The dice and the token are still moving — a pending panel opens only after they land. */\nexport function setRolling(value: boolean): void {\n if (rolling === value) return;\n rolling = value;\n emit();\n}\n\n/** A full-screen panel is on screen (intro, choice, result or reward flight). */\nexport function setPanelBusy(value: boolean): void {\n if (panelBusy === value) return;\n panelBusy = value;\n emit();\n}\n\nfunction subscribe(cb: () => void): () => void {\n listeners.add(cb);\n return () => listeners.delete(cb);\n}\n\nexport function useRolling(): boolean {\n return useSyncExternalStore(subscribe
|
|
214
|
+
"content": "import { useSyncExternalStore } from \"react\";\n\n// Board-wide \"something is still playing\" flags (the reference's AttackPanel/RaidPanel/SpecialPanel\n// IsBusy + the roll in flight). The server clears a pending the moment the action is accepted, while\n// its panel is still showing the result and the reward flight — AUTO must wait for the panel, not for\n// the pending, or the next roll starts on top of it.\n\nlet rolling = false;\nlet panelBusy = false;\nconst listeners = new Set<() => void>();\n\nconst emit = (): void => {\n for (const l of [...listeners]) l();\n};\n\n/** The dice and the token are still moving — a pending panel opens only after they land. */\nexport function setRolling(value: boolean): void {\n if (rolling === value) return;\n rolling = value;\n emit();\n}\n\n/** A full-screen panel is on screen (intro, choice, result or reward flight). */\nexport function setPanelBusy(value: boolean): void {\n if (panelBusy === value) return;\n panelBusy = value;\n emit();\n}\n\nfunction subscribe(cb: () => void): () => void {\n listeners.add(cb);\n return () => listeners.delete(cb);\n}\n\nexport function useRolling(): boolean {\n return useSyncExternalStore(\n subscribe,\n () => rolling,\n () => false,\n );\n}\n\nexport function usePanelBusy(): boolean {\n return useSyncExternalStore(\n subscribe,\n () => panelBusy,\n () => false,\n );\n}\n"
|
|
215
215
|
},
|
|
216
216
|
{
|
|
217
217
|
"path": "react/context.tsx",
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
},
|
|
228
228
|
{
|
|
229
229
|
"path": "react/useRunAction.ts",
|
|
230
|
-
"content": "import { useCallback, useState } from \"react\";\
|
|
230
|
+
"content": "import { useCallback, useState } from \"react\";\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport { grantLines, useCelebrate } from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"./context\";\nimport { useStatus } from \"./status\";\n\nexport interface ActionResult {\n ok: boolean;\n error?: string;\n data?: unknown;\n}\n\n/**\n * Shared \"run a server action, surface the result in the status bar, track busy\" helper. What the\n * action granted (an attack's loot, a finished raid, a special's prize) is shown in the kit's\n * \"You got\" popup — without it a raid closed its panel and the coins just changed in the corner.\n */\nexport function useRunAction(): {\n busy: boolean;\n run: (label: string, action: () => Promise<ActionResult>) => Promise<void>;\n} {\n const client = useIDosGamesClient();\n const { setStatus } = useStatus();\n const celebrate = useCelebrate();\n const [busy, setBusy] = useState(false);\n const run = useCallback(\n async (\n label: string,\n action: () => Promise<ActionResult>,\n ): Promise<void> => {\n setBusy(true);\n const result = await action();\n setStatus(\n result.ok ? `${label} ✓` : `${label} failed: ${result.error}`,\n result.ok ? \"success\" : \"error\",\n );\n setBusy(false);\n if (!result.ok) return;\n const lines = grantLines(\n grantOf(result.data, client.auth.context?.userID),\n ).filter((l) => l.amount > 0);\n if (lines.length > 0) celebrate(lines);\n },\n [client, setStatus, celebrate],\n );\n return { busy, run };\n}\n\ninterface GrantCarrier {\n Operation?: { Grant?: ResourceGrant | null } | null;\n DualResult?: {\n FromUserID?: string | null;\n ToUserID?: string | null;\n FromResult?: { Grant?: ResourceGrant | null } | null;\n ToResult?: { Grant?: ResourceGrant | null } | null;\n } | null;\n}\n\n/**\n * What THIS player got. A raid or an attack on a real player answers with a two-party `DualResult`\n * (no `Operation`); the player's half is the one with their id.\n */\nfunction grantOf(\n data: unknown,\n me: string | undefined,\n): ResourceGrant | null | undefined {\n const d = data as GrantCarrier | null | undefined;\n if (grantLines(d?.Operation?.Grant).length > 0) return d?.Operation?.Grant;\n const dual = d?.DualResult;\n if (!dual || !me) return undefined;\n if (dual.FromUserID === me) return dual.FromResult?.Grant;\n if (dual.ToUserID === me) return dual.ToResult?.Grant;\n return undefined;\n}\n"
|
|
231
231
|
},
|
|
232
232
|
{
|
|
233
233
|
"path": "RootPanel.tsx",
|
|
@@ -239,7 +239,7 @@
|
|
|
239
239
|
},
|
|
240
240
|
{
|
|
241
241
|
"path": "module.meta.json",
|
|
242
|
-
"content": "{\n \"id\": \"board-game\",\n \"type\": \"template\",\n \"summary\": \"3D tycoon board game: roll dice, move around the board, buy and upgrade city tiles.\",\n \"description\": \"A turn-based property board game rendered in Three.js with a React overlay. Players roll dice, move around a looping board, and buy, sell and upgrade tiles to build a city and earn income. The board layout, tiles and interactions are data-driven, so the theme and economy are easy to re-skin.\",\n \"provides\": [\n \"turn-based dice movement around a looping board\",\n \"buy / sell / upgrade board tiles\",\n \"player-vs-player property economy\",\n \"3D board rendering with a React HUD overlay\",\n \"data-driven board layout and tile configuration\"\n ],\n \"tags\": [\"board\", \"tycoon\", \"3d\", \"turn-based\", \"economy\", \"three\"],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/board-game/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/board-game/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/board-game/demo/\",\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.2.
|
|
242
|
+
"content": "{\n \"id\": \"board-game\",\n \"type\": \"template\",\n \"summary\": \"3D tycoon board game: roll dice, move around the board, buy and upgrade city tiles.\",\n \"description\": \"A turn-based property board game rendered in Three.js with a React overlay. Players roll dice, move around a looping board, and buy, sell and upgrade tiles to build a city and earn income. The board layout, tiles and interactions are data-driven, so the theme and economy are easy to re-skin.\",\n \"provides\": [\n \"turn-based dice movement around a looping board\",\n \"buy / sell / upgrade board tiles\",\n \"player-vs-player property economy\",\n \"3D board rendering with a React HUD overlay\",\n \"data-driven board layout and tile configuration\"\n ],\n \"tags\": [\"board\", \"tycoon\", \"3d\", \"turn-based\", \"economy\", \"three\"],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/board-game/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/board-game/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/board-game/demo/\",\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.2.1\",\n \"events\": {\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"shows an 'Idle RPG hero reached Lv N' news chip in the board HUD (optional — works without idle-rpg)\"\n }\n ]\n }\n}\n"
|
|
243
243
|
}
|
|
244
244
|
]
|
|
245
245
|
}
|