@idosgames/mcp 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +2235 -27
  3. package/package.json +4 -1
  4. package/registry/host.json +3 -3
  5. package/registry/index.json +309 -156
  6. package/registry/modules/ai.json +7 -7
  7. package/registry/modules/board-game.json +14 -14
  8. package/registry/modules/boosts.json +3 -3
  9. package/registry/modules/character.json +32 -15
  10. package/registry/modules/chat.json +3 -3
  11. package/registry/modules/collections.json +7 -7
  12. package/registry/modules/coop-events.json +7 -7
  13. package/registry/modules/craft.json +3 -3
  14. package/registry/modules/deals.json +7 -7
  15. package/registry/modules/events.json +8 -8
  16. package/registry/modules/friends.json +9 -9
  17. package/registry/modules/hero-run.json +497 -0
  18. package/registry/modules/idle-rpg.json +70 -18
  19. package/registry/modules/inventory.json +3 -3
  20. package/registry/modules/invite.json +7 -7
  21. package/registry/modules/leaderboard.json +10 -10
  22. package/registry/modules/lootboxes.json +7 -7
  23. package/registry/modules/mailbox.json +8 -8
  24. package/registry/modules/marketplace.json +8 -8
  25. package/registry/modules/mini-game.json +8 -8
  26. package/registry/modules/multiplayer.json +7 -7
  27. package/registry/modules/premium.json +7 -7
  28. package/registry/modules/profile.json +7 -7
  29. package/registry/modules/pvp.json +7 -7
  30. package/registry/modules/quests.json +8 -8
  31. package/registry/modules/rewards.json +8 -8
  32. package/registry/modules/season.json +7 -7
  33. package/registry/modules/store.json +7 -7
  34. package/registry/modules/tutorials.json +7 -7
  35. package/registry/modules/video-studio.json +228 -0
  36. package/registry/modules/voxelcraft.json +42 -125
  37. package/registry/modules/workshop.json +3 -3
  38. package/registry/skills/hero-run.json +6 -0
  39. package/registry/skills/idosgames-module-contract.json +2 -2
  40. package/registry/skills/idosgames-project-structure.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +1 -1
  42. package/registry/skills/video-studio.json +6 -0
  43. package/registry/video.json +9800 -0
@@ -14,7 +14,7 @@
14
14
  "store"
15
15
  ],
16
16
  "summary": "Idle RPG in the style of Legend Slime: the hero auto-battles waves and bosses, gold flows in online and offline, stats are enhanced for gold.",
17
- "description": "A Legend Slime-style idle auto-battler on Phaser with a React screen. The selected hero (the Heroes system — character:hero-selected@1) stands on the left and shoots waves of monsters walking in from the right: crits, multi-shot, HP regen, three skills with cooldowns and an Auto switch. Stages come in chapters of ten (Chapter-Stage, themes meadow → volcano, difficulty loops); each stage is four waves and a boss with a 30-second timer — a failed boss drops back to farming until the player taps \"Boss!\". The hero's fight numbers are the server's character stats (ValueCurve, RankCurve, RankStatCurve, gear flat/percent bonuses — a mirror of PvPBattleEngine.CalculateStats). Gold is the title's idle accrual (Reward → IdleAccruals, rate = base + PowerCoefficient × the strongest hero's Power), collected every 15 s while playing and on entry with a \"while you were away\" popup; coins from kills draw that stream. Enhance: the hero's stats with level, value, next value and price, ×1 / ×10 / MAX and hold-to-repeat (one request in flight per stat, taps added up). A quest strip shows the next guide quest with Claim. The bottom bar opens the Heroes hub, gear summons, quests and the shop through ctx.features. Stage progress is saved in the player's private custom data (idle_rpg_progress); cleared stages and enhanced levels are reported to quests that count stage_cleared / stat_enhanced. Ships title-seed.json: GOLD/GEMS, three heroes on the engine's standard stat ids, twelve pieces of gear, the idle_gold accrual, a gear summon, a shop and a guide quest chain.",
17
+ "description": "A Legend Slime-style idle auto-battler on Phaser with a React screen. The selected hero (the Heroes system — character:hero-selected@1) stands on the left — a layered 2D character (src/character: its own chibi skeleton with bending meshes and IK played by its own runtime, parts as skins with vector SVG art, colours, ten animations of its own plus ranged attacks; dressed from its class and worn gear by src/character/looks, the attack swing timed to the shot; the same hero is drawn on the Heroes screen through ctx.characterArt — src/heroArt.ts, a live WebGL canvas, a new weapon swung once) — and marches along the road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls past in painted parallax layers per chapter theme; shadows under everyone; a fallen hero leaves the enemies standing) to the enemies standing in line ahead — people on the hero's own rig dressed in the maker's looks; brawlers fight one at a time face to face, shooters fire once the hero is in their range; a melee weapon hits only at the enemy, a bow, gun or staff from afar: crits, multi-shot, HP regen, three skills with cooldowns and an Auto switch. Stages come in chapters of ten (Chapter-Stage, themes meadow → volcano, difficulty loops); each stage is four waves and a boss with a 30-second timer — a failed boss drops back to farming until the player taps \"Boss!\". The hero's fight numbers are the server's character stats (ValueCurve, RankCurve, RankStatCurve, gear flat/percent bonuses — a mirror of PvPBattleEngine.CalculateStats). Gold is the title's idle accrual (Reward → IdleAccruals, rate = base + PowerCoefficient × the strongest hero's Power), collected every 15 s while playing and on entry with a \"while you were away\" popup; coins from kills draw that stream. Enhance: the hero's stats with level, value, next value and price, ×1 / ×10 / MAX and hold-to-repeat (one request in flight per stat, taps added up). A quest strip shows the next guide quest with Claim. The bottom bar opens the Heroes hub, gear summons, quests and the shop through ctx.features. Stage progress is saved in the player's private custom data (idle_rpg_progress); cleared stages and enhanced levels are reported to quests that count stage_cleared / stat_enhanced. Ships title-seed.json: GOLD/GEMS, three heroes on the engine's standard stat ids, twelve pieces of gear, the idle_gold accrual, a gear summon, a shop and a guide quest chain.",
18
18
  "provides": [
19
19
  "auto-battling hero vs waves of monsters",
20
20
  "stages, chapters and timed bosses",
@@ -23,7 +23,8 @@
23
23
  "skills with cooldowns and auto-cast",
24
24
  "guide quest strip",
25
25
  "bottom bar to heroes, summons, quests and shop",
26
- "2D procedural art (Phaser)"
26
+ "2D procedural art (Phaser)",
27
+ "layered 2D character: skeleton, swappable parts (skins), colours, 10 animations + ranged attacks (own runtime, vector art)"
27
28
  ],
28
29
  "tags": [
29
30
  "idle",
@@ -32,7 +33,9 @@
32
33
  "auto-battler",
33
34
  "legend-slime",
34
35
  "phaser",
35
- "2d"
36
+ "2d",
37
+ "character-customization",
38
+ "skeletal-animation"
36
39
  ],
37
40
  "media": {
38
41
  "image": "https://cloud.idosgames.com/drive/modules/idle-rpg/cover.png",
@@ -43,7 +46,7 @@
43
46
  "name": "iDos Games",
44
47
  "url": "https://idosgames.com"
45
48
  },
46
- "version": "0.2.2",
49
+ "version": "0.4.2",
47
50
  "events": {
48
51
  "emits": [
49
52
  {
@@ -72,14 +75,35 @@
72
75
  }
73
76
  },
74
77
  "dependencies": {
75
- "@idosgames/core": "0.22.1",
76
- "@idosgames/module-sdk": "0.12.0",
77
- "@idosgames/react": "0.5.2",
78
+ "@idosgames/characters": "0.1.1",
79
+ "@idosgames/core": "0.23.0",
80
+ "@idosgames/module-sdk": "0.13.0",
81
+ "@idosgames/react": "0.6.0",
78
82
  "phaser": "4.2.1",
79
83
  "react": "19.2.7"
80
84
  },
81
- "contentHash": "a236d5dc71a80c419f57aa5e41a1470203a5a4347f37069d98a36828cfda2257",
85
+ "contentHash": "3bb2350ea232f151853a4e0b69db86ae95285851a91e03d04e2c46349f8524ba",
82
86
  "files": [
87
+ {
88
+ "path": "character/index.ts",
89
+ "content": "// Персонаж игры: 2D-герой из пакета @idosgames/characters (риг, анимации, весь гардероб, рендер) +\n// то, что знает только эта игра — откуда берётся вид героя (класс, надетая экипировка, разнообразие\n// по id). Игра импортирует персонажа только отсюда.\n\nexport * from \"@idosgames/characters/rig2d\";\nexport {\n CharacterView,\n createCharacterStage,\n vectorArt,\n type ArtSource,\n type CharacterStage,\n type CharacterStageOptions,\n type CharacterViewOptions,\n} from \"@idosgames/characters/rig2d/phaser\";\nexport {\n CharacterEditor,\n type CharacterEditorProps,\n} from \"@idosgames/characters/rig2d/editor\";\n\nexport type { LookSource, LookSubject } from \"./looks/sources\";\nexport {\n DEFAULT_LOOK_CHAIN,\n classLook,\n equipmentLook,\n resolveLook,\n varietyLook,\n} from \"./looks/resolve\";\nexport { CLASS_LOOKS, VARIETY } from \"./looks/classLooks\";\nexport { ITEM_LOOKS } from \"./looks/itemLooks\";\n"
90
+ },
91
+ {
92
+ "path": "character/looks/classLooks.ts",
93
+ "content": "import { LOOK_VERSION, type CharacterLook } from \"@idosgames/characters/rig2d\";\n\n// What each hero class looks like by default — edit freely: a look is part ids (skin names of the\n// rig, \"top/5\") and colours; a type left out keeps what the base gives, null takes it off.\n// The maker page (playground/maker.html → \"Copy look\") makes these.\n\nexport const CLASS_LOOKS: Readonly<Record<string, CharacterLook>> = {\n Warrior: {\n v: LOOK_VERSION,\n parts: {\n Hat: \"hat/knight_helm\",\n Hair: \"hair/3\",\n Beard: \"beard/4\",\n Eyes: \"eyes/4\",\n Brows: \"brows/4\",\n Mouth: \"mouth/1\",\n Top: \"top/plate_armor\",\n Pants: \"pants/26\",\n Boots: \"boots/plate_boots\",\n Gloves: \"gloves/gauntlets\",\n Weapon: \"weapon/knight_sword\",\n Offhand: \"offhand/kite_shield\",\n Back: null,\n Glasses: null,\n },\n colors: {\n Skin: \"#f6d2b0\",\n Hair: \"#b5532f\",\n Beard: \"#b5532f\",\n Brows: \"#7a3418\",\n },\n },\n Ranger: {\n v: LOOK_VERSION,\n parts: {\n Hat: \"hat/ranger_hood\",\n Hair: \"hair/2\",\n Beard: null,\n Eyes: \"eyes/10\",\n Brows: \"brows/1\",\n Mouth: \"mouth/3\",\n Top: \"top/leather_armor\",\n Pants: \"pants/42\",\n Boots: \"boots/leather_boots\",\n Gloves: \"gloves/bracers\",\n Weapon: \"weapon/hunting_bow\",\n Offhand: null,\n Back: null,\n Glasses: null,\n },\n colors: { Skin: \"#e8b48a\", Hair: \"#e8c060\", Brows: \"#a8742a\" },\n },\n Mage: {\n v: LOOK_VERSION,\n parts: {\n Hat: \"hat/wizard_hat\",\n Hair: \"hair/10\",\n Beard: \"beard/10\",\n Eyes: \"eyes/1\",\n Brows: \"brows/2\",\n Mouth: \"mouth/1\",\n Top: \"top/mage_robe\",\n Pants: \"pants/3\",\n Boots: \"boots/leather_boots\",\n Gloves: \"gloves/bracers\",\n Weapon: \"weapon/wizard_staff\",\n Offhand: null,\n Back: \"back/mage_cape\",\n Glasses: null,\n },\n colors: {\n Skin: \"#f8dcc4\",\n Hair: \"#e8e8f0\",\n Beard: \"#e8e8f0\",\n Brows: \"#d0d0dc\",\n },\n },\n};\n\n/** Faces, hair and colours a hero with no class look is dressed in — picked by its id. */\nexport const VARIETY = {\n hair: [\"hair/2\", \"hair/3\", \"hair/5\", \"hair/10\", \"hair/13\"],\n eyes: [\"eyes/1\", \"eyes/2\", \"eyes/4\", \"eyes/10\"],\n tops: [\"top/1\", \"top/5\", \"top/19\", \"top/25\", \"top/40\"],\n skin: [\"#f8dcc4\", \"#f6d2b0\", \"#e8b48a\", \"#c98a5e\", \"#8d5a3a\"],\n hairColor: [\"#2a2026\", \"#6b3f2a\", \"#b5532f\", \"#e8c060\", \"#e8e8f0\", \"#3f6fb5\"],\n} as const;\n"
94
+ },
95
+ {
96
+ "path": "character/looks/itemLooks.ts",
97
+ "content": "import { LOOK_VERSION, type CharacterLook } from \"@idosgames/characters/rig2d\";\n\n// What a worn item changes on the character — by item id (the demo world's items, title-seed.json);\n// an item not listed changes nothing (a ring). Edit freely; a title with its own items adds lines here.\n\nexport const ITEM_LOOKS: Readonly<Record<string, CharacterLook>> = {\n wooden_sword: {\n v: LOOK_VERSION,\n parts: { Weapon: \"weapon/14\" },\n },\n iron_sword: {\n v: LOOK_VERSION,\n parts: { Weapon: \"weapon/knight_sword\" },\n },\n dragon_blade: {\n v: LOOK_VERSION,\n parts: { Weapon: \"weapon/flame_sword\" },\n },\n crystal_staff: {\n v: LOOK_VERSION,\n parts: { Weapon: \"weapon/wizard_staff\" },\n },\n leather_vest: {\n v: LOOK_VERSION,\n parts: { Top: \"top/leather_armor\" },\n },\n chainmail: { v: LOOK_VERSION, parts: { Top: \"top/chainmail\" } },\n knight_plate: {\n v: LOOK_VERSION,\n parts: {\n Top: \"top/plate_armor\",\n Hat: \"hat/great_helm\",\n },\n },\n dragon_scale: {\n v: LOOK_VERSION,\n parts: { Top: \"top/25\", Back: \"back/17\" },\n },\n};\n"
98
+ },
99
+ {
100
+ "path": "character/looks/resolve.ts",
101
+ "content": "import {\n LOOK_VERSION,\n mergeLooks,\n pick,\n seededRandom,\n seedOf,\n type CharacterLook,\n} from \"@idosgames/characters/rig2d\";\nimport { CLASS_LOOKS, VARIETY } from \"./classLooks\";\nimport { ITEM_LOOKS } from \"./itemLooks\";\nimport type { LookSource, LookSubject } from \"./sources\";\n\n// The chain that dresses a hero: variety by its id ← its class ← what it wears. A later source wins\n// type by type. Pass your own chain to add a source (the player's saved choice, a server's look).\n\n/** A face, hair, top and colours picked by the hero's id — the same on every device. */\nexport const varietyLook: LookSource = (s) => {\n const rng = seededRandom(seedOf(s.id));\n const hairColor = pick(rng, VARIETY.hairColor)!;\n return {\n v: LOOK_VERSION,\n parts: {\n Hair: pick(rng, VARIETY.hair)!,\n Eyes: pick(rng, VARIETY.eyes)!,\n Top: pick(rng, VARIETY.tops)!,\n Hat: null,\n Beard: null,\n Glasses: null,\n Back: null,\n Offhand: null,\n Gloves: null,\n },\n colors: {\n Skin: pick(rng, VARIETY.skin)!,\n Hair: hairColor,\n Beard: hairColor,\n Brows: hairColor,\n },\n };\n};\n\nexport const classLook: LookSource = (s) => CLASS_LOOKS[s.classId] ?? null;\n\nexport const equipmentLook: LookSource = (s) =>\n mergeLooks(...Object.values(s.equipment).map((item) => ITEM_LOOKS[item]));\n\nexport const DEFAULT_LOOK_CHAIN: readonly LookSource[] = [\n varietyLook,\n classLook,\n equipmentLook,\n];\n\nexport function resolveLook(\n subject: LookSubject,\n chain: readonly LookSource[] = DEFAULT_LOOK_CHAIN,\n): CharacterLook {\n return mergeLooks(...chain.map((source) => source(subject)));\n}\n"
102
+ },
103
+ {
104
+ "path": "character/looks/sources.ts",
105
+ "content": "import type { CharacterLook } from \"@idosgames/characters/rig2d\";\n\n// Where a character's look comes from: a chain of sources, each giving part of a look (or nothing);\n// later sources win type by type (mergeLooks). The game asks for a hero: its id, class and what it\n// wears. Another source later — the player's own choice saved in custom data, a look set on the\n// server for an item — is one more function in the chain (resolve.ts), nothing else changes.\n\nexport interface LookSubject {\n /** Stable id (seeds the variety of a hero nothing else describes). */\n id: string;\n /** The class, e.g. the engine's ClassID (\"Warrior\", \"Ranger\", \"Mage\"). */\n classId: string;\n /** What it wears: equipment slot → item id. */\n equipment: Readonly<Record<string, string>>;\n}\n\nexport type LookSource = (subject: LookSubject) => CharacterLook | null;\n"
106
+ },
83
107
  {
84
108
  "path": "controller-box.ts",
85
109
  "content": "// A one-slot observable that bridges the imperative scene and the React panel. The EngineScene\n// creates the Phaser controller at mount time (it needs the canvas host), which is AFTER the panel\n// first renders; the panel subscribes here and re-renders once the controller is published. This is\n// what lets one controller instance be shared by the scene and the UI without prop-drilling across\n// the imperative/React boundary.\n\nexport interface ControllerBox<T> {\n get(): T | null;\n set(value: T | null): void;\n subscribe(listener: () => void): () => void;\n}\n\nexport function createControllerBox<T>(): ControllerBox<T> {\n let value: T | null = null;\n const listeners = new Set<() => void>();\n return {\n get: () => value,\n set(next) {\n value = next;\n for (const listener of [...listeners]) listener();\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n"
@@ -90,12 +114,16 @@
90
114
  },
91
115
  {
92
116
  "path": "game/battle.ts",
93
- "content": "// The fight itself — a deterministic simulation with no Phaser in it, so it is tested on its own\n// (battle.test.ts) and drawn by the scene from the events each tick returns.\n//\n// One lane, normalized: the hero stands at HERO_X and shoots right; monsters spawn past the right\n// edge, walk to melee range and hit. A stage is WAVES_PER_STAGE waves, then the boss with a timer.\n// A failed boss (time out or the hero down) drops back to farming the stage's waves until the player\n// challenges it again — the Legend Slime loop.\n\nimport type { FighterStats } from \"./heroStats\";\nimport {\n BOSS_SECONDS,\n WAVES_PER_STAGE,\n bossMonster,\n waveMonsters,\n type MonsterSpec,\n} from \"./stages\";\n\nexport const HERO_X = 0.16;\nexport const MELEE_X = 0.3;\nexport const SPAWN_X = 1.08;\n/** The hero shoots at monsters closer than this. */\nexport const RANGE_X = 0.97;\nexport const PROJECTILE_SPEED = 1.9;\nexport const SPAWN_GAP_SECONDS = 0.55;\nexport const RESPAWN_SECONDS = 2.5;\n/** The server's MinHitDamage: armor never turns a hit into a heal. */\nexport const MIN_HIT = 1;\n\nexport type SkillId = \"meteor\" | \"frenzy\" | \"heal\";\n\nexport interface SkillDef {\n id: SkillId;\n cooldown: number;\n /** How long the effect lasts (0 = instant). */\n duration: number;\n /** Best stage the skill opens at. */\n unlockStage: number;\n}\n\nexport const SKILLS: SkillDef[] = [\n { id: \"meteor\", cooldown: 15, duration: 0, unlockStage: 1 },\n { id: \"frenzy\", cooldown: 22, duration: 6, unlockStage: 3 },\n { id: \"heal\", cooldown: 28, duration: 0, unlockStage: 6 },\n];\n/** Meteor hits every monster on screen for this many hero hits. */\nexport const METEOR_POWER = 4;\nexport const FRENZY_SPEED = 2;\nexport const HEAL_SHARE = 0.35;\n/** Auto-cast of Heal waits until the hero is this hurt. */\nexport const AUTO_HEAL_BELOW = 0.55;\n\nexport interface Monster {\n id: number;\n spec: MonsterSpec;\n hp: number;\n maxHp: number;\n x: number;\n cooldown: number;\n}\n\nexport interface Projectile {\n id: number;\n x: number;\n targetId: number;\n damage: number;\n crit: boolean;\n extra: boolean;\n}\n\nexport type BattleEvent =\n | { type: \"spawn\"; monster: Monster }\n | { type: \"shoot\"; projectile: Projectile }\n | { type: \"hit\"; monsterId: number; x: number; damage: number; crit: boolean }\n | { type: \"kill\"; monsterId: number; x: number; boss: boolean }\n | { type: \"heroHit\"; damage: number }\n | { type: \"dodge\" }\n | { type: \"heroDown\" }\n | { type: \"heroUp\" }\n | { type: \"waveCleared\"; wave: number }\n | { type: \"bossStart\" }\n | { type: \"bossFailed\"; reason: \"time\" | \"down\" }\n | { type: \"stageCleared\"; stage: number }\n | { type: \"skill\"; skill: SkillId };\n\nexport interface BattleSnapshot {\n stage: number;\n best: number;\n mode: \"waves\" | \"boss\";\n wave: number;\n waves: number;\n bossTimeLeft: number;\n bossHp: number;\n bossMaxHp: number;\n heroHp: number;\n heroMaxHp: number;\n heroDown: boolean;\n monsters: number;\n autoBoss: boolean;\n autoSkills: boolean;\n skills: Array<{\n id: SkillId;\n unlocked: boolean;\n unlockStage: number;\n cooldownLeft: number;\n cooldown: number;\n activeLeft: number;\n }>;\n}\n\n/** mulberry32 — a small seeded PRNG, so a test replays the same crits. */\nexport function seededRandom(seed: number): () => number {\n let a = seed >>> 0;\n return () => {\n a = (a + 0x6d2b79f5) >>> 0;\n let t = a;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\nconst IDLE_STATS: FighterStats = {\n maxHp: 100,\n damage: 10,\n armor: 0,\n attackSpeed: 1,\n critChance: 0,\n critMultiplier: 1.5,\n dodge: 0,\n regen: 0,\n multiShot: 0,\n};\n\nexport class Battle {\n stage = 1;\n best = 1;\n mode: \"waves\" | \"boss\" = \"waves\";\n wave = 0;\n autoBoss = true;\n autoSkills = true;\n bossTimeLeft = 0;\n\n stats: FighterStats = IDLE_STATS;\n heroHp = IDLE_STATS.maxHp;\n heroDownLeft = 0;\n monsters: Monster[] = [];\n projectiles: Projectile[] = [];\n\n private heroCooldown = 0;\n private spawnQueue: Array<{ spec: MonsterSpec; delay: number }> = [];\n private skillCooldown: Record<SkillId, number> = {\n meteor: 0,\n frenzy: 0,\n heal: 0,\n };\n private skillActive: Record<SkillId, number> = {\n meteor: 0,\n frenzy: 0,\n heal: 0,\n };\n private nextId = 1;\n private readonly random: () => number;\n private events: BattleEvent[] = [];\n\n constructor(opts: { stage?: number; best?: number; seed?: number } = {}) {\n this.random = seededRandom(opts.seed ?? Date.now());\n this.stage = Math.max(1, Math.floor(opts.stage ?? 1));\n this.best = Math.max(this.stage, Math.floor(opts.best ?? 1));\n this.startWave(0);\n }\n\n // ── Inputs ─────────────────────────────────────────────────────────────────────────────────\n\n /** New numbers of the hero (an upgrade, other gear, another hero): HP keeps its share. */\n setStats(stats: FighterStats): void {\n const share = this.stats.maxHp > 0 ? this.heroHp / this.stats.maxHp : 1;\n this.stats = stats;\n this.heroHp = Math.max(1, Math.min(stats.maxHp, share * stats.maxHp));\n }\n\n /** Jump to a stage (progress loaded from the server); the fight restarts there. */\n setStage(stage: number, best = this.best): void {\n this.stage = Math.max(1, Math.floor(stage));\n this.best = Math.max(this.stage, Math.floor(best));\n this.mode = \"waves\";\n this.monsters = [];\n this.projectiles = [];\n this.startWave(0);\n }\n\n /** The \"Boss\" button: fight the boss now and keep advancing after it. */\n challengeBoss(): boolean {\n if (this.mode === \"boss\" || this.heroDownLeft > 0) return false;\n this.autoBoss = true;\n this.startBoss();\n return true;\n }\n\n setAutoSkills(on: boolean): void {\n this.autoSkills = on;\n }\n\n /** Cast a skill now. `false` — locked, cooling down, or the hero is down. */\n castSkill(id: SkillId): boolean {\n const def = SKILLS.find((s) => s.id === id);\n if (!def || !this.skillUnlocked(def)) return false;\n if (this.skillCooldown[id] > 0 || this.heroDownLeft > 0) return false;\n this.skillCooldown[id] = def.cooldown;\n this.skillActive[id] = def.duration;\n this.emit({ type: \"skill\", skill: id });\n if (id === \"meteor\") {\n const damage = this.stats.damage * METEOR_POWER;\n for (const m of [...this.monsters])\n if (m.x < 1) this.damageMonster(m, damage, false);\n } else if (id === \"heal\") {\n this.heroHp = Math.min(\n this.stats.maxHp,\n this.heroHp + this.stats.maxHp * HEAL_SHARE,\n );\n }\n return true;\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** Advance by `dt` seconds (clamped — a hidden tab must not resolve a minute in one step). */\n tick(dt: number): BattleEvent[] {\n this.events = [];\n let left = Math.min(Math.max(0, dt), 0.25);\n while (left > 0) {\n const step = Math.min(left, 1 / 30);\n this.step(step);\n left -= step;\n }\n return this.events;\n }\n\n private step(dt: number): void {\n for (const def of SKILLS) {\n this.skillCooldown[def.id] = Math.max(0, this.skillCooldown[def.id] - dt);\n this.skillActive[def.id] = Math.max(0, this.skillActive[def.id] - dt);\n }\n\n if (this.heroDownLeft > 0) {\n this.heroDownLeft -= dt;\n if (this.heroDownLeft <= 0) {\n this.heroDownLeft = 0;\n this.heroHp = this.stats.maxHp;\n this.emit({ type: \"heroUp\" });\n this.startWave(0);\n }\n return;\n }\n\n this.heroHp = Math.min(\n this.stats.maxHp,\n this.heroHp + this.stats.regen * dt,\n );\n if (this.autoSkills) this.autoCast();\n this.spawn(dt);\n this.heroAttack(dt);\n this.moveProjectiles(dt);\n this.monstersAct(dt);\n if (this.heroDownLeft > 0) return;\n\n if (this.mode === \"boss\") {\n this.bossTimeLeft -= dt;\n if (this.bossTimeLeft <= 0 && this.monsters.some((m) => m.spec.boss))\n return this.failBoss(\"time\");\n }\n\n if (this.monsters.length === 0 && this.spawnQueue.length === 0) {\n if (this.mode === \"boss\") return; // cleared by the kill handler\n this.emit({ type: \"waveCleared\", wave: this.wave });\n const next = this.wave + 1;\n if (next < WAVES_PER_STAGE) this.startWave(next);\n else if (this.autoBoss) this.startBoss();\n else this.startWave(0);\n }\n }\n\n private autoCast(): void {\n for (const def of SKILLS) {\n if (!this.skillUnlocked(def) || this.skillCooldown[def.id] > 0) continue;\n if (def.id === \"heal\") {\n if (this.heroHp < this.stats.maxHp * AUTO_HEAL_BELOW)\n this.castSkill(\"heal\");\n } else if (this.monsters.some((m) => m.x < RANGE_X)) {\n this.castSkill(def.id);\n }\n }\n }\n\n private spawn(dt: number): void {\n const first = this.spawnQueue[0];\n if (!first) return;\n first.delay -= dt;\n if (first.delay > 0) return;\n this.spawnQueue.shift();\n const m: Monster = {\n id: this.nextId++,\n spec: first.spec,\n hp: first.spec.hp,\n maxHp: first.spec.hp,\n x: SPAWN_X,\n cooldown: first.spec.attackInterval * 0.5,\n };\n this.monsters.push(m);\n this.emit({ type: \"spawn\", monster: m });\n }\n\n private heroAttack(dt: number): void {\n const speed =\n this.stats.attackSpeed * (this.skillActive.frenzy > 0 ? FRENZY_SPEED : 1);\n this.heroCooldown -= dt * speed;\n if (this.heroCooldown > 0) return;\n const targets = this.monsters\n .filter((m) => m.x < RANGE_X)\n .sort((a, b) => a.x - b.x);\n const target = targets[0];\n if (!target) {\n this.heroCooldown = 0; // ready the moment someone walks in\n return;\n }\n this.heroCooldown += 1;\n this.fire(target, false);\n if (this.stats.multiShot > 0 && this.random() < this.stats.multiShot)\n this.fire(targets[1] ?? target, true);\n }\n\n private fire(target: Monster, extra: boolean): void {\n const crit = this.random() < this.stats.critChance;\n const p: Projectile = {\n id: this.nextId++,\n x: HERO_X + 0.04,\n targetId: target.id,\n damage: this.stats.damage * (crit ? this.stats.critMultiplier : 1),\n crit,\n extra,\n };\n this.projectiles.push(p);\n this.emit({ type: \"shoot\", projectile: p });\n }\n\n private moveProjectiles(dt: number): void {\n for (const p of [...this.projectiles]) {\n let target = this.monsters.find((m) => m.id === p.targetId);\n if (!target) {\n // Its target died on the way: the shot flies on to the next monster, or fizzles.\n target = this.monsters\n .filter((m) => m.x > p.x)\n .sort((a, b) => a.x - b.x)[0];\n if (!target) {\n this.dropProjectile(p);\n continue;\n }\n p.targetId = target.id;\n }\n p.x += PROJECTILE_SPEED * dt;\n if (p.x < target.x) continue;\n this.dropProjectile(p);\n this.damageMonster(target, p.damage, p.crit);\n }\n }\n\n // A kill can clear the stage mid-loop and replace the list: a shot of the old list is just gone.\n private dropProjectile(p: Projectile): void {\n const i = this.projectiles.indexOf(p);\n if (i >= 0) this.projectiles.splice(i, 1);\n }\n\n private damageMonster(m: Monster, damage: number, crit: boolean): void {\n if (!this.monsters.includes(m)) return;\n m.hp -= damage;\n this.emit({ type: \"hit\", monsterId: m.id, x: m.x, damage, crit });\n if (m.hp > 0) return;\n this.monsters.splice(this.monsters.indexOf(m), 1);\n this.emit({ type: \"kill\", monsterId: m.id, x: m.x, boss: m.spec.boss });\n if (m.spec.boss) this.clearStage();\n }\n\n private monstersAct(dt: number): void {\n for (const m of this.monsters) {\n const stop = MELEE_X + (m.spec.boss ? 0.05 : 0);\n if (m.x > stop) {\n m.x = Math.max(stop, m.x - m.spec.speed * dt);\n continue;\n }\n m.cooldown -= dt;\n if (m.cooldown > 0) continue;\n m.cooldown += m.spec.attackInterval;\n if (this.random() < this.stats.dodge) {\n this.emit({ type: \"dodge\" });\n continue;\n }\n const damage = Math.max(MIN_HIT, m.spec.attack - this.stats.armor);\n this.heroHp -= damage;\n this.emit({ type: \"heroHit\", damage });\n if (this.heroHp <= 0) return this.heroDown();\n }\n }\n\n // ── Stage flow ─────────────────────────────────────────────────────────────────────────────\n\n private startWave(wave: number): void {\n this.mode = \"waves\";\n this.wave = wave;\n this.spawnQueue = waveMonsters(this.stage, wave).map((spec, i) => ({\n spec,\n delay: i === 0 ? 0.3 : SPAWN_GAP_SECONDS,\n }));\n }\n\n private startBoss(): void {\n this.mode = \"boss\";\n this.monsters = [];\n this.projectiles = [];\n this.bossTimeLeft = BOSS_SECONDS;\n this.spawnQueue = [{ spec: bossMonster(this.stage), delay: 0.4 }];\n this.emit({ type: \"bossStart\" });\n }\n\n private clearStage(): void {\n const cleared = this.stage;\n this.stage += 1;\n this.best = Math.max(this.best, this.stage);\n this.monsters = [];\n this.projectiles = [];\n this.emit({ type: \"stageCleared\", stage: cleared });\n this.startWave(0);\n }\n\n private failBoss(reason: \"time\" | \"down\"): void {\n this.autoBoss = false;\n this.monsters = [];\n this.projectiles = [];\n this.emit({ type: \"bossFailed\", reason });\n if (reason === \"time\") this.startWave(0);\n }\n\n private heroDown(): void {\n this.heroHp = 0;\n this.heroDownLeft = RESPAWN_SECONDS;\n this.emit({ type: \"heroDown\" });\n if (this.mode === \"boss\") this.failBoss(\"down\");\n this.monsters = [];\n this.projectiles = [];\n this.spawnQueue = [];\n this.mode = \"waves\";\n }\n\n private skillUnlocked(def: SkillDef): boolean {\n return this.best >= def.unlockStage;\n }\n\n private emit(e: BattleEvent): void {\n this.events.push(e);\n }\n\n // ── Reading ────────────────────────────────────────────────────────────────────────────────\n\n snapshot(): BattleSnapshot {\n const boss = this.monsters.find((m) => m.spec.boss);\n return {\n stage: this.stage,\n best: this.best,\n mode: this.mode,\n wave: this.wave,\n waves: WAVES_PER_STAGE,\n bossTimeLeft: this.mode === \"boss\" ? Math.max(0, this.bossTimeLeft) : 0,\n bossHp: boss ? Math.max(0, boss.hp) : 0,\n bossMaxHp: boss?.maxHp ?? 0,\n heroHp: Math.max(0, this.heroHp),\n heroMaxHp: this.stats.maxHp,\n heroDown: this.heroDownLeft > 0,\n monsters: this.monsters.length,\n autoBoss: this.autoBoss,\n autoSkills: this.autoSkills,\n skills: SKILLS.map((def) => ({\n id: def.id,\n unlocked: this.skillUnlocked(def),\n unlockStage: def.unlockStage,\n cooldownLeft: this.skillCooldown[def.id],\n cooldown: def.cooldown,\n activeLeft: this.skillActive[def.id],\n })),\n };\n }\n}\n"
117
+ "content": "// The fight itself — a deterministic simulation with no Phaser in it, so it is tested on its own\n// (game.test.ts) and drawn by the scene from the events each tick returns.\n//\n// One road, in lane widths (Obsidian Knight's march): the hero walks right along it and the world\n// scrolls past — on screen the hero stays at HERO_X. A wave's monsters stand on the road ahead and\n// wait. The hero stops when the first of them is within its reach — face to face with a melee weapon\n// (or bare hands), a bow's or a staff's shot away with a ranged one — and fights it; the next ones\n// wait their turn. A melee monster fights only when it is the first in line and the hero is at it (it\n// closes the last step itself — a hero with a bow does not farm it for free); a ranged one shoots as\n// soon as the hero is within its range, wherever it stands in the line.\n//\n// Armor (Obsidian Knight's blue bar): a pool on top of the health that takes every blow first. The\n// hero's is as big as its health and fills up again at the start of each stage and when it gets up;\n// an enemy in plate, chainmail or a helm (enemyLooks) has its own, a share of its health.\n//\n// A stage is WAVES_PER_STAGE waves, then the boss with a timer. A failed boss (time out or the hero\n// down) drops back to farming the stage's waves until the player challenges it again — the Legend\n// Slime loop. A fallen hero does not take the wave with it: the enemies stand where they are while it\n// lies, and when it gets up they are back to full health and the same fight goes on (a boss that won\n// leaves the road).\n\nimport type { FighterStats } from \"./heroStats\";\nimport { enemyArmored } from \"./enemyLooks\";\nimport {\n BOSS_SECONDS,\n WAVES_PER_STAGE,\n bossMonster,\n waveMonsters,\n type MonsterSpec,\n} from \"./stages\";\n\n/** Where the hero stands on screen (share of the lane); the road scrolls under it. */\nexport const HERO_X = 0.2;\n/** The hero's walk, lane widths per second — the most; the scene lowers it to what the hero's legs\n * carry at its size (`setWalkSpeed`), so the feet never skate. */\nexport const WALK_SPEED = 0.2;\n/** A melee hit lands from this far: the hero stops here before a monster, face to face. */\nexport const MELEE_REACH = 0.13;\n/** The boss is drawn this much bigger (the scene) — face to face with it is that much farther. */\nexport const BOSS_SIZE = 1.4;\n/** A ranged weapon (a bow, a gun, a staff) shoots from this far. */\nexport const RANGED_REACH = 0.55;\n/** The first monster of a new wave stands this far ahead (just past the screen edge — it comes into view). */\nexport const WAVE_AHEAD = 0.85;\n/** The boss stands this far ahead — in view at once (its timer is already running). */\nexport const BOSS_AHEAD = 0.72;\n/** The gap between monsters waiting in line. */\nexport const MONSTER_GAP = 0.14;\nexport const PROJECTILE_SPEED = 1.9;\n/** A monster's shot (spear, spell, spike, fire) flies left at this speed. */\nexport const ENEMY_SHOT_SPEED = 1.1;\nexport const RESPAWN_SECONDS = 2.5;\n/** The server's MinHitDamage: armor never turns a hit into a heal. */\nexport const MIN_HIT = 1;\n/** The hero's armor pool — this share of its max health (the sheet: 333 armor over 322 HP). */\nexport const HERO_ARMOR_SHARE = 1;\n/** An armored enemy's pool — this share of its health. */\nexport const ENEMY_ARMOR_SHARE = 0.6;\n\nexport type SkillId = \"meteor\" | \"frenzy\" | \"heal\";\nexport type Reach = \"melee\" | \"ranged\";\n\nexport interface SkillDef {\n id: SkillId;\n cooldown: number;\n /** How long the effect lasts (0 = instant). */\n duration: number;\n /** Best stage the skill opens at. */\n unlockStage: number;\n}\n\nexport const SKILLS: SkillDef[] = [\n { id: \"meteor\", cooldown: 15, duration: 0, unlockStage: 1 },\n { id: \"frenzy\", cooldown: 22, duration: 6, unlockStage: 3 },\n { id: \"heal\", cooldown: 28, duration: 0, unlockStage: 6 },\n];\n/** Meteor hits every monster on screen for this many hero hits. */\nexport const METEOR_POWER = 4;\nexport const FRENZY_SPEED = 2;\nexport const HEAL_SHARE = 0.35;\n/** Auto-cast of Heal waits until the hero is this hurt. */\nexport const AUTO_HEAL_BELOW = 0.55;\n\nexport interface Monster {\n id: number;\n spec: MonsterSpec;\n hp: number;\n maxHp: number;\n /** Its armor pool (0 — none): blows take it first. */\n armor: number;\n maxArmor: number;\n /** Where it stands on the road (lane widths, the same axis as `Battle.heroX`). */\n x: number;\n cooldown: number;\n}\n\n/** The hero's shot (a ranged weapon only — a melee hit lands at once). */\nexport interface Projectile {\n id: number;\n x: number;\n targetId: number;\n damage: number;\n crit: boolean;\n extra: boolean;\n}\n\n/** A monster's shot flying at the hero. */\nexport interface EnemyShot {\n id: number;\n x: number;\n damage: number;\n kind: MonsterSpec[\"kind\"];\n}\n\nexport type BattleEvent =\n | { type: \"spawn\"; monster: Monster }\n | { type: \"shoot\"; projectile: Projectile }\n | { type: \"strike\"; monsterId: number }\n | { type: \"enemyShoot\"; shot: EnemyShot; monsterId: number }\n | { type: \"enemyStrike\"; monsterId: number }\n | {\n type: \"hit\";\n monsterId: number;\n x: number;\n damage: number;\n crit: boolean;\n melee: boolean;\n }\n | { type: \"kill\"; monsterId: number; x: number; boss: boolean }\n | { type: \"heroHit\"; damage: number; armor: number }\n | { type: \"dodge\" }\n | { type: \"heroDown\" }\n | { type: \"heroUp\" }\n | { type: \"waveCleared\"; wave: number }\n | { type: \"bossStart\" }\n | { type: \"bossFailed\"; reason: \"time\" | \"down\" }\n | { type: \"stageCleared\"; stage: number }\n | { type: \"skill\"; skill: SkillId };\n\nexport interface BattleSnapshot {\n stage: number;\n best: number;\n mode: \"waves\" | \"boss\";\n wave: number;\n waves: number;\n bossTimeLeft: number;\n bossHp: number;\n bossMaxHp: number;\n heroHp: number;\n heroMaxHp: number;\n heroArmor: number;\n heroMaxArmor: number;\n heroDown: boolean;\n monsters: number;\n walking: boolean;\n autoBoss: boolean;\n autoSkills: boolean;\n skills: Array<{\n id: SkillId;\n unlocked: boolean;\n unlockStage: number;\n cooldownLeft: number;\n cooldown: number;\n activeLeft: number;\n }>;\n}\n\n/** mulberry32 — a small seeded PRNG, so a test replays the same crits. */\nexport function seededRandom(seed: number): () => number {\n let a = seed >>> 0;\n return () => {\n a = (a + 0x6d2b79f5) >>> 0;\n let t = a;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\nconst IDLE_STATS: FighterStats = {\n maxHp: 100,\n damage: 10,\n armor: 0,\n attackSpeed: 1,\n critChance: 0,\n critMultiplier: 1.5,\n dodge: 0,\n regen: 0,\n multiShot: 0,\n};\n\nexport class Battle {\n stage = 1;\n best = 1;\n mode: \"waves\" | \"boss\" = \"waves\";\n wave = 0;\n autoBoss = true;\n autoSkills = true;\n bossTimeLeft = 0;\n\n stats: FighterStats = IDLE_STATS;\n heroHp = IDLE_STATS.maxHp;\n /** The hero's armor pool — takes the blows before its health. */\n heroArmor = IDLE_STATS.maxHp * HERO_ARMOR_SHARE;\n heroDownLeft = 0;\n /** How far the hero has walked (lane widths). */\n heroX = 0;\n /** The hero is on the move this step (nobody within its reach). */\n walking = false;\n /** Lane widths a second the hero walks (and a monster closes in at `spec.speed` × it). */\n walkSpeed = WALK_SPEED;\n /** How far ahead a new wave lines up (the scene puts it just past what the screen shows). */\n waveAhead = WAVE_AHEAD;\n /** The gap between monsters in line (the scene sizes it to the people drawn). */\n monsterGap = MONSTER_GAP;\n reach: Reach = \"melee\";\n /** Seconds from the start of the hero's attack to its hit (the scene sets it from the animation). */\n windup = 0;\n /** Seconds the attack plays on after its hit — the hero does not set off before it is over. */\n recover = 0;\n /** Standing in line, nearest first. */\n monsters: Monster[] = [];\n projectiles: Projectile[] = [];\n enemyShots: EnemyShot[] = [];\n\n private heroCooldown = 0;\n private heroRecoverLeft = 0;\n private skillCooldown: Record<SkillId, number> = {\n meteor: 0,\n frenzy: 0,\n heal: 0,\n };\n private skillActive: Record<SkillId, number> = {\n meteor: 0,\n frenzy: 0,\n heal: 0,\n };\n private nextId = 1;\n private readonly random: () => number;\n private events: BattleEvent[] = [];\n\n constructor(opts: { stage?: number; best?: number; seed?: number } = {}) {\n this.random = seededRandom(opts.seed ?? Date.now());\n this.stage = Math.max(1, Math.floor(opts.stage ?? 1));\n this.best = Math.max(this.stage, Math.floor(opts.best ?? 1));\n this.startWave(0);\n }\n\n // ── Inputs ─────────────────────────────────────────────────────────────────────────────────\n\n /** New numbers of the hero (an upgrade, other gear, another hero): HP and armor keep their share. */\n setStats(stats: FighterStats): void {\n const share = this.stats.maxHp > 0 ? this.heroHp / this.stats.maxHp : 1;\n const before = this.heroMaxArmor();\n const armorShare = before > 0 ? this.heroArmor / before : 1;\n this.stats = stats;\n this.heroHp = Math.max(1, Math.min(stats.maxHp, share * stats.maxHp));\n this.heroArmor = armorShare * this.heroMaxArmor();\n }\n\n /** The hero's full armor pool for its current numbers. */\n heroMaxArmor(): number {\n return this.stats.maxHp * HERO_ARMOR_SHARE;\n }\n\n /** How fast the hero walks, lane widths a second (the scene matches it to the legs on screen). */\n setWalkSpeed(speed: number): void {\n this.walkSpeed = Math.max(0.01, Math.min(WALK_SPEED, speed));\n }\n\n /** The gap between monsters in line, lane widths (kept between 0.08 and 0.3). */\n setMonsterGap(gap: number): void {\n this.monsterGap = Math.max(0.08, Math.min(0.3, gap));\n }\n\n /** Where a new wave stands, lane widths ahead of the hero (kept between 0.6 and 2 — a wide screen). */\n setWaveAhead(ahead: number): void {\n this.waveAhead = Math.max(0.6, Math.min(2, ahead));\n }\n\n /** What the hero holds decides how close it fights: melee — face to face, ranged — from afar. */\n setReach(reach: Reach): void {\n this.reach = reach;\n }\n\n /**\n * How the hero's attack is timed (seconds, each kept to 0…1): the swing before its hit — the first hit\n * on someone the hero has just reached comes that long after it stops — and the follow-through after\n * it, which plays out before the hero runs on (a kill with one blow is a whole blow, not a touch).\n */\n setSwing(windup: number, recover: number): void {\n this.windup = Math.max(0, Math.min(1, windup));\n this.recover = Math.max(0, Math.min(1, recover));\n }\n\n /** Jump to a stage (progress loaded from the server); the fight restarts there. */\n setStage(stage: number, best = this.best): void {\n this.stage = Math.max(1, Math.floor(stage));\n this.best = Math.max(this.stage, Math.floor(best));\n this.clearField();\n this.heroArmor = this.heroMaxArmor();\n this.startWave(0);\n }\n\n /** The \"Boss\" button: fight the boss now and keep advancing after it. */\n challengeBoss(): boolean {\n if (this.mode === \"boss\" || this.heroDownLeft > 0) return false;\n this.autoBoss = true;\n this.startBoss();\n return true;\n }\n\n setAutoSkills(on: boolean): void {\n this.autoSkills = on;\n }\n\n /** Cast a skill now. `false` — locked, cooling down, or the hero is down. */\n castSkill(id: SkillId): boolean {\n const def = SKILLS.find((s) => s.id === id);\n if (!def || !this.skillUnlocked(def)) return false;\n if (this.skillCooldown[id] > 0 || this.heroDownLeft > 0) return false;\n this.skillCooldown[id] = def.cooldown;\n this.skillActive[id] = def.duration;\n this.emit({ type: \"skill\", skill: id });\n if (id === \"meteor\") {\n const damage = this.stats.damage * METEOR_POWER;\n for (const m of [...this.monsters])\n if (this.onScreen(m)) this.damageMonster(m, damage, false, false);\n } else if (id === \"heal\") {\n this.heroHp = Math.min(\n this.stats.maxHp,\n this.heroHp + this.stats.maxHp * HEAL_SHARE,\n );\n }\n return true;\n }\n\n // ── Reading the road ───────────────────────────────────────────────────────────────────────\n\n /** Where a point of the road is on screen (share of the lane): the hero is always at HERO_X. */\n screenX(x: number): number {\n return HERO_X + (x - this.heroX);\n }\n\n onScreen(m: Monster): boolean {\n return this.screenX(m.x) < 1;\n }\n\n /** How far the hero hits from with what it holds. */\n reachDistance(): number {\n if (this.reach === \"ranged\") return RANGED_REACH;\n const m = this.front();\n return m ? this.contact(m) : MELEE_REACH;\n }\n\n /** How far from the hero this monster stands face to face (a bigger boss — farther). */\n contact(m: Monster): number {\n return m.spec.boss ? MELEE_REACH * BOSS_SIZE : MELEE_REACH;\n }\n\n /** The first monster in line — the one the hero walks up to. */\n front(): Monster | undefined {\n return this.monsters[0];\n }\n\n /**\n * Is this monster fighting the hero now — a shooter with the hero within its range, a brawler\n * first in line and face to face with the hero (the scene times their swings by it).\n */\n engaged(m: Monster): boolean {\n if (this.heroDownLeft > 0) return false;\n const distance = m.x - this.heroX;\n if (m.spec.range > 0) return distance <= m.spec.range + 1e-6;\n return m === this.front() && distance <= this.contact(m) + 1e-6;\n }\n\n /** A brawler first in line that steps up to the hero (it is within the hero's reach, not yet face to face). */\n closing(m: Monster): boolean {\n return (\n m.spec.range === 0 &&\n m === this.front() &&\n this.hasTarget() &&\n m.x - this.heroX > this.contact(m) + 1e-6\n );\n }\n\n /** Seconds until the hero's next hit at its current speed (0 — ready, as soon as a target is in reach). */\n heroNextShotIn(): number {\n return Math.max(0, this.heroCooldown) / Math.max(0.01, this.heroSpeed());\n }\n\n /** Is the first monster within the hero's reach (it hits when ready). */\n hasTarget(): boolean {\n const m = this.front();\n return !!m && m.x - this.heroX <= this.reachDistance() + 1e-6;\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** Advance by `dt` seconds (clamped — a hidden tab must not resolve a minute in one step). */\n tick(dt: number): BattleEvent[] {\n this.events = [];\n let left = Math.min(Math.max(0, dt), 0.25);\n while (left > 0) {\n const step = Math.min(left, 1 / 30);\n this.step(step);\n left -= step;\n }\n return this.events;\n }\n\n private step(dt: number): void {\n for (const def of SKILLS) {\n this.skillCooldown[def.id] = Math.max(0, this.skillCooldown[def.id] - dt);\n this.skillActive[def.id] = Math.max(0, this.skillActive[def.id] - dt);\n }\n\n if (this.heroDownLeft > 0) {\n this.walking = false;\n this.heroDownLeft -= dt;\n if (this.heroDownLeft <= 0) {\n this.heroDownLeft = 0;\n this.heroHp = this.stats.maxHp;\n this.heroArmor = this.heroMaxArmor();\n this.emit({ type: \"heroUp\" });\n // the same line, healed and armored again — or a fresh wave when the boss has left\n if (this.monsters.length > 0)\n for (const m of this.monsters) {\n m.hp = m.maxHp;\n m.armor = m.maxArmor;\n }\n else this.startWave(0);\n }\n return;\n }\n\n this.heroHp = Math.min(\n this.stats.maxHp,\n this.heroHp + this.stats.regen * dt,\n );\n if (this.autoSkills) this.autoCast();\n this.heroMove(dt);\n this.heroAttack(dt);\n this.moveProjectiles(dt);\n this.monstersAct(dt);\n this.moveEnemyShots(dt);\n if (this.heroDownLeft > 0) return;\n\n if (this.mode === \"boss\") {\n this.bossTimeLeft -= dt;\n if (this.bossTimeLeft <= 0 && this.monsters.some((m) => m.spec.boss))\n return this.failBoss(\"time\");\n }\n\n if (this.monsters.length === 0) {\n if (this.mode === \"boss\") return; // cleared by the kill handler\n this.emit({ type: \"waveCleared\", wave: this.wave });\n const next = this.wave + 1;\n if (next < WAVES_PER_STAGE) this.startWave(next);\n else if (this.autoBoss) this.startBoss();\n else this.startWave(0);\n }\n }\n\n private autoCast(): void {\n for (const def of SKILLS) {\n if (!this.skillUnlocked(def) || this.skillCooldown[def.id] > 0) continue;\n if (def.id === \"heal\") {\n if (this.heroHp < this.stats.maxHp * AUTO_HEAL_BELOW)\n this.castSkill(\"heal\");\n } else if (this.hasTarget()) {\n this.castSkill(def.id);\n }\n }\n }\n\n /** The hero walks until the first monster is within its reach — never past it. */\n private heroMove(dt: number): void {\n // the blow plays out before the hero sets off again\n this.heroRecoverLeft = Math.max(0, this.heroRecoverLeft - dt);\n if (this.heroRecoverLeft > 0) {\n this.walking = false;\n return;\n }\n const m = this.front();\n const stopAt = m ? m.x - this.reachDistance() : Infinity;\n if (this.heroX >= stopAt - 1e-6) {\n this.walking = false;\n return;\n }\n this.walking = true;\n this.heroX = Math.min(stopAt, this.heroX + this.walkSpeed * dt);\n }\n\n private heroSpeed(): number {\n return (\n this.stats.attackSpeed * (this.skillActive.frenzy > 0 ? FRENZY_SPEED : 1)\n );\n }\n\n private heroAttack(dt: number): void {\n this.heroCooldown -= dt * this.heroSpeed();\n if (!this.hasTarget()) {\n // nobody at hand: the next hit lands a swing after the hero reaches someone\n this.heroCooldown = Math.max(\n this.heroCooldown,\n this.windup * this.heroSpeed(),\n );\n return;\n }\n if (this.heroCooldown > 0) return;\n const target = this.front()!;\n this.heroCooldown += 1;\n this.heroRecoverLeft = this.recover;\n this.attackOnce(target, false);\n if (this.stats.multiShot > 0 && this.random() < this.stats.multiShot)\n this.attackOnce(this.monsters[1] ?? target, true);\n }\n\n private attackOnce(target: Monster, extra: boolean): void {\n const crit = this.random() < this.stats.critChance;\n const damage = this.stats.damage * (crit ? this.stats.critMultiplier : 1);\n if (this.reach === \"melee\") {\n // A melee hit lands at once — on the first in line (the extra hit of multi-shot too: on the\n // next one only if it already stands within reach).\n const m = this.front();\n if (!m || !this.hasTarget()) return;\n this.emit({ type: \"strike\", monsterId: m.id });\n this.damageMonster(m, damage, crit, true);\n return;\n }\n const p: Projectile = {\n id: this.nextId++,\n x: this.heroX + 0.04,\n targetId: target.id,\n damage,\n crit,\n extra,\n };\n this.projectiles.push(p);\n this.emit({ type: \"shoot\", projectile: p });\n }\n\n private moveProjectiles(dt: number): void {\n for (const p of [...this.projectiles]) {\n let target = this.monsters.find((m) => m.id === p.targetId);\n if (!target) {\n // Its target died on the way: the shot flies on to the next monster, or fizzles.\n target = this.monsters.find((m) => m.x > p.x);\n if (!target) {\n this.drop(this.projectiles, p);\n continue;\n }\n p.targetId = target.id;\n }\n p.x += PROJECTILE_SPEED * dt;\n if (p.x < target.x) continue;\n this.drop(this.projectiles, p);\n this.damageMonster(target, p.damage, p.crit, false);\n }\n }\n\n // A kill can clear the stage mid-loop and replace the list: a shot of the old list is just gone.\n private drop<T>(list: T[], item: T): void {\n const i = list.indexOf(item);\n if (i >= 0) list.splice(i, 1);\n }\n\n private damageMonster(\n m: Monster,\n damage: number,\n crit: boolean,\n melee: boolean,\n ): void {\n if (!this.monsters.includes(m)) return;\n // the armor takes the blow first, what is left goes to the health\n const absorbed = Math.min(m.armor, damage);\n m.armor -= absorbed;\n m.hp -= damage - absorbed;\n this.emit({ type: \"hit\", monsterId: m.id, x: m.x, damage, crit, melee });\n if (m.hp > 0) return;\n this.monsters.splice(this.monsters.indexOf(m), 1);\n this.emit({ type: \"kill\", monsterId: m.id, x: m.x, boss: m.spec.boss });\n if (m.spec.boss) this.clearStage();\n }\n\n private monstersAct(dt: number): void {\n for (const m of [...this.monsters]) {\n // A melee fighter waits in line; the first one, once the hero is at it, closes the last step.\n if (this.closing(m)) {\n m.x = Math.max(\n this.heroX + this.contact(m),\n m.x - m.spec.speed * this.walkSpeed * dt,\n );\n continue;\n }\n // A shooter stays where it stands and fires once the hero is within its range.\n if (!this.engaged(m)) continue;\n m.cooldown -= dt;\n if (m.cooldown > 0) continue;\n m.cooldown += m.spec.attackInterval;\n const damage = Math.max(MIN_HIT, m.spec.attack - this.stats.armor);\n if (m.spec.range > 0) {\n const shot: EnemyShot = {\n id: this.nextId++,\n x: m.x - 0.03,\n damage,\n kind: m.spec.kind,\n };\n this.enemyShots.push(shot);\n this.emit({ type: \"enemyShoot\", shot, monsterId: m.id });\n continue;\n }\n this.emit({ type: \"enemyStrike\", monsterId: m.id });\n this.hurtHero(damage);\n if (this.heroDownLeft > 0) return;\n }\n }\n\n private moveEnemyShots(dt: number): void {\n for (const s of [...this.enemyShots]) {\n s.x -= ENEMY_SHOT_SPEED * dt;\n if (s.x > this.heroX + 0.02) continue;\n this.drop(this.enemyShots, s);\n this.hurtHero(s.damage);\n if (this.heroDownLeft > 0) return;\n }\n }\n\n private hurtHero(damage: number): void {\n if (this.random() < this.stats.dodge) {\n this.emit({ type: \"dodge\" });\n return;\n }\n // the armor takes the blow first, what is left goes to the health\n const absorbed = Math.min(this.heroArmor, damage);\n this.heroArmor -= absorbed;\n this.heroHp -= damage - absorbed;\n this.emit({ type: \"heroHit\", damage, armor: absorbed });\n if (this.heroHp <= 0) this.heroDown();\n }\n\n // ── Stage flow ─────────────────────────────────────────────────────────────────────────────\n\n /** The wave's monsters line up on the road ahead, waiting. */\n private startWave(wave: number): void {\n this.mode = \"waves\";\n this.wave = wave;\n this.lineUp(waveMonsters(this.stage, wave), this.waveAhead);\n }\n\n private startBoss(): void {\n this.mode = \"boss\";\n this.clearField();\n this.bossTimeLeft = BOSS_SECONDS;\n this.lineUp([bossMonster(this.stage)], BOSS_AHEAD);\n this.emit({ type: \"bossStart\" });\n }\n\n private lineUp(specs: MonsterSpec[], ahead: number): void {\n this.monsters = specs.map((spec, i) => {\n const id = this.nextId++;\n const armor = enemyArmored(spec.kind, id, spec.boss)\n ? spec.hp * ENEMY_ARMOR_SHARE\n : 0;\n return {\n id,\n spec,\n hp: spec.hp,\n maxHp: spec.hp,\n armor,\n maxArmor: armor,\n x: this.heroX + ahead + i * this.monsterGap,\n cooldown: spec.attackInterval * 0.5,\n };\n });\n for (const m of this.monsters) this.emit({ type: \"spawn\", monster: m });\n }\n\n private clearField(): void {\n this.monsters = [];\n this.projectiles = [];\n this.enemyShots = [];\n }\n\n private clearStage(): void {\n const cleared = this.stage;\n this.stage += 1;\n this.best = Math.max(this.best, this.stage);\n this.clearField();\n this.emit({ type: \"stageCleared\", stage: cleared });\n // a new stage: the hero's armor is whole again\n this.heroArmor = this.heroMaxArmor();\n this.startWave(0);\n }\n\n private failBoss(reason: \"time\" | \"down\"): void {\n this.autoBoss = false;\n this.clearField();\n this.emit({ type: \"bossFailed\", reason });\n if (reason === \"time\") this.startWave(0);\n }\n\n /** The hero falls; the enemies stay standing (only what is in flight is gone). */\n private heroDown(): void {\n this.heroHp = 0;\n this.heroDownLeft = RESPAWN_SECONDS;\n this.heroRecoverLeft = 0;\n this.walking = false;\n this.projectiles = [];\n this.enemyShots = [];\n this.emit({ type: \"heroDown\" });\n if (this.mode === \"boss\") this.failBoss(\"down\");\n this.mode = \"waves\";\n }\n\n private skillUnlocked(def: SkillDef): boolean {\n return this.best >= def.unlockStage;\n }\n\n private emit(e: BattleEvent): void {\n this.events.push(e);\n }\n\n // ── Reading ────────────────────────────────────────────────────────────────────────────────\n\n snapshot(): BattleSnapshot {\n const boss = this.monsters.find((m) => m.spec.boss);\n return {\n stage: this.stage,\n best: this.best,\n mode: this.mode,\n wave: this.wave,\n waves: WAVES_PER_STAGE,\n bossTimeLeft: this.mode === \"boss\" ? Math.max(0, this.bossTimeLeft) : 0,\n bossHp: boss ? Math.max(0, boss.hp) : 0,\n bossMaxHp: boss?.maxHp ?? 0,\n heroHp: Math.max(0, this.heroHp),\n heroMaxHp: this.stats.maxHp,\n heroArmor: Math.max(0, this.heroArmor),\n heroMaxArmor: this.heroMaxArmor(),\n heroDown: this.heroDownLeft > 0,\n monsters: this.monsters.length,\n walking: this.walking,\n autoBoss: this.autoBoss,\n autoSkills: this.autoSkills,\n skills: SKILLS.map((def) => ({\n id: def.id,\n unlocked: this.skillUnlocked(def),\n unlockStage: def.unlockStage,\n cooldownLeft: this.skillCooldown[def.id],\n cooldown: def.cooldown,\n activeLeft: this.skillActive[def.id],\n })),\n };\n }\n}\n"
94
118
  },
95
119
  {
96
120
  "path": "game/economy.ts",
97
121
  "content": "// Where the gold comes from: the title's idle accrual (Reward → IdleAccruals) — the SERVER pays it,\n// at a rate that grows with the Power of the player's strongest hero. The game collects it every few\n// seconds while it is on screen and once on entry (\"while you were away\"); the coins that pop out of\n// monsters are that same stream drawn, never a second source. These helpers only READ the config to\n// show the rate — the server computes what is actually paid.\n\nexport interface AccrualView {\n AccrualID?: string;\n DisplayName?: string | null;\n Rate?: {\n BaseRatePerSecond?: number | null;\n PowerCoefficient?: number | null;\n BoardRankCoefficient?: number | null;\n EquipmentBonusEnabled?: boolean | null;\n EquipmentCharacterID?: string | null;\n } | null;\n Rewards?: {\n Standard?: {\n Entries?: Array<{\n Type?: string | null;\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n } | null;\n MaxAccumulationSeconds?: number | null;\n MinClaimSeconds?: number | null;\n}\n\nexport interface RewardSection {\n IdleAccruals?: Record<string, AccrualView | null> | null;\n}\n\n/** The accrual the game draws as battle loot: \"idle_gold\" if the title has it, else the first that pays a currency. */\nexport const PREFERRED_ACCRUAL = \"idle_gold\";\n\nexport interface Income {\n accrualID: string;\n currencyID: string;\n /** Currency per unit of rate (the accrual's grant amount). */\n perUnit: number;\n base: number;\n powerCoefficient: number;\n maxSeconds: number;\n minClaimSeconds: number;\n}\n\nexport function pickIncome(section: RewardSection | undefined): Income | null {\n const entries = Object.entries(section?.IdleAccruals ?? {}).filter(\n (e): e is [string, AccrualView] => !!e[1],\n );\n entries.sort(\n ([a], [b]) =>\n Number(b === PREFERRED_ACCRUAL) - Number(a === PREFERRED_ACCRUAL),\n );\n for (const [key, a] of entries) {\n const pay = a.Rewards?.Standard?.Entries?.find(\n (e) =>\n e?.CurrencyID && (e.Type ?? \"VirtualCurrency\") === \"VirtualCurrency\",\n );\n if (!pay?.CurrencyID) continue;\n return {\n accrualID: a.AccrualID ?? key,\n currencyID: pay.CurrencyID,\n perUnit: Number(pay.Amount ?? 1) || 1,\n base: Number(a.Rate?.BaseRatePerSecond ?? 0),\n powerCoefficient: Number(a.Rate?.PowerCoefficient ?? 0),\n maxSeconds: Number(a.MaxAccumulationSeconds ?? 0),\n minClaimSeconds: Number(a.MinClaimSeconds ?? 0),\n };\n }\n return null;\n}\n\n/**\n * Currency per second, as the server will compute it: base + coefficient × the strongest hero's\n * Power (premium multipliers and gear bonuses left out — a display, not a promise).\n */\nexport function incomePerSecond(\n income: Income,\n heroPowers: ReadonlyArray<number | null | undefined>,\n): number {\n const power = Math.max(0, ...heroPowers.map((p) => Number(p ?? 0)));\n return (income.base + income.powerCoefficient * power) * income.perUnit;\n}\n\n// ── Quests that count the game's own metrics ──────────────────────────────────────────────────\n\n/** Metrics the game reports with `client.quest.addQuestProgress`. */\nexport const METRIC_STAGE_CLEARED = \"stage_cleared\";\nexport const METRIC_STAT_ENHANCED = \"stat_enhanced\";\n\nexport interface QuestSection {\n Quests?: Record<\n string,\n {\n Objectives?: Record<\n string,\n {\n MetricID?: string | null;\n Source?: string | null;\n MaxProgressPerCall?: number | null;\n } | null\n > | null;\n } | null\n > | null;\n}\n\n/**\n * Whether some quest counts `metricID` from the client, and the most one call may add (0 = no cap).\n * `null` — nobody counts it: the call is skipped (the server refuses an unknown metric anyway).\n *\n * ⚠ The cap is the SMALLEST one among the objectives: a call above an objective's\n * MaxProgressPerCall is treated as cheating and BANS the player, it is not clamped.\n */\nexport function questMetric(\n section: QuestSection | undefined,\n metricID: string,\n): { maxPerCall: number } | null {\n let found = false;\n let cap = 0;\n for (const q of Object.values(section?.Quests ?? {}))\n for (const o of Object.values(q?.Objectives ?? {})) {\n if (!o || o.MetricID !== metricID) continue;\n if ((o.Source ?? \"ClientApi\") !== \"ClientApi\") continue;\n found = true;\n const max = Number(o.MaxProgressPerCall ?? 0);\n if (max > 0) cap = cap === 0 ? max : Math.min(cap, max);\n }\n return found ? { maxPerCall: cap } : null;\n}\n"
98
122
  },
123
+ {
124
+ "path": "game/enemyLooks.ts",
125
+ "content": "// Enemies are people like the hero (Obsidian Knight's bandits): the same rig, dressed from the\n// maker's ready looks. A monster kind only says HOW it fights — a shooter wears a look with a bow, a\n// crossbow, a gun or a staff, a brawler one with a sword, an axe or bare fists — and the monster's id\n// picks which look and its skin and hair colours, so a wave is a varied crowd and the same monster\n// looks the same on every frame. The knight is left out: it is the hero's own class look.\n\n// engine-free parts of the character system only (its index brings the Phaser renderer along)\nimport { mergeLooks, type CharacterLook } from \"@idosgames/characters/rig2d\";\nimport { seedOf, seededRandom } from \"@idosgames/characters/rig2d\";\nimport { VARIETY } from \"../character/looks/classLooks\";\nimport { HERO_PRESETS } from \"@idosgames/characters/rig2d\";\nimport { heroIsMelee } from \"@idosgames/characters/rig2d\";\nimport { RANGED_KINDS, type MonsterKind } from \"./stages\";\n\nconst ENEMY_PRESETS = HERO_PRESETS.filter((p) => p.id !== \"knight\");\n\n/** Looks that fight face to face / from afar (by the weapon in their hand). */\nexport const MELEE_LOOKS = ENEMY_PRESETS.filter((p) =>\n heroIsMelee(p.look.parts?.Weapon ?? null),\n);\nexport const RANGED_LOOKS = ENEMY_PRESETS.filter(\n (p) => !heroIsMelee(p.look.parts?.Weapon ?? null),\n);\n/** A boss is a heavy brawler. */\nconst BOSS_IDS = [\"dark_knight\", \"berserker\", \"king\", \"viking\", \"crusader\"];\n\n/** Plate, chainmail or a helm: the enemy has armor (its own blue bar) on top of its health. */\nexport function isArmored(look: CharacterLook): boolean {\n const top = look.parts?.Top ?? \"\";\n const hat = look.parts?.Hat ?? \"\";\n return /plate|chain/.test(top) || /helm/.test(hat);\n}\n\n/** Does this monster come in armor (by the look it is drawn in)? */\nexport function enemyArmored(\n kind: MonsterKind,\n monsterId: number,\n boss = false,\n): boolean {\n return isArmored(enemyLook(kind, monsterId, boss));\n}\n\nexport function enemyLook(\n kind: MonsterKind,\n monsterId: number,\n boss = false,\n): CharacterLook {\n const rng = seededRandom(seedOf(`${kind}:${monsterId}`));\n const pool = boss\n ? MELEE_LOOKS.filter((p) => BOSS_IDS.includes(p.id))\n : RANGED_KINDS.has(kind)\n ? RANGED_LOOKS\n : MELEE_LOOKS;\n const list = pool.length > 0 ? pool : ENEMY_PRESETS;\n // the kind shifts the start, so two kinds of one chapter do not wear the same looks\n const shift = seedOf(kind) % list.length;\n const preset = list[(shift + Math.floor(rng() * list.length)) % list.length]!;\n const hair = VARIETY.hairColor[Math.floor(rng() * VARIETY.hairColor.length)]!;\n return mergeLooks(preset.look, {\n v: preset.look.v,\n parts: {},\n colors: {\n Skin: VARIETY.skin[Math.floor(rng() * VARIETY.skin.length)]!,\n Hair: hair,\n Beard: hair,\n Brows: hair,\n },\n });\n}\n"
126
+ },
99
127
  {
100
128
  "path": "game/format.ts",
101
129
  "content": "// Idle-game numbers: they outgrow \"1.2M\" within an hour, so after the usual K/M/B/T come the\n// two-letter units of the genre — aa, ab, … az, ba, … (Legend Slime's \"1.36J\" is the same idea).\n\nconst SMALL = [\"\", \"K\", \"M\", \"B\", \"T\"];\n\n/** 950 → \"950\", 12_300 → \"12.3K\", 4.5e15 → \"4.50aa\". Never longer than ~6 characters. */\nexport function formatBig(value: number): string {\n if (!Number.isFinite(value)) return \"∞\";\n const sign = value < 0 ? \"-\" : \"\";\n let n = Math.abs(value);\n if (n < 1000) return sign + (Number.isInteger(n) ? String(n) : trim(n, 1));\n let unit = 0;\n while (n >= 1000) {\n n /= 1000;\n unit++;\n }\n return sign + trim(n, n < 10 ? 2 : n < 100 ? 1 : 0) + unitName(unit);\n}\n\nfunction unitName(unit: number): string {\n const small = SMALL[unit];\n if (small !== undefined) return small;\n const k = unit - SMALL.length;\n const a = \"a\".charCodeAt(0);\n return (\n String.fromCharCode(a + (Math.floor(k / 26) % 26)) +\n String.fromCharCode(a + (k % 26))\n );\n}\n\nfunction trim(n: number, digits: number): string {\n return n.toFixed(digits).replace(/\\.?0+$/, \"\");\n}\n\n/** A stat value as the Enhance list shows it: chances as percent, small values with decimals. */\nexport function formatStat(value: number, percent: boolean): string {\n if (percent) return `${trim(value * 100, 1)}%`;\n if (Math.abs(value) < 100 && !Number.isInteger(value)) return trim(value, 2);\n return formatBig(Math.round(value));\n}\n\n/** 3725 s → \"1h 2m\", 95 s → \"1m 35s\". */\nexport function formatDuration(seconds: number): string {\n const s = Math.max(0, Math.floor(seconds));\n const h = Math.floor(s / 3600);\n const m = Math.floor((s % 3600) / 60);\n if (h > 0) return `${h}h ${m}m`;\n if (m > 0) return `${m}m ${s % 60}s`;\n return `${s}s`;\n}\n"
@@ -110,7 +138,7 @@
110
138
  },
111
139
  {
112
140
  "path": "game/IdleRpgController.ts",
113
- "content": "import Phaser from \"phaser\";\nimport { BattleScene } from \"../phaser/BattleScene\";\nimport type { IdleSession } from \"./session\";\n\n/**\n * Owns the Phaser game that draws the session's fight. Created on mount (Phaser needs the host\n * element), destroyed with the mode — the session (and with it the fight and the progress) lives on.\n */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n\n constructor(\n parent: HTMLElement,\n readonly session: IdleSession,\n ) {\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: parent.clientWidth || 480,\n height: parent.clientHeight || 800,\n backgroundColor: \"#7ec8ff\",\n scene: new BattleScene(session),\n scale: {\n // The canvas fills the full-bleed host; the scene draws the field into its top part and the\n // module's panel covers the rest (layout.ts).\n mode: Phaser.Scale.RESIZE,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n }\n\n /** Whether the Phaser loop is running (the agent's \"am I even running\" flag). */\n get running(): boolean {\n return this.game.loop.running;\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs) — and the fight with it. */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
141
+ "content": "import Phaser from \"phaser\";\nimport { BattleScene } from \"../phaser/BattleScene\";\nimport type { IdleSession } from \"./session\";\n\n/**\n * Owns the Phaser game that draws the session's fight. Created on mount (Phaser needs the host\n * element), destroyed with the mode — the session (and with it the fight and the progress) lives on.\n */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly sizeWatch: ResizeObserver | null;\n\n constructor(\n parent: HTMLElement,\n readonly session: IdleSession,\n ) {\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: parent.clientWidth || 480,\n height: parent.clientHeight || 800,\n backgroundColor: \"#7ec8ff\",\n scene: new BattleScene(session),\n scale: {\n // The canvas fills the full-bleed host; the scene draws the field into its top part and the\n // module's panel covers the rest (layout.ts).\n mode: Phaser.Scale.RESIZE,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n\n // The canvas follows its host, not only the window: the lobby lays its header and tabs around the\n // game after it mounts (and Phaser's own first measure can come before that), and a canvas left\n // at another size overflows the frame — which then scrolls, the field sliding up under the HUD.\n const fit = () => {\n const w = parent.clientWidth;\n const h = parent.clientHeight;\n if (\n w > 0 &&\n h > 0 &&\n (w !== this.game.scale.width || h !== this.game.scale.height)\n )\n this.game.scale.resize(w, h);\n };\n this.sizeWatch =\n typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(fit);\n this.sizeWatch?.observe(parent);\n this.game.scale.on(\"resize\", () => queueMicrotask(fit));\n this.game.events.once(\"ready\", fit);\n }\n\n /** Whether the Phaser loop is running (the agent's \"am I even running\" flag). */\n get running(): boolean {\n return this.game.loop.running;\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs) — and the fight with it. */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.sizeWatch?.disconnect();\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
114
142
  },
115
143
  {
116
144
  "path": "game/session.ts",
@@ -118,7 +146,11 @@
118
146
  },
119
147
  {
120
148
  "path": "game/stages.ts",
121
- "content": "// Stages: chapters of ten, each stage a few waves and a boss with a timer. Numbered from 1 across\n// chapters (\"3-6\" is stage 26). Monsters grow geometrically — a hero's stats grow linearly with their\n// levels while level prices grow geometrically, so the wall comes on its own; ranks, gear and the\n// next hero push it back.\n\nexport const STAGES_PER_CHAPTER = 10;\nexport const WAVES_PER_STAGE = 4;\nexport const MONSTERS_PER_WAVE = 5;\nexport const BOSS_SECONDS = 30;\n\nconst BASE_HP = 22;\nconst HP_GROWTH = 1.12;\nconst BASE_ATTACK = 3;\nconst ATTACK_GROWTH = 1.105;\nconst BOSS_HP = 9;\nconst BOSS_ATTACK = 2;\n\nexport interface StageInfo {\n /** 1-based, across chapters. */\n index: number;\n chapter: number;\n /** 1..STAGES_PER_CHAPTER. */\n stage: number;\n theme: Theme;\n /** Difficulty word of the chapter loop (\"Normal\", \"Hard\", …) — an i18n key. */\n difficulty: Difficulty;\n}\n\nexport type Difficulty = \"normal\" | \"hard\" | \"veryHard\" | \"hell\" | \"abyss\";\nconst DIFFICULTIES: Difficulty[] = [\n \"normal\",\n \"hard\",\n \"veryHard\",\n \"hell\",\n \"abyss\",\n];\n\nexport interface Theme {\n id: \"meadow\" | \"forest\" | \"desert\" | \"snow\" | \"volcano\";\n sky: [number, number];\n hillFar: number;\n hillNear: number;\n ground: number;\n grass: number;\n /** Monster kinds of the chapter, by art id. */\n monsters: MonsterKind[];\n}\n\nexport type MonsterKind =\n \"mushroom\" | \"bat\" | \"goblin\" | \"skull\" | \"golem\" | \"cactus\" | \"yeti\" | \"imp\";\n\nexport const THEMES: Theme[] = [\n {\n id: \"meadow\",\n sky: [0x7ec8ff, 0xd8f1ff],\n hillFar: 0x8fd17a,\n hillNear: 0x5fb85a,\n ground: 0xc9a46a,\n grass: 0x4fa64a,\n monsters: [\"mushroom\", \"goblin\", \"bat\"],\n },\n {\n id: \"forest\",\n sky: [0x5a9fd6, 0xbfe3d4],\n hillFar: 0x3f8a5a,\n hillNear: 0x2f6e44,\n ground: 0x8a6a45,\n grass: 0x2f7d3a,\n monsters: [\"goblin\", \"bat\", \"skull\"],\n },\n {\n id: \"desert\",\n sky: [0xffb35c, 0xffe7b0],\n hillFar: 0xe6b86a,\n hillNear: 0xd49a4a,\n ground: 0xe8c98a,\n grass: 0xb98f45,\n monsters: [\"cactus\", \"skull\", \"golem\"],\n },\n {\n id: \"snow\",\n sky: [0x8fb8e6, 0xeef6ff],\n hillFar: 0xdbe8f5,\n hillNear: 0xb9cfe6,\n ground: 0xf2f7fc,\n grass: 0x9fc0dd,\n monsters: [\"yeti\", \"bat\", \"golem\"],\n },\n {\n id: \"volcano\",\n sky: [0x3a1f3f, 0xb8473a],\n hillFar: 0x5a2a2a,\n hillNear: 0x3d1d1d,\n ground: 0x4a3030,\n grass: 0xd2552d,\n monsters: [\"imp\", \"skull\", \"golem\"],\n },\n];\n\nexport function stageInfo(index: number): StageInfo {\n const i = Math.max(1, Math.floor(index));\n const chapter = Math.ceil(i / STAGES_PER_CHAPTER);\n return {\n index: i,\n chapter,\n stage: i - (chapter - 1) * STAGES_PER_CHAPTER,\n theme: THEMES[(chapter - 1) % THEMES.length]!,\n difficulty:\n DIFFICULTIES[\n Math.min(\n DIFFICULTIES.length - 1,\n Math.floor((chapter - 1) / THEMES.length),\n )\n ]!,\n };\n}\n\nexport interface MonsterSpec {\n kind: MonsterKind;\n hp: number;\n attack: number;\n /** Seconds between its hits. */\n attackInterval: number;\n /** Lane widths per second. */\n speed: number;\n boss: boolean;\n}\n\nexport function monsterHp(index: number): number {\n return BASE_HP * Math.pow(HP_GROWTH, Math.max(0, index - 1));\n}\n\nexport function monsterAttack(index: number): number {\n return BASE_ATTACK * Math.pow(ATTACK_GROWTH, Math.max(0, index - 1));\n}\n\n/** The monsters of one wave (the last monster of the last wave is not the boss — it comes alone). */\nexport function waveMonsters(index: number, wave: number): MonsterSpec[] {\n const info = stageInfo(index);\n const out: MonsterSpec[] = [];\n for (let i = 0; i < MONSTERS_PER_WAVE; i++) {\n const kind = info.theme.monsters[(wave + i) % info.theme.monsters.length]!;\n const fast = kind === \"bat\" || kind === \"imp\";\n out.push({\n kind,\n hp: monsterHp(index) * (fast ? 0.7 : 1),\n attack: monsterAttack(index),\n attackInterval: fast ? 0.8 : 1.2,\n speed: fast ? 0.32 : 0.22,\n boss: false,\n });\n }\n return out;\n}\n\nexport function bossMonster(index: number): MonsterSpec {\n const info = stageInfo(index);\n return {\n kind: info.theme.monsters[info.stage % info.theme.monsters.length]!,\n hp: monsterHp(index) * BOSS_HP,\n attack: monsterAttack(index) * BOSS_ATTACK,\n attackInterval: 1.4,\n speed: 0.16,\n boss: true,\n };\n}\n"
149
+ "content": "// Stages: chapters of ten, each stage a few waves and a boss with a timer. Numbered from 1 across\n// chapters (\"3-6\" is stage 26). Monsters grow geometrically — a hero's stats grow linearly with their\n// levels while level prices grow geometrically, so the wall comes on its own; ranks, gear and the\n// next hero push it back.\n\nexport const STAGES_PER_CHAPTER = 10;\nexport const WAVES_PER_STAGE = 4;\nexport const MONSTERS_PER_WAVE = 5;\nexport const BOSS_SECONDS = 30;\n\nconst BASE_HP = 22;\nconst HP_GROWTH = 1.12;\nconst BASE_ATTACK = 3;\nconst ATTACK_GROWTH = 1.105;\nconst BOSS_HP = 9;\nconst BOSS_ATTACK = 2;\n\nexport interface StageInfo {\n /** 1-based, across chapters. */\n index: number;\n chapter: number;\n /** 1..STAGES_PER_CHAPTER. */\n stage: number;\n theme: Theme;\n /** Difficulty word of the chapter loop (\"Normal\", \"Hard\", …) — an i18n key. */\n difficulty: Difficulty;\n}\n\nexport type Difficulty = \"normal\" | \"hard\" | \"veryHard\" | \"hell\" | \"abyss\";\nconst DIFFICULTIES: Difficulty[] = [\n \"normal\",\n \"hard\",\n \"veryHard\",\n \"hell\",\n \"abyss\",\n];\n\nexport interface Theme {\n id: \"meadow\" | \"forest\" | \"desert\" | \"snow\" | \"volcano\";\n sky: [number, number];\n hillFar: number;\n hillNear: number;\n ground: number;\n grass: number;\n /** Monster kinds of the chapter, by art id. */\n monsters: MonsterKind[];\n}\n\nexport type MonsterKind =\n \"mushroom\" | \"bat\" | \"goblin\" | \"skull\" | \"golem\" | \"cactus\" | \"yeti\" | \"imp\";\n\nexport const THEMES: Theme[] = [\n {\n id: \"meadow\",\n sky: [0x7ec8ff, 0xd8f1ff],\n hillFar: 0x8fd17a,\n hillNear: 0x5fb85a,\n ground: 0xc9a46a,\n grass: 0x4fa64a,\n monsters: [\"mushroom\", \"goblin\", \"bat\"],\n },\n {\n id: \"forest\",\n sky: [0x5a9fd6, 0xbfe3d4],\n hillFar: 0x3f8a5a,\n hillNear: 0x2f6e44,\n ground: 0x8a6a45,\n grass: 0x2f7d3a,\n monsters: [\"goblin\", \"bat\", \"skull\"],\n },\n {\n id: \"desert\",\n sky: [0xffb35c, 0xffe7b0],\n hillFar: 0xe6b86a,\n hillNear: 0xd49a4a,\n ground: 0xe8c98a,\n grass: 0xb98f45,\n monsters: [\"cactus\", \"skull\", \"golem\"],\n },\n {\n id: \"snow\",\n sky: [0x8fb8e6, 0xeef6ff],\n hillFar: 0xdbe8f5,\n hillNear: 0xb9cfe6,\n ground: 0xf2f7fc,\n grass: 0x9fc0dd,\n monsters: [\"yeti\", \"bat\", \"golem\"],\n },\n {\n id: \"volcano\",\n sky: [0x3a1f3f, 0xb8473a],\n hillFar: 0x5a2a2a,\n hillNear: 0x3d1d1d,\n ground: 0x4a3030,\n grass: 0xd2552d,\n monsters: [\"imp\", \"skull\", \"golem\"],\n },\n];\n\nexport function stageInfo(index: number): StageInfo {\n const i = Math.max(1, Math.floor(index));\n const chapter = Math.ceil(i / STAGES_PER_CHAPTER);\n return {\n index: i,\n chapter,\n stage: i - (chapter - 1) * STAGES_PER_CHAPTER,\n theme: THEMES[(chapter - 1) % THEMES.length]!,\n difficulty:\n DIFFICULTIES[\n Math.min(\n DIFFICULTIES.length - 1,\n Math.floor((chapter - 1) / THEMES.length),\n )\n ]!,\n };\n}\n\nexport interface MonsterSpec {\n kind: MonsterKind;\n hp: number;\n attack: number;\n /** Seconds between its hits. */\n attackInterval: number;\n /** How fast a melee monster closes the last step to the hero, × the hero's walk (1 — as fast). */\n speed: number;\n /** Lane widths it shoots from; 0 — a melee fighter (it waits for the hero and fights face to face). */\n range: number;\n boss: boolean;\n}\n\n/** The kinds that shoot: a goblin throws spears, a skull casts, a cactus fires spikes, an imp fire. */\nexport const RANGED_KINDS: ReadonlySet<MonsterKind> = new Set<MonsterKind>([\n \"goblin\",\n \"skull\",\n \"cactus\",\n \"imp\",\n]);\n/** How far a ranged monster shoots. */\nexport const MONSTER_RANGE = 0.5;\n\nexport function monsterHp(index: number): number {\n return BASE_HP * Math.pow(HP_GROWTH, Math.max(0, index - 1));\n}\n\nexport function monsterAttack(index: number): number {\n return BASE_ATTACK * Math.pow(ATTACK_GROWTH, Math.max(0, index - 1));\n}\n\n/** The monsters of one wave (the last monster of the last wave is not the boss — it comes alone). */\nexport function waveMonsters(index: number, wave: number): MonsterSpec[] {\n const info = stageInfo(index);\n const out: MonsterSpec[] = [];\n for (let i = 0; i < MONSTERS_PER_WAVE; i++) {\n const kind = info.theme.monsters[(wave + i) % info.theme.monsters.length]!;\n const fast = kind === \"bat\" || kind === \"imp\";\n const ranged = RANGED_KINDS.has(kind);\n out.push({\n kind,\n // a shooter is frailer and hits softer — it hits from afar\n hp: monsterHp(index) * (fast ? 0.7 : 1) * (ranged ? 0.8 : 1),\n attack: monsterAttack(index) * (ranged ? 0.7 : 1),\n attackInterval: fast ? 0.8 : ranged ? 1.6 : 1.2,\n speed: fast ? 1.4 : 1.1,\n range: ranged ? MONSTER_RANGE : 0,\n boss: false,\n });\n }\n return out;\n}\n\nexport function bossMonster(index: number): MonsterSpec {\n const info = stageInfo(index);\n return {\n kind: info.theme.monsters[info.stage % info.theme.monsters.length]!,\n hp: monsterHp(index) * BOSS_HP,\n attack: monsterAttack(index) * BOSS_ATTACK,\n attackInterval: 1.4,\n speed: 0.9,\n // the boss always comes face to face\n range: 0,\n boss: true,\n };\n}\n"
150
+ },
151
+ {
152
+ "path": "heroArt.ts",
153
+ "content": "import type { CharacterArt, CharacterArtSubject } from \"@idosgames/module-sdk\";\nimport {\n CharacterCanvas,\n createCatalog,\n HERO_ANIMATIONS,\n heroAttack,\n heroRig,\n resolveLook,\n type ArtCatalog,\n} from \"./character\";\n\n// The game's hero on the other screens of the app (ctx.characterArt): the Heroes screen draws the\n// same layered hero that fights here — dressed by the same looks (class + worn gear), facing the way\n// it fights. A big view moves (breathes, blinks); a piece of gear put on shows itself — a new weapon\n// is swung once. A list of heroes gets still pictures.\n\nlet catalog: ArtCatalog | null = null;\n\nconst lookOf = (s: CharacterArtSubject) =>\n resolveLook({\n id: s.characterId,\n classId: s.classId ?? \"\",\n equipment: s.equipment,\n });\n\nexport const heroArt: CharacterArt = {\n mount(host, subject, options) {\n catalog ??= createCatalog();\n const live = options?.mode !== \"still\";\n const view = new CharacterCanvas(host, {\n rig: heroRig,\n catalog,\n look: lookOf(subject),\n facing: -1, // the art faces left; the hero fights to the right\n mode: live ? \"live\" : \"still\",\n });\n let key = JSON.stringify(lookOf(subject));\n const weapon = () =>\n view.parts.isWorn(\"Weapon\") ? view.parts.part(\"Weapon\") : null;\n let held = weapon();\n view.on(\"end\", () => view.play(HERO_ANIMATIONS.idle));\n return {\n update(next) {\n const look = lookOf(next);\n const k = JSON.stringify(look);\n if (k === key) return;\n key = k;\n view.setLook(look);\n const now = weapon();\n if (live && now !== held && now)\n view.play(heroAttack(now), { loop: false, mix: 0.1 });\n held = now;\n },\n destroy: () => view.destroy(),\n };\n },\n};\n"
122
154
  },
123
155
  {
124
156
  "path": "i18n.ts",
@@ -126,7 +158,7 @@
126
158
  },
127
159
  {
128
160
  "path": "index.ts",
129
- "content": "// @idosgames/mod-idle-rpg — the Idle RPG game (Phaser + React) for the host shell: a Legend\n// Slime-style auto-battler — waves, bosses with a timer, skills, gold from the server's idle accrual\n// spent on the hero's stats. Heroes, gear, summons, quests and the shop are the platform's systems.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// (a different screen over the same fight, the fight under a different screen) after copying this\n// into src/modules/.\n\nexport { idleRpgModule } from \"./module\";\n\nexport { IdleSession, pickHero, PROGRESS_KEY } from \"./game/session\";\nexport {\n Battle,\n SKILLS,\n type BattleEvent,\n type BattleSnapshot,\n} from \"./game/battle\";\nexport {\n fighterStats,\n resolveStats,\n statCost,\n statCap,\n type FighterStats,\n} from \"./game/heroStats\";\nexport { stageInfo, THEMES } from \"./game/stages\";\nexport { pickIncome, incomePerSecond } from \"./game/economy\";\nexport { formatBig } from \"./game/format\";\nexport { IdleRpgController } from \"./game/IdleRpgController\";\nexport { BattleScene } from \"./phaser/BattleScene\";\nexport { createIdleRpgScene } from \"./scene\";\nexport { makeGamePanel } from \"./ui/GamePanel\";\nexport { characterUpgraded, stageCleared } from \"./events\";\n"
161
+ "content": "// @idosgames/mod-idle-rpg — the Idle RPG game (Phaser + React) for the host shell: a Legend\n// Slime-style auto-battler — waves, bosses with a timer, skills, gold from the server's idle accrual\n// spent on the hero's stats. Heroes, gear, summons, quests and the shop are the platform's systems.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// (a different screen over the same fight, the fight under a different screen) after copying this\n// into src/modules/.\n\nexport { idleRpgModule } from \"./module\";\n\nexport { IdleSession, pickHero, PROGRESS_KEY } from \"./game/session\";\nexport {\n Battle,\n SKILLS,\n type BattleEvent,\n type BattleSnapshot,\n} from \"./game/battle\";\nexport {\n fighterStats,\n resolveStats,\n statCost,\n statCap,\n type FighterStats,\n} from \"./game/heroStats\";\nexport { stageInfo, THEMES } from \"./game/stages\";\nexport { pickIncome, incomePerSecond } from \"./game/economy\";\nexport { formatBig } from \"./game/format\";\nexport { IdleRpgController } from \"./game/IdleRpgController\";\nexport { BattleScene, type BattleSceneOptions } from \"./phaser/BattleScene\";\nexport { createIdleRpgScene } from \"./scene\";\nexport { makeGamePanel } from \"./ui/GamePanel\";\nexport { characterUpgraded, stageCleared } from \"./events\";\n\n// The character system (layered 2D characters: skeleton, parts, colours, clips) — see character/index.ts.\nexport * from \"./character\";\n"
130
162
  },
131
163
  {
132
164
  "path": "layout.ts",
@@ -134,15 +166,35 @@
134
166
  },
135
167
  {
136
168
  "path": "module.ts",
137
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { IdleSession } from \"./game/session\";\nimport { SKILLS, type SkillId } from \"./game/battle\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeGamePanel } from \"./ui/GamePanel\";\nimport { characterUpgraded, heroSelected, stageCleared } from \"./events\";\nimport { t } from \"./i18n\";\n\n// The Idle RPG game (Legend Slime-style): the hero fights waves and bosses on its own, gold flows in\n// from the server's idle accrual, and the player spends it on the hero's stats. Heroes, gear, summons,\n// quests and the shop are the platform's systems (module.meta.json → uses), opened from the game's\n// bottom bar — the game draws only the fight and Enhance.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const session = new IdleSession(ctx.client, {\n onStageCleared: (chapter, stage) =>\n ctx.events.emit(stageCleared, { chapter, stage }),\n // A hero's rank went up anywhere (the Heroes screen) — the signal other modules know this game by.\n onRankUp: (characterId, level) =>\n ctx.events.emit(characterUpgraded, { characterId, level }),\n });\n const box = createControllerBox<IdleRpgController>();\n\n // The hero the player picked on the Heroes screen fights. Subscribed here, in setup(): the Heroes\n // system announces its saved choice as soon as its watcher mounts, which is after every setup().\n ctx.events.on(heroSelected, ({ characterId }) =>\n session.selectHero(characterId),\n );\n\n ctx.registerScene(createIdleRpgScene(box, session));\n ctx.registerPanel({\n id: \"game\",\n slot: \"overlay\",\n component: makeGamePanel(\n session,\n ctx.features,\n ctx.sharedUi.shouldDraw(\"menu\"),\n ),\n });\n // Lives inside the lobby: its balances and tabs around the fight, the app starts here.\n ctx.registerRoute({\n id: \"idle-rpg\",\n label: t(\"title\"),\n icon: \"⚔️\",\n inLobby: true,\n });\n\n // The fight is drawn on a canvas — the agent cannot read it from the DOM, so it is published\n // here: the stage, the hero and the enemies, plus the actions the player's own buttons take.\n ctx.exposeToAgent({\n state: () => {\n const snap = session.battle.snapshot();\n const hero = session.hero;\n return {\n mounted: box.get() !== null,\n playing: box.get()?.running ?? false,\n stage: snap.stage,\n best: snap.best,\n mode: snap.mode,\n wave: `${snap.wave + 1}/${snap.waves}`,\n bossTimeLeft: Math.round(snap.bossTimeLeft),\n autoBoss: snap.autoBoss,\n autoSkills: snap.autoSkills,\n hero: hero\n ? {\n id: hero.id,\n rank: hero.rank,\n power: hero.power,\n hp: Math.round(snap.heroHp),\n maxHp: Math.round(snap.heroMaxHp),\n down: snap.heroDown,\n fighter: hero.fighter,\n stats: hero.stats.map((s) => ({\n id: s.id,\n level: hero.input.model?.StatLevels?.[s.id] ?? 0,\n })),\n }\n : null,\n monsters: session.battle.monsters.map((m) => ({\n kind: m.spec.kind,\n boss: m.spec.boss,\n hp: Math.round(m.hp),\n x: Math.round(m.x * 100) / 100,\n })),\n skills: snap.skills,\n goldPerSecond: Math.round(session.ratePerSecond() * 10) / 10,\n income: session.income,\n };\n },\n actions: {\n enhance: async (args) => {\n const statID = String(args?.[\"stat\"] ?? \"\");\n const hero = session.hero;\n if (!hero?.stats.some((s) => s.id === statID))\n throw new Error(\n `unknown stat \"${statID}\" — one of: ${hero?.stats.map((s) => s.id).join(\", \") ?? \"(no hero)\"}`,\n );\n const levels = Math.min(\n 100,\n Math.max(1, Number(args?.[\"levels\"] ?? 1)),\n );\n session.enhance(statID, levels);\n },\n challengeBoss: async () => {\n if (!session.battle.challengeBoss())\n throw new Error(\n \"not now — a boss fight is on, or the hero is down\",\n );\n },\n cast: async (args) => {\n const id = String(args?.[\"skill\"] ?? \"\") as SkillId;\n if (!SKILLS.some((s) => s.id === id))\n throw new Error(\n `unknown skill — one of: ${SKILLS.map((s) => s.id).join(\", \")}`,\n );\n if (!session.battle.castSkill(id))\n throw new Error(\"locked, cooling down, or the hero is down\");\n },\n setAuto: async (args) => {\n session.battle.setAutoSkills(Boolean(args?.[\"on\"]));\n },\n },\n describeActions: {\n enhance:\n \"Buy stat levels of the fighting hero for gold: { stat: StatID, levels?: 1..100 } (limited by gold and the cap).\",\n challengeBoss:\n \"Fight the current stage's boss now (the 'Boss!' button).\",\n cast: \"Cast a skill: { skill: meteor | frenzy | heal }.\",\n setAuto: \"Auto-cast skills on or off: { on: boolean }.\",\n },\n });\n },\n});\n"
169
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { IdleSession } from \"./game/session\";\nimport { SKILLS, type SkillId } from \"./game/battle\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeGamePanel } from \"./ui/GamePanel\";\nimport { characterUpgraded, heroSelected, stageCleared } from \"./events\";\nimport { t } from \"./i18n\";\nimport { heroArt } from \"./heroArt\";\n\n// The Idle RPG game (Legend Slime-style): the hero fights waves and bosses on its own, gold flows in\n// from the server's idle accrual, and the player spends it on the hero's stats. Heroes, gear, summons,\n// quests and the shop are the platform's systems (module.meta.json → uses), opened from the game's\n// bottom bar — the game draws only the fight and Enhance.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const session = new IdleSession(ctx.client, {\n onStageCleared: (chapter, stage) =>\n ctx.events.emit(stageCleared, { chapter, stage }),\n // A hero's rank went up anywhere (the Heroes screen) — the signal other modules know this game by.\n onRankUp: (characterId, level) =>\n ctx.events.emit(characterUpgraded, { characterId, level }),\n });\n const box = createControllerBox<IdleRpgController>();\n\n // The hero the player picked on the Heroes screen fights. Subscribed here, in setup(): the Heroes\n // system announces its saved choice as soon as its watcher mounts, which is after every setup().\n ctx.events.on(heroSelected, ({ characterId }) =>\n session.selectHero(characterId),\n );\n\n // The Heroes screen (and any other) draws this game's hero, not a picture of its own.\n ctx.characterArt.set(heroArt);\n\n ctx.registerScene(createIdleRpgScene(box, session));\n ctx.registerPanel({\n id: \"game\",\n slot: \"overlay\",\n component: makeGamePanel(\n session,\n ctx.features,\n ctx.sharedUi.shouldDraw(\"menu\"),\n ),\n });\n // Lives inside the lobby: its balances and tabs around the fight, the app starts here.\n ctx.registerRoute({\n id: \"idle-rpg\",\n label: t(\"title\"),\n icon: \"⚔️\",\n inLobby: true,\n });\n\n // The fight is drawn on a canvas — the agent cannot read it from the DOM, so it is published\n // here: the stage, the hero and the enemies, plus the actions the player's own buttons take.\n ctx.exposeToAgent({\n state: () => {\n const snap = session.battle.snapshot();\n const hero = session.hero;\n return {\n mounted: box.get() !== null,\n playing: box.get()?.running ?? false,\n stage: snap.stage,\n best: snap.best,\n mode: snap.mode,\n wave: `${snap.wave + 1}/${snap.waves}`,\n bossTimeLeft: Math.round(snap.bossTimeLeft),\n autoBoss: snap.autoBoss,\n autoSkills: snap.autoSkills,\n hero: hero\n ? {\n id: hero.id,\n rank: hero.rank,\n power: hero.power,\n hp: Math.round(snap.heroHp),\n maxHp: Math.round(snap.heroMaxHp),\n down: snap.heroDown,\n fighter: hero.fighter,\n stats: hero.stats.map((s) => ({\n id: s.id,\n level: hero.input.model?.StatLevels?.[s.id] ?? 0,\n })),\n }\n : null,\n monsters: session.battle.monsters.map((m) => ({\n kind: m.spec.kind,\n boss: m.spec.boss,\n hp: Math.round(m.hp),\n armor: Math.round(m.armor),\n ranged: m.spec.range > 0,\n // how far ahead of the hero it stands on the road (lane widths)\n ahead: Math.round((m.x - session.battle.heroX) * 100) / 100,\n })),\n skills: snap.skills,\n goldPerSecond: Math.round(session.ratePerSecond() * 10) / 10,\n income: session.income,\n };\n },\n actions: {\n enhance: async (args) => {\n const statID = String(args?.[\"stat\"] ?? \"\");\n const hero = session.hero;\n if (!hero?.stats.some((s) => s.id === statID))\n throw new Error(\n `unknown stat \"${statID}\" — one of: ${hero?.stats.map((s) => s.id).join(\", \") ?? \"(no hero)\"}`,\n );\n const levels = Math.min(\n 100,\n Math.max(1, Number(args?.[\"levels\"] ?? 1)),\n );\n session.enhance(statID, levels);\n },\n challengeBoss: async () => {\n if (!session.battle.challengeBoss())\n throw new Error(\n \"not now — a boss fight is on, or the hero is down\",\n );\n },\n cast: async (args) => {\n const id = String(args?.[\"skill\"] ?? \"\") as SkillId;\n if (!SKILLS.some((s) => s.id === id))\n throw new Error(\n `unknown skill — one of: ${SKILLS.map((s) => s.id).join(\", \")}`,\n );\n if (!session.battle.castSkill(id))\n throw new Error(\"locked, cooling down, or the hero is down\");\n },\n setAuto: async (args) => {\n session.battle.setAutoSkills(Boolean(args?.[\"on\"]));\n },\n },\n describeActions: {\n enhance:\n \"Buy stat levels of the fighting hero for gold: { stat: StatID, levels?: 1..100 } (limited by gold and the cap).\",\n challengeBoss:\n \"Fight the current stage's boss now (the 'Boss!' button).\",\n cast: \"Cast a skill: { skill: meteor | frenzy | heal }.\",\n setAuto: \"Auto-cast skills on or off: { on: boolean }.\",\n },\n });\n },\n});\n"
138
170
  },
139
171
  {
140
172
  "path": "phaser/art.ts",
141
- "content": "import Phaser from \"phaser\";\nimport type { MonsterKind } from \"../game/stages\";\n\n// All the art is drawn here in code and baked into textures once (Graphics → generateTexture): the\n// module ships no image files, so it builds offline and runs in the preview as is. Cute and chunky,\n// thick dark outlines — the Legend Slime look.\n\nconst OUTLINE = 0x2b1d33;\n\nexport const HERO_SIZE = { w: 120, h: 112 };\nexport const MONSTER_SIZE = 96;\n\ntype G = Phaser.GameObjects.Graphics;\n\nfunction bake(\n scene: Phaser.Scene,\n key: string,\n w: number,\n h: number,\n draw: (g: G) => void,\n): string {\n if (scene.textures.exists(key)) return key;\n const g = scene.make.graphics({ x: 0, y: 0 }, false);\n draw(g);\n g.generateTexture(key, w, h);\n g.destroy();\n return key;\n}\n\n/** A darker / lighter shade of a colour. */\nexport function shade(color: number, k: number): number {\n const c = Phaser.Display.Color.IntegerToColor(color);\n const f = (v: number) =>\n Math.max(\n 0,\n Math.min(255, Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k)),\n );\n return Phaser.Display.Color.GetColor(f(c.red), f(c.green), f(c.blue));\n}\n\nfunction eyes(\n g: G,\n cx: number,\n cy: number,\n gap: number,\n r: number,\n angry = false,\n): void {\n for (const dx of [-gap, gap]) {\n g.fillStyle(0xffffff, 1);\n g.fillCircle(cx + dx, cy, r);\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(cx + dx + r * 0.2, cy + r * 0.15, r * 0.55);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(cx + dx + r * 0.4, cy - r * 0.2, r * 0.2);\n }\n if (angry) {\n g.lineStyle(3, OUTLINE, 1);\n g.lineBetween(cx - gap - r, cy - r * 1.4, cx - gap + r, cy - r * 0.8);\n g.lineBetween(cx + gap + r, cy - r * 1.4, cx + gap - r, cy - r * 0.8);\n }\n}\n\n// ── The hero: a slime with the hat of its class ──────────────────────────────────────────────\n\nexport const CLASS_COLORS: Record<string, number> = {\n Warrior: 0x4fa8ff,\n Ranger: 0x5fd36a,\n Mage: 0xb36bff,\n};\n\nexport function heroColor(classID: string, heroID: string): number {\n const known = CLASS_COLORS[classID];\n if (known) return known;\n let h = 0;\n for (const ch of heroID) h = (h * 31 + ch.charCodeAt(0)) >>> 0;\n return Phaser.Display.Color.HSVToRGB((h % 360) / 360, 0.55, 0.95).color;\n}\n\nexport function heroTexture(\n scene: Phaser.Scene,\n classID: string,\n heroID: string,\n): string {\n const color = heroColor(classID, heroID);\n const hat =\n classID === \"Mage\"\n ? \"wizard\"\n : classID === \"Ranger\"\n ? \"hood\"\n : classID === \"Warrior\"\n ? \"helmet\"\n : \"none\";\n const { w, h } = HERO_SIZE;\n return bake(scene, `hero-${color.toString(16)}-${hat}`, w, h, (g) => {\n const cx = w / 2;\n const base = h - 6;\n // Shadow-outline, body, belly highlight, shine.\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(cx, base - 30, 96, 66);\n g.fillStyle(shade(color, -0.25), 1);\n g.fillEllipse(cx, base - 30, 90, 60);\n g.fillStyle(color, 1);\n g.fillEllipse(cx, base - 33, 86, 52);\n g.fillStyle(shade(color, 0.45), 1);\n g.fillEllipse(cx - 18, base - 46, 26, 14);\n g.fillStyle(0xffffff, 0.9);\n g.fillCircle(cx - 24, base - 49, 4);\n eyes(g, cx + 6, base - 34, 13, 7);\n g.fillStyle(0xff7a9a, 0.6);\n g.fillEllipse(cx - 14, base - 22, 10, 5);\n g.fillEllipse(cx + 28, base - 22, 10, 5);\n g.lineStyle(3, OUTLINE, 1);\n g.beginPath();\n g.arc(cx + 7, base - 24, 5, 0.2, Math.PI - 0.2);\n g.strokePath();\n // Hat.\n if (hat === \"wizard\") {\n g.fillStyle(OUTLINE, 1);\n g.fillTriangle(\n cx - 30,\n base - 52,\n cx + 30,\n base - 52,\n cx + 8,\n base - 108,\n );\n g.fillStyle(0x6a3fd0, 1);\n g.fillTriangle(\n cx - 26,\n base - 55,\n cx + 26,\n base - 55,\n cx + 8,\n base - 103,\n );\n g.fillStyle(0xffd34d, 1);\n g.fillCircle(cx + 4, base - 72, 5);\n g.fillStyle(0x6a3fd0, 1);\n g.fillEllipse(cx, base - 54, 70, 12);\n } else if (hat === \"hood\") {\n g.fillStyle(OUTLINE, 1);\n g.fillTriangle(\n cx - 34,\n base - 46,\n cx + 34,\n base - 46,\n cx - 20,\n base - 92,\n );\n g.fillStyle(0x2f8a3a, 1);\n g.fillTriangle(\n cx - 30,\n base - 49,\n cx + 30,\n base - 49,\n cx - 18,\n base - 87,\n );\n g.fillStyle(0xd94040, 1);\n g.fillTriangle(cx - 14, base - 74, cx - 4, base - 66, cx + 8, base - 92);\n } else if (hat === \"helmet\") {\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(cx, base - 56, 70, 40);\n g.fillStyle(0xb8c2d1, 1);\n g.fillEllipse(cx, base - 57, 64, 34);\n g.fillStyle(0xe6ecf5, 1);\n g.fillEllipse(cx - 10, base - 64, 22, 10);\n g.fillStyle(0xd94040, 1);\n g.fillRect(cx - 3, base - 90, 6, 18);\n g.fillCircle(cx, base - 90, 6);\n }\n });\n}\n\n// ── Monsters ─────────────────────────────────────────────────────────────────────────────────\n\nexport function monsterTexture(scene: Phaser.Scene, kind: MonsterKind): string {\n const s = MONSTER_SIZE;\n return bake(scene, `monster-${kind}`, s, s, (g) => {\n const cx = s / 2;\n const base = s - 6;\n switch (kind) {\n case \"mushroom\":\n g.fillStyle(OUTLINE, 1);\n g.fillRoundedRect(cx - 18, base - 38, 36, 38, 12);\n g.fillStyle(0xf3dfbf, 1);\n g.fillRoundedRect(cx - 15, base - 36, 30, 34, 10);\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(cx, base - 46, 78, 50);\n g.fillStyle(0xe2453c, 1);\n g.fillEllipse(cx, base - 47, 72, 44);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(cx - 18, base - 54, 7);\n g.fillCircle(cx + 14, base - 60, 5);\n g.fillCircle(cx + 22, base - 44, 4);\n eyes(g, cx - 4, base - 22, 8, 5, true);\n break;\n case \"bat\":\n g.fillStyle(OUTLINE, 1);\n g.fillTriangle(\n cx - 46,\n base - 58,\n cx - 8,\n base - 50,\n cx - 14,\n base - 30,\n );\n g.fillTriangle(\n cx + 46,\n base - 58,\n cx + 8,\n base - 50,\n cx + 14,\n base - 30,\n );\n g.fillStyle(0x6c3f8f, 1);\n g.fillTriangle(\n cx - 41,\n base - 56,\n cx - 10,\n base - 49,\n cx - 15,\n base - 34,\n );\n g.fillTriangle(\n cx + 41,\n base - 56,\n cx + 10,\n base - 49,\n cx + 15,\n base - 34,\n );\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(cx, base - 44, 22);\n g.fillStyle(0x8e5bb8, 1);\n g.fillCircle(cx, base - 44, 19);\n eyes(g, cx - 2, base - 46, 8, 5, true);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(cx - 6, base - 32, cx - 2, base - 32, cx - 4, base - 26);\n g.fillTriangle(cx + 2, base - 32, cx + 6, base - 32, cx + 4, base - 26);\n break;\n case \"goblin\":\n g.fillStyle(OUTLINE, 1);\n g.fillTriangle(\n cx - 44,\n base - 52,\n cx - 18,\n base - 44,\n cx - 22,\n base - 30,\n );\n g.fillTriangle(\n cx + 44,\n base - 52,\n cx + 18,\n base - 44,\n cx + 22,\n base - 30,\n );\n g.fillEllipse(cx, base - 30, 64, 58);\n g.fillStyle(0x6cc04a, 1);\n g.fillTriangle(\n cx - 40,\n base - 50,\n cx - 19,\n base - 43,\n cx - 22,\n base - 33,\n );\n g.fillTriangle(\n cx + 40,\n base - 50,\n cx + 19,\n base - 43,\n cx + 22,\n base - 33,\n );\n g.fillEllipse(cx, base - 30, 58, 52);\n g.fillStyle(shade(0x6cc04a, 0.35), 1);\n g.fillEllipse(cx - 10, base - 44, 18, 9);\n eyes(g, cx - 3, base - 34, 10, 6, true);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(cx - 2, base - 18, cx + 6, base - 18, cx + 2, base - 11);\n break;\n case \"skull\":\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(cx, base - 40, 30);\n g.fillRoundedRect(cx - 18, base - 22, 36, 22, 6);\n g.fillStyle(0xf1ece0, 1);\n g.fillCircle(cx, base - 40, 26);\n g.fillRoundedRect(cx - 15, base - 22, 30, 19, 5);\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(cx - 11, base - 40, 14, 16);\n g.fillEllipse(cx + 11, base - 40, 14, 16);\n g.fillTriangle(cx - 4, base - 26, cx + 4, base - 26, cx, base - 32);\n g.fillStyle(0xff4d4d, 1);\n g.fillCircle(cx - 11, base - 40, 3);\n g.fillCircle(cx + 11, base - 40, 3);\n break;\n case \"golem\":\n g.fillStyle(OUTLINE, 1);\n g.fillRoundedRect(cx - 36, base - 66, 72, 66, 18);\n g.fillStyle(0x8a8f9c, 1);\n g.fillRoundedRect(cx - 32, base - 62, 64, 60, 15);\n g.fillStyle(shade(0x8a8f9c, 0.3), 1);\n g.fillRoundedRect(cx - 26, base - 58, 26, 14, 6);\n g.lineStyle(3, shade(0x8a8f9c, -0.4), 1);\n g.lineBetween(cx + 6, base - 60, cx + 14, base - 46);\n g.lineBetween(cx + 14, base - 46, cx + 8, base - 34);\n g.fillStyle(0x7fe0ff, 1);\n g.fillRect(cx - 18, base - 38, 12, 6);\n g.fillRect(cx + 4, base - 38, 12, 6);\n break;\n case \"cactus\":\n g.fillStyle(OUTLINE, 1);\n g.fillRoundedRect(cx - 18, base - 70, 36, 70, 18);\n g.fillRoundedRect(cx - 40, base - 52, 20, 30, 10);\n g.fillRoundedRect(cx + 20, base - 60, 20, 30, 10);\n g.fillStyle(0x47a85a, 1);\n g.fillRoundedRect(cx - 15, base - 67, 30, 64, 15);\n g.fillRoundedRect(cx - 37, base - 49, 14, 24, 7);\n g.fillRoundedRect(cx + 23, base - 57, 14, 24, 7);\n g.fillStyle(0xff7aa8, 1);\n g.fillCircle(cx + 6, base - 68, 6);\n eyes(g, cx, base - 44, 7, 5, true);\n break;\n case \"yeti\":\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(cx, base - 34, 76, 66);\n g.fillStyle(0xf4f8ff, 1);\n g.fillEllipse(cx, base - 34, 70, 60);\n g.fillStyle(0x7fa8d6, 1);\n g.fillEllipse(cx, base - 32, 40, 30);\n eyes(g, cx, base - 36, 9, 5, true);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(cx - 8, base - 22, cx - 2, base - 22, cx - 5, base - 15);\n g.fillTriangle(cx + 2, base - 22, cx + 8, base - 22, cx + 5, base - 15);\n break;\n case \"imp\":\n default:\n g.fillStyle(OUTLINE, 1);\n g.fillTriangle(\n cx - 26,\n base - 50,\n cx - 10,\n base - 58,\n cx - 28,\n base - 78,\n );\n g.fillTriangle(\n cx + 26,\n base - 50,\n cx + 10,\n base - 58,\n cx + 28,\n base - 78,\n );\n g.fillCircle(cx, base - 34, 30);\n g.fillStyle(0xe8483a, 1);\n g.fillCircle(cx, base - 34, 26);\n g.fillStyle(0xffd34d, 1);\n g.fillTriangle(\n cx - 23,\n base - 53,\n cx - 12,\n base - 57,\n cx - 25,\n base - 73,\n );\n g.fillTriangle(\n cx + 23,\n base - 53,\n cx + 12,\n base - 57,\n cx + 25,\n base - 73,\n );\n eyes(g, cx, base - 36, 10, 6, true);\n g.lineStyle(3, OUTLINE, 1);\n g.lineBetween(cx - 8, base - 20, cx + 8, base - 20);\n break;\n }\n });\n}\n\n// ── Small things ─────────────────────────────────────────────────────────────────────────────\n\nexport function bakeCommon(scene: Phaser.Scene): void {\n bake(scene, \"coin\", 26, 26, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(13, 13, 12);\n g.fillStyle(0xf2a516, 1);\n g.fillCircle(13, 13, 10);\n g.fillStyle(0xffd34d, 1);\n g.fillCircle(13, 13, 7);\n g.fillStyle(0xfff3b0, 1);\n g.fillCircle(10, 10, 2.5);\n });\n bake(scene, \"spark\", 16, 16, (g) => {\n g.fillStyle(0xffffff, 0.35);\n g.fillCircle(8, 8, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(8, 8, 4);\n });\n bake(scene, \"puff\", 32, 32, (g) => {\n g.fillStyle(0xffffff, 0.9);\n g.fillCircle(16, 16, 14);\n });\n bake(scene, \"cloud\", 170, 70, (g) => {\n g.fillStyle(0xffffff, 0.95);\n g.fillCircle(40, 44, 24);\n g.fillCircle(72, 32, 30);\n g.fillCircle(108, 38, 26);\n g.fillCircle(136, 48, 20);\n g.fillRoundedRect(30, 44, 120, 24, 12);\n });\n bake(scene, \"meteor\", 48, 48, (g) => {\n g.fillStyle(0xff7a1a, 0.4);\n g.fillCircle(24, 24, 22);\n g.fillStyle(0xffb03a, 1);\n g.fillCircle(24, 24, 14);\n g.fillStyle(0xfff0a0, 1);\n g.fillCircle(20, 20, 6);\n });\n}\n\n/** The hero's shot: an orb in the hero's colour. */\nexport function shotTexture(scene: Phaser.Scene, color: number): string {\n return bake(scene, `shot-${color.toString(16)}`, 28, 28, (g) => {\n g.fillStyle(color, 0.35);\n g.fillCircle(14, 14, 13);\n g.fillStyle(shade(color, 0.3), 1);\n g.fillCircle(14, 14, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(12, 12, 3.5);\n });\n}\n"
173
+ "content": "import Phaser from \"phaser\";\nimport type { MonsterKind } from \"../game/stages\";\n\n// The fight's art is drawn here in code and baked into textures once (Graphics → generateTexture): the\n// module ships no image files, so it builds offline and runs in the preview as is. Cute and chunky,\n// thick dark outlines. The hero is not here — it is the character system's (../character, hero.ts).\n\nconst OUTLINE = 0x2b1d33;\n\nexport const MONSTER_SIZE = 96;\n\ntype G = Phaser.GameObjects.Graphics;\n\nfunction bake(\n scene: Phaser.Scene,\n key: string,\n w: number,\n h: number,\n draw: (g: G) => void,\n): string {\n if (scene.textures.exists(key)) return key;\n const g = scene.make.graphics({ x: 0, y: 0 }, false);\n draw(g);\n g.generateTexture(key, w, h);\n g.destroy();\n return key;\n}\n\n/** A darker / lighter shade of a colour. */\nexport function shade(color: number, k: number): number {\n const c = Phaser.Display.Color.IntegerToColor(color);\n const f = (v: number) =>\n Math.max(\n 0,\n Math.min(255, Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k)),\n );\n return Phaser.Display.Color.GetColor(f(c.red), f(c.green), f(c.blue));\n}\n\nfunction eyes(\n g: G,\n cx: number,\n cy: number,\n gap: number,\n r: number,\n angry = false,\n): void {\n for (const dx of [-gap, gap]) {\n g.fillStyle(0xffffff, 1);\n g.fillCircle(cx + dx, cy, r);\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(cx + dx + r * 0.2, cy + r * 0.15, r * 0.55);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(cx + dx + r * 0.4, cy - r * 0.2, r * 0.2);\n }\n if (angry) {\n g.lineStyle(3, OUTLINE, 1);\n g.lineBetween(cx - gap - r, cy - r * 1.4, cx - gap + r, cy - r * 0.8);\n g.lineBetween(cx + gap + r, cy - r * 1.4, cx + gap - r, cy - r * 0.8);\n }\n}\n\n// ── The hero's colour (its shots) ─────────────────────────────────────────────────────────────\n\nexport const CLASS_COLORS: Record<string, number> = {\n Warrior: 0x4fa8ff,\n Ranger: 0x5fd36a,\n Mage: 0xb36bff,\n};\n\nexport function heroColor(classID: string, heroID: string): number {\n const known = CLASS_COLORS[classID];\n if (known) return known;\n let h = 0;\n for (const ch of heroID) h = (h * 31 + ch.charCodeAt(0)) >>> 0;\n return Phaser.Display.Color.HSVToRGB((h % 360) / 360, 0.55, 0.95).color;\n}\n\n// ── Monsters ─────────────────────────────────────────────────────────────────────────────────\n\nexport function monsterTexture(scene: Phaser.Scene, kind: MonsterKind): string {\n const s = MONSTER_SIZE;\n return bake(scene, `monster-${kind}`, s, s, (g) => {\n const cx = s / 2;\n const base = s - 6;\n switch (kind) {\n case \"mushroom\":\n g.fillStyle(OUTLINE, 1);\n g.fillRoundedRect(cx - 18, base - 38, 36, 38, 12);\n g.fillStyle(0xf3dfbf, 1);\n g.fillRoundedRect(cx - 15, base - 36, 30, 34, 10);\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(cx, base - 46, 78, 50);\n g.fillStyle(0xe2453c, 1);\n g.fillEllipse(cx, base - 47, 72, 44);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(cx - 18, base - 54, 7);\n g.fillCircle(cx + 14, base - 60, 5);\n g.fillCircle(cx + 22, base - 44, 4);\n eyes(g, cx - 4, base - 22, 8, 5, true);\n break;\n case \"bat\":\n g.fillStyle(OUTLINE, 1);\n g.fillTriangle(\n cx - 46,\n base - 58,\n cx - 8,\n base - 50,\n cx - 14,\n base - 30,\n );\n g.fillTriangle(\n cx + 46,\n base - 58,\n cx + 8,\n base - 50,\n cx + 14,\n base - 30,\n );\n g.fillStyle(0x6c3f8f, 1);\n g.fillTriangle(\n cx - 41,\n base - 56,\n cx - 10,\n base - 49,\n cx - 15,\n base - 34,\n );\n g.fillTriangle(\n cx + 41,\n base - 56,\n cx + 10,\n base - 49,\n cx + 15,\n base - 34,\n );\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(cx, base - 44, 22);\n g.fillStyle(0x8e5bb8, 1);\n g.fillCircle(cx, base - 44, 19);\n eyes(g, cx - 2, base - 46, 8, 5, true);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(cx - 6, base - 32, cx - 2, base - 32, cx - 4, base - 26);\n g.fillTriangle(cx + 2, base - 32, cx + 6, base - 32, cx + 4, base - 26);\n break;\n case \"goblin\":\n g.fillStyle(OUTLINE, 1);\n g.fillTriangle(\n cx - 44,\n base - 52,\n cx - 18,\n base - 44,\n cx - 22,\n base - 30,\n );\n g.fillTriangle(\n cx + 44,\n base - 52,\n cx + 18,\n base - 44,\n cx + 22,\n base - 30,\n );\n g.fillEllipse(cx, base - 30, 64, 58);\n g.fillStyle(0x6cc04a, 1);\n g.fillTriangle(\n cx - 40,\n base - 50,\n cx - 19,\n base - 43,\n cx - 22,\n base - 33,\n );\n g.fillTriangle(\n cx + 40,\n base - 50,\n cx + 19,\n base - 43,\n cx + 22,\n base - 33,\n );\n g.fillEllipse(cx, base - 30, 58, 52);\n g.fillStyle(shade(0x6cc04a, 0.35), 1);\n g.fillEllipse(cx - 10, base - 44, 18, 9);\n eyes(g, cx - 3, base - 34, 10, 6, true);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(cx - 2, base - 18, cx + 6, base - 18, cx + 2, base - 11);\n break;\n case \"skull\":\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(cx, base - 40, 30);\n g.fillRoundedRect(cx - 18, base - 22, 36, 22, 6);\n g.fillStyle(0xf1ece0, 1);\n g.fillCircle(cx, base - 40, 26);\n g.fillRoundedRect(cx - 15, base - 22, 30, 19, 5);\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(cx - 11, base - 40, 14, 16);\n g.fillEllipse(cx + 11, base - 40, 14, 16);\n g.fillTriangle(cx - 4, base - 26, cx + 4, base - 26, cx, base - 32);\n g.fillStyle(0xff4d4d, 1);\n g.fillCircle(cx - 11, base - 40, 3);\n g.fillCircle(cx + 11, base - 40, 3);\n break;\n case \"golem\":\n g.fillStyle(OUTLINE, 1);\n g.fillRoundedRect(cx - 36, base - 66, 72, 66, 18);\n g.fillStyle(0x8a8f9c, 1);\n g.fillRoundedRect(cx - 32, base - 62, 64, 60, 15);\n g.fillStyle(shade(0x8a8f9c, 0.3), 1);\n g.fillRoundedRect(cx - 26, base - 58, 26, 14, 6);\n g.lineStyle(3, shade(0x8a8f9c, -0.4), 1);\n g.lineBetween(cx + 6, base - 60, cx + 14, base - 46);\n g.lineBetween(cx + 14, base - 46, cx + 8, base - 34);\n g.fillStyle(0x7fe0ff, 1);\n g.fillRect(cx - 18, base - 38, 12, 6);\n g.fillRect(cx + 4, base - 38, 12, 6);\n break;\n case \"cactus\":\n g.fillStyle(OUTLINE, 1);\n g.fillRoundedRect(cx - 18, base - 70, 36, 70, 18);\n g.fillRoundedRect(cx - 40, base - 52, 20, 30, 10);\n g.fillRoundedRect(cx + 20, base - 60, 20, 30, 10);\n g.fillStyle(0x47a85a, 1);\n g.fillRoundedRect(cx - 15, base - 67, 30, 64, 15);\n g.fillRoundedRect(cx - 37, base - 49, 14, 24, 7);\n g.fillRoundedRect(cx + 23, base - 57, 14, 24, 7);\n g.fillStyle(0xff7aa8, 1);\n g.fillCircle(cx + 6, base - 68, 6);\n eyes(g, cx, base - 44, 7, 5, true);\n break;\n case \"yeti\":\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(cx, base - 34, 76, 66);\n g.fillStyle(0xf4f8ff, 1);\n g.fillEllipse(cx, base - 34, 70, 60);\n g.fillStyle(0x7fa8d6, 1);\n g.fillEllipse(cx, base - 32, 40, 30);\n eyes(g, cx, base - 36, 9, 5, true);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(cx - 8, base - 22, cx - 2, base - 22, cx - 5, base - 15);\n g.fillTriangle(cx + 2, base - 22, cx + 8, base - 22, cx + 5, base - 15);\n break;\n case \"imp\":\n default:\n g.fillStyle(OUTLINE, 1);\n g.fillTriangle(\n cx - 26,\n base - 50,\n cx - 10,\n base - 58,\n cx - 28,\n base - 78,\n );\n g.fillTriangle(\n cx + 26,\n base - 50,\n cx + 10,\n base - 58,\n cx + 28,\n base - 78,\n );\n g.fillCircle(cx, base - 34, 30);\n g.fillStyle(0xe8483a, 1);\n g.fillCircle(cx, base - 34, 26);\n g.fillStyle(0xffd34d, 1);\n g.fillTriangle(\n cx - 23,\n base - 53,\n cx - 12,\n base - 57,\n cx - 25,\n base - 73,\n );\n g.fillTriangle(\n cx + 23,\n base - 53,\n cx + 12,\n base - 57,\n cx + 25,\n base - 73,\n );\n eyes(g, cx, base - 36, 10, 6, true);\n g.lineStyle(3, OUTLINE, 1);\n g.lineBetween(cx - 8, base - 20, cx + 8, base - 20);\n break;\n }\n });\n}\n\n// ── Small things ─────────────────────────────────────────────────────────────────────────────\n\nexport function bakeCommon(scene: Phaser.Scene): void {\n bake(scene, \"coin\", 26, 26, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(13, 13, 12);\n g.fillStyle(0xf2a516, 1);\n g.fillCircle(13, 13, 10);\n g.fillStyle(0xffd34d, 1);\n g.fillCircle(13, 13, 7);\n g.fillStyle(0xfff3b0, 1);\n g.fillCircle(10, 10, 2.5);\n });\n bake(scene, \"spark\", 16, 16, (g) => {\n g.fillStyle(0xffffff, 0.35);\n g.fillCircle(8, 8, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(8, 8, 4);\n });\n bake(scene, \"puff\", 32, 32, (g) => {\n g.fillStyle(0xffffff, 0.9);\n g.fillCircle(16, 16, 14);\n });\n bake(scene, \"cloud\", 170, 70, (g) => {\n g.fillStyle(0xffffff, 0.95);\n g.fillCircle(40, 44, 24);\n g.fillCircle(72, 32, 30);\n g.fillCircle(108, 38, 26);\n g.fillCircle(136, 48, 20);\n g.fillRoundedRect(30, 44, 120, 24, 12);\n });\n bake(scene, \"meteor\", 48, 48, (g) => {\n g.fillStyle(0xff7a1a, 0.4);\n g.fillCircle(24, 24, 22);\n g.fillStyle(0xffb03a, 1);\n g.fillCircle(24, 24, 14);\n g.fillStyle(0xfff0a0, 1);\n g.fillCircle(20, 20, 6);\n });\n}\n\n/** The hero's shot: an orb in the hero's colour. */\n/** A soft round shadow (drawn squashed under a character's feet). */\nexport function shadowTexture(scene: Phaser.Scene): string {\n const key = \"idle-shadow\";\n if (scene.textures.exists(key)) return key;\n const tex = scene.textures.createCanvas(key, 128, 128)!;\n const ctx = tex.getContext();\n const g = ctx.createRadialGradient(64, 64, 6, 64, 64, 62);\n g.addColorStop(0, \"rgba(20, 12, 24, .7)\");\n g.addColorStop(0.5, \"rgba(20, 12, 24, .45)\");\n g.addColorStop(1, \"rgba(20, 12, 24, 0)\");\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 128, 128);\n tex.refresh();\n return key;\n}\n\nexport function shotTexture(scene: Phaser.Scene, color: number): string {\n return bake(scene, `shot-${color.toString(16)}`, 28, 28, (g) => {\n g.fillStyle(color, 0.35);\n g.fillCircle(14, 14, 13);\n g.fillStyle(shade(color, 0.3), 1);\n g.fillCircle(14, 14, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(12, 12, 3.5);\n });\n}\n\n/** A shot that is not an orb, flying right (towards the monsters): an arrow, a crossbow bolt, a\n * bullet with a streak of light behind it. Crits are tinted by the scene. */\nexport function missileTexture(\n scene: Phaser.Scene,\n kind: \"arrow\" | \"bolt\" | \"bullet\" | \"slash\" | \"thrust\",\n): string {\n if (kind === \"slash\")\n // The wave of a swing: a crescent of air, its round side forward, a white edge in a pale glow.\n return bake(scene, \"shot-slash\", 60, 100, (g) => {\n const crescent = (\n r: number,\n back: number,\n alpha: number,\n color: number,\n ) => {\n g.fillStyle(color, alpha);\n g.beginPath();\n g.arc(14, 50, r, -1.25, 1.25, false);\n g.arc(14 - back, 50, r + back * 0.35, 1.05, -1.05, true);\n g.closePath();\n g.fillPath();\n };\n crescent(45, 30, 0.3, 0x9fe8ff);\n crescent(42, 22, 0.6, 0xc8f4ff);\n crescent(39, 14, 1, 0xffffff);\n });\n if (kind === \"thrust\")\n // The wave of a stab: a narrow spike of air with a bright point.\n return bake(scene, \"shot-thrust\", 50, 18, (g) => {\n g.fillStyle(0x9fe8ff, 0.3);\n g.fillTriangle(0, 3, 49, 9, 0, 15);\n g.fillStyle(0xc8f4ff, 0.6);\n g.fillTriangle(8, 6, 48, 9, 8, 12);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(22, 7.5, 47, 9, 22, 10.5);\n });\n if (kind === \"arrow\")\n return bake(scene, \"shot-arrow\", 52, 14, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillRect(6, 5, 36, 4);\n g.fillTriangle(40, 1, 51, 7, 40, 13);\n g.fillStyle(0xc8a070, 1);\n g.fillRect(7, 6, 34, 2);\n g.fillStyle(0xd8dde4, 1);\n g.fillTriangle(41, 3, 48.5, 7, 41, 11);\n g.fillStyle(0xe04040, 1);\n g.fillTriangle(1, 1, 12, 7, 5, 7);\n g.fillTriangle(1, 13, 12, 7, 5, 7);\n });\n if (kind === \"bolt\")\n return bake(scene, \"shot-bolt\", 38, 12, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillRect(4, 4, 26, 4);\n g.fillTriangle(28, 0, 37, 6, 28, 12);\n g.fillStyle(0xb08860, 1);\n g.fillRect(5, 5, 24, 2);\n g.fillStyle(0xc0c8d0, 1);\n g.fillTriangle(29, 2, 35, 6, 29, 10);\n g.fillStyle(0xe04040, 1);\n g.fillTriangle(0, 2, 9, 6, 3, 6);\n g.fillTriangle(0, 10, 9, 6, 3, 6);\n });\n return bake(scene, \"shot-bullet\", 34, 10, (g) => {\n g.fillStyle(0xffe08a, 0.25);\n g.fillRect(0, 3, 24, 4);\n g.fillStyle(0xffe08a, 0.55);\n g.fillRect(10, 3.5, 16, 3);\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(28, 5, 12, 8);\n g.fillStyle(0xf2c040, 1);\n g.fillEllipse(28, 5, 8, 4.6);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(29.5, 4, 1.2);\n });\n}\n"
174
+ },
175
+ {
176
+ "path": "phaser/backdrop.ts",
177
+ "content": "import type Phaser from \"phaser\";\nimport { seededRandom } from \"../game/battle\";\nimport type { Theme } from \"../game/stages\";\n\n// The world behind the fight — a painted casual landscape per chapter theme, with no outlines: soft\n// gradients, light from the upper left (lit faces, shaded sides), a haze that fades the far planes\n// into the sky and contact shadows on the ground. Drawn once per theme and field size on canvases and\n// tiled sideways, so the road scrolls without end; the layers move at their own pace as the hero runs\n// (parallax): the sky with its sun stays, the far skyline barely moves, the hills behind and the near\n// props (cacti, palms, trees, rocks) faster, the road exactly with the world — the fighters stand on\n// it — and a strip of foreground at the bottom edge faster still.\n//\n// Every scrolling layer is one TILE wide and repeats seamlessly: skylines are periodic over the tile\n// and props keep clear of its edges.\n\nconst TILE = 1536;\n/** The sky does not scroll: wide enough for any screen. */\nconst SKY_W = 2560;\n\nexport interface BackdropLayout {\n w: number;\n h: number;\n /** The top edge of the road (the line the near props stand on). */\n horizon: number;\n}\n\ninterface Layer {\n sprite: Phaser.GameObjects.TileSprite;\n factor: number;\n}\n\ntype Painter = (\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n) => void;\n\nconst LAYERS: ReadonlyArray<{\n name: string;\n factor: number;\n width: number;\n paint: Painter;\n /** Over the fighters (the foreground strip) rather than behind them. */\n front?: boolean;\n}> = [\n { name: \"sky\", factor: 0, width: SKY_W, paint: paintSky },\n { name: \"far\", factor: 0.08, width: TILE, paint: paintFar },\n { name: \"hills\", factor: 0.22, width: TILE, paint: paintHills },\n { name: \"near\", factor: 0.45, width: TILE, paint: paintNear },\n { name: \"road\", factor: 1, width: TILE, paint: paintRoad },\n { name: \"front\", factor: 1.35, width: TILE, paint: paintFront, front: true },\n];\n\nexport class Backdrop {\n private layers: Layer[] = [];\n private key = \"\";\n\n constructor(private readonly scene: Phaser.Scene) {}\n\n /**\n * (Re)builds the layers for a theme and a field size; cheap when nothing changed. `depth` — the\n * layers behind the fighters start there, `frontDepth` — the foreground strip over them.\n */\n layout(\n theme: Theme,\n at: BackdropLayout,\n depth: number,\n frontDepth: number,\n ): void {\n const key = `${theme.id}-${Math.round(at.h)}-${Math.round(at.horizon)}`;\n if (key === this.key) {\n for (const l of this.layers) l.sprite.setSize(at.w, at.h);\n return;\n }\n this.key = key;\n for (const l of this.layers) l.sprite.destroy();\n this.layers = LAYERS.map((layer, i) => {\n const tex = `idle-bd2-${layer.name}-${key}`;\n if (!this.scene.textures.exists(tex)) {\n const canvas = this.scene.textures.createCanvas(\n tex,\n layer.width,\n at.h,\n )!;\n layer.paint(canvas.getContext(), theme, at.h, at.horizon);\n canvas.refresh();\n }\n const sprite = this.scene.add\n .tileSprite(0, 0, at.w, at.h, tex)\n .setOrigin(0, 0)\n .setDepth(layer.front ? frontDepth : depth + i * 0.1);\n return { sprite, factor: layer.factor };\n });\n }\n\n /** Scrolls the layers: `px` — how far the road has moved, in screen pixels. */\n scroll(px: number): void {\n for (const l of this.layers) l.sprite.tilePositionX = px * l.factor;\n }\n}\n\n// ── Colour helpers ───────────────────────────────────────────────────────────────────────────\n\ntype Rgb = [number, number, number];\nconst rgbOf = (n: number): Rgb => [(n >> 16) & 255, (n >> 8) & 255, n & 255];\nconst css = ([r, g, b]: Rgb, a = 1): string =>\n `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})`;\n\n/** `a` blended towards `b` by `t` (0 — a, 1 — b). */\nfunction mix(a: number, b: number, t: number): Rgb {\n const x = rgbOf(a);\n const y = rgbOf(b);\n return [0, 1, 2].map((i) => x[i]! + (y[i]! - x[i]!) * t) as Rgb;\n}\n\n/** Lighter (+) or darker (−) by a share. */\nfunction shade(c: number, amount: number): Rgb {\n return amount >= 0 ? mix(c, 0xffffff, amount) : mix(c, 0x000000, -amount);\n}\nconst hex = ([r, g, b]: Rgb): number =>\n (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);\n\n/** A far colour: the theme's colour sunk into the sky's haze. */\nfunction hazed(c: number, theme: Theme, amount: number): number {\n return hex(mix(c, theme.sky[1], amount));\n}\n\nfunction vertical(\n ctx: CanvasRenderingContext2D,\n y0: number,\n y1: number,\n stops: ReadonlyArray<[number, string]>,\n): CanvasGradient {\n const g = ctx.createLinearGradient(0, y0, 0, y1);\n for (const [at, c] of stops) g.addColorStop(at, c);\n return g;\n}\n\n/** A soft dark ellipse on the ground under a prop. */\nfunction contactShadow(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n w: number,\n alpha = 0.28,\n): void {\n ctx.save();\n ctx.translate(x, y);\n ctx.scale(1, 0.22);\n const g = ctx.createRadialGradient(0, 0, 0, 0, 0, w);\n g.addColorStop(0, `rgba(30, 18, 28, ${alpha})`);\n g.addColorStop(1, \"rgba(30, 18, 28, 0)\");\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(0, 0, w, 0, Math.PI * 2);\n ctx.fill();\n ctx.restore();\n}\n\n/** A height profile periodic over the tile (whole waves only), so the skyline joins at the seam. */\nfunction profile(\n seed: number,\n waves: ReadonlyArray<[cycles: number, amp: number]>,\n): (x: number) => number {\n const rnd = seededRandom(seed);\n const phases = waves.map(() => rnd() * Math.PI * 2);\n return (x) =>\n waves.reduce(\n (sum, [k, a], i) =>\n sum + a * Math.sin((x / TILE) * Math.PI * 2 * k + phases[i]!),\n 0,\n );\n}\n\n/** A band from `base` up to a periodic top, filled with a vertical gradient, a soft lit rim on top. */\nfunction band(\n ctx: CanvasRenderingContext2D,\n top: (x: number) => number,\n base: number,\n fill: string | CanvasGradient,\n rim: string | null,\n): void {\n ctx.beginPath();\n ctx.moveTo(-10, base + 10);\n for (let x = -10; x <= TILE + 10; x += 6) ctx.lineTo(x, top(x));\n ctx.lineTo(TILE + 10, base + 10);\n ctx.closePath();\n ctx.fillStyle = fill;\n ctx.fill();\n if (!rim) return;\n ctx.beginPath();\n ctx.moveTo(-10, top(-10) + 1.5);\n for (let x = -4; x <= TILE + 10; x += 6) ctx.lineTo(x, top(x) + 1.5);\n ctx.lineWidth = 3;\n ctx.strokeStyle = rim;\n ctx.stroke();\n}\n\n/** Spots for props along the tile, clear of its edges (a prop never straddles the seam). */\nfunction spots(seed: number, count: number, margin: number): number[] {\n const rnd = seededRandom(seed);\n const step = (TILE - margin * 2) / count;\n return Array.from(\n { length: count },\n (_, i) => margin + step * (i + 0.2 + rnd() * 0.6),\n );\n}\n\n// ── The sky ──────────────────────────────────────────────────────────────────────────────────\n\nfunction paintSky(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const [top, low] = theme.sky;\n ctx.fillStyle = vertical(ctx, 0, horizon, [\n [0, css(rgbOf(top))],\n [0.7, css(mix(top, low, 0.75))],\n [1, css(rgbOf(low))],\n ]);\n ctx.fillRect(0, 0, SKY_W, horizon + 2);\n // the sun (a red moon over the volcano) with a wide soft glow\n const night = theme.id === \"volcano\";\n const sx = SKY_W * 0.36;\n const sy = horizon * 0.42;\n const r = Math.max(18, h * 0.07);\n const glow = ctx.createRadialGradient(sx, sy, r * 0.6, sx, sy, r * 6);\n glow.addColorStop(\n 0,\n night ? \"rgba(255, 120, 80, .45)\" : \"rgba(255, 252, 225, .7)\",\n );\n glow.addColorStop(\n 1,\n night ? \"rgba(255, 120, 80, 0)\" : \"rgba(255, 252, 225, 0)\",\n );\n ctx.fillStyle = glow;\n ctx.fillRect(sx - r * 6, sy - r * 6, r * 12, r * 12);\n const disc = ctx.createRadialGradient(\n sx - r * 0.3,\n sy - r * 0.3,\n 0,\n sx,\n sy,\n r,\n );\n disc.addColorStop(0, night ? \"#ffd2b0\" : \"#fffef2\");\n disc.addColorStop(1, night ? \"#ff7a4a\" : \"#fff1b0\");\n ctx.fillStyle = disc;\n ctx.beginPath();\n ctx.arc(sx, sy, r, 0, Math.PI * 2);\n ctx.fill();\n}\n\n// ── The far skyline ──────────────────────────────────────────────────────────────────────────\n\nfunction paintFar(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const far = hazed(theme.hillFar, theme, 0.42);\n const rnd = seededRandom(11);\n if (theme.id === \"desert\") {\n // Mesas: flat-topped plateaus, the lit top face, the banded cliffs, the shaded right side.\n for (const x of spots(12, 5, 150)) {\n const w = 150 + rnd() * 150;\n const top = horizon - h * (0.17 + rnd() * 0.14);\n const slant = 16 + rnd() * 18;\n const left = x - w / 2;\n const right = x + w / 2;\n ctx.beginPath();\n ctx.moveTo(left - slant, horizon + 4);\n ctx.lineTo(left, top + 6);\n ctx.quadraticCurveTo(left + 3, top, left + 14, top);\n ctx.lineTo(right - 14, top);\n ctx.quadraticCurveTo(right - 3, top, right, top + 6);\n ctx.lineTo(right + slant, horizon + 4);\n ctx.closePath();\n const side = ctx.createLinearGradient(left - slant, 0, right + slant, 0);\n side.addColorStop(0, css(shade(far, 0.1)));\n side.addColorStop(0.62, css(rgbOf(far)));\n side.addColorStop(1, css(shade(far, -0.16)));\n ctx.fillStyle = side;\n ctx.fill();\n ctx.save();\n ctx.clip();\n for (let y = top + 16, i = 0; y < horizon; y += 20 + (i % 2) * 6, i++) {\n ctx.fillStyle = css(shade(far, -0.1), 0.55);\n ctx.fillRect(left - slant, y, w + slant * 2, 5);\n }\n ctx.fillStyle = css(shade(far, 0.22));\n ctx.fillRect(left - 2, top, w + 4, 5);\n ctx.restore();\n }\n band(\n ctx,\n (x) =>\n horizon -\n 12 +\n profile(13, [\n [3, 5],\n [7, 3],\n ])(x),\n horizon,\n css(rgbOf(hazed(theme.hillFar, theme, 0.25))),\n null,\n );\n return;\n }\n if (theme.id === \"snow\" || theme.id === \"volcano\") {\n // Mountains lit from the left: a light face and a shaded one split along the ridge.\n for (const x of spots(22, 6, 130)) {\n const w = 190 + rnd() * 170;\n const peak = horizon - h * (0.22 + rnd() * 0.18);\n const px = x + (rnd() - 0.5) * w * 0.2;\n ctx.beginPath();\n ctx.moveTo(x - w / 2, horizon + 4);\n ctx.quadraticCurveTo(px - w * 0.12, peak + 18, px, peak);\n ctx.quadraticCurveTo(px + w * 0.14, peak + 22, x + w / 2, horizon + 4);\n ctx.closePath();\n ctx.fillStyle = css(shade(far, 0.06));\n ctx.fill();\n ctx.beginPath();\n ctx.moveTo(px, peak);\n ctx.quadraticCurveTo(px + w * 0.14, peak + 22, x + w / 2, horizon + 4);\n ctx.lineTo(px + w * 0.05, horizon + 4);\n ctx.quadraticCurveTo(px + w * 0.02, peak + 40, px, peak);\n ctx.fillStyle = css(shade(far, -0.14), 0.9);\n ctx.fill();\n if (theme.id === \"snow\") {\n ctx.beginPath();\n ctx.moveTo(px, peak);\n ctx.quadraticCurveTo(\n px - w * 0.08,\n peak + 22,\n px - w * 0.16,\n peak + 40,\n );\n ctx.quadraticCurveTo(px - w * 0.05, peak + 34, px, peak + 44);\n ctx.quadraticCurveTo(\n px + w * 0.06,\n peak + 30,\n px + w * 0.15,\n peak + 42,\n );\n ctx.quadraticCurveTo(px + w * 0.08, peak + 20, px, peak);\n ctx.fillStyle = \"rgba(255, 255, 255, .95)\";\n ctx.fill();\n } else {\n const lava = ctx.createRadialGradient(\n px,\n peak + 4,\n 0,\n px,\n peak + 4,\n 40,\n );\n lava.addColorStop(0, \"rgba(255, 170, 70, .95)\");\n lava.addColorStop(1, \"rgba(255, 90, 40, 0)\");\n ctx.fillStyle = lava;\n ctx.fillRect(px - 40, peak - 36, 80, 80);\n for (let i = 0; i < 4; i++) {\n ctx.beginPath();\n ctx.arc(\n px + i * 9 - 8,\n peak - 18 - i * 16,\n 12 + i * 5,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"rgba(60, 40, 50, .28)\";\n ctx.fill();\n }\n }\n }\n band(\n ctx,\n (x) =>\n horizon -\n 16 +\n profile(23, [\n [4, 7],\n [9, 3],\n ])(x),\n horizon,\n css(rgbOf(hazed(theme.hillFar, theme, 0.25))),\n null,\n );\n return;\n }\n // Meadow and forest: a far row of soft rolling hills (the forest's crowned with pines).\n const top = (x: number) =>\n horizon -\n h * 0.19 +\n profile(31, [\n [2, h * 0.05],\n [5, h * 0.025],\n ])(x);\n band(\n ctx,\n top,\n horizon,\n vertical(ctx, horizon - h * 0.26, horizon, [\n [0, css(shade(far, 0.1))],\n [1, css(rgbOf(far))],\n ]),\n css(shade(far, 0.25), 0.6),\n );\n if (theme.id === \"forest\")\n for (let x = 20; x < TILE - 20; x += 22 + (x % 3) * 4)\n pine(ctx, x, top(x) + 10, 24, hazed(theme.grass, theme, 0.4), false);\n}\n\n// ── The hills behind the road ────────────────────────────────────────────────────────────────\n\nfunction paintHills(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const c = hazed(theme.hillNear, theme, 0.18);\n const top = (x: number) =>\n horizon -\n h * 0.085 +\n profile(35, [\n [3, h * 0.035],\n [7, h * 0.015],\n ])(x);\n band(\n ctx,\n top,\n horizon,\n vertical(ctx, horizon - h * 0.14, horizon, [\n [0, css(shade(c, 0.12))],\n [1, css(shade(c, -0.08))],\n ]),\n css(shade(c, 0.3), 0.55),\n );\n // small hazed silhouettes on the crest — distance in between\n for (const x of spots(36, 14, 40)) {\n const y = top(x) + 3;\n const size = h * 0.06;\n const tint = hazed(\n theme.id === \"desert\"\n ? 0x5fa84a\n : theme.id === \"volcano\"\n ? 0x4a3436\n : theme.grass,\n theme,\n 0.3,\n );\n if (theme.id === \"desert\") cactus(ctx, x, y, size, tint, false);\n else if (theme.id === \"meadow\")\n roundTree(ctx, x, y, size * 1.3, tint, false);\n else if (theme.id === \"volcano\") rock(ctx, x, y, size * 0.5, tint, false);\n else\n pine(ctx, x, y, size * 1.4, theme.id === \"snow\" ? 0x6f9a8c : tint, false);\n }\n}\n\n// ── The near props ───────────────────────────────────────────────────────────────────────────\n\nfunction paintNear(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n // a low dune / bank right behind the road\n const c = theme.hillNear;\n band(\n ctx,\n (x) =>\n horizon -\n h * 0.035 +\n profile(41, [\n [4, h * 0.018],\n [11, 3],\n ])(x),\n horizon,\n vertical(ctx, horizon - h * 0.06, horizon, [\n [0, css(shade(c, 0.08))],\n [1, css(shade(c, -0.12))],\n ]),\n css(shade(c, 0.3), 0.6),\n );\n const rnd = seededRandom(43);\n const base = horizon + 3;\n spots(44, 8, 110).forEach((x, i) => {\n const size = h * (0.17 + rnd() * 0.09);\n const lean: 1 | -1 = rnd() > 0.5 ? 1 : -1;\n switch (theme.id) {\n case \"desert\":\n if (i % 3 === 0) palm(ctx, x, base, size * 1.6, lean);\n else if (i % 3 === 1) cactus(ctx, x, base, size, 0x5fb04a, true);\n else rock(ctx, x, base, size * 0.42, 0xd8a86a, true);\n break;\n case \"meadow\":\n if (i % 3 === 2) bush(ctx, x, base, size * 0.5, theme.grass);\n else roundTree(ctx, x, base, size * 1.25, theme.grass, true);\n break;\n case \"forest\":\n if (i % 4 === 3) rock(ctx, x, base, size * 0.4, 0x8a8a96, true);\n else pine(ctx, x, base, size * 1.45, theme.grass, true);\n break;\n case \"snow\":\n if (i % 3 === 2) rock(ctx, x, base, size * 0.45, 0xb9d3ec, true);\n else snowyPine(ctx, x, base, size * 1.3);\n break;\n case \"volcano\":\n if (i % 3 === 0) deadTree(ctx, x, base, size * 1.15);\n else rock(ctx, x, base, size * 0.5, 0x4a3a40, true, true);\n break;\n }\n });\n}\n\n// ── The road ─────────────────────────────────────────────────────────────────────────────────\n\nfunction paintRoad(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const g = theme.ground;\n ctx.fillStyle = vertical(ctx, horizon, h, [\n [0, css(shade(g, 0.1))],\n [0.5, css(rgbOf(g))],\n [1, css(shade(g, -0.1))],\n ]);\n ctx.fillRect(0, horizon, TILE, h - horizon);\n // the far edge of the road, softly shaded by the bank behind it\n ctx.fillStyle = vertical(ctx, horizon, horizon + 14, [\n [0, css(shade(g, -0.18), 0.55)],\n [1, css(shade(g, -0.18), 0)],\n ]);\n ctx.fillRect(0, horizon, TILE, 14);\n const rnd = seededRandom(51);\n const span = h - horizon;\n if (theme.id === \"desert\" || theme.id === \"snow\") {\n // wind ripples in the sand / the snow\n for (let i = 0; i < 26; i++) {\n const x = 40 + rnd() * (TILE - 160);\n const y = horizon + 14 + rnd() * (span - 24);\n const w = 40 + rnd() * 70;\n ctx.beginPath();\n ctx.moveTo(x, y);\n ctx.quadraticCurveTo(x + w / 2, y - 4, x + w, y);\n ctx.lineWidth = 2.5;\n ctx.strokeStyle = css(shade(g, theme.id === \"snow\" ? -0.08 : 0.18), 0.7);\n ctx.stroke();\n ctx.beginPath();\n ctx.moveTo(x + 2, y + 3);\n ctx.quadraticCurveTo(x + w / 2, y - 1, x + w - 2, y + 3);\n ctx.lineWidth = 2;\n ctx.strokeStyle = css(shade(g, -0.14), 0.45);\n ctx.stroke();\n }\n } else if (theme.id === \"volcano\") {\n for (const x of spots(54, 7, 90)) {\n const y = horizon + 18 + rnd() * (span - 30);\n ctx.beginPath();\n ctx.moveTo(x - 30, y);\n ctx.lineTo(x - 10, y - 4);\n ctx.lineTo(x + 6, y + 2);\n ctx.lineTo(x + 28, y - 3);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"rgba(255, 110, 40, .75)\";\n ctx.stroke();\n }\n } else {\n // a grassy fringe along the far edge, worn patches on the path\n for (let x = 4; x < TILE - 4; x += 7) {\n const tall = 5 + ((x * 7) % 9);\n ctx.beginPath();\n ctx.moveTo(x - 3, horizon + 3);\n ctx.lineTo(x, horizon + 3 - tall);\n ctx.lineTo(x + 3, horizon + 3);\n ctx.fillStyle = css(shade(theme.grass, (x % 3) * 0.08));\n ctx.fill();\n }\n for (const x of spots(55, 10, 80)) {\n const y = horizon + 16 + rnd() * (span - 30);\n contactShadow(ctx, x, y, 40 + rnd() * 30, 0.12);\n }\n }\n // pebbles and tufts, clear of the tile's edges\n for (const x of spots(52, 24, 30)) {\n const y = horizon + 12 + rnd() * (span - 20);\n if (rnd() < 0.6) pebble(ctx, x, y, 3 + rnd() * 5, g);\n else\n tuft(\n ctx,\n x,\n y + 4,\n theme.id === \"snow\" ? 0x9fc0dd : theme.grass,\n 0.8 + rnd() * 0.5,\n );\n }\n}\n\n// ── The foreground strip ─────────────────────────────────────────────────────────────────────\n\nfunction paintFront(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n): void {\n // a few large, darker shapes cut by the bottom edge — close to the eye, moving fastest\n const rnd = seededRandom(61);\n const c =\n theme.id === \"snow\"\n ? 0xc9dcef\n : theme.id === \"desert\"\n ? 0xc99a58\n : theme.grass;\n for (const x of spots(62, 5, 140)) {\n const r = h * (0.07 + rnd() * 0.05);\n const y = h + r * 0.35;\n if (theme.id === \"meadow\" || theme.id === \"forest\") {\n for (let i = -3; i <= 3; i++) {\n ctx.beginPath();\n ctx.moveTo(x + i * r * 0.28 - r * 0.12, h + 2);\n ctx.quadraticCurveTo(\n x + i * r * 0.3,\n h - r * (1.1 - Math.abs(i) * 0.14),\n x + i * r * 0.34 + r * 0.18,\n h - r * (1.25 - Math.abs(i) * 0.14),\n );\n ctx.quadraticCurveTo(\n x + i * r * 0.3 + r * 0.06,\n h - r * 0.4,\n x + i * r * 0.28 + r * 0.12,\n h + 2,\n );\n ctx.fillStyle = css(shade(c, -0.25 + (i % 2) * 0.06));\n ctx.fill();\n }\n } else {\n const gr = ctx.createRadialGradient(\n x - r * 0.3,\n y - r * 0.6,\n r * 0.1,\n x,\n y,\n r * 1.2,\n );\n gr.addColorStop(0, css(shade(c, -0.05)));\n gr.addColorStop(1, css(shade(c, -0.35)));\n ctx.fillStyle = gr;\n ctx.beginPath();\n ctx.ellipse(x, y, r * 1.4, r, 0, Math.PI, 0);\n ctx.fill();\n }\n }\n}\n\n// ── Props (light from the upper left, a contact shadow under each) ───────────────────────────\n\nfunction cactus(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n): void {\n const w = size * 0.24;\n if (detail) contactShadow(ctx, x + w * 0.4, base, size * 0.5);\n const body = (x0: number, x1: number): CanvasGradient => {\n const g = ctx.createLinearGradient(x0, 0, x1, 0);\n g.addColorStop(0, css(shade(color, 0.18)));\n g.addColorStop(0.45, css(rgbOf(color)));\n g.addColorStop(1, css(shade(color, -0.25)));\n return g;\n };\n const arm = (dir: 1 | -1, at: number, up: number) => {\n const ax = x + dir * w * 0.45;\n const tip = ax + dir * w * 1.3;\n ctx.beginPath();\n ctx.moveTo(ax, base - at);\n ctx.lineTo(tip - dir * w * 0.3, base - at);\n ctx.quadraticCurveTo(tip, base - at, tip, base - at - w * 0.4);\n ctx.lineTo(tip, base - at - up);\n ctx.arc(\n tip - dir * w * 0.3,\n base - at - up,\n w * 0.3,\n dir > 0 ? 0 : Math.PI,\n dir > 0 ? Math.PI : 0,\n dir > 0,\n );\n ctx.lineTo(tip - dir * w * 0.6, base - at - w * 0.45);\n ctx.lineTo(ax, base - at - w * 0.45);\n ctx.closePath();\n ctx.fillStyle = body(Math.min(ax, tip) - 2, Math.max(ax, tip) + 2);\n ctx.fill();\n };\n arm(-1, size * 0.42, size * 0.28);\n arm(1, size * 0.58, size * 0.22);\n ctx.beginPath();\n ctx.moveTo(x - w / 2, base);\n ctx.lineTo(x - w / 2, base - size + w / 2);\n ctx.arc(x, base - size + w / 2, w / 2, Math.PI, 0);\n ctx.lineTo(x + w / 2, base);\n ctx.closePath();\n ctx.fillStyle = body(x - w / 2, x + w / 2);\n ctx.fill();\n if (!detail) return;\n // ribs: lit lines, then the flower\n for (const dx of [-0.22, 0.12]) {\n ctx.beginPath();\n ctx.moveTo(x + dx * w, base - 4);\n ctx.lineTo(x + dx * w, base - size + w * 0.8);\n ctx.lineWidth = 1.6;\n ctx.strokeStyle = css(shade(color, dx < 0 ? 0.35 : -0.35), 0.6);\n ctx.stroke();\n }\n const fx = x;\n const fy = base - size + 1;\n for (let i = 0; i < 5; i++) {\n const a = (i / 5) * Math.PI * 2;\n ctx.beginPath();\n ctx.ellipse(\n fx + Math.cos(a) * w * 0.18,\n fy + Math.sin(a) * w * 0.12,\n w * 0.16,\n w * 0.1,\n a,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"#ff6f96\";\n ctx.fill();\n }\n ctx.beginPath();\n ctx.arc(fx, fy, w * 0.09, 0, Math.PI * 2);\n ctx.fillStyle = \"#ffe066\";\n ctx.fill();\n}\n\nfunction palm(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n lean: 1 | -1,\n): void {\n const topX = x + lean * size * 0.24;\n const topY = base - size;\n contactShadow(ctx, x + size * 0.1, base, size * 0.32);\n // the trunk: curved, banded, lit on the left\n ctx.beginPath();\n ctx.moveTo(x - 9, base);\n ctx.quadraticCurveTo(\n x + lean * size * 0.04,\n base - size * 0.5,\n topX - 5,\n topY,\n );\n ctx.lineTo(topX + 5, topY);\n ctx.quadraticCurveTo(x + lean * size * 0.13, base - size * 0.5, x + 9, base);\n ctx.closePath();\n const trunk = ctx.createLinearGradient(x - 10, 0, x + 12, 0);\n trunk.addColorStop(0, \"#e2a868\");\n trunk.addColorStop(1, \"#9a6236\");\n ctx.fillStyle = trunk;\n ctx.fill();\n for (let i = 1; i < 8; i++) {\n const t = i / 8;\n const sx = x + (topX - x) * t * t;\n const sy = base - size * t;\n ctx.beginPath();\n ctx.ellipse(sx, sy, 8 - t * 3, 2.2, lean * 0.15 * t, 0, Math.PI);\n ctx.fillStyle = \"rgba(110, 60, 30, .45)\";\n ctx.fill();\n }\n // fronds: two-tone leaves arching out of the crown\n const leaves: Array<[number, number]> = [\n [-1, 0.32],\n [-0.85, -0.3],\n [-0.25, -0.62],\n [0.35, -0.55],\n [0.9, -0.22],\n [1, 0.36],\n ];\n for (const [dx, dy] of leaves) {\n const ex = topX + dx * size * 0.46;\n const ey = topY + dy * size * 0.3 + size * 0.13;\n const cx = topX + dx * size * 0.25;\n const cy = topY + dy * size * 0.36 - size * 0.12;\n ctx.beginPath();\n ctx.moveTo(topX, topY);\n ctx.quadraticCurveTo(cx, cy, ex, ey);\n ctx.quadraticCurveTo(\n topX + dx * size * 0.22,\n topY + dy * size * 0.2 + 4,\n topX,\n topY + 7,\n );\n ctx.closePath();\n const leaf = ctx.createLinearGradient(topX, topY - size * 0.2, ex, ey + 6);\n leaf.addColorStop(0, \"#7fd660\");\n leaf.addColorStop(1, \"#3b8c3a\");\n ctx.fillStyle = leaf;\n ctx.fill();\n ctx.beginPath();\n ctx.moveTo(topX, topY + 2);\n ctx.quadraticCurveTo(cx, cy + 5, ex, ey);\n ctx.lineWidth = 1.4;\n ctx.strokeStyle = \"rgba(240, 255, 200, .45)\";\n ctx.stroke();\n }\n for (const [dx, dy] of [\n [-4, 7],\n [5, 9],\n [0, 12],\n ] as const) {\n const g = ctx.createRadialGradient(\n topX + dx - 1.5,\n topY + dy - 1.5,\n 0.5,\n topX + dx,\n topY + dy,\n 5,\n );\n g.addColorStop(0, \"#b98050\");\n g.addColorStop(1, \"#6a4022\");\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(topX + dx, topY + dy, 5, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nfunction rock(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n glow = false,\n): void {\n if (detail) contactShadow(ctx, x + size * 0.15, base, size * 1.3, 0.3);\n ctx.beginPath();\n ctx.moveTo(x - size, base);\n ctx.quadraticCurveTo(\n x - size * 1.05,\n base - size * 0.7,\n x - size * 0.4,\n base - size * 0.88,\n );\n ctx.quadraticCurveTo(\n x + size * 0.2,\n base - size * 1.08,\n x + size * 0.75,\n base - size * 0.6,\n );\n ctx.quadraticCurveTo(x + size * 1.05, base - size * 0.3, x + size, base);\n ctx.closePath();\n const g = ctx.createRadialGradient(\n x - size * 0.4,\n base - size * 0.8,\n size * 0.1,\n x,\n base - size * 0.3,\n size * 1.3,\n );\n g.addColorStop(0, css(shade(color, 0.22)));\n g.addColorStop(0.6, css(rgbOf(color)));\n g.addColorStop(1, css(shade(color, -0.3)));\n ctx.fillStyle = g;\n ctx.fill();\n if (!detail) return;\n ctx.beginPath();\n ctx.ellipse(\n x - size * 0.3,\n base - size * 0.7,\n size * 0.26,\n size * 0.1,\n -0.35,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"rgba(255, 255, 255, .28)\";\n ctx.fill();\n if (glow) {\n ctx.beginPath();\n ctx.moveTo(x - size * 0.5, base - size * 0.2);\n ctx.lineTo(x - size * 0.1, base - size * 0.45);\n ctx.lineTo(x + size * 0.3, base - size * 0.25);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"#ff8a3a\";\n ctx.stroke();\n }\n}\n\nfunction pebble(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n r: number,\n ground: number,\n): void {\n contactShadow(ctx, x + r * 0.4, y + r * 0.4, r * 1.6, 0.25);\n const g = ctx.createRadialGradient(\n x - r * 0.4,\n y - r * 0.4,\n 0,\n x,\n y,\n r * 1.2,\n );\n g.addColorStop(0, css(shade(ground, 0.12)));\n g.addColorStop(1, css(shade(ground, -0.28)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.ellipse(x, y, r * 1.3, r * 0.75, 0, 0, Math.PI * 2);\n ctx.fill();\n}\n\nfunction roundTree(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n green: number,\n detail: boolean,\n): void {\n if (detail) contactShadow(ctx, x + size * 0.08, base, size * 0.42);\n ctx.beginPath();\n ctx.moveTo(x - size * 0.05, base);\n ctx.lineTo(x - size * 0.035, base - size * 0.52);\n ctx.lineTo(x + size * 0.035, base - size * 0.52);\n ctx.lineTo(x + size * 0.05, base);\n ctx.closePath();\n const trunk = ctx.createLinearGradient(\n x - size * 0.05,\n 0,\n x + size * 0.05,\n 0,\n );\n trunk.addColorStop(0, \"#b07a48\");\n trunk.addColorStop(1, \"#6e4626\");\n ctx.fillStyle = trunk;\n ctx.fill();\n const r = size * 0.3;\n const blobs: Array<[number, number, number]> = [\n [-0.62, -0.58, 0.8],\n [0.62, -0.6, 0.8],\n [0, -0.82, 1],\n [-0.3, -0.98, 0.62],\n [0.32, -0.96, 0.6],\n ];\n for (const [dx, dy, k] of blobs) {\n const cx = x + r * dx;\n const cy = base + size * dy;\n const g = ctx.createRadialGradient(\n cx - r * k * 0.35,\n cy - r * k * 0.4,\n r * k * 0.1,\n cx,\n cy,\n r * k,\n );\n g.addColorStop(0, css(shade(green, 0.28)));\n g.addColorStop(0.7, css(shade(green, 0.02)));\n g.addColorStop(1, css(shade(green, -0.22)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(cx, cy, r * k, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nfunction bush(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n green: number,\n): void {\n contactShadow(ctx, x + size * 0.1, base, size * 1.3);\n const blobs: Array<[number, number, number]> = [\n [-0.5, -0.32, 0.45],\n [0.2, -0.46, 0.55],\n [0.8, -0.3, 0.38],\n ];\n for (const [dx, dy, k] of blobs) {\n const cx = x + size * dx;\n const cy = base + size * dy;\n const r = size * k;\n const g = ctx.createRadialGradient(\n cx - r * 0.35,\n cy - r * 0.4,\n r * 0.1,\n cx,\n cy,\n r,\n );\n g.addColorStop(0, css(shade(green, 0.3)));\n g.addColorStop(1, css(shade(green, -0.18)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(cx, cy, r, Math.PI, 0);\n ctx.lineTo(cx + r, base);\n ctx.lineTo(cx - r, base);\n ctx.closePath();\n ctx.fill();\n }\n for (const [dx, c] of [\n [-0.45, \"#ffffff\"],\n [0.25, \"#ffd34d\"],\n [0.75, \"#ff7aa8\"],\n ] as const) {\n ctx.beginPath();\n ctx.arc(x + size * dx, base - size * 0.52, 3, 0, Math.PI * 2);\n ctx.fillStyle = c;\n ctx.fill();\n }\n}\n\nfunction pine(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n): void {\n if (detail) {\n contactShadow(ctx, x + size * 0.06, base, size * 0.34);\n ctx.fillStyle = \"#7a5a3a\";\n ctx.fillRect(x - size * 0.035, base - size * 0.2, size * 0.07, size * 0.2);\n }\n for (let i = 0; i < 3; i++) {\n const y = base - size * (0.15 + i * 0.27);\n const w = size * (0.42 - i * 0.1);\n const tip = y - size * 0.42;\n ctx.beginPath();\n ctx.moveTo(x - w, y);\n ctx.lineTo(x, tip);\n ctx.lineTo(x + w, y);\n ctx.quadraticCurveTo(x, y + size * 0.05, x - w, y);\n ctx.closePath();\n if (detail) {\n const g = ctx.createLinearGradient(x - w, 0, x + w, 0);\n g.addColorStop(0, css(shade(color, 0.2)));\n g.addColorStop(0.5, css(rgbOf(color)));\n g.addColorStop(0.5, css(shade(color, -0.16)));\n g.addColorStop(1, css(shade(color, -0.28)));\n ctx.fillStyle = g;\n } else ctx.fillStyle = css(rgbOf(color));\n ctx.fill();\n }\n}\n\nfunction snowyPine(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n): void {\n pine(ctx, x, base, size, 0x3f7a62, true);\n for (let i = 0; i < 3; i++) {\n const y = base - size * (0.15 + i * 0.27) - size * 0.31;\n const w = size * (0.2 - i * 0.045);\n ctx.beginPath();\n ctx.moveTo(x - w, y + size * 0.09);\n ctx.lineTo(x, y - size * 0.11);\n ctx.lineTo(x + w, y + size * 0.09);\n ctx.quadraticCurveTo(x, y + size * 0.03, x - w, y + size * 0.09);\n ctx.fillStyle = \"rgba(255, 255, 255, .95)\";\n ctx.fill();\n }\n}\n\nfunction deadTree(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n): void {\n contactShadow(ctx, x + size * 0.1, base, size * 0.34);\n ctx.lineCap = \"round\";\n const branch = (\n x0: number,\n y0: number,\n x1: number,\n y1: number,\n w: number,\n ) => {\n const g = ctx.createLinearGradient(x0 - w, y0, x0 + w, y0);\n g.addColorStop(0, \"#8a6a58\");\n g.addColorStop(1, \"#4a3430\");\n ctx.beginPath();\n ctx.moveTo(x0, y0);\n ctx.lineTo(x1, y1);\n ctx.lineWidth = w;\n ctx.strokeStyle = g;\n ctx.stroke();\n };\n branch(x, base, x + size * 0.05, base - size * 0.75, 10);\n branch(\n x + size * 0.03,\n base - size * 0.45,\n x - size * 0.3,\n base - size * 0.72,\n 6,\n );\n branch(\n x + size * 0.05,\n base - size * 0.6,\n x + size * 0.32,\n base - size * 0.85,\n 6,\n );\n branch(\n x - size * 0.2,\n base - size * 0.64,\n x - size * 0.28,\n base - size * 0.9,\n 4,\n );\n}\n\nfunction tuft(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n color: number,\n k: number,\n): void {\n const blades: Array<[number, number]> = [\n [-6, 8],\n [-2.5, 12],\n [1, 10],\n [4.5, 13],\n [7, 7],\n ];\n for (const [dx, tall] of blades) {\n ctx.beginPath();\n ctx.moveTo(x + dx * k - 2, y);\n ctx.quadraticCurveTo(\n x + dx * k,\n y - tall * k * 0.6,\n x + dx * k + dx * 0.25,\n y - tall * k,\n );\n ctx.quadraticCurveTo(x + dx * k + 1, y - tall * k * 0.5, x + dx * k + 2, y);\n ctx.closePath();\n ctx.fillStyle = css(shade(color, dx < 0 ? 0.15 : -0.12));\n ctx.fill();\n }\n}\n"
178
+ },
179
+ {
180
+ "path": "phaser/bars.ts",
181
+ "content": "import type Phaser from \"phaser\";\nimport { formatBig } from \"../game/format\";\n\n// The bars under a fighter's feet (Obsidian Knight's sheet): the red health bar with the health left\n// on it (the number alone, no \"/max\"), and — for the hero and for an enemy in plate, while any armor\n// is left — the blue armor bar right under it, which takes the blows first. Drawn on one Graphics\n// plus a text per bar; the texts change only when the numbers do.\n\nconst INK = 0x2b2230;\nconst HP = { fill: 0xe8433f, light: 0xff7a6a, track: 0x5a2230 };\nconst ARMOR = { fill: 0x3d8ef5, light: 0x8fc3ff, track: 0x1f2f5a };\n\nexport interface BarValues {\n hp: number;\n maxHp: number;\n armor: number;\n maxArmor: number;\n}\n\nexport class StatusBars {\n private readonly g: Phaser.GameObjects.Graphics;\n private readonly hpText: Phaser.GameObjects.Text;\n private readonly armorText: Phaser.GameObjects.Text;\n private shown = { hp: \"\", armor: \"\" };\n\n constructor(scene: Phaser.Scene, depth: number) {\n this.g = scene.add.graphics().setDepth(depth);\n const text = () =>\n scene.add\n .text(0, 0, \"\", {\n fontFamily: \"Rubik, system-ui, sans-serif\",\n fontStyle: \"900\",\n fontSize: \"11px\",\n color: \"#ffffff\",\n stroke: \"#2b2230\",\n strokeThickness: 3,\n })\n .setOrigin(0.5, 0.5)\n .setDepth(depth + 0.1);\n this.hpText = text();\n this.armorText = text();\n }\n\n /**\n * Draws the bars centred at `x`, their top at `y`, `w` wide. The armor bar shows only while there is\n * armor left — a broken one is gone until the armor is whole again.\n */\n draw(x: number, y: number, w: number, v: BarValues): void {\n const g = this.g.clear();\n const h = 13;\n this.bar(g, x, y, w, h, v.hp / Math.max(1, v.maxHp), HP);\n this.label(this.hpText, \"hp\", num(v.hp), x, y + h / 2);\n const armored = v.maxArmor > 0 && v.armor > 0;\n this.armorText.setVisible(armored);\n if (!armored) return;\n const ay = y + h + 3;\n this.bar(g, x, ay, w, h - 2, v.armor / v.maxArmor, ARMOR);\n this.label(this.armorText, \"armor\", num(v.armor), x, ay + (h - 2) / 2);\n }\n\n setVisible(visible: boolean): void {\n this.g.setVisible(visible);\n this.hpText.setVisible(visible);\n if (!visible) this.armorText.setVisible(false);\n }\n\n destroy(): void {\n this.g.destroy();\n this.hpText.destroy();\n this.armorText.destroy();\n }\n\n private bar(\n g: Phaser.GameObjects.Graphics,\n x: number,\n y: number,\n w: number,\n h: number,\n share: number,\n c: { fill: number; light: number; track: number },\n ): void {\n const s = Math.max(0, Math.min(1, share));\n const left = x - w / 2;\n g.fillStyle(INK, 1);\n g.fillRoundedRect(left - 2, y - 2, w + 4, h + 4, 5);\n g.fillStyle(c.track, 1);\n g.fillRoundedRect(left, y, w, h, 3);\n if (s <= 0) return;\n const fw = Math.max(4, w * s);\n g.fillStyle(c.fill, 1);\n g.fillRoundedRect(left, y, fw, h, 3);\n // the lit top half\n g.fillStyle(c.light, 0.55);\n g.fillRoundedRect(left + 1, y + 1, Math.max(2, fw - 2), h * 0.4, 2);\n }\n\n private label(\n t: Phaser.GameObjects.Text,\n key: \"hp\" | \"armor\",\n value: string,\n x: number,\n y: number,\n ): void {\n if (this.shown[key] !== value) {\n this.shown[key] = value;\n t.setText(value);\n }\n t.setPosition(x, y);\n }\n}\n\nconst num = (n: number): string => formatBig(Math.max(0, Math.ceil(n)));\n"
142
182
  },
143
183
  {
144
184
  "path": "phaser/BattleScene.ts",
145
- "content": "import Phaser from \"phaser\";\nimport { HERO_X, type BattleEvent, type Monster } from \"../game/battle\";\nimport { formatBig } from \"../game/format\";\nimport type { IdleSession } from \"../game/session\";\nimport { stageInfo, type Theme } from \"../game/stages\";\nimport { LANE_MAX_PX, battleHeight } from \"../layout\";\nimport {\n HERO_SIZE,\n MONSTER_SIZE,\n bakeCommon,\n heroColor,\n heroTexture,\n monsterTexture,\n shade,\n shotTexture,\n} from \"./art\";\n\n// Draws the fight the session simulates: it owns no rules. Each frame it advances the session's\n// battle, turns the returned events into effects (numbers, coins, bursts) and moves the sprites to\n// where the simulation says things are. The field is the top part of the canvas (layout.ts); the\n// Enhance panel covers the rest.\n\ninterface MonsterView {\n body: Phaser.GameObjects.Image;\n bar: Phaser.GameObjects.Graphics;\n wobble: number;\n boss: boolean;\n}\n\nconst SHOT_Y = 0.52; // of the hero's height above the ground\n\nexport class BattleScene extends Phaser.Scene {\n private field = {\n w: 480,\n h: 360,\n laneLeft: 0,\n laneW: 480,\n ground: 290,\n unit: 1,\n };\n private sky?: Phaser.GameObjects.Graphics;\n private hills?: Phaser.GameObjects.Graphics;\n private clouds: Phaser.GameObjects.Image[] = [];\n private theme: Theme | null = null;\n private hero?: Phaser.GameObjects.Image;\n private heroKey = \"\";\n private heroBar?: Phaser.GameObjects.Graphics;\n private monsters = new Map<number, MonsterView>();\n private shots = new Map<number, Phaser.GameObjects.Image>();\n private sparks?: Phaser.GameObjects.Particles.ParticleEmitter;\n private puffs?: Phaser.GameObjects.Particles.ParticleEmitter;\n private heals?: Phaser.GameObjects.Particles.ParticleEmitter;\n private fire?: Phaser.GameObjects.Particles.ParticleEmitter;\n private frenzyTween?: Phaser.Tweens.Tween;\n private lastCollectAt = 0;\n /** Pixels the hero is pushed back by its own shot; decays every frame. */\n private recoil = 0;\n\n constructor(private readonly session: IdleSession) {\n super(\"idle-battle\");\n }\n\n create(): void {\n bakeCommon(this);\n this.sky = this.add.graphics().setDepth(0);\n this.hills = this.add.graphics().setDepth(2);\n for (let i = 0; i < 4; i++)\n this.clouds.push(\n this.add.image(0, 0, \"cloud\").setDepth(1).setAlpha(0.85),\n );\n this.heroBar = this.add.graphics().setDepth(30);\n\n const particles = (\n texture: string,\n config: Phaser.Types.GameObjects.Particles.ParticleEmitterConfig,\n ) =>\n this.add\n .particles(0, 0, texture, { emitting: false, ...config })\n .setDepth(40);\n this.sparks = particles(\"spark\", {\n speed: { min: 80, max: 260 },\n lifespan: 420,\n scale: { start: 0.9, end: 0 },\n tint: [0xffffff, 0xffe27a, 0xffa53d],\n });\n this.puffs = particles(\"puff\", {\n speed: { min: 30, max: 120 },\n lifespan: 520,\n scale: { start: 0.9, end: 0.1 },\n alpha: { start: 0.8, end: 0 },\n });\n this.heals = particles(\"spark\", {\n speedY: { min: -140, max: -60 },\n speedX: { min: -40, max: 40 },\n lifespan: 900,\n scale: { start: 1, end: 0 },\n tint: 0x6dff8a,\n });\n this.fire = particles(\"spark\", {\n speed: { min: 120, max: 340 },\n lifespan: 520,\n scale: { start: 1.6, end: 0 },\n tint: [0xff4d1a, 0xffa53d, 0xffe27a],\n });\n\n this.scale.on(\"resize\", () => this.layout());\n this.layout();\n }\n\n // ── Layout ─────────────────────────────────────────────────────────────────────────────────\n\n private layout(): void {\n const w = this.scale.width;\n const h = battleHeight(this.scale.height);\n const laneW = Math.min(w, LANE_MAX_PX);\n this.field = {\n w,\n h,\n laneLeft: (w - laneW) / 2,\n laneW,\n ground: Math.round(h * 0.86),\n // Sprites are drawn for a ~620 px lane; a phone shrinks them so the hero does not cover the\n // melee line, a desktop keeps them at their natural size.\n unit: Math.min(1, Math.max(0.6, laneW / 620)),\n };\n this.theme = null; // repaint the background for the new size\n this.paintBackground();\n this.clouds.forEach((c, i) =>\n c.setPosition(\n ((i + 0.3) / this.clouds.length) * w,\n h * (0.1 + 0.08 * (i % 2)),\n ),\n );\n }\n\n private laneX(x: number): number {\n return this.field.laneLeft + x * this.field.laneW;\n }\n\n private paintBackground(): void {\n const theme = stageInfo(this.session.battle.stage).theme;\n if (theme === this.theme || !this.sky || !this.hills) return;\n this.theme = theme;\n const { w, h, ground } = this.field;\n this.cameras.main.setBackgroundColor(theme.sky[0]);\n\n const sky = this.sky.clear();\n sky.fillGradientStyle(\n theme.sky[0],\n theme.sky[0],\n theme.sky[1],\n theme.sky[1],\n 1,\n );\n sky.fillRect(0, 0, w, h);\n\n const g = this.hills.clear();\n // Far and near hills: rows of overlapping humps.\n g.fillStyle(theme.hillFar, 1);\n for (let x = -40; x < w + 80; x += 90)\n g.fillEllipse(x, ground - 34 + ((x / 90) % 2) * 10, 170, 110);\n g.fillStyle(theme.hillNear, 1);\n for (let x = 10; x < w + 80; x += 120)\n g.fillEllipse(x, ground - 6, 200, 80);\n // Ground with a grass rim, tufts and flowers.\n g.fillStyle(theme.ground, 1);\n g.fillRect(0, ground, w, h - ground + 2);\n g.fillStyle(shade(theme.ground, -0.15), 1);\n for (let x = 0; x < w; x += 46) g.fillRect(x + 12, ground + 14, 18, 4);\n g.fillStyle(theme.grass, 1);\n g.fillRect(0, ground - 4, w, 8);\n for (let x = 4; x < w; x += 26)\n g.fillTriangle(x, ground, x + 6, ground - 12, x + 12, ground);\n const petals = [0xffffff, 0xffd34d, 0xff7aa8];\n for (let i = 0, x = 30; x < w; x += 140, i++) {\n g.fillStyle(petals[i % petals.length]!, 1);\n g.fillCircle(x, ground - 8, 4);\n }\n // A dark strip under the field — the panel starts right below it.\n g.fillStyle(0x000000, 0.25);\n g.fillRect(0, h - 3, w, 3);\n }\n\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\n\n override update(_time: number, deltaMs: number): void {\n const events = this.session.tick(deltaMs / 1000);\n this.paintBackground();\n this.syncHero();\n for (const e of events) this.react(e);\n this.syncMonsters();\n this.syncShots();\n this.driftClouds(deltaMs / 1000);\n const collect = this.session.lastCollect;\n if (collect && collect.at !== this.lastCollectAt) {\n this.lastCollectAt = collect.at;\n this.coinShower(\n Math.min(14, 4 + Math.floor(Math.log10(collect.amount + 1) * 3)),\n );\n }\n }\n\n private heroPos(): { x: number; y: number } {\n return { x: this.laneX(HERO_X), y: this.field.ground };\n }\n\n private syncHero(): void {\n const hero = this.session.hero;\n const { x, y } = this.heroPos();\n const key = hero ? heroTexture(this, hero.classID, hero.id) : \"\";\n if (!this.hero) {\n this.hero = this.add\n .image(x, y, key || \"puff\")\n .setOrigin(0.5, 1)\n .setDepth(20);\n }\n if (key && key !== this.heroKey) {\n this.heroKey = key;\n this.hero.setTexture(key);\n this.burstAt(this.puffs, x, y - 40, 14);\n }\n this.recoil *= 0.75;\n this.hero.setPosition(x - this.recoil, y);\n // The slime's idle wobble: squash and stretch around its own size.\n const wobble = Math.sin(this.time.now / 260);\n const unit = this.field.unit;\n this.hero.setScale(unit * (1 + 0.04 * wobble), unit * (1 - 0.05 * wobble));\n this.hero.setVisible(!!hero);\n\n const snap = this.session.battle;\n const bar = this.heroBar!.clear();\n if (!hero || snap.heroDownLeft > 0) return;\n const share = Math.max(0, snap.heroHp / Math.max(1, snap.stats.maxHp));\n const bw = 70;\n const by = y - HERO_SIZE.h * this.field.unit - 4;\n bar.fillStyle(0x2b1d33, 0.85);\n bar.fillRoundedRect(x - bw / 2 - 2, by - 2, bw + 4, 10, 4);\n bar.fillStyle(share > 0.35 ? 0x5fd36a : 0xff5a4f, 1);\n bar.fillRoundedRect(x - bw / 2, by, Math.max(4, bw * share), 6, 3);\n }\n\n private syncMonsters(): void {\n const alive = new Set<number>();\n for (const m of this.session.battle.monsters) {\n alive.add(m.id);\n const view = this.monsters.get(m.id) ?? this.spawnView(m);\n const x = this.laneX(m.x) + Math.sin((m.id * 7.3) % 6.28) * 10;\n const bob = Math.abs(Math.sin(this.time.now / 160 + view.wobble)) * 5;\n view.body.setPosition(x, this.field.ground - bob);\n const share = Math.max(0, m.hp / m.maxHp);\n const bw = view.boss ? 110 : 54;\n const top = this.field.ground - view.body.displayHeight - 8;\n view.bar.clear();\n if (share < 1 || view.boss) {\n view.bar.fillStyle(0x2b1d33, 0.85);\n view.bar.fillRoundedRect(x - bw / 2 - 2, top - 2, bw + 4, 9, 4);\n view.bar.fillStyle(view.boss ? 0xff5a4f : 0xffb03a, 1);\n view.bar.fillRoundedRect(\n x - bw / 2,\n top,\n Math.max(3, bw * share),\n 5,\n 2,\n );\n }\n }\n for (const [id, view] of this.monsters)\n if (!alive.has(id)) {\n view.body.destroy();\n view.bar.destroy();\n this.monsters.delete(id);\n }\n }\n\n private spawnView(m: Monster): MonsterView {\n const body = this.add\n .image(\n this.laneX(m.x),\n this.field.ground,\n monsterTexture(this, m.spec.kind),\n )\n .setOrigin(0.5, 1)\n .setDepth(m.spec.boss ? 18 : 15)\n .setFlipX(true);\n const scale = (m.spec.boss ? 1.6 : 0.8) * this.field.unit;\n body.setScale(0);\n this.tweens.add({\n targets: body,\n scale,\n duration: 260,\n ease: \"Back.easeOut\",\n });\n const view: MonsterView = {\n body,\n bar: this.add.graphics().setDepth(31),\n wobble: m.id,\n boss: m.spec.boss,\n };\n this.monsters.set(m.id, view);\n return view;\n }\n\n private syncShots(): void {\n const live = new Set<number>();\n const y = this.field.ground - HERO_SIZE.h * this.field.unit * SHOT_Y;\n for (const p of this.session.battle.projectiles) {\n live.add(p.id);\n let img = this.shots.get(p.id);\n if (!img) {\n const hero = this.session.hero;\n const color = p.crit\n ? 0xff7a1a\n : heroColor(hero?.classID ?? \"\", hero?.id ?? \"\");\n img = this.add\n .image(this.laneX(p.x), y, shotTexture(this, color))\n .setDepth(25)\n .setScale(p.crit ? 1.35 : p.extra ? 0.75 : 1);\n this.shots.set(p.id, img);\n }\n img.setPosition(this.laneX(p.x), y + (p.extra ? 10 : 0));\n img.rotation += 0.3;\n }\n for (const [id, img] of this.shots)\n if (!live.has(id)) {\n img.destroy();\n this.shots.delete(id);\n }\n }\n\n private driftClouds(dt: number): void {\n for (const [i, c] of this.clouds.entries()) {\n c.x -= (6 + i * 3) * dt;\n if (c.x < -100) c.x = this.field.w + 100;\n }\n }\n\n // ── Effects ────────────────────────────────────────────────────────────────────────────────\n\n private react(e: BattleEvent): void {\n const { ground } = this.field;\n switch (e.type) {\n case \"shoot\":\n this.recoil = 7;\n break;\n case \"hit\": {\n const view = this.monsters.get(e.monsterId);\n const x = view?.body.x ?? this.laneX(e.x);\n const top =\n ground - (view?.body.displayHeight ?? MONSTER_SIZE * 0.7) * 0.75;\n this.floatText(\n formatBig(Math.round(e.damage)),\n x,\n top,\n e.crit ? \"crit\" : \"hit\",\n );\n this.burstAt(this.sparks, x - 10, top + 30, e.crit ? 10 : 4);\n if (view) {\n view.body.setTint(0xffb0b0);\n this.time.delayedCall(\n 70,\n () => view.body.active && view.body.clearTint(),\n );\n }\n break;\n }\n case \"kill\": {\n const view = this.monsters.get(e.monsterId);\n const x = view?.body.x ?? this.laneX(e.x);\n this.burstAt(this.puffs, x, ground - 30, e.boss ? 30 : 10);\n this.coinBurst(x, ground - 30, e.boss ? 14 : 3);\n if (e.boss) this.cameras.main.shake(260, 0.012);\n break;\n }\n case \"heroHit\":\n if (this.hero) {\n const { x } = this.heroPos();\n this.floatText(\n `-${formatBig(Math.round(e.damage))}`,\n x,\n ground - HERO_SIZE.h * this.field.unit,\n \"hurt\",\n );\n this.hero.setTint(0xff8080);\n this.time.delayedCall(80, () => this.hero?.clearTint());\n }\n break;\n case \"dodge\": {\n const { x } = this.heroPos();\n this.floatText(\n \"MISS\",\n x,\n ground - HERO_SIZE.h * this.field.unit,\n \"hit\",\n );\n break;\n }\n case \"heroDown\":\n if (this.hero)\n this.tweens.add({\n targets: this.hero,\n alpha: 0.25,\n angle: -80,\n duration: 300,\n });\n this.burstAt(this.puffs, this.heroPos().x, ground - 30, 16);\n break;\n case \"heroUp\":\n if (this.hero) {\n this.hero.setAlpha(1).setAngle(0);\n this.burstAt(this.heals, this.heroPos().x, ground - 30, 20);\n }\n break;\n case \"bossStart\":\n this.cameras.main.flash(200, 255, 80, 60);\n break;\n case \"stageCleared\":\n this.cameras.main.flash(260, 255, 240, 160);\n this.coinBurst(this.field.w / 2, ground - 60, 10);\n break;\n case \"skill\":\n this.skillFx(e.skill);\n break;\n default:\n break;\n }\n }\n\n private skillFx(skill: \"meteor\" | \"frenzy\" | \"heal\"): void {\n const { ground } = this.field;\n if (skill === \"meteor\") {\n for (const [, view] of this.monsters) {\n const tx = view.body.x;\n const m = this.add\n .image(tx + 120, -40, \"meteor\")\n .setDepth(35)\n .setScale(1.2);\n this.tweens.add({\n targets: m,\n x: tx,\n y: ground - 30,\n duration: 280,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.fire, tx, ground - 30, 16);\n m.destroy();\n },\n });\n }\n this.cameras.main.shake(200, 0.008);\n } else if (skill === \"frenzy\" && this.hero) {\n this.frenzyTween?.stop();\n const hero = this.hero;\n hero.setTint(0xffa0a0);\n this.frenzyTween = this.tweens.add({\n targets: hero,\n alpha: 0.75,\n yoyo: true,\n repeat: 11,\n duration: 250,\n onComplete: () => {\n hero.clearTint();\n hero.setAlpha(1);\n },\n });\n } else if (skill === \"heal\") {\n this.burstAt(this.heals, this.heroPos().x, ground - 40, 26);\n }\n }\n\n private burstAt(\n emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,\n x: number,\n y: number,\n count: number,\n ): void {\n emitter?.explode(count, x, y);\n }\n\n /** Coins pop out of a kill and fly down into the gold counter of the panel. */\n private coinBurst(x: number, y: number, count: number): void {\n const target = { x: this.field.w / 2, y: this.field.h + 24 };\n for (let i = 0; i < count; i++) {\n const coin = this.add.image(x, y, \"coin\").setDepth(45).setScale(0.9);\n const ox = Phaser.Math.Between(-50, 50);\n const oy = Phaser.Math.Between(-70, -30);\n this.tweens.add({\n targets: coin,\n x: x + ox,\n y: y + oy,\n duration: 260,\n ease: \"Quad.easeOut\",\n onComplete: () =>\n this.tweens.add({\n targets: coin,\n x: target.x,\n y: target.y,\n scale: 0.5,\n delay: 120 + i * 30,\n duration: 520,\n ease: \"Cubic.easeIn\",\n onComplete: () => coin.destroy(),\n }),\n });\n }\n }\n\n /** The server's collect landed: a shower of coins into the counter. */\n private coinShower(count: number): void {\n for (let i = 0; i < count; i++)\n this.time.delayedCall(i * 40, () =>\n this.coinBurst(\n Phaser.Math.Between(this.field.w * 0.25, this.field.w * 0.85),\n this.field.ground - 50,\n 1,\n ),\n );\n }\n\n private floatText(\n text: string,\n x: number,\n y: number,\n kind: \"hit\" | \"crit\" | \"hurt\",\n ): void {\n const t = this.add\n .text(x + Phaser.Math.Between(-12, 12), y, text, {\n fontFamily: \"system-ui, sans-serif\",\n fontSize: kind === \"crit\" ? \"26px\" : \"18px\",\n fontStyle: \"bold\",\n color:\n kind === \"crit\" ? \"#ffb02e\" : kind === \"hurt\" ? \"#ff5a4f\" : \"#ffffff\",\n stroke: \"#2b1d33\",\n strokeThickness: 4,\n })\n .setOrigin(0.5, 1)\n .setDepth(50);\n if (kind === \"crit\") t.setScale(1.4);\n this.tweens.add({\n targets: t,\n y: y - 46,\n scale: 1,\n alpha: 0,\n duration: 750,\n ease: \"Cubic.easeOut\",\n onComplete: () => t.destroy(),\n });\n }\n}\n"
185
+ "content": "import Phaser from \"phaser\";\nimport {\n BOSS_SIZE,\n HERO_X,\n type BattleEvent,\n type Monster,\n} from \"../game/battle\";\nimport { formatBig } from \"../game/format\";\nimport type { IdleSession } from \"../game/session\";\nimport { stageInfo, type Theme } from \"../game/stages\";\nimport { LANE_MAX_PX, battleHeight } from \"../layout\";\nimport {\n bakeCommon,\n heroColor,\n missileTexture,\n shadowTexture,\n shotTexture,\n} from \"./art\";\nimport { heroRig, type LookSource } from \"../character\";\nimport { Backdrop } from \"./backdrop\";\nimport { StatusBars } from \"./bars\";\nimport { EnemyActor } from \"./enemy\";\nimport { footPixels } from \"./gait\";\nimport { HERO_SCALE, HeroActor } from \"./hero\";\n\n// Draws the fight the session simulates: it owns no rules. Each frame it advances the session's\n// battle, turns the returned events into effects (numbers, coins, bursts) and puts the sprites where\n// the simulation says things are. The road scrolls under the hero (it stays at HERO_X on screen):\n// the backdrop's layers follow it at their own pace (parallax), the enemies — people dressed like\n// the hero — stand on the road. The field is the top part of the canvas (layout.ts); the Enhance\n// panel covers the rest.\n\ninterface EnemyView {\n actor: EnemyActor;\n bars: StatusBars;\n /** Where it last stood on the road — a fallen enemy stays there while the road moves on. */\n x: number;\n /** Who it is (a fallen one keeps its size through a resize). */\n monster: Monster;\n}\n\nexport interface BattleSceneOptions {\n /** How the hero is dressed (character/looks): the default chain when omitted; a project or a dev\n * page adds a source (the player's saved choice, a weapon picked in a preview). */\n lookChain?: readonly LookSource[];\n}\n\n/** Enemies stand a size bigger than the hero only when they are the boss (the battle keeps them that much farther apart). */\nconst BOSS_SCALE = BOSS_SIZE;\n/** The run is played at most this fast: a wider road walks the hero slower, never skating. */\nconst MAX_GAIT = 2.2;\n/** Characters stand at most this share of the field's height. */\nconst HERO_SHARE = 0.42;\n/** The bars start this far under the feet (below the shadow). */\nconst BARS_BELOW = 10;\n/** The rig's height, skeleton units (× scale = a character's height on screen). */\nconst RIG_HEIGHT = heroRig.data.height;\n/** Screen pixels a lane width of the road takes at unit 1 (the size the sprites were drawn for). */\nconst ROAD_PX = 620;\n\nexport class BattleScene extends Phaser.Scene {\n private field = {\n w: 480,\n h: 360,\n laneLeft: 0,\n laneW: 480,\n horizon: 220,\n ground: 290,\n unit: 1,\n road: ROAD_PX,\n };\n private sky?: Phaser.GameObjects.Graphics;\n private backdrop?: Backdrop;\n private clouds: Phaser.GameObjects.Image[] = [];\n private theme: Theme | null = null;\n private hero?: HeroActor;\n private heroBars?: StatusBars;\n private enemies = new Map<number, EnemyView>();\n /** Killed (or left) enemies still on the road until they fade. */\n private corpses: EnemyView[] = [];\n private killed = new Set<number>();\n private heroShadow?: Phaser.GameObjects.Image;\n private shots = new Map<number, Phaser.GameObjects.Image>();\n private enemyShots = new Map<number, Phaser.GameObjects.Image>();\n private sparks?: Phaser.GameObjects.Particles.ParticleEmitter;\n private puffs?: Phaser.GameObjects.Particles.ParticleEmitter;\n private heals?: Phaser.GameObjects.Particles.ParticleEmitter;\n private fire?: Phaser.GameObjects.Particles.ParticleEmitter;\n private frenzyTween?: Phaser.Tweens.Tween;\n private lastCollectAt = 0;\n /** Pixels the hero is pushed back by its own blow; decays every frame. */\n private recoil = 0;\n\n constructor(\n private readonly session: IdleSession,\n private readonly options: BattleSceneOptions = {},\n ) {\n super(\"idle-battle\");\n }\n\n create(): void {\n bakeCommon(this);\n this.sky = this.add.graphics().setDepth(0);\n this.backdrop = new Backdrop(this);\n for (let i = 0; i < 4; i++)\n this.clouds.push(this.add.image(0, 0, \"cloud\").setDepth(1).setAlpha(0.9));\n this.heroBars = new StatusBars(this, 30);\n this.heroShadow = this.add.image(0, 0, shadowTexture(this)).setDepth(12);\n this.hero = new HeroActor(this, this.options.lookChain);\n this.hero.view.setDepth(20);\n\n const particles = (\n texture: string,\n config: Phaser.Types.GameObjects.Particles.ParticleEmitterConfig,\n ) =>\n this.add\n .particles(0, 0, texture, { emitting: false, ...config })\n .setDepth(40);\n this.sparks = particles(\"spark\", {\n speed: { min: 80, max: 260 },\n lifespan: 420,\n scale: { start: 0.9, end: 0 },\n tint: [0xffffff, 0xffe27a, 0xffa53d],\n });\n this.puffs = particles(\"puff\", {\n speed: { min: 30, max: 120 },\n lifespan: 520,\n scale: { start: 0.9, end: 0.1 },\n alpha: { start: 0.8, end: 0 },\n });\n this.heals = particles(\"spark\", {\n speedY: { min: -140, max: -60 },\n speedX: { min: -40, max: 40 },\n lifespan: 900,\n scale: { start: 1, end: 0 },\n tint: 0x6dff8a,\n });\n this.fire = particles(\"spark\", {\n speed: { min: 120, max: 340 },\n lifespan: 520,\n scale: { start: 1.6, end: 0 },\n tint: [0xff4d1a, 0xffa53d, 0xffe27a],\n });\n\n this.scale.on(\"resize\", () => this.layout());\n this.layout();\n }\n\n // ── Layout ─────────────────────────────────────────────────────────────────────────────────\n\n private layout(): void {\n const w = this.scale.width;\n const h = battleHeight(this.scale.height);\n const laneW = Math.min(w, LANE_MAX_PX);\n this.field = {\n w,\n h,\n laneLeft: (w - laneW) / 2,\n laneW,\n // the road starts a little below the middle; the fighters stand on its far third\n horizon: Math.round(h * 0.56),\n ground: Math.round(h * 0.8),\n // Sprites are drawn for a ~620 px lane: a phone shrinks them so the hero does not cover the\n // first enemy, a wide field grows them — never taller than HERO_SHARE of the field.\n unit: Math.min(\n 1.35,\n Math.max(0.6, laneW / 620),\n (h * HERO_SHARE) / (RIG_HEIGHT * HERO_SCALE),\n ),\n road: ROAD_PX,\n };\n // The road is measured in the people standing on it, not in the screen: a lane width is as many\n // pixels as the characters' size makes it, so the line, the reach and the gaps keep their look on\n // any screen and through a resize (a narrow screen shows fewer enemies, never squeezed ones).\n this.field.road = ROAD_PX * this.field.unit;\n this.theme = null; // repaint the background for the new size\n this.paintBackground();\n this.clouds.forEach((c, i) =>\n c.setPosition(\n ((i + 0.3) / this.clouds.length) * w,\n h * (0.1 + 0.08 * (i % 2)),\n ),\n );\n }\n\n /** A point of the road on screen. */\n private roadX(x: number): number {\n return this.heroPos().x + (x - this.session.battle.heroX) * this.field.road;\n }\n\n private paintBackground(): void {\n const theme = stageInfo(this.session.battle.stage).theme;\n if (theme === this.theme || !this.sky || !this.backdrop) return;\n this.theme = theme;\n const { w, h, horizon } = this.field;\n this.cameras.main.setBackgroundColor(theme.sky[0]);\n const sky = this.sky.clear();\n sky.fillGradientStyle(\n theme.sky[0],\n theme.sky[0],\n theme.sky[1],\n theme.sky[1],\n 1,\n );\n sky.fillRect(0, 0, w, horizon + 4);\n // behind everything from depth 2; the foreground strip over the fighters, under shots and bars\n this.backdrop.layout(theme, { w, h, horizon }, 2, 22);\n }\n\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\n\n override update(_time: number, deltaMs: number): void {\n const dt = deltaMs / 1000;\n const battle = this.session.battle;\n if (this.hero) {\n // what the hero holds decides how close it fights (a sword at the enemy, a bow from afar)\n battle.setReach(this.hero.isMelee() ? \"melee\" : \"ranged\");\n // the first blow on someone comes a whole swing after the stop, and plays out before the run\n const swing = this.hero.swing();\n battle.setSwing(swing.windup, swing.recover);\n // the road moves no faster than the legs of the hero carry it at its size (no skating)\n battle.setWalkSpeed(\n (footPixels(this.hero.view) * MAX_GAIT) / this.field.road,\n );\n }\n // the line keeps a person's width and a bit between its people (their weapons stick out)\n battle.setMonsterGap(\n (HERO_SCALE * this.field.unit * RIG_HEIGHT * 0.85) / this.field.road,\n );\n // a new wave stands just past the right edge of what the screen shows\n battle.setWaveAhead(\n (this.field.w - this.heroPos().x) / this.field.road + 0.06,\n );\n const events = this.session.tick(dt);\n this.paintBackground();\n this.backdrop?.scroll(this.session.battle.heroX * this.field.road);\n this.syncHero();\n for (const e of events) this.react(e);\n this.syncEnemies();\n this.syncShots();\n this.syncEnemyShots();\n this.driftClouds(dt);\n const collect = this.session.lastCollect;\n if (collect && collect.at !== this.lastCollectAt) {\n this.lastCollectAt = collect.at;\n this.coinShower(\n Math.min(14, 4 + Math.floor(Math.log10(collect.amount + 1) * 3)),\n );\n }\n }\n\n private heroPos(): { x: number; y: number } {\n return {\n x: this.field.laneLeft + HERO_X * this.field.laneW,\n y: this.field.ground,\n };\n }\n\n /** The hero's height on screen. */\n private heroHeight(): number {\n return (this.hero?.view.displayHeight ?? 110) * 1.02;\n }\n\n private syncHero(): void {\n const hero = this.session.hero;\n const actor = this.hero;\n if (!actor) return;\n const { x, y } = this.heroPos();\n if (actor.setHero(hero)) this.burstAt(this.puffs, x, y - 40, 14);\n actor.tick(\n this.session.battle,\n this.session.battle.walkSpeed * this.field.road,\n );\n this.recoil *= 0.75;\n actor.view.setPosition(x - this.recoil, y);\n actor.view.setScale(HERO_SCALE * this.field.unit);\n actor.view.setVisible(!!hero);\n const sw = actor.view.displayHeight * 0.62;\n this.heroShadow\n ?.setPosition(x - this.recoil, y + 2)\n .setDisplaySize(sw, sw * 0.24)\n .setVisible(!!hero);\n\n // health and armor under the feet, with their numbers\n const b = this.session.battle;\n const bars = this.heroBars!;\n bars.setVisible(!!hero && b.heroDownLeft <= 0);\n if (!hero || b.heroDownLeft > 0) return;\n bars.draw(x, y + BARS_BELOW, this.barWidth(actor.view.displayHeight), {\n hp: b.heroHp,\n maxHp: b.stats.maxHp,\n armor: b.heroArmor,\n maxArmor: b.heroMaxArmor(),\n });\n }\n\n /** The bars are about a character wide. */\n private barWidth(height: number): number {\n return Math.max(58, Math.round(height * 0.55));\n }\n\n private syncEnemies(): void {\n const battle = this.session.battle;\n const alive = new Set<number>();\n battle.monsters.forEach((m, i) => {\n alive.add(m.id);\n const view = this.enemies.get(m.id) ?? this.spawnView(m);\n const x = this.roadX(m.x);\n view.x = m.x;\n // the size follows the screen like the hero's (a resize must not leave the line at its old size)\n view.actor.view.setScale(this.enemyScale(m));\n // the line stands a touch staggered in depth, the nearer one in front\n const y = this.field.ground + (i % 2) * 3;\n view.actor.place(x, y);\n view.actor.view.setDepth(m.spec.boss ? 18 : 16 - i * 0.01);\n view.actor.tick(\n battle,\n m,\n m.spec.speed * battle.walkSpeed * this.field.road,\n );\n view.bars.draw(\n x,\n y + BARS_BELOW,\n this.barWidth(view.actor.view.displayHeight),\n m,\n );\n });\n for (const [id, view] of this.enemies)\n if (!alive.has(id)) {\n view.bars.destroy();\n // killed — it falls where it stood; gone without a fight — it fades there\n if (this.killed.delete(id)) view.actor.die();\n else view.actor.vanish();\n this.enemies.delete(id);\n this.corpses.push(view);\n }\n // the fallen stay on their spot of the road while the hero runs on\n this.corpses = this.corpses.filter((c) => !c.actor.gone);\n for (const c of this.corpses) {\n c.actor.view.setScale(this.enemyScale(c.monster));\n c.actor.place(this.roadX(c.x), this.field.ground);\n }\n }\n\n private enemyScale(m: Monster): number {\n return HERO_SCALE * this.field.unit * (m.spec.boss ? BOSS_SCALE : 1);\n }\n\n private spawnView(m: Monster): EnemyView {\n const actor = new EnemyActor(this, m, shadowTexture(this));\n actor.view\n .setScale(this.enemyScale(m))\n .setPosition(this.roadX(m.x), this.field.ground);\n const view: EnemyView = {\n actor,\n bars: new StatusBars(this, 31),\n x: m.x,\n monster: m,\n };\n this.enemies.set(m.id, view);\n return view;\n }\n\n private syncShots(): void {\n const live = new Set<number>();\n const y = this.hero?.handY() ?? this.field.ground - 50;\n for (const p of this.session.battle.projectiles) {\n live.add(p.id);\n let img = this.shots.get(p.id);\n if (!img) {\n const hero = this.session.hero;\n // What the weapon in hand shoots: an arrow, a bolt, a bullet — or the class's glowing orb.\n const kind = this.hero?.shotKind() ?? \"orb\";\n const color = p.crit\n ? 0xff7a1a\n : heroColor(hero?.classID ?? \"\", hero?.id ?? \"\");\n img = this.add\n .image(\n this.roadX(p.x),\n y,\n kind === \"orb\"\n ? shotTexture(this, color)\n : missileTexture(this, kind),\n )\n .setDepth(25)\n .setScale(p.crit ? 1.35 : p.extra ? 0.75 : 1)\n .setData(\"spin\", kind === \"orb\");\n if (kind !== \"orb\" && p.crit) img.setTint(0xffb070);\n this.shots.set(p.id, img);\n }\n img.setPosition(this.roadX(p.x), y + (p.extra ? 10 : 0));\n if (img.getData(\"spin\")) img.rotation += 0.3;\n }\n for (const [id, img] of this.shots)\n if (!live.has(id)) {\n img.destroy();\n this.shots.delete(id);\n }\n }\n\n /** The enemies' shots fly left, at the hero — what their weapon shoots. */\n private syncEnemyShots(): void {\n const live = new Set<number>();\n for (const s of this.session.battle.enemyShots) {\n live.add(s.id);\n let img = this.enemyShots.get(s.id);\n if (!img) {\n img = this.add.image(this.roadX(s.x), 0, shotTexture(this, 0xff5a3c));\n img.setDepth(25).setData(\"spin\", true);\n this.enemyShots.set(s.id, img);\n }\n img.setPosition(\n this.roadX(s.x),\n img.getData(\"y\") ?? this.field.ground - 46,\n );\n if (img.getData(\"spin\")) img.rotation -= 0.3;\n }\n for (const [id, img] of this.enemyShots)\n if (!live.has(id)) {\n img.destroy();\n this.enemyShots.delete(id);\n }\n }\n\n private driftClouds(dt: number): void {\n // the clouds drift on their own and a little with the road (the farthest layer of all)\n const walking = this.session.battle.walking ? 1 : 0;\n for (const [i, c] of this.clouds.entries()) {\n c.x -= (6 + i * 3 + walking * this.field.road * 0.02) * dt;\n if (c.x < -100) c.x = this.field.w + 100;\n }\n }\n\n // ── Effects ────────────────────────────────────────────────────────────────────────────────\n\n private react(e: BattleEvent): void {\n const { ground } = this.field;\n switch (e.type) {\n case \"shoot\":\n case \"strike\":\n this.recoil = e.type === \"strike\" ? -5 : 3;\n break;\n case \"enemyShoot\": {\n const view = this.enemies.get(e.monsterId);\n // a bow looses an arrow, a crossbow a bolt, a gun a bullet; a staff — a red orb\n const kind = view?.actor.shotKind() ?? \"orb\";\n const missile =\n kind === \"arrow\" || kind === \"bolt\" || kind === \"bullet\";\n const img = this.add\n .image(\n this.roadX(e.shot.x),\n 0,\n missile ? missileTexture(this, kind) : shotTexture(this, 0xff5a3c),\n )\n .setDepth(25)\n .setFlipX(true);\n // the enemy's shots run red, so a bullet is never taken for a coin\n if (missile) img.setTint(0xff6a5a);\n img.setData(\"spin\", !missile);\n img.setData(\"y\", view?.actor.handY() ?? ground - 46);\n this.enemyShots.set(e.shot.id, img);\n break;\n }\n case \"hit\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n const top = ground - (view?.actor.view.displayHeight ?? 70) * 0.75;\n this.floatText(\n formatBig(Math.round(e.damage)),\n x,\n top,\n e.crit ? \"crit\" : \"hit\",\n );\n this.burstAt(this.sparks, x - 10, top + 30, e.crit ? 10 : 4);\n view?.actor.view.flash(0xffffff, 80);\n break;\n }\n case \"kill\": {\n this.killed.add(e.monsterId);\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.puffs, x, ground - 30, e.boss ? 30 : 10);\n this.coinBurst(x, ground - 30, e.boss ? 14 : 3);\n if (e.boss) this.cameras.main.shake(260, 0.012);\n break;\n }\n case \"heroHit\":\n if (this.hero) {\n const { x } = this.heroPos();\n // a blow the armor took whole shows blue; one that reached the health — red\n const onArmor = e.armor >= e.damage - 1e-9;\n this.floatText(\n `-${formatBig(Math.round(e.damage))}`,\n x,\n ground - this.heroHeight(),\n onArmor ? \"armor\" : \"hurt\",\n );\n this.hero.view.flash(onArmor ? 0x9fc8ff : 0xff6060, 90);\n }\n break;\n case \"dodge\": {\n const { x } = this.heroPos();\n this.floatText(\"MISS\", x, ground - this.heroHeight(), \"hit\");\n break;\n }\n case \"heroDown\":\n this.hero?.fall();\n this.burstAt(this.puffs, this.heroPos().x, ground - 30, 16);\n break;\n case \"heroUp\":\n if (this.hero) {\n this.hero.rise();\n this.burstAt(this.heals, this.heroPos().x, ground - 30, 20);\n }\n break;\n case \"bossStart\":\n this.cameras.main.flash(200, 255, 80, 60);\n break;\n case \"stageCleared\":\n this.cameras.main.flash(260, 255, 240, 160);\n this.coinBurst(this.field.w / 2, ground - 60, 10);\n break;\n case \"skill\":\n this.skillFx(e.skill);\n break;\n default:\n break;\n }\n }\n\n private skillFx(skill: \"meteor\" | \"frenzy\" | \"heal\"): void {\n const { ground } = this.field;\n if (skill === \"meteor\") {\n for (const [, view] of this.enemies) {\n const tx = view.actor.view.root.x;\n if (tx > this.field.w) continue;\n const m = this.add\n .image(tx + 120, -40, \"meteor\")\n .setDepth(35)\n .setScale(1.2);\n this.tweens.add({\n targets: m,\n x: tx,\n y: ground - 30,\n duration: 280,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.fire, tx, ground - 30, 16);\n m.destroy();\n },\n });\n }\n this.cameras.main.shake(200, 0.008);\n } else if (skill === \"frenzy\" && this.hero) {\n this.frenzyTween?.stop();\n const view = this.hero.view;\n view.setOverlay(0xffa0a0);\n const glow = { a: 1 };\n this.frenzyTween = this.tweens.add({\n targets: glow,\n a: 0.75,\n yoyo: true,\n repeat: 11,\n duration: 250,\n onUpdate: () => view.setAlpha(glow.a),\n onComplete: () => {\n view.setOverlay(null);\n view.setAlpha(1);\n },\n });\n } else if (skill === \"heal\") {\n this.burstAt(this.heals, this.heroPos().x, ground - 40, 26);\n }\n }\n\n private burstAt(\n emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,\n x: number,\n y: number,\n count: number,\n ): void {\n emitter?.explode(count, x, y);\n }\n\n /** Coins pop out of a kill and fly down into the gold counter of the panel. */\n private coinBurst(x: number, y: number, count: number): void {\n const target = { x: this.field.w / 2, y: this.field.h + 24 };\n for (let i = 0; i < count; i++) {\n const coin = this.add.image(x, y, \"coin\").setDepth(45).setScale(0.9);\n const ox = Phaser.Math.Between(-50, 50);\n const oy = Phaser.Math.Between(-70, -30);\n this.tweens.add({\n targets: coin,\n x: x + ox,\n y: y + oy,\n duration: 260,\n ease: \"Quad.easeOut\",\n onComplete: () =>\n this.tweens.add({\n targets: coin,\n x: target.x,\n y: target.y,\n scale: 0.5,\n delay: 120 + i * 30,\n duration: 520,\n ease: \"Cubic.easeIn\",\n onComplete: () => coin.destroy(),\n }),\n });\n }\n }\n\n /** The server's collect landed: a shower of coins into the counter. */\n private coinShower(count: number): void {\n for (let i = 0; i < count; i++)\n this.time.delayedCall(i * 40, () =>\n this.coinBurst(\n Phaser.Math.Between(this.field.w * 0.25, this.field.w * 0.85),\n this.field.ground - 50,\n 1,\n ),\n );\n }\n\n private floatText(\n text: string,\n x: number,\n y: number,\n kind: \"hit\" | \"crit\" | \"hurt\" | \"armor\",\n ): void {\n const t = this.add\n .text(x + Phaser.Math.Between(-12, 12), y, text, {\n fontFamily: \"system-ui, sans-serif\",\n fontSize: kind === \"crit\" ? \"26px\" : \"18px\",\n fontStyle: \"bold\",\n color:\n kind === \"crit\"\n ? \"#ffb02e\"\n : kind === \"hurt\"\n ? \"#ff5a4f\"\n : kind === \"armor\"\n ? \"#7fc0ff\"\n : \"#ffffff\",\n stroke: \"#2b1d33\",\n strokeThickness: 4,\n })\n .setOrigin(0.5, 1)\n .setDepth(50);\n if (kind === \"crit\") t.setScale(1.4);\n this.tweens.add({\n targets: t,\n y: y - 46,\n scale: 1,\n alpha: 0,\n duration: 750,\n ease: \"Cubic.easeOut\",\n onComplete: () => t.destroy(),\n });\n }\n}\n"
186
+ },
187
+ {
188
+ "path": "phaser/enemy.ts",
189
+ "content": "import type Phaser from \"phaser\";\nimport {\n HERO_ANIMATIONS,\n heroAttack,\n heroShot,\n CharacterView,\n heroRig,\n vectorArt,\n type ShotKind,\n} from \"../character\";\nimport type { Battle, Monster } from \"../game/battle\";\nimport { enemyLook } from \"../game/enemyLooks\";\nimport { isRunning, run } from \"./gait\";\nimport { heroCatalog } from \"./hero\";\n\n// An enemy on the road — a person on the hero's own rig (game/enemyLooks dresses it), facing the\n// hero, with its shadow under its feet. It waits in its stance, runs up when it closes the gap (feet in\n// step with the road, gait.ts), swings or shoots so the hit frame of its attack lands when the battle\n// deals the blow. Killed, it falls where it stood on the road; gone without a fight (a boss that won)\n// it fades away.\n\nexport class EnemyActor {\n readonly view: CharacterView;\n readonly boss: boolean;\n private readonly attack: string;\n private readonly shot: ShotKind;\n private readonly hitAt: number;\n private dying = false;\n /** Faded out and destroyed. */\n gone = false;\n readonly shadow: Phaser.GameObjects.Image;\n\n constructor(\n private readonly scene: Phaser.Scene,\n monster: Monster,\n shadowTexture: string,\n ) {\n this.boss = monster.spec.boss;\n this.view = new CharacterView(scene, {\n rig: heroRig,\n art: vectorArt(scene.textures, heroCatalog, 2),\n look: enemyLook(monster.spec.kind, monster.id, monster.spec.boss),\n });\n // the art faces left — towards the hero coming from the left\n this.view.setFacing(1);\n const parts = this.view.parts;\n const held = parts.isWorn(\"Weapon\") ? parts.part(\"Weapon\") : null;\n this.attack = heroAttack(held);\n this.shot = heroShot(held);\n const event = heroRig.data.animations\n .get(this.attack)\n ?.events.find((e) => e.name === \"Attack\");\n this.hitAt = event?.time ?? 0.25;\n this.view.on(\"end\", (e) => {\n if (!this.dying && e.animation !== HERO_ANIMATIONS.die)\n this.view.play(HERO_ANIMATIONS.idle);\n });\n this.shadow = scene.add.image(0, 0, shadowTexture).setDepth(12);\n // it comes into view softly (on a wide screen the line can stand inside the field already)\n this.view.setAlpha(0);\n scene.tweens.add({\n targets: { a: 0 },\n a: 1,\n duration: 350,\n onUpdate: (tw) => this.setAlpha(tw.getValue() ?? 1),\n });\n }\n\n /** Where its feet are on screen (the shadow follows). */\n place(x: number, y: number): void {\n this.view.setPosition(x, y);\n const w = this.view.displayHeight * 0.62;\n this.shadow.setPosition(x, y + 2).setDisplaySize(w, w * 0.24);\n }\n\n private setAlpha(a: number): void {\n this.view.setAlpha(a);\n this.shadow.setAlpha(a);\n }\n\n /**\n * Each frame: run while closing the gap (`closingPx` — how fast it moves along the road, screen\n * pixels a second), swing so the blow lands with the battle's hit.\n */\n tick(battle: Battle, monster: Monster, closingPx: number): void {\n if (this.dying) return;\n if (battle.closing(monster)) return run(this.view, closingPx);\n if (isRunning(this.view))\n this.view.play(HERO_ANIMATIONS.idle, { mix: 0.12 });\n if (!battle.engaged(monster)) return;\n const next = Math.max(0, monster.cooldown);\n const current = this.view.animator.current();\n const swinging = current === this.attack && !this.view.animator.finished();\n if (swinging && this.view.animator.time() < this.hitAt) return;\n if (next <= this.hitAt)\n this.view.play(this.attack, {\n loop: false,\n from: this.hitAt - next,\n mix: 0.08,\n });\n }\n\n /** What its shots look like in flight (by the weapon in its hand). */\n shotKind(): ShotKind {\n return this.shot;\n }\n\n /** The weapon hand's y on screen (where its shots leave). */\n handY(): number {\n return this.view.worldPoint(\"hand\").y;\n }\n\n /** Killed: falls on the spot, lies a moment, fades and is gone. */\n die(): void {\n this.dying = true;\n this.view.play(HERO_ANIMATIONS.die, { loop: false, mix: 0.05 });\n this.fadeOut(700, 400);\n }\n\n /** Left the road without a fight (a boss that won): fades where it stands. */\n vanish(): void {\n this.dying = true;\n this.fadeOut(0, 350);\n }\n\n private fadeOut(delay: number, duration: number): void {\n this.scene.tweens.add({\n targets: { a: 1 },\n a: 0,\n delay,\n duration,\n onUpdate: (tw) => this.setAlpha(tw.getValue() ?? 0),\n onComplete: () => this.destroy(),\n });\n }\n\n destroy(): void {\n if (this.gone) return;\n this.gone = true;\n this.view.destroy();\n this.shadow.destroy();\n }\n}\n"
190
+ },
191
+ {
192
+ "path": "phaser/gait.ts",
193
+ "content": "import {\n GAIT_GROUND_SPEED,\n HERO_ANIMATIONS,\n type CharacterView,\n} from \"../character\";\n\n// Running on the road without skating: a planted foot slides back at the gait's ground speed\n// (GAIT_GROUND_SPEED, skeleton units a second) × the character's scale × the animation's speed, so the\n// run is played at exactly the speed that matches how fast the road moves under it.\n\n/** The run a character plays: weapon held ready when it holds one, arms swinging when it does not. */\nexport function gaitOf(view: CharacterView): string {\n return view.parts.isWorn(\"Weapon\")\n ? HERO_ANIMATIONS.runArmed\n : HERO_ANIMATIONS.run;\n}\n\n/** Screen pixels a second the road passes under a planted foot at animation speed 1. */\nexport function footPixels(view: CharacterView): number {\n const scale = view.displayHeight / view.rig.data.height;\n return (GAIT_GROUND_SPEED[gaitOf(view)] ?? 1) * scale;\n}\n\n/** Plays the run so its feet keep pace with ground moving `groundPx` screen pixels a second. */\nexport function run(view: CharacterView, groundPx: number): void {\n const gait = gaitOf(view);\n if (view.animation() !== gait) view.play(gait, { loop: true, mix: 0.1 });\n view.animator.setSpeed(groundPx / Math.max(1e-3, footPixels(view)));\n}\n\n/** Is it running now (so a stop goes back to its stance)? */\nexport function isRunning(view: CharacterView): boolean {\n const asked = view.animation();\n return asked === HERO_ANIMATIONS.run || asked === HERO_ANIMATIONS.runArmed;\n}\n"
194
+ },
195
+ {
196
+ "path": "phaser/hero.ts",
197
+ "content": "import type Phaser from \"phaser\";\nimport {\n HERO_ANIMATIONS,\n heroAttack,\n heroIsMelee,\n heroShot,\n CharacterView,\n heroRig,\n createCatalog,\n DEFAULT_LOOK_CHAIN,\n resolveLook,\n vectorArt,\n type LookSource,\n type LookSubject,\n type ShotKind,\n} from \"../character\";\nimport type { Battle } from \"../game/battle\";\nimport type { HeroView } from \"../game/session\";\nimport { isRunning, run } from \"./gait\";\n\n// The fighting hero, drawn by the character system (character/rigs/hero): dressed from its class and\n// gear (character/looks), playing its animations in time with the fight — the run while it heads down\n// the road (its feet kept in step with the road, gait.ts), the attack landing on the hit once a monster\n// is within reach, Die when it falls, Idle otherwise. Which attack it plays follows what is in its hand (heroAttack): fists punch, a spear\n// stabs, a sword or an axe swings — face to face (heroIsMelee) — a bow or a gun shoots from afar.\n\n/** The hero's height on screen at unit 1 (the art was laid out for ~110 px). */\nexport const HERO_SCALE = 0.62;\n\n/** The part catalog the hero and the enemies are drawn from (one, so their art is baked once). */\nexport const heroCatalog = createCatalog();\n\nexport function lookSubject(hero: HeroView): LookSubject {\n const equipment: Record<string, string> = {};\n for (const [slot, worn] of Object.entries(hero.input.model?.Equipment ?? {}))\n if (worn?.ItemID) equipment[slot] = worn.ItemID;\n return { id: hero.id, classId: hero.classID, equipment };\n}\n\nexport class HeroActor {\n readonly view: CharacterView;\n private key = \"\";\n private attack: string = heroAttack(null);\n private shot: ShotKind = \"orb\";\n private melee = true;\n private hitAt = new Map<string, number>();\n private length = new Map<string, number>();\n private down = false;\n\n constructor(\n scene: Phaser.Scene,\n private readonly lookChain: readonly LookSource[] = DEFAULT_LOOK_CHAIN,\n ) {\n this.view = new CharacterView(scene, {\n rig: heroRig,\n art: vectorArt(scene.textures, heroCatalog, 2),\n });\n this.view.setFacing(-1); // the art faces left; the hero fights to the right\n for (const [name, a] of heroRig.data.animations) {\n const e = a.events.find((x) => x.name === \"Attack\");\n if (e) this.hitAt.set(name, e.time);\n this.length.set(name, a.duration);\n }\n this.view.on(\"end\", (e) => {\n if (!this.down && e.animation !== HERO_ANIMATIONS.die)\n this.view.play(HERO_ANIMATIONS.idle);\n });\n }\n\n /** Dresses the hero; true when the look changed (the scene puffs). */\n setHero(hero: HeroView | null): boolean {\n if (!hero) return false;\n const subject = lookSubject(hero);\n const key = JSON.stringify(subject);\n if (key === this.key) return false;\n this.key = key;\n this.view.parts.reset();\n this.view.setLook(resolveLook(subject, this.lookChain));\n const parts = this.view.parts;\n const held = parts.isWorn(\"Weapon\") ? parts.part(\"Weapon\") : null;\n this.attack = heroAttack(held);\n this.shot = heroShot(held);\n this.melee = heroIsMelee(held);\n return true;\n }\n\n /**\n * Runs while the road is clear (`groundPx` — how fast the road moves under it, screen pixels a\n * second); at a monster, starts the swing so its hit frame lands on the hit.\n */\n tick(battle: Battle, groundPx: number): void {\n if (this.down) return;\n if (battle.walking) return run(this.view, groundPx);\n if (isRunning(this.view))\n this.view.play(HERO_ANIMATIONS.idle, { mix: 0.12 });\n if (!battle.hasTarget()) return;\n const hit = this.hitAt.get(this.attack) ?? 0.25;\n const next = battle.heroNextShotIn();\n const current = this.view.animator.current();\n const swinging = current === this.attack && !this.view.animator.finished();\n if (swinging && this.view.animator.time() < hit) return;\n if (next <= hit)\n this.view.play(this.attack, { loop: false, from: hit - next, mix: 0.08 });\n }\n\n /** The attack in hand, timed: seconds of the swing before its hit and of the follow-through after it. */\n swing(): { windup: number; recover: number } {\n const hit = this.hitAt.get(this.attack) ?? 0.25;\n return {\n windup: hit,\n recover: Math.max(0, (this.length.get(this.attack) ?? hit) - hit),\n };\n }\n\n fall(): void {\n this.down = true;\n this.view.play(HERO_ANIMATIONS.die, { loop: false, mix: 0.05 });\n }\n\n rise(): void {\n this.down = false;\n this.view.play(HERO_ANIMATIONS.idle, { mix: 0.15 });\n }\n\n /** Does it fight face to face with what it holds (the battle's reach follows it)? */\n isMelee(): boolean {\n return this.melee;\n }\n\n /** What the hero's shots look like in flight (by the weapon in hand). */\n shotKind(): ShotKind {\n return this.shot;\n }\n\n /** The weapon hand's y on screen (where shots leave). */\n handY(): number {\n return this.view.worldPoint(\"hand\").y;\n }\n}\n"
146
198
  },
147
199
  {
148
200
  "path": "scene.ts",
@@ -158,11 +210,11 @@
158
210
  },
159
211
  {
160
212
  "path": "ui/EnhancePanel.tsx",
161
- "content": "import { memo, useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Icon,\n ResourceCounter,\n buttonStyle,\n outlined,\n useCatalog,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport type { HeroView, IdleSession } from \"../game/session\";\nimport {\n PERCENT_ROLES,\n enhanceValue,\n statCap,\n statCost,\n type Role,\n type StatInfo,\n} from \"../game/heroStats\";\nimport { formatBig, formatStat } from \"../game/format\";\nimport { t } from \"../i18n\";\n\n// \"Enhance\" — the stats of the fighting hero, bought with gold level by level (Legend Slime's main\n// list). ×1 / ×10 / MAX sets how many levels one tap asks for; holding the button keeps buying. The\n// session sends one request per stat at a time and adds the taps in between up — see IdleSession.enhance.\n\ntype Mult = 1 | 10 | \"max\";\nconst MAX_LEVELS = 1000;\nconst HOLD_START_MS = 380;\nconst HOLD_EVERY_MS = 140;\n\nconst ROLE_GLYPH: Record<Role, Glyph> = {\n damage: \"sword\",\n health: \"heart\",\n regen: \"potion\",\n armor: \"shield\",\n attackSpeed: \"energy\",\n critChance: \"star\",\n critDamage: \"flame\",\n dodge: \"bulb\",\n multiShot: \"swords\",\n allMight: \"crown\",\n};\n\nexport function EnhancePanel({\n session,\n hero,\n}: {\n session: IdleSession;\n hero: HeroView;\n}): ReactNode {\n const [mult, setMult] = useState<Mult>(1);\n const catalog = useCatalog();\n const currency =\n session.income?.currencyID ??\n hero.stats.find((s) => s.price)?.price?.currencyID ??\n \"\";\n const rate = session.ratePerSecond();\n\n return (\n <div style={{ display: \"grid\", gap: 8, minHeight: 0 }}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n justifyContent: \"space-between\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n {currency ? (\n <ResourceCounter\n currencyID={currency}\n amount={session.balance(currency)}\n size={26}\n />\n ) : null}\n {rate > 0 ? (\n <span style={{ ...outlined, fontSize: 12, color: v.green }}>\n +{formatBig(Math.round(rate * 10) / 10)}\n {t(\"perSecond\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 4 }}>\n {([1, 10, \"max\"] as Mult[]).map((m) => (\n <button\n key={String(m)}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setMult(m)}\n data-tutorial-anchor={`idle:mult:${m}`}\n style={{\n ...buttonStyle(mult === m ? \"gold\" : \"grey\", false, \"sm\"),\n padding: \"4px 10px\",\n minWidth: 44,\n }}\n >\n {m === \"max\" ? t(\"max\") : `×${m}`}\n </button>\n ))}\n </div>\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"baseline\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <span style={{ color: v.text, fontSize: 15 }}>\n {catalog.localize(hero.name)}\n </span>\n <span>\n {t(\"rank\")} {hero.rank}\n </span>\n <span>\n {t(\"power\")} {formatBig(hero.power)}\n </span>\n </div>\n <Rows session={session} hero={hero} mult={mult} />\n </div>\n );\n}\n\n// The rows redraw when the hero changes (an upgrade, gear, the balance) — not with the fight.\nconst Rows = memo(function Rows({\n session,\n hero,\n mult,\n}: {\n session: IdleSession;\n hero: HeroView;\n mult: Mult;\n}): ReactNode {\n const stats = hero.stats.filter((s) => s.price);\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n {stats.map((stat) => (\n <StatRow\n key={stat.id}\n session={session}\n hero={hero}\n stat={stat}\n mult={mult}\n />\n ))}\n </div>\n );\n});\n\nfunction StatRow({\n session,\n hero,\n stat,\n mult,\n}: {\n session: IdleSession;\n hero: HeroView;\n stat: StatInfo;\n mult: Mult;\n}): ReactNode {\n const catalog = useCatalog();\n const level = Number(hero.input.model?.StatLevels?.[stat.id] ?? 0);\n const cap = statCap(stat, Math.max(1, hero.rank), hero.def);\n const capped = cap > 0 && level >= cap;\n const want = mult === \"max\" ? MAX_LEVELS : mult;\n const plan = session.plan(stat.id, want);\n const levels = plan.levels;\n const percent = stat.role ? PERCENT_ROLES.has(stat.role) : false;\n const value = enhanceValue(hero.input, stat.id, level);\n const next = enhanceValue(hero.input, stat.id, level + Math.max(1, levels));\n const missing = stat.requirements.filter(\n (r) => Number(hero.input.model?.StatLevels?.[r.statID] ?? 0) < r.level,\n );\n const blocked = capped || missing.length > 0;\n const can = !blocked && levels > 0;\n const cost = levels > 0 ? plan.cost : statCost(stat, level + 1);\n\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: \"6px 8px\",\n borderRadius: 12,\n background: v.panel,\n border: `2px solid ${v.panelEdge}`,\n boxShadow: v.panelShadow,\n }}\n >\n <div\n style={{\n width: 46,\n height: 46,\n flex: \"0 0 46px\",\n borderRadius: 12,\n display: \"grid\",\n placeItems: \"center\",\n background: v.wellSoft,\n border: `2px solid ${v.panelEdge}`,\n position: \"relative\",\n }}\n >\n <Icon glyph={stat.role ? ROLE_GLYPH[stat.role] : \"star\"} size={28} />\n <span\n style={{\n ...outlined,\n position: \"absolute\",\n bottom: -6,\n fontSize: 10,\n padding: \"0 4px\",\n borderRadius: 6,\n background: v.panelDeep,\n }}\n >\n Lv {formatBig(level)}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(stat.name)}\n </div>\n <div style={{ ...outlined, fontSize: 15, color: v.gold }}>\n {formatStat(value, percent)}\n {!blocked && levels > 0 ? (\n <span style={{ fontSize: 11, color: v.green }}>\n {\" \"}\n → {formatStat(next, percent)}\n </span>\n ) : null}\n </div>\n {cap > 0 ? (\n <div style={{ ...outlined, fontSize: 10, color: v.textDim }}>\n {level}/{cap}\n {capped ? ` · ${t(\"rankUpToRaise\")}` : \"\"}\n {missing.length > 0\n ? ` · ${t(\"needs\")}: ${missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}`\n : \"\"}\n </div>\n ) : null}\n </div>\n <HoldButton\n disabled={!can}\n repeat={mult !== \"max\"}\n onPress={() => session.enhance(stat.id, levels > 0 ? levels : 1)}\n anchor={`idle:enhance:${stat.id}`}\n >\n {capped ? (\n t(\"maxed\")\n ) : (\n <span\n style={{ display: \"grid\", justifyItems: \"center\", lineHeight: 1.1 }}\n >\n <span style={{ fontSize: 13 }}>\n {t(\"enhance\")}\n {levels > 1 ? ` ×${levels}` : \"\"}\n </span>\n {stat.price ? (\n <span\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 3,\n fontSize: 12,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: stat.price.currencyID,\n amount: cost,\n })}\n size={14}\n />\n {formatBig(cost)}\n </span>\n ) : null}\n </span>\n )}\n </HoldButton>\n </div>\n );\n}\n\n/** A button that fires on press and keeps firing while held (after a short pause). */\nfunction HoldButton({\n children,\n disabled,\n repeat,\n onPress,\n anchor,\n}: {\n children: ReactNode;\n disabled: boolean;\n repeat: boolean;\n onPress: () => void;\n anchor: string;\n}): ReactNode {\n const kit = useUiKit();\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const press = useRef(onPress);\n press.current = onPress;\n const live = useRef(!disabled);\n live.current = !disabled;\n\n const stop = () => {\n if (timer.current) clearTimeout(timer.current);\n timer.current = null;\n };\n useEffect(() => stop, []);\n useEffect(() => {\n if (disabled) stop();\n }, [disabled]);\n\n const loop = () => {\n timer.current = setTimeout(() => {\n if (!live.current) return stop();\n press.current();\n loop();\n }, HOLD_EVERY_MS);\n };\n\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={disabled}\n data-tutorial-anchor={anchor}\n onPointerDown={(e) => {\n if (disabled || e.button !== 0) return;\n kit.play(\"click\");\n press.current();\n if (!repeat) return;\n stop();\n timer.current = setTimeout(loop, HOLD_START_MS);\n }}\n onPointerUp={stop}\n onPointerLeave={stop}\n onPointerCancel={stop}\n onContextMenu={(e) => e.preventDefault()}\n style={{\n ...buttonStyle(\"green\", disabled, \"sm\"),\n minWidth: 104,\n minHeight: 46,\n touchAction: \"none\",\n userSelect: \"none\",\n }}\n >\n {children}\n </button>\n );\n}\n"
213
+ "content": "import { memo, useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Columns,\n Icon,\n ResourceCounter,\n buttonStyle,\n outlined,\n useCatalog,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport type { HeroView, IdleSession } from \"../game/session\";\nimport {\n PERCENT_ROLES,\n enhanceValue,\n statCap,\n statCost,\n type Role,\n type StatInfo,\n} from \"../game/heroStats\";\nimport { formatBig, formatStat } from \"../game/format\";\nimport { t } from \"../i18n\";\n\n// \"Enhance\" — the stats of the fighting hero, bought with gold level by level (Legend Slime's main\n// list). ×1 / ×10 / MAX sets how many levels one tap asks for; holding the button keeps buying. The\n// session sends one request per stat at a time and adds the taps in between up — see IdleSession.enhance.\n\ntype Mult = 1 | 10 | \"max\";\nconst MAX_LEVELS = 1000;\nconst HOLD_START_MS = 380;\nconst HOLD_EVERY_MS = 140;\n\nconst ROLE_GLYPH: Record<Role, Glyph> = {\n damage: \"sword\",\n health: \"heart\",\n regen: \"potion\",\n armor: \"shield\",\n attackSpeed: \"energy\",\n critChance: \"star\",\n critDamage: \"flame\",\n dodge: \"bulb\",\n multiShot: \"swords\",\n allMight: \"crown\",\n};\n\nexport function EnhancePanel({\n session,\n hero,\n}: {\n session: IdleSession;\n hero: HeroView;\n}): ReactNode {\n const [mult, setMult] = useState<Mult>(1);\n const catalog = useCatalog();\n const currency =\n session.income?.currencyID ??\n hero.stats.find((s) => s.price)?.price?.currencyID ??\n \"\";\n const rate = session.ratePerSecond();\n\n return (\n <div style={{ display: \"grid\", gap: 8, minHeight: 0 }}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n justifyContent: \"space-between\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n {currency ? (\n <ResourceCounter\n currencyID={currency}\n amount={session.balance(currency)}\n size={26}\n />\n ) : null}\n {rate > 0 ? (\n <span style={{ ...outlined, fontSize: 12, color: v.green }}>\n +{formatBig(Math.round(rate * 10) / 10)}\n {t(\"perSecond\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 4 }}>\n {([1, 10, \"max\"] as Mult[]).map((m) => (\n <button\n key={String(m)}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setMult(m)}\n data-tutorial-anchor={`idle:mult:${m}`}\n style={{\n ...buttonStyle(mult === m ? \"gold\" : \"grey\", false, \"sm\"),\n padding: \"4px 10px\",\n minWidth: 44,\n }}\n >\n {m === \"max\" ? t(\"max\") : `×${m}`}\n </button>\n ))}\n </div>\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"baseline\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <span style={{ color: v.text, fontSize: 15 }}>\n {catalog.localize(hero.name)}\n </span>\n <span>\n {t(\"rank\")} {hero.rank}\n </span>\n <span>\n {t(\"power\")} {formatBig(hero.power)}\n </span>\n </div>\n <Rows session={session} hero={hero} mult={mult} />\n </div>\n );\n}\n\n// The rows redraw when the hero changes (an upgrade, gear, the balance) — not with the fight.\nconst Rows = memo(function Rows({\n session,\n hero,\n mult,\n}: {\n session: IdleSession;\n hero: HeroView;\n mult: Mult;\n}): ReactNode {\n const stats = hero.stats.filter((s) => s.price);\n return (\n <Columns gap={6} min={340}>\n {stats.map((stat) => (\n <StatRow\n key={stat.id}\n session={session}\n hero={hero}\n stat={stat}\n mult={mult}\n />\n ))}\n </Columns>\n );\n});\n\nfunction StatRow({\n session,\n hero,\n stat,\n mult,\n}: {\n session: IdleSession;\n hero: HeroView;\n stat: StatInfo;\n mult: Mult;\n}): ReactNode {\n const catalog = useCatalog();\n const level = Number(hero.input.model?.StatLevels?.[stat.id] ?? 0);\n const cap = statCap(stat, Math.max(1, hero.rank), hero.def);\n const capped = cap > 0 && level >= cap;\n const want = mult === \"max\" ? MAX_LEVELS : mult;\n const plan = session.plan(stat.id, want);\n const levels = plan.levels;\n const percent = stat.role ? PERCENT_ROLES.has(stat.role) : false;\n const value = enhanceValue(hero.input, stat.id, level);\n const next = enhanceValue(hero.input, stat.id, level + Math.max(1, levels));\n const missing = stat.requirements.filter(\n (r) => Number(hero.input.model?.StatLevels?.[r.statID] ?? 0) < r.level,\n );\n const blocked = capped || missing.length > 0;\n const can = !blocked && levels > 0;\n const cost = levels > 0 ? plan.cost : statCost(stat, level + 1);\n\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: \"6px 8px\",\n borderRadius: 12,\n background: v.panel,\n border: `2px solid ${v.panelEdge}`,\n boxShadow: v.panelShadow,\n }}\n >\n <div\n style={{\n width: 46,\n height: 46,\n flex: \"0 0 46px\",\n borderRadius: 12,\n display: \"grid\",\n placeItems: \"center\",\n background: v.wellSoft,\n border: `2px solid ${v.panelEdge}`,\n position: \"relative\",\n }}\n >\n <Icon glyph={stat.role ? ROLE_GLYPH[stat.role] : \"star\"} size={28} />\n <span\n style={{\n ...outlined,\n position: \"absolute\",\n bottom: -6,\n fontSize: 10,\n padding: \"0 4px\",\n borderRadius: 6,\n background: v.panelDeep,\n }}\n >\n Lv {formatBig(level)}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(stat.name)}\n </div>\n <div style={{ ...outlined, fontSize: 15, color: v.gold }}>\n {formatStat(value, percent)}\n {!blocked && levels > 0 ? (\n <span style={{ fontSize: 11, color: v.green }}>\n {\" \"}\n → {formatStat(next, percent)}\n </span>\n ) : null}\n </div>\n {cap > 0 ? (\n <div style={{ ...outlined, fontSize: 10, color: v.textDim }}>\n {level}/{cap}\n {capped ? ` · ${t(\"rankUpToRaise\")}` : \"\"}\n {missing.length > 0\n ? ` · ${t(\"needs\")}: ${missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}`\n : \"\"}\n </div>\n ) : null}\n </div>\n <HoldButton\n disabled={!can}\n repeat={mult !== \"max\"}\n onPress={() => session.enhance(stat.id, levels > 0 ? levels : 1)}\n anchor={`idle:enhance:${stat.id}`}\n >\n {capped ? (\n t(\"maxed\")\n ) : (\n <span\n style={{ display: \"grid\", justifyItems: \"center\", lineHeight: 1.1 }}\n >\n <span style={{ fontSize: 13 }}>\n {t(\"enhance\")}\n {levels > 1 ? ` ×${levels}` : \"\"}\n </span>\n {stat.price ? (\n <span\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 3,\n fontSize: 12,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: stat.price.currencyID,\n amount: cost,\n })}\n size={14}\n />\n {formatBig(cost)}\n </span>\n ) : null}\n </span>\n )}\n </HoldButton>\n </div>\n );\n}\n\n/** A button that fires on press and keeps firing while held (after a short pause). */\nfunction HoldButton({\n children,\n disabled,\n repeat,\n onPress,\n anchor,\n}: {\n children: ReactNode;\n disabled: boolean;\n repeat: boolean;\n onPress: () => void;\n anchor: string;\n}): ReactNode {\n const kit = useUiKit();\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const press = useRef(onPress);\n press.current = onPress;\n const live = useRef(!disabled);\n live.current = !disabled;\n\n const stop = () => {\n if (timer.current) clearTimeout(timer.current);\n timer.current = null;\n };\n useEffect(() => stop, []);\n useEffect(() => {\n if (disabled) stop();\n }, [disabled]);\n\n const loop = () => {\n timer.current = setTimeout(() => {\n if (!live.current) return stop();\n press.current();\n loop();\n }, HOLD_EVERY_MS);\n };\n\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={disabled}\n data-tutorial-anchor={anchor}\n onPointerDown={(e) => {\n if (disabled || e.button !== 0) return;\n kit.play(\"click\");\n press.current();\n if (!repeat) return;\n stop();\n timer.current = setTimeout(loop, HOLD_START_MS);\n }}\n onPointerUp={stop}\n onPointerLeave={stop}\n onPointerCancel={stop}\n onContextMenu={(e) => e.preventDefault()}\n style={{\n ...buttonStyle(\"green\", disabled, \"sm\"),\n minWidth: 104,\n minHeight: 46,\n touchAction: \"none\",\n userSelect: \"none\",\n }}\n >\n {children}\n </button>\n );\n}\n"
162
214
  },
163
215
  {
164
216
  "path": "ui/GamePanel.tsx",
165
- "content": "import {\n useEffect,\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n Button,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n errorText,\n outlined,\n screenBackground,\n useCatalog,\n useCelebrate,\n useNotify,\n v,\n} from \"@idosgames/react/ui\";\nimport type { IdleSession } from \"../game/session\";\nimport { formatBig, formatDuration } from \"../game/format\";\nimport { BATTLE_HEIGHT_CSS } from \"../layout\";\nimport { t } from \"../i18n\";\nimport { BattleHud } from \"./BattleHud\";\nimport { BottomNav } from \"./BottomNav\";\nimport { EnhancePanel } from \"./EnhancePanel\";\nimport { GuideQuest } from \"./GuideQuest\";\nimport { SkillBar } from \"./SkillBar\";\n\n// The game's whole interface over the canvas: the HUD on top of the fight, and under it the panel —\n// the quest strip, the skills, Enhance, and the bottom bar to the systems. The lobby, balances and\n// wallet are the base's; this is the game's own screen, Legend Slime-style. Next to the base the game\n// sits inside the lobby, whose tabs open the systems — then the game's own bar is not drawn (`nav`).\n\nexport function makeGamePanel(\n session: IdleSession,\n features: FeatureRegistry,\n nav = true,\n): ComponentType {\n return function IdleRpgPanel(): ReactNode {\n useSyncExternalStore(session.subscribe, session.getVersion);\n useNotices(session);\n const hero = session.hero;\n\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n fontFamily: v.font,\n color: v.text,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 0,\n left: 0,\n right: 0,\n height: BATTLE_HEIGHT_CSS,\n }}\n >\n <BattleHud session={session} />\n </div>\n <div\n style={{\n position: \"absolute\",\n top: BATTLE_HEIGHT_CSS,\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n flexDirection: \"column\",\n background: screenBackground,\n pointerEvents: \"auto\",\n }}\n >\n <div\n className=\"idos-scroll\"\n style={{\n flex: 1,\n minHeight: 0,\n overflowY: \"auto\",\n padding: \"8px 10px 12px\",\n }}\n >\n <div\n style={{\n maxWidth: 620,\n margin: \"0 auto\",\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 8,\n }}\n >\n <GuideQuest features={features} />\n <SkillBar session={session} />\n {hero ? (\n <EnhancePanel session={session} hero={hero} />\n ) : session.loaded ? (\n <EmptyState glyph=\"hero\" text={t(\"noHero\")} />\n ) : (\n <div\n style={{ ...outlined, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"loading\")}\n </div>\n )}\n </div>\n </div>\n {nav ? <BottomNav features={features} /> : null}\n </div>\n {session.away ? <AwayPopup session={session} /> : null}\n </div>\n );\n };\n}\n\n/** \"While you were away\": what the hero earned off screen — already credited by the server. */\nfunction AwayPopup({ session }: { session: IdleSession }): ReactNode {\n const away = session.away!;\n const celebrate = useCelebrate();\n const catalog = useCatalog();\n const lines = [\n { kind: \"currency\" as const, id: away.currencyID, amount: away.amount },\n ];\n const close = () => {\n celebrate(lines);\n session.dismissAway();\n };\n return (\n <Popup\n title={t(\"welcomeBack\")}\n onClose={close}\n width={380}\n variant=\"center\"\n >\n <div\n style={{\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n textAlign: \"center\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph={catalog.iconOf(lines[0]!)} size={64} />\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"awayText\")} · {formatDuration(away.seconds)}\n </div>\n <div style={{ ...outlined, fontSize: 28, color: v.gold }}>\n +{formatBig(away.amount)}\n </div>\n <ResourceList lines={lines} size={20} />\n <Button tone=\"gold\" size=\"lg\" attract onClick={close}>\n {t(\"collect\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\n/** The session's refusals as toasts (\"not enough gold\", a server error). */\nfunction useNotices(session: IdleSession): void {\n const notify = useNotify();\n const catalog = useCatalog();\n const notice = session.notice;\n useEffect(() => {\n if (!notice) return;\n if (notice.kind === \"notEnough\")\n notify.toast({\n level: \"warning\",\n key: \"idle-not-enough\",\n title: `${t(\"notEnough\")}: ${catalog.currencyName(notice.currencyID)}`,\n });\n else notify.toast({ level: \"error\", message: errorText(notice.text) });\n // Keyed on the notice's time: each refusal shows once.\n }, [notice?.at]);\n}\n"
217
+ "content": "import {\n useEffect,\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n Button,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n errorText,\n outlined,\n screenBackground,\n useCatalog,\n useCelebrate,\n useNotify,\n v,\n} from \"@idosgames/react/ui\";\nimport type { IdleSession } from \"../game/session\";\nimport { formatBig, formatDuration } from \"../game/format\";\nimport { BATTLE_HEIGHT_CSS } from \"../layout\";\nimport { t } from \"../i18n\";\nimport { BattleHud } from \"./BattleHud\";\nimport { BottomNav } from \"./BottomNav\";\nimport { EnhancePanel } from \"./EnhancePanel\";\nimport { GuideQuest } from \"./GuideQuest\";\nimport { SkillBar } from \"./SkillBar\";\n\n// The game's whole interface over the canvas: the HUD on top of the fight, and under it the panel —\n// the quest strip, the skills, Enhance, and the bottom bar to the systems. The lobby, balances and\n// wallet are the base's; this is the game's own screen, Legend Slime-style. Next to the base the game\n// sits inside the lobby, whose tabs open the systems — then the game's own bar is not drawn (`nav`).\n\nexport function makeGamePanel(\n session: IdleSession,\n features: FeatureRegistry,\n nav = true,\n): ComponentType {\n return function IdleRpgPanel(): ReactNode {\n useSyncExternalStore(session.subscribe, session.getVersion);\n useNotices(session);\n const hero = session.hero;\n\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n fontFamily: v.font,\n color: v.text,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 0,\n left: 0,\n right: 0,\n height: BATTLE_HEIGHT_CSS,\n }}\n >\n <BattleHud session={session} />\n </div>\n <div\n className=\"idos-screen\"\n style={{\n position: \"absolute\",\n top: BATTLE_HEIGHT_CSS,\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n flexDirection: \"column\",\n background: screenBackground,\n pointerEvents: \"auto\",\n }}\n >\n <div\n className=\"idos-scroll\"\n style={{\n flex: 1,\n minHeight: 0,\n overflowY: \"auto\",\n padding: \"8px 10px 12px\",\n }}\n >\n <div\n style={{\n // a phone: one column; a tablet or a PC: the stats side by side (EnhancePanel)\n maxWidth: 1120,\n margin: \"0 auto\",\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 8,\n }}\n >\n <GuideQuest features={features} />\n <SkillBar session={session} />\n {hero ? (\n <EnhancePanel session={session} hero={hero} />\n ) : session.loaded ? (\n <EmptyState glyph=\"hero\" text={t(\"noHero\")} />\n ) : (\n <div\n style={{ ...outlined, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"loading\")}\n </div>\n )}\n </div>\n </div>\n {nav ? <BottomNav features={features} /> : null}\n </div>\n {session.away ? <AwayPopup session={session} /> : null}\n </div>\n );\n };\n}\n\n/** \"While you were away\": what the hero earned off screen — already credited by the server. */\nfunction AwayPopup({ session }: { session: IdleSession }): ReactNode {\n const away = session.away!;\n const celebrate = useCelebrate();\n const catalog = useCatalog();\n const lines = [\n { kind: \"currency\" as const, id: away.currencyID, amount: away.amount },\n ];\n const close = () => {\n celebrate(lines);\n session.dismissAway();\n };\n return (\n <Popup\n title={t(\"welcomeBack\")}\n onClose={close}\n width={380}\n variant=\"center\"\n >\n <div\n style={{\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n textAlign: \"center\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph={catalog.iconOf(lines[0]!)} size={64} />\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"awayText\")} · {formatDuration(away.seconds)}\n </div>\n <div style={{ ...outlined, fontSize: 28, color: v.gold }}>\n +{formatBig(away.amount)}\n </div>\n <ResourceList lines={lines} size={20} />\n <Button tone=\"gold\" size=\"lg\" attract onClick={close}>\n {t(\"collect\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\n/** The session's refusals as toasts (\"not enough gold\", a server error). */\nfunction useNotices(session: IdleSession): void {\n const notify = useNotify();\n const catalog = useCatalog();\n const notice = session.notice;\n useEffect(() => {\n if (!notice) return;\n if (notice.kind === \"notEnough\")\n notify.toast({\n level: \"warning\",\n key: \"idle-not-enough\",\n title: `${t(\"notEnough\")}: ${catalog.currencyName(notice.currencyID)}`,\n });\n else notify.toast({ level: \"error\", message: errorText(notice.text) });\n // Keyed on the notice's time: each refusal shows once.\n }, [notice?.at]);\n}\n"
166
218
  },
167
219
  {
168
220
  "path": "ui/GuideQuest.tsx",
@@ -174,7 +226,7 @@
174
226
  },
175
227
  {
176
228
  "path": "module.meta.json",
177
- "content": "{\n \"id\": \"idle-rpg\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\"],\n \"summary\": \"Idle RPG in the style of Legend Slime: the hero auto-battles waves and bosses, gold flows in online and offline, stats are enhanced for gold.\",\n \"description\": \"A Legend Slime-style idle auto-battler on Phaser with a React screen. The selected hero (the Heroes system — character:hero-selected@1) stands on the left and shoots waves of monsters walking in from the right: crits, multi-shot, HP regen, three skills with cooldowns and an Auto switch. Stages come in chapters of ten (Chapter-Stage, themes meadow → volcano, difficulty loops); each stage is four waves and a boss with a 30-second timer — a failed boss drops back to farming until the player taps \\\"Boss!\\\". The hero's fight numbers are the server's character stats (ValueCurve, RankCurve, RankStatCurve, gear flat/percent bonuses — a mirror of PvPBattleEngine.CalculateStats). Gold is the title's idle accrual (Reward → IdleAccruals, rate = base + PowerCoefficient × the strongest hero's Power), collected every 15 s while playing and on entry with a \\\"while you were away\\\" popup; coins from kills draw that stream. Enhance: the hero's stats with level, value, next value and price, ×1 / ×10 / MAX and hold-to-repeat (one request in flight per stat, taps added up). A quest strip shows the next guide quest with Claim. The bottom bar opens the Heroes hub, gear summons, quests and the shop through ctx.features. Stage progress is saved in the player's private custom data (idle_rpg_progress); cleared stages and enhanced levels are reported to quests that count stage_cleared / stat_enhanced. Ships title-seed.json: GOLD/GEMS, three heroes on the engine's standard stat ids, twelve pieces of gear, the idle_gold accrual, a gear summon, a shop and a guide quest chain.\",\n \"provides\": [\n \"auto-battling hero vs waves of monsters\",\n \"stages, chapters and timed bosses\",\n \"idle gold income online and offline (server accrual)\",\n \"stat enhance for gold with x1 / x10 / MAX and hold\",\n \"skills with cooldowns and auto-cast\",\n \"guide quest strip\",\n \"bottom bar to heroes, summons, quests and shop\",\n \"2D procedural art (Phaser)\"\n ],\n \"tags\": [\n \"idle\",\n \"rpg\",\n \"incremental\",\n \"auto-battler\",\n \"legend-slime\",\n \"phaser\",\n \"2d\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.2.2\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"when\": \"a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)\",\n \"payload\": {\n \"characterId\": \"string\",\n \"level\": \"number\"\n }\n },\n {\n \"topic\": \"idle-rpg:stage-cleared@1\",\n \"when\": \"the hero beat a stage's boss; chapter-stage is the stage just cleared\",\n \"payload\": {\n \"chapter\": \"number\",\n \"stage\": \"number\"\n }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"why\": \"fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)\"\n }\n ]\n }\n}\n"
229
+ "content": "{\n \"id\": \"idle-rpg\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\"],\n \"summary\": \"Idle RPG in the style of Legend Slime: the hero auto-battles waves and bosses, gold flows in online and offline, stats are enhanced for gold.\",\n \"description\": \"A Legend Slime-style idle auto-battler on Phaser with a React screen. The selected hero (the Heroes system — character:hero-selected@1) stands on the left — a layered 2D character (src/character: its own chibi skeleton with bending meshes and IK played by its own runtime, parts as skins with vector SVG art, colours, ten animations of its own plus ranged attacks; dressed from its class and worn gear by src/character/looks, the attack swing timed to the shot; the same hero is drawn on the Heroes screen through ctx.characterArt — src/heroArt.ts, a live WebGL canvas, a new weapon swung once) — and marches along the road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls past in painted parallax layers per chapter theme; shadows under everyone; a fallen hero leaves the enemies standing) to the enemies standing in line ahead — people on the hero's own rig dressed in the maker's looks; brawlers fight one at a time face to face, shooters fire once the hero is in their range; a melee weapon hits only at the enemy, a bow, gun or staff from afar: crits, multi-shot, HP regen, three skills with cooldowns and an Auto switch. Stages come in chapters of ten (Chapter-Stage, themes meadow → volcano, difficulty loops); each stage is four waves and a boss with a 30-second timer — a failed boss drops back to farming until the player taps \\\"Boss!\\\". The hero's fight numbers are the server's character stats (ValueCurve, RankCurve, RankStatCurve, gear flat/percent bonuses — a mirror of PvPBattleEngine.CalculateStats). Gold is the title's idle accrual (Reward → IdleAccruals, rate = base + PowerCoefficient × the strongest hero's Power), collected every 15 s while playing and on entry with a \\\"while you were away\\\" popup; coins from kills draw that stream. Enhance: the hero's stats with level, value, next value and price, ×1 / ×10 / MAX and hold-to-repeat (one request in flight per stat, taps added up). A quest strip shows the next guide quest with Claim. The bottom bar opens the Heroes hub, gear summons, quests and the shop through ctx.features. Stage progress is saved in the player's private custom data (idle_rpg_progress); cleared stages and enhanced levels are reported to quests that count stage_cleared / stat_enhanced. Ships title-seed.json: GOLD/GEMS, three heroes on the engine's standard stat ids, twelve pieces of gear, the idle_gold accrual, a gear summon, a shop and a guide quest chain.\",\n \"provides\": [\n \"auto-battling hero vs waves of monsters\",\n \"stages, chapters and timed bosses\",\n \"idle gold income online and offline (server accrual)\",\n \"stat enhance for gold with x1 / x10 / MAX and hold\",\n \"skills with cooldowns and auto-cast\",\n \"guide quest strip\",\n \"bottom bar to heroes, summons, quests and shop\",\n \"2D procedural art (Phaser)\",\n \"layered 2D character: skeleton, swappable parts (skins), colours, 10 animations + ranged attacks (own runtime, vector art)\"\n ],\n \"tags\": [\n \"idle\",\n \"rpg\",\n \"incremental\",\n \"auto-battler\",\n \"legend-slime\",\n \"phaser\",\n \"2d\",\n \"character-customization\",\n \"skeletal-animation\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.4.2\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"when\": \"a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)\",\n \"payload\": {\n \"characterId\": \"string\",\n \"level\": \"number\"\n }\n },\n {\n \"topic\": \"idle-rpg:stage-cleared@1\",\n \"when\": \"the hero beat a stage's boss; chapter-stage is the stage just cleared\",\n \"payload\": {\n \"chapter\": \"number\",\n \"stage\": \"number\"\n }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"why\": \"fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)\"\n }\n ]\n }\n}\n"
178
230
  }
179
231
  ]
180
232
  }