@idosgames/mcp 0.1.23 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +86 -53
- package/registry/modules/ai.json +1 -1
- package/registry/modules/board-game.json +1 -1
- package/registry/modules/boosts.json +1 -1
- package/registry/modules/character.json +32 -10
- package/registry/modules/chat.json +1 -1
- package/registry/modules/collections.json +1 -1
- package/registry/modules/coop-events.json +1 -1
- package/registry/modules/craft.json +1 -1
- package/registry/modules/deals.json +1 -1
- package/registry/modules/events.json +1 -1
- package/registry/modules/friends.json +1 -1
- package/registry/modules/idle-rpg.json +91 -52
- package/registry/modules/inventory.json +1 -1
- package/registry/modules/invite.json +1 -1
- package/registry/modules/leaderboard.json +1 -1
- package/registry/modules/lootboxes.json +1 -1
- package/registry/modules/mailbox.json +1 -1
- package/registry/modules/marketplace.json +1 -1
- package/registry/modules/mini-game.json +1 -1
- package/registry/modules/multiplayer.json +1 -1
- package/registry/modules/premium.json +1 -1
- package/registry/modules/profile.json +1 -1
- package/registry/modules/pvp.json +1 -1
- package/registry/modules/quests.json +4 -4
- package/registry/modules/rewards.json +1 -1
- package/registry/modules/season.json +1 -1
- package/registry/modules/store.json +1 -1
- package/registry/modules/tutorials.json +1 -1
- package/registry/modules/workshop.json +1 -1
|
@@ -8,20 +8,29 @@
|
|
|
8
8
|
},
|
|
9
9
|
"catalog": {
|
|
10
10
|
"type": "template",
|
|
11
|
-
"
|
|
12
|
-
|
|
11
|
+
"uses": [
|
|
12
|
+
"character",
|
|
13
|
+
"lootboxes",
|
|
14
|
+
"store"
|
|
15
|
+
],
|
|
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.",
|
|
13
18
|
"provides": [
|
|
14
|
-
"auto-battling
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
+
"auto-battling hero vs waves of monsters",
|
|
20
|
+
"stages, chapters and timed bosses",
|
|
21
|
+
"idle gold income online and offline (server accrual)",
|
|
22
|
+
"stat enhance for gold with x1 / x10 / MAX and hold",
|
|
23
|
+
"skills with cooldowns and auto-cast",
|
|
24
|
+
"guide quest strip",
|
|
25
|
+
"bottom bar to heroes, summons, quests and shop",
|
|
26
|
+
"2D procedural art (Phaser)"
|
|
19
27
|
],
|
|
20
28
|
"tags": [
|
|
21
29
|
"idle",
|
|
22
30
|
"rpg",
|
|
23
31
|
"incremental",
|
|
24
32
|
"auto-battler",
|
|
33
|
+
"legend-slime",
|
|
25
34
|
"phaser",
|
|
26
35
|
"2d"
|
|
27
36
|
],
|
|
@@ -34,16 +43,30 @@
|
|
|
34
43
|
"name": "iDos Games",
|
|
35
44
|
"url": "https://idosgames.com"
|
|
36
45
|
},
|
|
37
|
-
"version": "0.
|
|
46
|
+
"version": "0.2.0",
|
|
38
47
|
"events": {
|
|
39
48
|
"emits": [
|
|
40
49
|
{
|
|
41
50
|
"topic": "idle-rpg:character-upgraded@1",
|
|
42
|
-
"when": "a hero's
|
|
51
|
+
"when": "a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)",
|
|
43
52
|
"payload": {
|
|
44
53
|
"characterId": "string",
|
|
45
54
|
"level": "number"
|
|
46
55
|
}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"topic": "idle-rpg:stage-cleared@1",
|
|
59
|
+
"when": "the hero beat a stage's boss; chapter-stage is the stage just cleared",
|
|
60
|
+
"payload": {
|
|
61
|
+
"chapter": "number",
|
|
62
|
+
"stage": "number"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"listens": [
|
|
67
|
+
{
|
|
68
|
+
"topic": "character:hero-selected@1",
|
|
69
|
+
"why": "fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)"
|
|
47
70
|
}
|
|
48
71
|
]
|
|
49
72
|
}
|
|
@@ -51,91 +74,107 @@
|
|
|
51
74
|
"dependencies": {
|
|
52
75
|
"@idosgames/core": "0.21.0",
|
|
53
76
|
"@idosgames/module-sdk": "0.8.2",
|
|
54
|
-
"@idosgames/react": "0.4.
|
|
55
|
-
"@idosgames/wallet": "0.6.2",
|
|
56
|
-
"@tanstack/react-query": "5.101.2",
|
|
77
|
+
"@idosgames/react": "0.4.4",
|
|
57
78
|
"phaser": "4.2.1",
|
|
58
|
-
"react": "19.2.7"
|
|
59
|
-
"viem": "2.55.2",
|
|
60
|
-
"wagmi": "3.7.2"
|
|
79
|
+
"react": "19.2.7"
|
|
61
80
|
},
|
|
62
|
-
"contentHash": "
|
|
81
|
+
"contentHash": "579dc7d7ae18b6c071d760e4f8290f427a98d3884a67502a085881f0c8a4b578",
|
|
63
82
|
"files": [
|
|
64
83
|
{
|
|
65
|
-
"path": "
|
|
66
|
-
"content": "//
|
|
84
|
+
"path": "controller-box.ts",
|
|
85
|
+
"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"
|
|
67
86
|
},
|
|
68
87
|
{
|
|
69
|
-
"path": "
|
|
70
|
-
"content": "import {
|
|
88
|
+
"path": "events.ts",
|
|
89
|
+
"content": "import { defineTopic, shape } from \"@idosgames/module-sdk\";\n\n// Topics this module EMITS. Declared in module.meta.json (`events.emits`) with the same descriptor,\n// so other modules can copy it from the catalog — they never import this file.\n\n/** A hero's level went up (after a successful `client.character.upgradeCharacterLevel`). */\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n\n/** The hero beat a stage's boss: `chapter`-`stage` is the stage just cleared (\"3-6\"). */\nexport const stageCleared = defineTopic(\n \"idle-rpg:stage-cleared@1\",\n shape({ chapter: \"number\", stage: \"number\" }),\n);\n\n// Topics this module LISTENS to — its own copy of the emitter's descriptor (from the catalog), with\n// only the fields it reads. Optional: without the Heroes system the strongest hero fights.\n\n/** The Heroes screen's selected hero (`modules/character`). */\nexport const heroSelected = defineTopic(\n \"character:hero-selected@1\",\n shape({ characterId: \"string\" }),\n);\n"
|
|
71
90
|
},
|
|
72
91
|
{
|
|
73
|
-
"path": "
|
|
74
|
-
"content": "import {\n useEffect,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n type Availability,\n} from \"../data/characterConfig\";\n\nexport interface RosterEntry {\n id: string;\n name: string;\n rarity?: string;\n className?: string;\n availability: Availability;\n level?: number;\n power?: number;\n}\n\n/** Merges character definitions and the player's characters into a roster, and keeps it live\n * across cache updates. Both come with the login (title config + state); the central cache asks\n * the server only for what is missing (definitions the config lacks, a hero given by default). */\nexport function useRoster(): { entries: RosterEntry[]; loading: boolean } {\n const client = useIDosGamesClient();\n const [loading, setLoading] = useState(true);\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.character.getCharacterDefinitions();\n await client.cache.ensureState([\"Character\"]);\n if (active) setLoading(false);\n })();\n const off = client.on(\"user:anyUpdated\", () => {\n force();\n });\n return () => {\n active = false;\n off();\n };\n }, [client]);\n\n const config = readCharacterConfig(client);\n const defs = config.Definitions ?? {};\n const owned = client.data.user.state?.Character?.Characters ?? {};\n const ids = new Set<string>([...Object.keys(defs), ...Object.keys(owned)]);\n\n const entries: RosterEntry[] = [...ids]\n .map((id) => {\n const def = defs[id];\n const character: CharacterModel | undefined = owned[id];\n const entry: RosterEntry = {\n id,\n name: def?.Identity?.DisplayName ?? id,\n rarity: def?.Classification?.RarityID,\n className: def?.Classification?.ClassID,\n availability: resolveAvailability(def, character !== undefined),\n level: character?.Level ?? undefined,\n power: character?.Power ?? undefined,\n };\n return { entry, sortOrder: def?.Identity?.SortOrder ?? 999 };\n })\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((x) => x.entry);\n\n return { entries, loading };\n}\n\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n};\n\nconst statusStyle: Record<Availability, CSSProperties> = {\n owned: {\n color: \"#ffffff\",\n background: \"#052e7e\",\n border: \"1px solid #1e59c8\",\n },\n available: {\n color: \"#cfe0ff\",\n background: \"#063073\",\n border: \"1px solid #3b78e0\",\n },\n locked: {\n color: \"#a9c6ff\",\n background: \"#04276b\",\n border: \"1px solid #1e59c8\",\n },\n};\n\nfunction card(availability: Availability, selected: boolean): CSSProperties {\n return {\n textAlign: \"left\",\n borderRadius: 12,\n padding: 12,\n cursor: \"pointer\",\n ...statusStyle[availability],\n ...(selected ? { border: \"2px solid #ffd479\" } : {}),\n };\n}\n\nexport function rosterStatusLine(entry: RosterEntry): string {\n switch (entry.availability) {\n case \"owned\":\n return `Lv ${entry.level ?? 0} · Power ${entry.power ?? 0}`;\n case \"available\":\n return \"Available\";\n case \"locked\":\n return \"Locked\";\n }\n}\n\nexport function CharacterRoster({\n selectedID,\n onSelect,\n onSelectedChange,\n}: {\n selectedID: string | null;\n onSelect: (entry: RosterEntry) => void;\n /**\n * The selected character changed under the player (a level-up, an unlock): the scene redraws its\n * card. Without it the Phaser card kept the level it had when the character was picked.\n */\n onSelectedChange?: (entry: RosterEntry) => void;\n}): ReactNode {\n const { entries, loading } = useRoster();\n const selected = entries.find((e) => e.id === selectedID);\n const selectedKey = selected\n ? `${selected.id}|${selected.availability}|${rosterStatusLine(selected)}`\n : \"\";\n useEffect(() => {\n if (selected) onSelectedChange?.(selected);\n // Keyed on what the card shows, not on the entry object (a new one every render).\n }, [selectedKey]);\n if (loading) return <div style={{ opacity: 0.6 }}>Loading characters…</div>;\n if (entries.length === 0)\n return <div style={{ opacity: 0.6 }}>No characters configured.</div>;\n\n return (\n <div style={grid}>\n {entries.map((entry) => (\n <button\n key={entry.id}\n type=\"button\"\n style={card(entry.availability, entry.id === selectedID)}\n onClick={() => {\n onSelect(entry);\n }}\n >\n <div style={{ fontWeight: 700, marginBottom: 4 }}>{entry.name}</div>\n <div style={{ fontSize: 11, opacity: 0.7 }}>\n {[entry.rarity, entry.className].filter(Boolean).join(\" · \")}\n </div>\n <div style={{ fontSize: 13, marginTop: 6 }}>\n {rosterStatusLine(entry)}\n </div>\n </button>\n ))}\n </div>\n );\n}\n"
|
|
92
|
+
"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 *\n (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"
|
|
75
94
|
},
|
|
76
95
|
{
|
|
77
|
-
"path": "
|
|
78
|
-
"content": "
|
|
96
|
+
"path": "game/economy.ts",
|
|
97
|
+
"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) => 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"
|
|
79
98
|
},
|
|
80
99
|
{
|
|
81
|
-
"path": "
|
|
82
|
-
"content": "
|
|
100
|
+
"path": "game/format.ts",
|
|
101
|
+
"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))\n 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"
|
|
83
102
|
},
|
|
84
103
|
{
|
|
85
|
-
"path": "
|
|
86
|
-
"content": "
|
|
104
|
+
"path": "game/guide.ts",
|
|
105
|
+
"content": "// Which quest the strip over the Enhance list shows (Legend Slime's \"Claim Rewards: Enhance HP\").\n// A reward waiting comes first; then the next step of the guide chain; then any open quest. The\n// quests system draws the full list — this is one line of it, kept in the player's eye.\n\nexport const GUIDE_CYCLE = \"guide\";\n\nexport interface QuestDefView {\n QuestID?: string;\n Identity?: { DisplayName?: string | null } | null;\n Linking?: {\n CycleIDs?: string[] | null;\n RequiredQuestIDs?: string[] | null;\n } | null;\n Reward?: { Grant?: unknown } | null;\n Objectives?: Record<string, { TargetValue?: number | null } | null> | null;\n}\n\nexport interface QuestConfigView {\n Quests?: Record<string, QuestDefView | null> | null;\n}\n\ninterface ProgressView {\n Status?: string | null;\n Objectives?: Record<string, { CurrentValue?: number | null } | null> | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n { Quests?: Record<string, ProgressView | null> | null } | null\n > | null;\n PermanentQuests?: Record<string, ProgressView | null> | null;\n}\n\nexport interface GuideView {\n questID: string;\n cycleID: string | undefined;\n name: string;\n current: number;\n target: number;\n claimable: boolean;\n grant: unknown;\n}\n\nexport function pickGuide(\n section: QuestConfigView | undefined,\n state: QuestStateView | undefined,\n): GuideView | null {\n const progressOf = (id: string, cycle: string | undefined) =>\n (cycle ? state?.Cycles?.[cycle]?.Quests?.[id] : undefined) ??\n state?.PermanentQuests?.[id] ??\n undefined;\n const claimed = (id: string) => {\n const q = section?.Quests?.[id];\n return progressOf(id, q?.Linking?.CycleIDs?.[0])?.Status === \"Claimed\";\n };\n\n const views: GuideView[] = [];\n for (const [key, q] of Object.entries(section?.Quests ?? {})) {\n if (!q) continue;\n const id = q.QuestID ?? key;\n const cycleID = q.Linking?.CycleIDs?.[0] ?? undefined;\n const p = progressOf(id, cycleID);\n const status = p?.Status ?? \"Active\";\n if (status === \"Claimed\" || status === \"Expired\") continue;\n if (!(q.Linking?.RequiredQuestIDs ?? []).every(claimed)) continue;\n const [objID, obj] = Object.entries(q.Objectives ?? {})[0] ?? [];\n const target = Math.max(1, Number(obj?.TargetValue ?? 1));\n const current = Math.min(\n target,\n Number(objID ? (p?.Objectives?.[objID]?.CurrentValue ?? 0) : 0),\n );\n views.push({\n questID: id,\n cycleID,\n name: q.Identity?.DisplayName ?? id,\n current,\n target,\n claimable: status === \"Completed\",\n grant: q.Reward?.Grant,\n });\n }\n return (\n views.find((g) => g.claimable) ??\n views.find((g) => g.cycleID === GUIDE_CYCLE) ??\n views[0] ??\n null\n );\n}\n"
|
|
87
106
|
},
|
|
88
107
|
{
|
|
89
|
-
"path": "
|
|
90
|
-
"content": "import type { ReactNode } from \"react\";\nimport { LazyWalletPanel } from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The in-game crypto wallet (deposit/withdraw) — the same lazy pattern as the login button. The\n// whole panel, including everything that touches wagmi/Reown AppKit, lives in @idosgames/wallet and\n// is fetched with `await import()` on mount, so Reown AppKit stays out of the page's initial module\n// graph and the live preview boots. Because the wallet config is memoised per project id, a wallet\n// the player connected on the sign-in screen is already connected here.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls AppKit\n// into the startup graph and blanks the preview for web3 titles.\nexport function WalletPanel(): ReactNode {\n const client = useIDosGamesClient();\n return (\n <LazyWalletPanel\n client={client}\n appName=\"iDosGames Idle RPG\"\n style={{ width: \"100%\", background: \"#04276b\" }}\n />\n );\n}\n"
|
|
108
|
+
"path": "game/heroStats.ts",
|
|
109
|
+
"content": "// The fighting hero's numbers, from the title's Character config and the player's server state.\n//\n// ⚠ A MIRROR of the server's `PvPBattleEngine.CalculateStats` (iDos_Games_Engine, Match/Services) —\n// the same three layers in the same order: (1) the stat's ValueCurve from level 0 and its RankCurve\n// from rank 1, (2) gear flat bonuses scaled by the instance level, (3) percent bonuses. Health and\n// damage also take the hero's RankStatCurve and \"AllMight\"; attack speed and chances do not. The\n// Heroes screen (`modules/character/src/model.ts`) shows the same numbers — modules never import each\n// other, so this is a copy kept to the server, not to that file.\n//\n// The server never sees this fight: stages are the game's own. What it does see is the hero's Power,\n// which is what the gold income is paid on — so a stronger hero here is a richer one there.\n\nimport {\n curveMultiplier,\n evaluateCurve,\n roundAmount,\n type ScalarCurveSpec,\n} from \"@idosgames/core\";\n\n// ── Config / state views (only the fields read here) ─────────────────────────────────────────\n\ninterface Binding {\n PresetID?: string | null;\n Remove?: string[] | null;\n}\n\ntype PriceOptions =\n | Record<\n string,\n {\n Cost?: {\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 } | null\n >\n | null\n | undefined;\n\nexport interface StatDefView {\n StatID?: string;\n DisplayName?: string | null;\n MaxLevel?: number | null;\n BaseStatValue?: number | null;\n ValueCurve?: ScalarCurveSpec | null;\n RankCurve?: ScalarCurveSpec | null;\n CostCurve?: ScalarCurveSpec | null;\n PriceOptions?: PriceOptions;\n Requirements?: Array<{\n RequiredStatID?: string;\n RequiredLevel?: number;\n } | null> | null;\n}\n\nexport interface CharacterDefView {\n CharacterID?: string;\n Identity?: {\n DisplayName?: string | null;\n SortOrder?: number | null;\n } | null;\n Classification?: { ClassID?: string | null; RarityID?: string | null } | null;\n Unlock?: { UnlockedByDefault?: boolean | null } | null;\n Stats?: Record<string, StatDefView | null> | null;\n RankStatCurve?: ScalarCurveSpec | null;\n StatMaxLevelCurve?: ScalarCurveSpec | null;\n Presets?: { Stats?: Binding | null } | null;\n}\n\nexport interface CharacterSection {\n Definitions?: Record<string, CharacterDefView | null> | null;\n Presets?: {\n Stats?: Record<\n string,\n { Stats?: Record<string, StatDefView | null> | null } | null\n > | null;\n } | null;\n}\n\nexport interface HeroModelView {\n CharacterID?: string;\n Level?: number | null;\n Power?: number | null;\n StatLevels?: Record<string, number> | null;\n Equipment?: Record<\n string,\n {\n ItemID?: string | null;\n CatalogID?: string | null;\n ItemInstanceID?: string | null;\n Level?: number | null;\n } | null\n > | null;\n}\n\nexport interface GearDefView {\n Stats?: {\n FlatBonuses?: Record<string, number> | null;\n PercentBonuses?: Record<string, number> | null;\n } | null;\n Upgrade?: {\n FlatBonusCurve?: ScalarCurveSpec | null;\n PercentBonusCurve?: ScalarCurveSpec | null;\n } | null;\n}\n\n// ── Combat roles ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Which stat plays which part in the fight. The first ids are the engine's own (DefaultData — the\n * only ones the server's Power counts); the rest let a title with its own names still fight.\n */\nexport const ROLE_STATS = {\n health: [\"Health\", \"HP\", \"MaxHp\"],\n damage: [\"Damage\", \"Attack\", \"ATK\", \"Magic\"],\n armor: [\"Armor\", \"Defense\", \"DEF\"],\n attackSpeed: [\"AttackSpeed\"],\n critChance: [\"CritChance\", \"Crit\"],\n critDamage: [\"CritDamage\"],\n dodge: [\"Speed\", \"Dodge\"],\n regen: [\"HpRegen\", \"HealthRegen\", \"Regen\"],\n multiShot: [\"MultiShot\", \"TripleShot\"],\n allMight: [\"AllMight\"],\n} as const;\n\nexport type Role = keyof typeof ROLE_STATS;\n\n/** Chances and the crit multiplier are shown in percent. */\nexport const PERCENT_ROLES: ReadonlySet<Role> = new Set([\n \"critChance\",\n \"critDamage\",\n \"dodge\",\n \"multiShot\",\n]);\n\n/** The server's clamps (MatchCombatSettings defaults). */\nexport const MAX_CRIT_CHANCE = 0.6;\nexport const MAX_DODGE_CHANCE = 0.4;\nexport const DEFAULT_CRIT_MULTIPLIER = 1.5;\nexport const MAX_MULTI_SHOT = 1;\n\nexport interface FighterStats {\n maxHp: number;\n damage: number;\n armor: number;\n /** Attacks per second. */\n attackSpeed: number;\n critChance: number;\n critMultiplier: number;\n dodge: number;\n /** HP restored per second. */\n regen: number;\n /** Chance of one extra shot at the next enemy. */\n multiShot: number;\n}\n\n// ── Stats of a hero ──────────────────────────────────────────────────────────────────────────\n\nexport interface StatInfo {\n id: string;\n name: string;\n role: Role | null;\n base: number;\n maxLevel: number;\n valueCurve?: ScalarCurveSpec | null;\n rankCurve?: ScalarCurveSpec | null;\n costCurve?: ScalarCurveSpec | null;\n /** First way to pay (\"default cost\"), per level 1. */\n price: { currencyID: string; amount: number } | null;\n requirements: Array<{ statID: string; level: number }>;\n}\n\nexport function roleOf(statID: string): Role | null {\n for (const [role, ids] of Object.entries(ROLE_STATS))\n if ((ids as readonly string[]).includes(statID)) return role as Role;\n return null;\n}\n\n/** Preset stats (Presets.Stats) under the inline ones; Remove drops keys — the server's merge. */\nexport function resolveStats(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const preset = binding?.PresetID\n ? section?.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const merged: Record<string, StatDefView> = {};\n for (const [k, s] of Object.entries(preset ?? {})) if (s) merged[k] = s;\n for (const [k, s] of Object.entries(def.Stats ?? {})) if (s) merged[k] = s;\n for (const k of binding?.Remove ?? []) delete merged[k];\n return Object.entries(merged).map(([key, s]) => {\n const id = s.StatID ?? key;\n const first = Object.values(s.PriceOptions ?? {}).find((o) => o);\n const entry = first?.Cost?.Standard?.Entries?.find(\n (e) => e?.CurrencyID && Number(e.Amount ?? 0) > 0,\n );\n return {\n id,\n name: s.DisplayName || id,\n role: roleOf(id),\n base: Number(s.BaseStatValue ?? 0),\n maxLevel: Number(s.MaxLevel ?? 0),\n valueCurve: s.ValueCurve,\n rankCurve: s.RankCurve,\n costCurve: s.CostCurve,\n price: entry\n ? { currencyID: entry.CurrencyID!, amount: Number(entry.Amount) }\n : null,\n requirements: (s.Requirements ?? [])\n .filter((r) => r?.RequiredStatID)\n .map((r) => ({\n statID: r!.RequiredStatID!,\n level: Number(r!.RequiredLevel ?? 0),\n })),\n };\n });\n}\n\n/** Layer 1 of one stat: ValueCurve from level 0, then the stat's own RankCurve from rank 1. */\nexport function levelValue(stat: StatInfo, level: number, rank: number): number {\n const own = evaluateCurve(stat.valueCurve, stat.base, Math.max(0, level), 0);\n return evaluateCurve(stat.rankCurve, own, Math.max(1, rank), 1);\n}\n\n/** The stat's cap at this rank: MaxLevel through the hero's StatMaxLevelCurve (0 = no cap). */\nexport function statCap(\n stat: StatInfo,\n rank: number,\n def: CharacterDefView | undefined,\n): number {\n if (stat.maxLevel <= 0) return 0;\n return Math.ceil(\n evaluateCurve(def?.StatMaxLevelCurve, stat.maxLevel, Math.max(1, rank), 1),\n );\n}\n\n/** What raising a stat TO `nextLevel` costs — price × CostCurve(target level), rounded up once. */\nexport function statCost(stat: StatInfo, nextLevel: number): number {\n if (!stat.price) return 0;\n return roundAmount(\n stat.price.amount * curveMultiplier(stat.costCurve, Math.max(1, nextLevel), 1),\n );\n}\n\n/**\n * How many levels from `level` the balance buys (at most `limit`, never past the cap), and what they\n * cost together — what \"×10\" and \"MAX\" send in one call.\n */\nexport function affordableLevels(\n stat: StatInfo,\n level: number,\n cap: number,\n balance: number,\n limit: number,\n): { levels: number; cost: number } {\n let levels = 0;\n let cost = 0;\n while (levels < limit && (cap <= 0 || level + levels < cap)) {\n const next = statCost(stat, level + levels + 1);\n if (next <= 0 || cost + next > balance) break;\n cost += next;\n levels++;\n }\n return { levels, cost };\n}\n\n// ── The fighter ──────────────────────────────────────────────────────────────────────────────\n\nexport interface HeroInput {\n section: CharacterSection | undefined;\n def: CharacterDefView | undefined;\n model: HeroModelView | undefined;\n /** Item definitions by item id. */\n items: Map<string, GearDefView>;\n /** Levels of item instances by instance id (the worn copy's own Level wins when present). */\n instanceLevels?: Record<string, number>;\n}\n\n/**\n * The rank the fight uses: the server's level, but a hero playable by default fights at rank 1 before\n * the server has materialized it (its first action does).\n */\nexport function heroRank(input: HeroInput): number {\n const level = Number(input.model?.Level ?? 0);\n if (level > 0) return level;\n return input.def?.Unlock?.UnlockedByDefault ? 1 : 0;\n}\n\nexport function fighterStats(input: HeroInput): FighterStats {\n const rank = Math.max(1, heroRank(input));\n const stats = resolveStats(input.section, input.def);\n const levels = input.model?.StatLevels ?? {};\n const rankMult = curveMultiplier(input.def?.RankStatCurve, rank, 1);\n\n const byRole = (role: Role): number => {\n for (const id of ROLE_STATS[role]) {\n const stat = stats.find((s) => s.id === id);\n if (stat) return levelValue(stat, Number(levels[stat.id] ?? 0), rank);\n }\n return 0;\n };\n\n const flat: Record<string, number> = {};\n const pct: Record<string, number> = {};\n for (const worn of Object.values(input.model?.Equipment ?? {})) {\n if (!worn?.ItemID) continue;\n const def = input.items.get(worn.ItemID);\n if (!def?.Stats) continue;\n const instLevel =\n worn.Level ??\n (worn.ItemInstanceID\n ? input.instanceLevels?.[worn.ItemInstanceID]\n : undefined);\n const level = Math.max(1, Number(instLevel ?? 1));\n const flatScale = curveMultiplier(def.Upgrade?.FlatBonusCurve, level, 1);\n const pctScale = curveMultiplier(def.Upgrade?.PercentBonusCurve, level, 1);\n for (const [k, n] of Object.entries(def.Stats.FlatBonuses ?? {}))\n flat[k] = (flat[k] ?? 0) + Number(n || 0) * flatScale;\n for (const [k, n] of Object.entries(def.Stats.PercentBonuses ?? {}))\n pct[k] = (pct[k] ?? 0) + Number(n || 0) * pctScale;\n }\n const gear = (table: Record<string, number>, role: Role): number =>\n ROLE_STATS[role].reduce((s, id) => s + (table[id] ?? 0), 0);\n\n const allMight = byRole(\"allMight\") + gear(pct, \"allMight\");\n const global = 1 + allMight;\n\n const critMultiplier =\n byRole(\"critDamage\") + gear(flat, \"critDamage\") + gear(pct, \"critDamage\");\n return {\n maxHp: Math.max(\n 1,\n (byRole(\"health\") * rankMult + gear(flat, \"health\")) * global,\n ),\n damage: (byRole(\"damage\") * rankMult + gear(flat, \"damage\")) * global,\n armor: byRole(\"armor\") * rankMult + gear(flat, \"armor\"),\n attackSpeed: Math.max(\n 0.1,\n (byRole(\"attackSpeed\") + gear(flat, \"attackSpeed\")) *\n (1 + gear(pct, \"attackSpeed\")),\n ),\n critChance: clamp(\n byRole(\"critChance\") + gear(flat, \"critChance\") + gear(pct, \"critChance\"),\n 0,\n MAX_CRIT_CHANCE,\n ),\n critMultiplier:\n critMultiplier > 0 ? critMultiplier : DEFAULT_CRIT_MULTIPLIER,\n dodge: clamp(\n byRole(\"dodge\") + gear(flat, \"dodge\") + gear(pct, \"dodge\"),\n 0,\n MAX_DODGE_CHANCE,\n ),\n regen: Math.max(0, byRole(\"regen\") * rankMult + gear(flat, \"regen\")),\n multiShot: clamp(\n byRole(\"multiShot\") + gear(flat, \"multiShot\") + gear(pct, \"multiShot\"),\n 0,\n MAX_MULTI_SHOT,\n ),\n };\n}\n\n/** The value a stat shows in the Enhance list at a given level (its role's final number). */\nexport function enhanceValue(\n input: HeroInput,\n statID: string,\n level: number,\n): number {\n const role = roleOf(statID);\n const model: HeroModelView = {\n ...input.model,\n StatLevels: { ...(input.model?.StatLevels ?? {}), [statID]: level },\n };\n const field = role ? ROLE_FIELD[role] : null;\n if (!field) {\n const stat = resolveStats(input.section, input.def).find(\n (s) => s.id === statID,\n );\n return stat ? levelValue(stat, level, Math.max(1, heroRank(input))) : 0;\n }\n return fighterStats({ ...input, model })[field];\n}\n\n/** Where a role ends up among the fighter's numbers (AllMight multiplies others — shown as its own value). */\nconst ROLE_FIELD: Record<Role, keyof FighterStats | null> = {\n health: \"maxHp\",\n damage: \"damage\",\n armor: \"armor\",\n attackSpeed: \"attackSpeed\",\n critChance: \"critChance\",\n critDamage: \"critMultiplier\",\n dodge: \"dodge\",\n regen: \"regen\",\n multiShot: \"multiShot\",\n allMight: null,\n};\n\nfunction clamp(n: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, n));\n}\n"
|
|
91
110
|
},
|
|
92
111
|
{
|
|
93
|
-
"path": "
|
|
94
|
-
"content": "
|
|
112
|
+
"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"
|
|
95
114
|
},
|
|
96
115
|
{
|
|
97
|
-
"path": "
|
|
98
|
-
"content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\nimport { curveMultiplier, evaluateCurve, roundAmount } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface PriceOption {\n Cost?: {\n Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] };\n };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n /** Growth of the value over the stat's own level; the step is numbered from **0**. */\n ValueCurve?: ScalarCurveSpec;\n /** Cost curve; the step is the TARGET level, numbered from 1. */\n CostCurve?: ScalarCurveSpec;\n /** Ways to pay; key = the option id. Replaced the removed `BaseCostResource`. */\n PriceOptions?: Record<string, PriceOption>;\n}\n\nexport interface StatsPreset {\n Stats?: Record<string, StatDefinition>;\n}\n\nexport interface CharacterPresetBindings {\n Stats?: PresetBinding;\n Levels?: PresetBinding;\n Equipment?: PresetBinding;\n}\n\nexport interface CharacterDefinition {\n CharacterID?: string;\n Presets?: CharacterPresetBindings;\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n Stats?: Record<string, StatDefinition>;\n Classification?: { ClassID?: string; RarityID?: string };\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\n Unlock?: { UnlockedByDefault?: boolean };\n /**\n * Skins (alternative looks). Each skin IS a catalog item; wearing it binds a copy to the\n * reserved equipment key \"@skin\" (`SKIN_SLOT` in @idosgames/core). Full shape: CharacterSkins.\n */\n Skins?: {\n DefaultSkinID?: string;\n Definitions?: Record<\n string,\n {\n SkinID?: string;\n Identity?: {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number;\n AssetPaths?: Record<string, string>;\n };\n Item?: { CatalogID?: string; ItemID?: string };\n Requirements?: { MinCharacterLevel?: number };\n }\n >;\n };\n}\n\nexport interface CharacterPresetRegistry {\n Stats?: Record<string, StatsPreset>;\n Equipment?: Record<string, EquipmentPreset>;\n}\n\nexport interface CharacterConfig {\n Definitions?: Record<string, CharacterDefinition>;\n Presets?: CharacterPresetRegistry;\n}\n\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\n // Fetched definitions first, else the section that came with the title config at login.\n return (\n client.data.config.getSection<CharacterConfig>(\"Character\") ??\n ((\n client.data.config.titlePublicConfiguration as Record<\n string,\n unknown\n > | null\n )?.[\"Character\"] as CharacterConfig | undefined) ??\n {}\n );\n}\n\nexport interface SlotInfo {\n slotID: string;\n minCharacterLevel: number;\n}\n\n/**\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveSlots(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): SlotInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const presetSlots = binding?.PresetID\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\n return (\n Object.entries(slots)\n // \"@…\" keys are engine-reserved (the worn skin lives under \"@skin\"); the server refuses\n // gear in them, so a slot declared that way by mistake must not render as a gear slot.\n .filter(([slotID]) => !slotID.startsWith(\"@\"))\n .map(([slotID, rule]) => ({\n slotID,\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\n }))\n );\n}\n\nexport interface StatInfo {\n statID: string;\n displayName: string;\n maxLevel?: number;\n baseValue: number;\n valueCurve?: ScalarCurveSpec;\n costCurve?: ScalarCurveSpec;\n costAmount: number;\n costCurrency?: string;\n}\n\n/**\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\n * StatID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveStats(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const presetStats = binding?.PresetID\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\n return Object.entries(stats).map(([statID, rule]) => {\n // The first option's first entry, mirroring the server's \"default cost\" for shop-like\n // displays (`PriceOptionSelector.DefaultCost`): the player has not chosen a way to pay yet.\n const option = Object.values(rule.PriceOptions ?? {})[0];\n const entry = option?.Cost?.Standard?.Entries?.[0];\n return {\n statID,\n displayName: rule.DisplayName ?? statID,\n maxLevel: rule.MaxLevel,\n baseValue: rule.BaseStatValue ?? 0,\n valueCurve: rule.ValueCurve,\n costCurve: rule.CostCurve,\n costAmount: entry?.Amount ?? 0,\n costCurrency: entry?.CurrencyID,\n };\n });\n}\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\nfunction mergeByKey<T>(\n preset: Record<string, T> | undefined,\n inline: Record<string, T> | undefined,\n remove: string[] | undefined,\n): Record<string, T> {\n if (!preset) return inline ?? {};\n const result: Record<string, T> = { ...preset, ...inline };\n for (const key of remove ?? []) delete result[key];\n return result;\n}\n\n/**\n * Effective stat value at a given level — the shared curve, evaluated exactly as the\n * server does it in `PvPBattleEngine.CalculateStats`.\n *\n * The step is the stat level with **firstStep = 0**: a stat the player never upgraded is\n * level 0 and is worth the plain base. This used to be a third hand-written copy of the\n * formula here, and it was wrong in two ways at once — it assumed the multiplicative\n * shape (the server's is whatever the publisher configured) and counted from level 1, so\n * every stat was displayed one step behind what the battle actually used.\n */\nexport function computeStatValue(stat: StatInfo, level: number): number {\n return evaluateCurve(stat.valueCurve, stat.baseValue, level, 0);\n}\n\n/**\n * Cost to upgrade a stat TO `nextLevel`. The step is the TARGET level, numbered from 1,\n * and the total is rounded UP once — the platform's single rounding convention. The old\n * `Math.round` here disagreed with the server by one unit on half the levels.\n */\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\n if (stat.costAmount <= 0) return 0;\n return roundAmount(\n stat.costAmount * curveMultiplier(stat.costCurve, nextLevel, 1),\n );\n}\n\nexport type Availability = \"owned\" | \"available\" | \"locked\";\n\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\n * (first level-up creates the model); locked = must be unlocked. */\nexport function resolveAvailability(\n def: CharacterDefinition | undefined,\n hasModel: boolean,\n): Availability {\n if (hasModel) return \"owned\";\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\n return \"locked\";\n}\n"
|
|
116
|
+
"path": "game/session.ts",
|
|
117
|
+
"content": "// The game's state for the whole session — created once in setup(), outliving the Phaser scene\r\n// (the Mode Router destroys a hidden mode; the fight, the progress and the queues must not reset).\r\n//\r\n// It owns: which hero fights (the Heroes screen tells us — `character:hero-selected@1`), that hero's\r\n// numbers (heroStats.ts, from the server state), the fight (battle.ts), the stage progress (saved in\r\n// the player's private custom data), the gold income (the server's idle accrual — economy.ts) and\r\n// the Enhance queue (one request in flight per stat, taps in between are added up).\r\n\r\nimport type { IDosGamesClient } from \"@idosgames/core\";\r\nimport { configSection, itemDefinitions, type ItemSection } from \"@idosgames/react/ui\";\r\nimport { Battle, type BattleEvent } from \"./battle\";\r\nimport {\r\n METRIC_STAGE_CLEARED,\r\n METRIC_STAT_ENHANCED,\r\n incomePerSecond,\r\n pickIncome,\r\n questMetric,\r\n type Income,\r\n type QuestSection,\r\n type RewardSection,\r\n} from \"./economy\";\r\nimport {\r\n affordableLevels,\r\n fighterStats,\r\n heroRank,\r\n resolveStats,\r\n statCap,\r\n type CharacterDefView,\r\n type CharacterSection,\r\n type FighterStats,\r\n type GearDefView,\r\n type HeroInput,\r\n type HeroModelView,\r\n type StatInfo,\r\n} from \"./heroStats\";\r\nimport { stageInfo } from \"./stages\";\r\n\r\n/** The private custom-data key of the stage progress (\".\" and \"$\" are not allowed in keys). */\r\nexport const PROGRESS_KEY = \"idle_rpg_progress\";\r\nconst SAVE_DELAY_MS = 4000;\r\n/** Below this, a collect on entry is just \"the counter ticked\", not \"while you were away\". */\r\nconst AWAY_MIN_SECONDS = 60;\r\nconst COLLECT_EVERY_MS = 15_000;\r\n/** How often the HUD redraws while the fight runs. */\r\nconst HUD_HZ = 8;\r\nconst TURN_EVENTS = new Set<BattleEvent[\"type\"]>([\r\n \"stageCleared\",\r\n \"bossStart\",\r\n \"bossFailed\",\r\n \"heroDown\",\r\n \"heroUp\",\r\n \"skill\",\r\n]);\r\n\r\nexport interface HeroView {\r\n id: string;\r\n name: string;\r\n classID: string;\r\n rarity: string;\r\n rank: number;\r\n power: number;\r\n def: CharacterDefView;\r\n input: HeroInput;\r\n stats: StatInfo[];\r\n fighter: FighterStats;\r\n}\r\n\r\nexport interface AwayReport {\r\n amount: number;\r\n seconds: number;\r\n currencyID: string;\r\n}\r\n\r\nexport type Notice =\r\n | { kind: \"error\"; text: string }\r\n | { kind: \"notEnough\"; currencyID: string };\r\n\r\nexport interface SessionCallbacks {\r\n /** A stage was cleared — the module turns it into its event for other modules. */\r\n onStageCleared?: (chapter: number, stage: number) => void;\r\n /** A hero's rank went up (anywhere — the Heroes screen too). */\r\n onRankUp?: (characterId: string, level: number) => void;\r\n}\r\n\r\ntype Listener = () => void;\r\n\r\nexport class IdleSession {\r\n readonly battle = new Battle();\r\n /** The hero the Heroes screen selected, or null until it says (then the fallback fights). */\r\n selectedID: string | null = null;\r\n hero: HeroView | null = null;\r\n income: Income | null = null;\r\n away: AwayReport | null = null;\r\n /** Gold the last collect brought (for the \"+X\" float), and when. */\r\n lastCollect: { amount: number; at: number } | null = null;\r\n notice: (Notice & { at: number }) | null = null;\r\n loaded = false;\r\n\r\n private version = 0;\r\n private listeners = new Set<Listener>();\r\n private hudClock = 0;\r\n private collectTimer: ReturnType<typeof setInterval> | null = null;\r\n private saveTimer: ReturnType<typeof setTimeout> | null = null;\r\n private collecting = false;\r\n private firstCollect = true;\r\n private enhanceQueue = new Map<string, { pending: number; busy: boolean }>();\r\n private metricQueue = new Map<string, { pending: number; busy: boolean }>();\r\n private offClient: Array<() => void> = [];\r\n\r\n constructor(\r\n private readonly client: IDosGamesClient,\r\n private readonly callbacks: SessionCallbacks = {},\r\n ) {\r\n // Removed in destroy(): setup() runs again on every login, and a client subscription that\r\n // outlived its session would keep a dead game recomputing — and emitting twice.\r\n this.offClient.push(\r\n client.on(\"user:anyUpdated\", () => this.refreshHero()),\r\n client.on(\"character:levelUpgraded\", (data) =>\r\n callbacks.onRankUp?.(data.CharacterID, data.NewLevel),\r\n ),\r\n );\r\n this.refreshHero();\r\n void this.load();\r\n }\r\n\r\n // ── React bridge ───────────────────────────────────────────────────────────────────────────\r\n\r\n subscribe = (listener: Listener): (() => void) => {\r\n this.listeners.add(listener);\r\n return () => this.listeners.delete(listener);\r\n };\r\n\r\n getVersion = (): number => this.version;\r\n\r\n private changed(): void {\r\n this.version++;\r\n for (const l of [...this.listeners]) l();\r\n }\r\n\r\n // ── The hero ───────────────────────────────────────────────────────────────────────────────\r\n\r\n selectHero(id: string): void {\r\n if (id === this.selectedID) return;\r\n this.selectedID = id;\r\n this.refreshHero();\r\n }\r\n\r\n /** Re-reads the hero from the cache: an upgrade, new gear or another hero changes the fighter. */\r\n refreshHero(): void {\r\n const section = configSection<CharacterSection>(this.client, \"Character\");\r\n const owned = (this.client.data.user.state?.Character?.Characters ??\r\n {}) as Record<string, HeroModelView | undefined>;\r\n const id = pickHero(section, owned, this.selectedID);\r\n this.income = pickIncome(configSection<RewardSection>(this.client, \"Reward\"));\r\n if (!id) {\r\n this.hero = null;\r\n return this.changed();\r\n }\r\n const def = section?.Definitions?.[id] ?? {};\r\n const items = itemDefinitions(\r\n configSection<ItemSection>(this.client, \"Item\"),\r\n ) as unknown as Map<string, GearDefView>;\r\n const instances = (\r\n this.client.data.user.state?.InventoryV2 as\r\n | { UnstackableItems?: Record<string, { Level?: number | null } | null> }\r\n | undefined\r\n )?.UnstackableItems;\r\n const instanceLevels: Record<string, number> = {};\r\n for (const [k, inst] of Object.entries(instances ?? {}))\r\n if (inst?.Level) instanceLevels[k] = inst.Level;\r\n const input: HeroInput = {\r\n section,\r\n def,\r\n model: owned[id],\r\n items,\r\n instanceLevels,\r\n };\r\n const fighter = fighterStats(input);\r\n this.hero = {\r\n id,\r\n name: def.Identity?.DisplayName ?? id,\r\n classID: def.Classification?.ClassID ?? \"\",\r\n rarity: def.Classification?.RarityID ?? \"\",\r\n rank: heroRank(input),\r\n power: Number(owned[id]?.Power ?? 0),\r\n def,\r\n input,\r\n stats: resolveStats(section, def),\r\n fighter,\r\n };\r\n this.battle.setStats(fighter);\r\n this.changed();\r\n }\r\n\r\n /** Gold per second the server pays now (display). */\r\n ratePerSecond(): number {\r\n if (!this.income) return 0;\r\n const owned = this.client.data.user.state?.Character?.Characters ?? {};\r\n return incomePerSecond(\r\n this.income,\r\n Object.values(owned).map((c) => (c as HeroModelView | undefined)?.Power),\r\n );\r\n }\r\n\r\n balance(currencyID: string): number {\r\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\r\n | Record<string, { Amount?: number } | undefined>\r\n | undefined;\r\n return Number(vc?.[currencyID]?.Amount ?? 0);\r\n }\r\n\r\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\r\n\r\n /** One frame of the fight (called by the scene). Returns what happened, for the drawing. */\r\n tick(dt: number): BattleEvent[] {\r\n const events = this.battle.tick(dt);\r\n for (const e of events) {\r\n if (e.type === \"stageCleared\") this.stageCleared(e.stage);\r\n else if (e.type === \"bossFailed\") this.scheduleSave();\r\n }\r\n // The HUD follows at a few frames a second; a turn of the stage shows at once.\r\n this.hudClock += dt;\r\n const turn = events.some((e) => TURN_EVENTS.has(e.type));\r\n if (turn || this.hudClock >= 1 / HUD_HZ) {\r\n this.hudClock = 0;\r\n this.changed();\r\n }\r\n return events;\r\n }\r\n\r\n /** The mode is on screen: collect what the hero earned meanwhile, then keep collecting. */\r\n activate(): void {\r\n void this.collect();\r\n this.collectTimer ??= setInterval(() => void this.collect(), COLLECT_EVERY_MS);\r\n }\r\n\r\n suspend(): void {\r\n if (this.collectTimer) clearInterval(this.collectTimer);\r\n this.collectTimer = null;\r\n this.flushSave();\r\n }\r\n\r\n destroy(): void {\r\n this.suspend();\r\n for (const off of this.offClient) off();\r\n this.offClient = [];\r\n this.listeners.clear();\r\n }\r\n\r\n // ── Income ─────────────────────────────────────────────────────────────────────────────────\r\n\r\n async collect(): Promise<void> {\r\n const income = this.income;\r\n if (!income || this.collecting) return;\r\n this.collecting = true;\r\n const first = this.firstCollect;\r\n this.firstCollect = false;\r\n const res = await this.client.reward.collectIdleAccrual(income.accrualID);\r\n this.collecting = false;\r\n // \"Too soon\" / \"Nothing to collect yet\" are the normal answers between collects — not errors.\r\n if (!res.ok) return;\r\n const amount = Number(res.data.IdleState?.LastClaimedAmount ?? 0);\r\n const seconds = Number(res.data.AccruedSeconds ?? 0);\r\n if (amount <= 0) return;\r\n this.lastCollect = { amount, at: Date.now() };\r\n if (first && seconds >= AWAY_MIN_SECONDS)\r\n this.away = { amount, seconds, currencyID: income.currencyID };\r\n this.changed();\r\n }\r\n\r\n dismissAway(): void {\r\n this.away = null;\r\n this.changed();\r\n }\r\n\r\n // ── Enhance ────────────────────────────────────────────────────────────────────────────────\r\n\r\n /**\r\n * Add `levels` to a stat. Taps while a request is out are added up and sent as ONE call when it\r\n * returns — so holding the button never floods the server (and the per-player rate limit).\r\n */\r\n enhance(statID: string, levels: number): void {\r\n const q = this.enhanceQueue.get(statID) ?? { pending: 0, busy: false };\r\n q.pending += Math.max(1, Math.floor(levels));\r\n this.enhanceQueue.set(statID, q);\r\n void this.pumpEnhance(statID);\r\n }\r\n\r\n /** What `levels` more would really buy now: limited by the cap and the balance. */\r\n plan(statID: string, levels: number): { levels: number; cost: number } {\r\n const hero = this.hero;\r\n const stat = hero?.stats.find((s) => s.id === statID);\r\n if (!hero || !stat?.price) return { levels: 0, cost: 0 };\r\n const level = Number(hero.input.model?.StatLevels?.[statID] ?? 0);\r\n return affordableLevels(\r\n stat,\r\n level,\r\n statCap(stat, Math.max(1, hero.rank), hero.def),\r\n this.balance(stat.price.currencyID),\r\n levels,\r\n );\r\n }\r\n\r\n private async pumpEnhance(statID: string): Promise<void> {\r\n const q = this.enhanceQueue.get(statID);\r\n const hero = this.hero;\r\n if (!q || q.busy || q.pending <= 0 || !hero) return;\r\n const { levels } = this.plan(statID, q.pending);\r\n if (levels <= 0) {\r\n q.pending = 0;\r\n const stat = hero.stats.find((s) => s.id === statID);\r\n if (stat?.price) this.notify({ kind: \"notEnough\", currencyID: stat.price.currencyID });\r\n return;\r\n }\r\n q.busy = true;\r\n const res = await this.client.character.upgradeStatLevel(hero.id, statID, {\r\n levels,\r\n });\r\n q.busy = false;\r\n if (!res.ok) {\r\n q.pending = 0;\r\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\r\n return;\r\n }\r\n q.pending = Math.max(0, q.pending - levels);\r\n this.report(METRIC_STAT_ENHANCED, levels);\r\n void this.pumpEnhance(statID);\r\n }\r\n\r\n private notify(notice: Notice): void {\r\n this.notice = { ...notice, at: Date.now() };\r\n this.changed();\r\n }\r\n\r\n // ── Progress ───────────────────────────────────────────────────────────────────────────────\r\n\r\n private async load(): Promise<void> {\r\n let raw = this.readProgress();\r\n if (raw === undefined) {\r\n const res = await this.client.userCustomData.getMyUserCustomData();\r\n raw = res.ok ? (res.data.Private?.[PROGRESS_KEY]?.Value ?? null) : null;\r\n }\r\n const saved = parseProgress(raw ?? null);\r\n if (saved) this.battle.setStage(saved.stage, saved.best);\r\n this.loaded = true;\r\n this.changed();\r\n }\r\n\r\n /** `undefined` — the login state did not bring the custom data at all (ask once). */\r\n private readProgress(): string | null | undefined {\r\n const cd = this.client.data.user.state?.CustomData as\r\n | { Private?: Record<string, { Value?: string | null } | undefined> }\r\n | null\r\n | undefined;\r\n if (!cd?.Private) return undefined;\r\n return cd.Private[PROGRESS_KEY]?.Value ?? null;\r\n }\r\n\r\n private stageCleared(stage: number): void {\r\n const info = stageInfo(stage);\r\n this.callbacks.onStageCleared?.(info.chapter, info.stage);\r\n this.report(METRIC_STAGE_CLEARED, 1);\r\n this.scheduleSave();\r\n }\r\n\r\n private scheduleSave(): void {\r\n if (this.saveTimer) clearTimeout(this.saveTimer);\r\n this.saveTimer = setTimeout(() => this.flushSave(), SAVE_DELAY_MS);\r\n }\r\n\r\n private flushSave(): void {\r\n if (this.saveTimer) clearTimeout(this.saveTimer);\r\n this.saveTimer = null;\r\n if (!this.loaded) return;\r\n const value = serializeProgress({\r\n stage: this.battle.stage,\r\n best: this.battle.best,\r\n });\r\n if (value === this.readProgress()) return;\r\n void this.client.userCustomData.setPrivateData(PROGRESS_KEY, value);\r\n }\r\n\r\n // ── Quest metrics ──────────────────────────────────────────────────────────────────────────\r\n\r\n /** Report a metric to the quests that count it — one call at a time, never above their cap. */\r\n private report(metricID: string, amount: number): void {\r\n if (!questMetric(this.questSection(), metricID)) return;\r\n const q = this.metricQueue.get(metricID) ?? { pending: 0, busy: false };\r\n q.pending += amount;\r\n this.metricQueue.set(metricID, q);\r\n void this.pumpMetric(metricID);\r\n }\r\n\r\n private async pumpMetric(metricID: string): Promise<void> {\r\n const q = this.metricQueue.get(metricID);\r\n const metric = questMetric(this.questSection(), metricID);\r\n if (!q || q.busy || q.pending <= 0 || !metric) return;\r\n const chunk =\r\n metric.maxPerCall > 0 ? Math.min(q.pending, metric.maxPerCall) : q.pending;\r\n q.busy = true;\r\n q.pending -= chunk;\r\n await this.client.quest.addQuestProgress(metricID, chunk);\r\n q.busy = false;\r\n void this.pumpMetric(metricID);\r\n }\r\n\r\n private questSection(): QuestSection | undefined {\r\n return configSection<QuestSection>(this.client, \"Quest\");\r\n }\r\n}\r\n\r\n// ── Pure helpers ─────────────────────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * The fighter: the selected hero while it is playable; else the strongest owned hero; else the\r\n * first hero playable by default (roster order).\r\n */\r\nexport function pickHero(\r\n section: CharacterSection | undefined,\r\n owned: Record<string, HeroModelView | undefined>,\r\n selectedID: string | null,\r\n): string | null {\r\n const defs = section?.Definitions ?? {};\r\n const playable = (id: string) =>\r\n Number(owned[id]?.Level ?? 0) > 0 || !!defs[id]?.Unlock?.UnlockedByDefault;\r\n if (selectedID && defs[selectedID] && playable(selectedID)) return selectedID;\r\n const strongest = Object.entries(owned)\r\n .filter(([id, m]) => defs[id] && Number(m?.Level ?? 0) > 0)\r\n .sort(([, a], [, b]) => Number(b?.Power ?? 0) - Number(a?.Power ?? 0))[0];\r\n if (strongest) return strongest[0];\r\n const byOrder = Object.entries(defs)\r\n .filter(([id, d]) => d && playable(id))\r\n .sort(\r\n ([a, x], [b, y]) =>\r\n Number(x?.Identity?.SortOrder ?? 0) - Number(y?.Identity?.SortOrder ?? 0) ||\r\n a.localeCompare(b),\r\n )[0];\r\n return byOrder?.[0] ?? null;\r\n}\r\n\r\nexport function parseProgress(\r\n raw: string | null,\r\n): { stage: number; best: number } | null {\r\n if (!raw) return null;\r\n try {\r\n const v = JSON.parse(raw) as { stage?: unknown; best?: unknown };\r\n const stage = Math.floor(Number(v.stage));\r\n if (!Number.isFinite(stage) || stage < 1) return null;\r\n const best = Math.max(stage, Math.floor(Number(v.best)) || stage);\r\n return { stage, best };\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nexport function serializeProgress(p: { stage: number; best: number }): string {\r\n return JSON.stringify({ stage: p.stage, best: p.best });\r\n}\r\n"
|
|
99
118
|
},
|
|
100
119
|
{
|
|
101
|
-
"path": "
|
|
102
|
-
"content": "//
|
|
120
|
+
"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\"\n | \"bat\"\n | \"goblin\"\n | \"skull\"\n | \"golem\"\n | \"cactus\"\n | \"yeti\"\n | \"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"
|
|
103
122
|
},
|
|
104
123
|
{
|
|
105
|
-
"path": "
|
|
106
|
-
"content": "import {
|
|
124
|
+
"path": "i18n.ts",
|
|
125
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Idle RPG\",\n loading: \"Getting ready…\",\n noHero: \"No heroes in this game yet. Add them in LiveOps → Characters.\",\n // Stage\n normal: \"Normal\",\n hard: \"Hard\",\n veryHard: \"Very Hard\",\n hell: \"Hell\",\n abyss: \"Abyss\",\n meadow: \"Meadow\",\n forest: \"Forest\",\n desert: \"Desert\",\n snow: \"Snowfield\",\n volcano: \"Volcano\",\n wave: \"Wave\",\n boss: \"Boss\",\n challenge: \"Boss!\",\n bossTime: \"Defeat the boss\",\n stageClear: \"Stage clear!\",\n bossEscaped: \"The boss got away\",\n heroDown: \"Knocked out…\",\n // Skills\n meteor: \"Meteor\",\n frenzy: \"Frenzy\",\n heal: \"Heal\",\n auto: \"Auto\",\n opensAt: \"Stage\",\n // Enhance\n enhance: \"Enhance\",\n perSecond: \"/s\",\n max: \"MAX\",\n maxed: \"Max\",\n rankUpToRaise: \"Rank up to raise\",\n needs: \"Needs\",\n notEnough: \"Not enough\",\n // Nav\n heroes: \"Hero\",\n summon: \"Summon\",\n quests: \"Quests\",\n shop: \"Shop\",\n // Guide\n guide: \"Quest\",\n claim: \"Claim\",\n // Away\n welcomeBack: \"Welcome back!\",\n awayText: \"Your hero kept fighting while you were away\",\n collect: \"Collect\",\n power: \"Power\",\n rank: \"Rank\",\n} as const;\n\nexport type Key = keyof typeof EN;\n\nexport const t = makeT<Key>(EN, {\n title: \"Idle RPG\",\n loading: \"Готовимся…\",\n noHero: \"В игре пока нет героев. Добавьте их в LiveOps → Персонажи.\",\n normal: \"Обычный\",\n hard: \"Сложный\",\n veryHard: \"Очень сложный\",\n hell: \"Ад\",\n abyss: \"Бездна\",\n meadow: \"Луг\",\n forest: \"Лес\",\n desert: \"Пустыня\",\n snow: \"Снега\",\n volcano: \"Вулкан\",\n wave: \"Волна\",\n boss: \"Босс\",\n challenge: \"Босс!\",\n bossTime: \"Победите босса\",\n stageClear: \"Этап пройден!\",\n bossEscaped: \"Босс ушёл\",\n heroDown: \"Герой повержен…\",\n meteor: \"Метеор\",\n frenzy: \"Ярость\",\n heal: \"Лечение\",\n auto: \"Авто\",\n opensAt: \"Этап\",\n enhance: \"Улучшить\",\n perSecond: \"/с\",\n max: \"МАКС\",\n maxed: \"Максимум\",\n rankUpToRaise: \"Повысьте ранг\",\n needs: \"Нужно\",\n notEnough: \"Не хватает\",\n heroes: \"Герой\",\n summon: \"Призыв\",\n quests: \"Задания\",\n shop: \"Магазин\",\n guide: \"Задание\",\n claim: \"Забрать\",\n welcomeBack: \"С возвращением!\",\n awayText: \"Пока вас не было, герой продолжал сражаться\",\n collect: \"Забрать\",\n power: \"Сила\",\n rank: \"Ранг\",\n});\n"
|
|
107
126
|
},
|
|
108
127
|
{
|
|
109
|
-
"path": "
|
|
110
|
-
"content": "
|
|
128
|
+
"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 { Battle, SKILLS, type BattleEvent, type BattleSnapshot } 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"
|
|
111
130
|
},
|
|
112
131
|
{
|
|
113
|
-
"path": "
|
|
114
|
-
"content": "//
|
|
132
|
+
"path": "layout.ts",
|
|
133
|
+
"content": "// One screen split in two: the fight on top (Phaser draws it on the full-bleed canvas) and the\n// Enhance panel below (React). Both sides compute the split from the SAME numbers — the canvas draws\n// the field into the top part, the panel starts right under it.\n\n/** The fight takes this share of the screen height… */\nexport const BATTLE_SHARE = 0.44;\n/** …but never more than this many pixels (a tall desktop window gets a bigger panel, not a stretched field). */\nexport const BATTLE_MAX_PX = 430;\n/** The lane is at most this wide; a wider screen centres it. */\nexport const LANE_MAX_PX = 980;\n\nexport function battleHeight(screenHeight: number): number {\n return Math.round(Math.min(screenHeight * BATTLE_SHARE, BATTLE_MAX_PX));\n}\n\n/** The same split as a CSS length, for the React side. */\nexport const BATTLE_HEIGHT_CSS = `min(${BATTLE_SHARE * 100}%, ${BATTLE_MAX_PX}px)`;\n"
|
|
115
134
|
},
|
|
116
135
|
{
|
|
117
136
|
"path": "module.ts",
|
|
118
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport {
|
|
137
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\r\nimport { IdleRpgController } from \"./game/IdleRpgController\";\r\nimport { IdleSession } from \"./game/session\";\r\nimport { SKILLS, type SkillId } from \"./game/battle\";\r\nimport { createControllerBox } from \"./controller-box\";\r\nimport { createIdleRpgScene } from \"./scene\";\r\nimport { makeGamePanel } from \"./ui/GamePanel\";\r\nimport { characterUpgraded, heroSelected, stageCleared } from \"./events\";\r\nimport { t } from \"./i18n\";\r\n\r\n// The Idle RPG game (Legend Slime-style): the hero fights waves and bosses on its own, gold flows in\r\n// from the server's idle accrual, and the player spends it on the hero's stats. Heroes, gear, summons,\r\n// quests and the shop are the platform's systems (module.meta.json → uses), opened from the game's\r\n// bottom bar — the game draws only the fight and Enhance.\r\nexport const idleRpgModule: Module = defineModule({\r\n id: \"idle-rpg\",\r\n meta: {\r\n name: \"Idle RPG\",\r\n type: \"game\",\r\n genre: \"idle-rpg\",\r\n engine: \"phaser\",\r\n },\r\n setup(ctx) {\r\n const session = new IdleSession(ctx.client, {\r\n onStageCleared: (chapter, stage) =>\r\n ctx.events.emit(stageCleared, { chapter, stage }),\r\n // A hero's rank went up anywhere (the Heroes screen) — the signal other modules know this game by.\r\n onRankUp: (characterId, level) =>\r\n ctx.events.emit(characterUpgraded, { characterId, level }),\r\n });\r\n const box = createControllerBox<IdleRpgController>();\r\n\r\n // The hero the player picked on the Heroes screen fights. Subscribed here, in setup(): the Heroes\r\n // system announces its saved choice as soon as its watcher mounts, which is after every setup().\r\n ctx.events.on(heroSelected, ({ characterId }) =>\r\n session.selectHero(characterId),\r\n );\r\n\r\n ctx.registerScene(createIdleRpgScene(box, session));\r\n ctx.registerPanel({\r\n id: \"game\",\r\n slot: \"overlay\",\r\n component: makeGamePanel(session, ctx.features),\r\n });\r\n ctx.registerRoute({ id: \"idle-rpg\", label: t(\"title\"), icon: \"⚔️\" });\r\n\r\n // The fight is drawn on a canvas — the agent cannot read it from the DOM, so it is published\r\n // here: the stage, the hero and the enemies, plus the actions the player's own buttons take.\r\n ctx.exposeToAgent({\r\n state: () => {\r\n const snap = session.battle.snapshot();\r\n const hero = session.hero;\r\n return {\r\n mounted: box.get() !== null,\r\n playing: box.get()?.running ?? false,\r\n stage: snap.stage,\r\n best: snap.best,\r\n mode: snap.mode,\r\n wave: `${snap.wave + 1}/${snap.waves}`,\r\n bossTimeLeft: Math.round(snap.bossTimeLeft),\r\n autoBoss: snap.autoBoss,\r\n autoSkills: snap.autoSkills,\r\n hero: hero\r\n ? {\r\n id: hero.id,\r\n rank: hero.rank,\r\n power: hero.power,\r\n hp: Math.round(snap.heroHp),\r\n maxHp: Math.round(snap.heroMaxHp),\r\n down: snap.heroDown,\r\n fighter: hero.fighter,\r\n stats: hero.stats.map((s) => ({\r\n id: s.id,\r\n level: hero.input.model?.StatLevels?.[s.id] ?? 0,\r\n })),\r\n }\r\n : null,\r\n monsters: session.battle.monsters.map((m) => ({\r\n kind: m.spec.kind,\r\n boss: m.spec.boss,\r\n hp: Math.round(m.hp),\r\n x: Math.round(m.x * 100) / 100,\r\n })),\r\n skills: snap.skills,\r\n goldPerSecond: Math.round(session.ratePerSecond() * 10) / 10,\r\n income: session.income,\r\n };\r\n },\r\n actions: {\r\n enhance: async (args) => {\r\n const statID = String(args?.[\"stat\"] ?? \"\");\r\n const hero = session.hero;\r\n if (!hero?.stats.some((s) => s.id === statID))\r\n throw new Error(\r\n `unknown stat \"${statID}\" — one of: ${hero?.stats.map((s) => s.id).join(\", \") ?? \"(no hero)\"}`,\r\n );\r\n const levels = Math.min(100, Math.max(1, Number(args?.[\"levels\"] ?? 1)));\r\n session.enhance(statID, levels);\r\n },\r\n challengeBoss: async () => {\r\n if (!session.battle.challengeBoss())\r\n throw new Error(\"not now — a boss fight is on, or the hero is down\");\r\n },\r\n cast: async (args) => {\r\n const id = String(args?.[\"skill\"] ?? \"\") as SkillId;\r\n if (!SKILLS.some((s) => s.id === id))\r\n throw new Error(`unknown skill — one of: ${SKILLS.map((s) => s.id).join(\", \")}`);\r\n if (!session.battle.castSkill(id))\r\n throw new Error(\"locked, cooling down, or the hero is down\");\r\n },\r\n setAuto: async (args) => {\r\n session.battle.setAutoSkills(Boolean(args?.[\"on\"]));\r\n },\r\n },\r\n describeActions: {\r\n enhance:\r\n \"Buy stat levels of the fighting hero for gold: { stat: StatID, levels?: 1..100 } (limited by gold and the cap).\",\r\n challengeBoss: \"Fight the current stage's boss now (the 'Boss!' button).\",\r\n cast: \"Cast a skill: { skill: meteor | frenzy | heal }.\",\r\n setAuto: \"Auto-cast skills on or off: { on: boolean }.\",\r\n },\r\n });\r\n },\r\n});\r\n"
|
|
119
138
|
},
|
|
120
139
|
{
|
|
121
|
-
"path": "phaser/
|
|
122
|
-
"content": "import Phaser from \"phaser\";\n\nexport interface CharacterDisplay {\n name: string;\n rarity?: string;\n className?: string;\n statusLine: string;\n owned: boolean;\n}\n\nconst RARITY_COLORS: Record<string, number> = {\n Common: 0x9aa0b5,\n Uncommon: 0x5ad19a,\n Rare: 0x4a90e2,\n Epic: 0x9c5cff,\n Legendary: 0xf0a020,\n Mythic: 0xe8534e,\n Exotic: 0xff5fa2,\n};\n\nfunction rarityColor(rarity?: string): number {\n const resolved = rarity ? RARITY_COLORS[rarity] : undefined;\n return resolved ?? 0x6c5ce7;\n}\n\n/** 2D character display: a rarity-framed card with a procedural emblem, the character's name/class,\n * and an idle bob. The IdleRPG screen is UI-driven (React overlays); Phaser owns the live visuals. */\nexport class IdleRpgScene extends Phaser.Scene {\n private container?: Phaser.GameObjects.Container;\n private current: CharacterDisplay | null = null;\n\n constructor() {\n super(\"idle-rpg\");\n }\n\n create(): void {\n const { width, height } = this.scale;\n this.cameras.main.setBackgroundColor(\"#063d99\");\n\n this.add\n .text(width / 2, 30, \"IdleRPG\", {\n fontFamily: \"sans-serif\",\n fontSize: \"30px\",\n color: \"#ffd479\",\n })\n .setOrigin(0.5, 0);\n\n this.container = this.add.container(width / 2, height / 2);\n this.tweens.add({\n targets: this.container,\n y: height / 2 - 12,\n yoyo: true,\n repeat: -1,\n duration: 1100,\n ease: \"Sine.easeInOut\",\n });\n\n this.render();\n }\n\n setSelected(display: CharacterDisplay): void {\n this.current = display;\n this.render();\n }\n\n private render(): void {\n const container = this.container;\n if (!container) return;\n container.removeAll(true);\n\n if (!this.current) {\n const glow = this.add.graphics();\n glow.fillStyle(0x6c5ce7, 0.12);\n glow.fillCircle(0, -30, 140);\n const ring = this.add.graphics();\n ring.lineStyle(3, 0x4a4368, 1);\n ring.strokeCircle(0, -40, 56);\n const hint = this.add\n .text(0, 70, \"Select a character\", {\n fontFamily: \"sans-serif\",\n fontSize: \"20px\",\n color: \"#a9c6ff\",\n })\n .setOrigin(0.5);\n container.add([glow, ring, hint]);\n return;\n }\n\n const color = rarityColor(this.current.rarity);\n const initial = (this.current.className ?? this.current.name)\n .charAt(0)\n .toUpperCase();\n const dim = !this.current.owned;\n\n const glow = this.add.graphics();\n glow.fillStyle(color, dim ? 0.08 : 0.18);\n glow.fillCircle(0, -40, 150);\n\n const card = this.add.graphics();\n card.fillStyle(0x1b1730, 0.92);\n card.fillRoundedRect(-110, -165, 220, 300, 16);\n card.lineStyle(4, color, dim ? 0.5 : 1);\n card.strokeRoundedRect(-110, -165, 220, 300, 16);\n\n const emblem = this.add.graphics();\n emblem.fillStyle(color, dim ? 0.5 : 1);\n emblem.fillCircle(0, -72, 26);\n emblem.fillRoundedRect(-34, -46, 68, 86, 16);\n\n const initialText = this.add\n .text(0, -2, initial, {\n fontFamily: \"sans-serif\",\n fontSize: \"40px\",\n color: \"#04276b\",\n fontStyle: \"bold\",\n })\n .setOrigin(0.5);\n\n const nameText = this.add\n .text(0, 70, this.current.name, {\n fontFamily: \"sans-serif\",\n fontSize: \"24px\",\n color: \"#ffffff\",\n fontStyle: \"bold\",\n })\n .setOrigin(0.5);\n\n const subParts = [this.current.rarity, this.current.className]\n .filter(Boolean)\n .join(\" · \");\n const subText = this.add\n .text(0, 100, subParts, {\n fontFamily: \"sans-serif\",\n fontSize: \"15px\",\n color: hexCss(color),\n })\n .setOrigin(0.5);\n\n const statusText = this.add\n .text(0, 126, this.current.statusLine, {\n fontFamily: \"sans-serif\",\n fontSize: \"15px\",\n color: \"#cfe0ff\",\n })\n .setOrigin(0.5);\n\n container.add([\n glow,\n card,\n emblem,\n initialText,\n nameText,\n subText,\n statusText,\n ]);\n }\n}\n\nfunction hexCss(color: number): string {\n return `#${color.toString(16).padStart(6, \"0\")}`;\n}\n"
|
|
140
|
+
"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(0, Math.min(255, Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k)));\n return Phaser.Display.Color.GetColor(f(c.red), f(c.green), f(c.blue));\n}\n\nfunction eyes(g: G, cx: number, cy: number, gap: number, r: number, angry = false): 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 = classID === \"Mage\" ? \"wizard\" : classID === \"Ranger\" ? \"hood\" : classID === \"Warrior\" ? \"helmet\" : \"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(cx - 30, base - 52, cx + 30, base - 52, cx + 8, base - 108);\n g.fillStyle(0x6a3fd0, 1);\n g.fillTriangle(cx - 26, base - 55, cx + 26, base - 55, cx + 8, base - 103);\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(cx - 34, base - 46, cx + 34, base - 46, cx - 20, base - 92);\n g.fillStyle(0x2f8a3a, 1);\n g.fillTriangle(cx - 30, base - 49, cx + 30, base - 49, cx - 18, base - 87);\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(cx - 46, base - 58, cx - 8, base - 50, cx - 14, base - 30);\n g.fillTriangle(cx + 46, base - 58, cx + 8, base - 50, cx + 14, base - 30);\n g.fillStyle(0x6c3f8f, 1);\n g.fillTriangle(cx - 41, base - 56, cx - 10, base - 49, cx - 15, base - 34);\n g.fillTriangle(cx + 41, base - 56, cx + 10, base - 49, cx + 15, base - 34);\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(cx - 44, base - 52, cx - 18, base - 44, cx - 22, base - 30);\n g.fillTriangle(cx + 44, base - 52, cx + 18, base - 44, cx + 22, base - 30);\n g.fillEllipse(cx, base - 30, 64, 58);\n g.fillStyle(0x6cc04a, 1);\n g.fillTriangle(cx - 40, base - 50, cx - 19, base - 43, cx - 22, base - 33);\n g.fillTriangle(cx + 40, base - 50, cx + 19, base - 43, cx + 22, base - 33);\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(cx - 26, base - 50, cx - 10, base - 58, cx - 28, base - 78);\n g.fillTriangle(cx + 26, base - 50, cx + 10, base - 58, cx + 28, base - 78);\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(cx - 23, base - 53, cx - 12, base - 57, cx - 25, base - 73);\n g.fillTriangle(cx + 23, base - 53, cx + 12, base - 57, cx + 25, base - 73);\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"
|
|
123
142
|
},
|
|
124
143
|
{
|
|
125
|
-
"path": "
|
|
126
|
-
"content": "import { createControllerContext } from \"@idosgames/react\";\nimport type { IdleRpgController } from \"../game/IdleRpgController\";\n\n// The module's controller context, built with the shared factory instead of hand-written boilerplate.\n// Panels call useIdleRpgController() to reach the Phaser controller (e.g. to select a character).\nexport const [IdleRpgControllerProvider, useIdleRpgController] =\n createControllerContext<IdleRpgController>(\"IdleRpgController\");\n"
|
|
144
|
+
"path": "phaser/BattleScene.ts",
|
|
145
|
+
"content": "import Phaser from \"phaser\";\r\nimport { HERO_X, type BattleEvent, type Monster } from \"../game/battle\";\r\nimport { formatBig } from \"../game/format\";\r\nimport type { IdleSession } from \"../game/session\";\r\nimport { stageInfo, type Theme } from \"../game/stages\";\r\nimport { LANE_MAX_PX, battleHeight } from \"../layout\";\r\nimport {\r\n HERO_SIZE,\r\n MONSTER_SIZE,\r\n bakeCommon,\r\n heroColor,\r\n heroTexture,\r\n monsterTexture,\r\n shade,\r\n shotTexture,\r\n} from \"./art\";\r\n\r\n// Draws the fight the session simulates: it owns no rules. Each frame it advances the session's\r\n// battle, turns the returned events into effects (numbers, coins, bursts) and moves the sprites to\r\n// where the simulation says things are. The field is the top part of the canvas (layout.ts); the\r\n// Enhance panel covers the rest.\r\n\r\ninterface MonsterView {\r\n body: Phaser.GameObjects.Image;\r\n bar: Phaser.GameObjects.Graphics;\r\n wobble: number;\r\n boss: boolean;\r\n}\r\n\r\nconst SHOT_Y = 0.52; // of the hero's height above the ground\r\n\r\nexport class BattleScene extends Phaser.Scene {\r\n private field = { w: 480, h: 360, laneLeft: 0, laneW: 480, ground: 290, unit: 1 };\r\n private sky?: Phaser.GameObjects.Graphics;\r\n private hills?: Phaser.GameObjects.Graphics;\r\n private clouds: Phaser.GameObjects.Image[] = [];\r\n private theme: Theme | null = null;\r\n private hero?: Phaser.GameObjects.Image;\r\n private heroKey = \"\";\r\n private heroBar?: Phaser.GameObjects.Graphics;\r\n private monsters = new Map<number, MonsterView>();\r\n private shots = new Map<number, Phaser.GameObjects.Image>();\r\n private sparks?: Phaser.GameObjects.Particles.ParticleEmitter;\r\n private puffs?: Phaser.GameObjects.Particles.ParticleEmitter;\r\n private heals?: Phaser.GameObjects.Particles.ParticleEmitter;\r\n private fire?: Phaser.GameObjects.Particles.ParticleEmitter;\r\n private frenzyTween?: Phaser.Tweens.Tween;\r\n private lastCollectAt = 0;\r\n /** Pixels the hero is pushed back by its own shot; decays every frame. */\r\n private recoil = 0;\r\n\r\n constructor(private readonly session: IdleSession) {\r\n super(\"idle-battle\");\r\n }\r\n\r\n create(): void {\r\n bakeCommon(this);\r\n this.sky = this.add.graphics().setDepth(0);\r\n this.hills = this.add.graphics().setDepth(2);\r\n for (let i = 0; i < 4; i++)\r\n this.clouds.push(\r\n this.add.image(0, 0, \"cloud\").setDepth(1).setAlpha(0.85),\r\n );\r\n this.heroBar = this.add.graphics().setDepth(30);\r\n\r\n const particles = (\r\n texture: string,\r\n config: Phaser.Types.GameObjects.Particles.ParticleEmitterConfig,\r\n ) =>\r\n this.add\r\n .particles(0, 0, texture, { emitting: false, ...config })\r\n .setDepth(40);\r\n this.sparks = particles(\"spark\", {\r\n speed: { min: 80, max: 260 },\r\n lifespan: 420,\r\n scale: { start: 0.9, end: 0 },\r\n tint: [0xffffff, 0xffe27a, 0xffa53d],\r\n });\r\n this.puffs = particles(\"puff\", {\r\n speed: { min: 30, max: 120 },\r\n lifespan: 520,\r\n scale: { start: 0.9, end: 0.1 },\r\n alpha: { start: 0.8, end: 0 },\r\n });\r\n this.heals = particles(\"spark\", {\r\n speedY: { min: -140, max: -60 },\r\n speedX: { min: -40, max: 40 },\r\n lifespan: 900,\r\n scale: { start: 1, end: 0 },\r\n tint: 0x6dff8a,\r\n });\r\n this.fire = particles(\"spark\", {\r\n speed: { min: 120, max: 340 },\r\n lifespan: 520,\r\n scale: { start: 1.6, end: 0 },\r\n tint: [0xff4d1a, 0xffa53d, 0xffe27a],\r\n });\r\n\r\n this.scale.on(\"resize\", () => this.layout());\r\n this.layout();\r\n }\r\n\r\n // ── Layout ─────────────────────────────────────────────────────────────────────────────────\r\n\r\n private layout(): void {\r\n const w = this.scale.width;\r\n const h = battleHeight(this.scale.height);\r\n const laneW = Math.min(w, LANE_MAX_PX);\r\n this.field = {\r\n w,\r\n h,\r\n laneLeft: (w - laneW) / 2,\r\n laneW,\r\n ground: Math.round(h * 0.86),\r\n // Sprites are drawn for a ~620 px lane; a phone shrinks them so the hero does not cover the\r\n // melee line, a desktop keeps them at their natural size.\r\n unit: Math.min(1, Math.max(0.6, laneW / 620)),\r\n };\r\n this.theme = null; // repaint the background for the new size\r\n this.paintBackground();\r\n this.clouds.forEach((c, i) =>\r\n c.setPosition(((i + 0.3) / this.clouds.length) * w, h * (0.1 + 0.08 * (i % 2))),\r\n );\r\n }\r\n\r\n private laneX(x: number): number {\r\n return this.field.laneLeft + x * this.field.laneW;\r\n }\r\n\r\n private paintBackground(): void {\r\n const theme = stageInfo(this.session.battle.stage).theme;\r\n if (theme === this.theme || !this.sky || !this.hills) return;\r\n this.theme = theme;\r\n const { w, h, ground } = this.field;\r\n this.cameras.main.setBackgroundColor(theme.sky[0]);\r\n\r\n const sky = this.sky.clear();\r\n sky.fillGradientStyle(theme.sky[0], theme.sky[0], theme.sky[1], theme.sky[1], 1);\r\n sky.fillRect(0, 0, w, h);\r\n\r\n const g = this.hills.clear();\r\n // Far and near hills: rows of overlapping humps.\r\n g.fillStyle(theme.hillFar, 1);\r\n for (let x = -40; x < w + 80; x += 90)\r\n g.fillEllipse(x, ground - 34 + ((x / 90) % 2) * 10, 170, 110);\r\n g.fillStyle(theme.hillNear, 1);\r\n for (let x = 10; x < w + 80; x += 120)\r\n g.fillEllipse(x, ground - 6, 200, 80);\r\n // Ground with a grass rim, tufts and flowers.\r\n g.fillStyle(theme.ground, 1);\r\n g.fillRect(0, ground, w, h - ground + 2);\r\n g.fillStyle(shade(theme.ground, -0.15), 1);\r\n for (let x = 0; x < w; x += 46) g.fillRect(x + 12, ground + 14, 18, 4);\r\n g.fillStyle(theme.grass, 1);\r\n g.fillRect(0, ground - 4, w, 8);\r\n for (let x = 4; x < w; x += 26)\r\n g.fillTriangle(x, ground, x + 6, ground - 12, x + 12, ground);\r\n const petals = [0xffffff, 0xffd34d, 0xff7aa8];\r\n for (let i = 0, x = 30; x < w; x += 140, i++) {\r\n g.fillStyle(petals[i % petals.length]!, 1);\r\n g.fillCircle(x, ground - 8, 4);\r\n }\r\n // A dark strip under the field — the panel starts right below it.\r\n g.fillStyle(0x000000, 0.25);\r\n g.fillRect(0, h - 3, w, 3);\r\n }\r\n\r\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\r\n\r\n override update(_time: number, deltaMs: number): void {\r\n const events = this.session.tick(deltaMs / 1000);\r\n this.paintBackground();\r\n this.syncHero();\r\n for (const e of events) this.react(e);\r\n this.syncMonsters();\r\n this.syncShots();\r\n this.driftClouds(deltaMs / 1000);\r\n const collect = this.session.lastCollect;\r\n if (collect && collect.at !== this.lastCollectAt) {\r\n this.lastCollectAt = collect.at;\r\n this.coinShower(Math.min(14, 4 + Math.floor(Math.log10(collect.amount + 1) * 3)));\r\n }\r\n }\r\n\r\n private heroPos(): { x: number; y: number } {\r\n return { x: this.laneX(HERO_X), y: this.field.ground };\r\n }\r\n\r\n private syncHero(): void {\r\n const hero = this.session.hero;\r\n const { x, y } = this.heroPos();\r\n const key = hero ? heroTexture(this, hero.classID, hero.id) : \"\";\r\n if (!this.hero) {\r\n this.hero = this.add\r\n .image(x, y, key || \"puff\")\r\n .setOrigin(0.5, 1)\r\n .setDepth(20);\r\n }\r\n if (key && key !== this.heroKey) {\r\n this.heroKey = key;\r\n this.hero.setTexture(key);\r\n this.burstAt(this.puffs, x, y - 40, 14);\r\n }\r\n this.recoil *= 0.75;\r\n this.hero.setPosition(x - this.recoil, y);\r\n // The slime's idle wobble: squash and stretch around its own size.\r\n const wobble = Math.sin(this.time.now / 260);\r\n const unit = this.field.unit;\r\n this.hero.setScale(unit * (1 + 0.04 * wobble), unit * (1 - 0.05 * wobble));\r\n this.hero.setVisible(!!hero);\r\n\r\n const snap = this.session.battle;\r\n const bar = this.heroBar!.clear();\r\n if (!hero || snap.heroDownLeft > 0) return;\r\n const share = Math.max(0, snap.heroHp / Math.max(1, snap.stats.maxHp));\r\n const bw = 70;\r\n const by = y - HERO_SIZE.h * this.field.unit - 4;\r\n bar.fillStyle(0x2b1d33, 0.85);\r\n bar.fillRoundedRect(x - bw / 2 - 2, by - 2, bw + 4, 10, 4);\r\n bar.fillStyle(share > 0.35 ? 0x5fd36a : 0xff5a4f, 1);\r\n bar.fillRoundedRect(x - bw / 2, by, Math.max(4, bw * share), 6, 3);\r\n }\r\n\r\n private syncMonsters(): void {\r\n const alive = new Set<number>();\r\n for (const m of this.session.battle.monsters) {\r\n alive.add(m.id);\r\n const view = this.monsters.get(m.id) ?? this.spawnView(m);\r\n const x = this.laneX(m.x) + Math.sin((m.id * 7.3) % 6.28) * 10;\r\n const bob = Math.abs(Math.sin(this.time.now / 160 + view.wobble)) * 5;\r\n view.body.setPosition(x, this.field.ground - bob);\r\n const share = Math.max(0, m.hp / m.maxHp);\r\n const bw = view.boss ? 110 : 54;\r\n const top = this.field.ground - view.body.displayHeight - 8;\r\n view.bar.clear();\r\n if (share < 1 || view.boss) {\r\n view.bar.fillStyle(0x2b1d33, 0.85);\r\n view.bar.fillRoundedRect(x - bw / 2 - 2, top - 2, bw + 4, 9, 4);\r\n view.bar.fillStyle(view.boss ? 0xff5a4f : 0xffb03a, 1);\r\n view.bar.fillRoundedRect(x - bw / 2, top, Math.max(3, bw * share), 5, 2);\r\n }\r\n }\r\n for (const [id, view] of this.monsters)\r\n if (!alive.has(id)) {\r\n view.body.destroy();\r\n view.bar.destroy();\r\n this.monsters.delete(id);\r\n }\r\n }\r\n\r\n private spawnView(m: Monster): MonsterView {\r\n const body = this.add\r\n .image(this.laneX(m.x), this.field.ground, monsterTexture(this, m.spec.kind))\r\n .setOrigin(0.5, 1)\r\n .setDepth(m.spec.boss ? 18 : 15)\r\n .setFlipX(true);\r\n const scale = (m.spec.boss ? 1.6 : 0.8) * this.field.unit;\r\n body.setScale(0);\r\n this.tweens.add({ targets: body, scale, duration: 260, ease: \"Back.easeOut\" });\r\n const view: MonsterView = {\r\n body,\r\n bar: this.add.graphics().setDepth(31),\r\n wobble: m.id,\r\n boss: m.spec.boss,\r\n };\r\n this.monsters.set(m.id, view);\r\n return view;\r\n }\r\n\r\n private syncShots(): void {\r\n const live = new Set<number>();\r\n const y = this.field.ground - HERO_SIZE.h * this.field.unit * SHOT_Y;\r\n for (const p of this.session.battle.projectiles) {\r\n live.add(p.id);\r\n let img = this.shots.get(p.id);\r\n if (!img) {\r\n const hero = this.session.hero;\r\n const color = p.crit ? 0xff7a1a : heroColor(hero?.classID ?? \"\", hero?.id ?? \"\");\r\n img = this.add\r\n .image(this.laneX(p.x), y, shotTexture(this, color))\r\n .setDepth(25)\r\n .setScale(p.crit ? 1.35 : p.extra ? 0.75 : 1);\r\n this.shots.set(p.id, img);\r\n }\r\n img.setPosition(this.laneX(p.x), y + (p.extra ? 10 : 0));\r\n img.rotation += 0.3;\r\n }\r\n for (const [id, img] of this.shots)\r\n if (!live.has(id)) {\r\n img.destroy();\r\n this.shots.delete(id);\r\n }\r\n }\r\n\r\n private driftClouds(dt: number): void {\r\n for (const [i, c] of this.clouds.entries()) {\r\n c.x -= (6 + i * 3) * dt;\r\n if (c.x < -100) c.x = this.field.w + 100;\r\n }\r\n }\r\n\r\n // ── Effects ────────────────────────────────────────────────────────────────────────────────\r\n\r\n private react(e: BattleEvent): void {\r\n const { ground } = this.field;\r\n switch (e.type) {\r\n case \"shoot\":\r\n this.recoil = 7;\r\n break;\r\n case \"hit\": {\r\n const view = this.monsters.get(e.monsterId);\r\n const x = view?.body.x ?? this.laneX(e.x);\r\n const top = ground - (view?.body.displayHeight ?? MONSTER_SIZE * 0.7) * 0.75;\r\n this.floatText(formatBig(Math.round(e.damage)), x, top, e.crit ? \"crit\" : \"hit\");\r\n this.burstAt(this.sparks, x - 10, top + 30, e.crit ? 10 : 4);\r\n if (view) {\r\n view.body.setTint(0xffb0b0);\r\n this.time.delayedCall(70, () => view.body.active && view.body.clearTint());\r\n }\r\n break;\r\n }\r\n case \"kill\": {\r\n const view = this.monsters.get(e.monsterId);\r\n const x = view?.body.x ?? this.laneX(e.x);\r\n this.burstAt(this.puffs, x, ground - 30, e.boss ? 30 : 10);\r\n this.coinBurst(x, ground - 30, e.boss ? 14 : 3);\r\n if (e.boss) this.cameras.main.shake(260, 0.012);\r\n break;\r\n }\r\n case \"heroHit\":\r\n if (this.hero) {\r\n const { x } = this.heroPos();\r\n this.floatText(`-${formatBig(Math.round(e.damage))}`, x, ground - HERO_SIZE.h * this.field.unit, \"hurt\");\r\n this.hero.setTint(0xff8080);\r\n this.time.delayedCall(80, () => this.hero?.clearTint());\r\n }\r\n break;\r\n case \"dodge\": {\r\n const { x } = this.heroPos();\r\n this.floatText(\"MISS\", x, ground - HERO_SIZE.h * this.field.unit, \"hit\");\r\n break;\r\n }\r\n case \"heroDown\":\r\n if (this.hero)\r\n this.tweens.add({ targets: this.hero, alpha: 0.25, angle: -80, duration: 300 });\r\n this.burstAt(this.puffs, this.heroPos().x, ground - 30, 16);\r\n break;\r\n case \"heroUp\":\r\n if (this.hero) {\r\n this.hero.setAlpha(1).setAngle(0);\r\n this.burstAt(this.heals, this.heroPos().x, ground - 30, 20);\r\n }\r\n break;\r\n case \"bossStart\":\r\n this.cameras.main.flash(200, 255, 80, 60);\r\n break;\r\n case \"stageCleared\":\r\n this.cameras.main.flash(260, 255, 240, 160);\r\n this.coinBurst(this.field.w / 2, ground - 60, 10);\r\n break;\r\n case \"skill\":\r\n this.skillFx(e.skill);\r\n break;\r\n default:\r\n break;\r\n }\r\n }\r\n\r\n private skillFx(skill: \"meteor\" | \"frenzy\" | \"heal\"): void {\r\n const { ground } = this.field;\r\n if (skill === \"meteor\") {\r\n for (const [, view] of this.monsters) {\r\n const tx = view.body.x;\r\n const m = this.add\r\n .image(tx + 120, -40, \"meteor\")\r\n .setDepth(35)\r\n .setScale(1.2);\r\n this.tweens.add({\r\n targets: m,\r\n x: tx,\r\n y: ground - 30,\r\n duration: 280,\r\n ease: \"Quad.easeIn\",\r\n onComplete: () => {\r\n this.burstAt(this.fire, tx, ground - 30, 16);\r\n m.destroy();\r\n },\r\n });\r\n }\r\n this.cameras.main.shake(200, 0.008);\r\n } else if (skill === \"frenzy\" && this.hero) {\r\n this.frenzyTween?.stop();\r\n const hero = this.hero;\r\n hero.setTint(0xffa0a0);\r\n this.frenzyTween = this.tweens.add({\r\n targets: hero,\r\n alpha: 0.75,\r\n yoyo: true,\r\n repeat: 11,\r\n duration: 250,\r\n onComplete: () => {\r\n hero.clearTint();\r\n hero.setAlpha(1);\r\n },\r\n });\r\n } else if (skill === \"heal\") {\r\n this.burstAt(this.heals, this.heroPos().x, ground - 40, 26);\r\n }\r\n }\r\n\r\n private burstAt(\r\n emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,\r\n x: number,\r\n y: number,\r\n count: number,\r\n ): void {\r\n emitter?.explode(count, x, y);\r\n }\r\n\r\n /** Coins pop out of a kill and fly down into the gold counter of the panel. */\r\n private coinBurst(x: number, y: number, count: number): void {\r\n const target = { x: this.field.w / 2, y: this.field.h + 24 };\r\n for (let i = 0; i < count; i++) {\r\n const coin = this.add.image(x, y, \"coin\").setDepth(45).setScale(0.9);\r\n const ox = Phaser.Math.Between(-50, 50);\r\n const oy = Phaser.Math.Between(-70, -30);\r\n this.tweens.add({\r\n targets: coin,\r\n x: x + ox,\r\n y: y + oy,\r\n duration: 260,\r\n ease: \"Quad.easeOut\",\r\n onComplete: () =>\r\n this.tweens.add({\r\n targets: coin,\r\n x: target.x,\r\n y: target.y,\r\n scale: 0.5,\r\n delay: 120 + i * 30,\r\n duration: 520,\r\n ease: \"Cubic.easeIn\",\r\n onComplete: () => coin.destroy(),\r\n }),\r\n });\r\n }\r\n }\r\n\r\n /** The server's collect landed: a shower of coins into the counter. */\r\n private coinShower(count: number): void {\r\n for (let i = 0; i < count; i++)\r\n this.time.delayedCall(i * 40, () =>\r\n this.coinBurst(\r\n Phaser.Math.Between(this.field.w * 0.25, this.field.w * 0.85),\r\n this.field.ground - 50,\r\n 1,\r\n ),\r\n );\r\n }\r\n\r\n private floatText(text: string, x: number, y: number, kind: \"hit\" | \"crit\" | \"hurt\"): void {\r\n const t = this.add\r\n .text(x + Phaser.Math.Between(-12, 12), y, text, {\r\n fontFamily: \"system-ui, sans-serif\",\r\n fontSize: kind === \"crit\" ? \"26px\" : \"18px\",\r\n fontStyle: \"bold\",\r\n color: kind === \"crit\" ? \"#ffb02e\" : kind === \"hurt\" ? \"#ff5a4f\" : \"#ffffff\",\r\n stroke: \"#2b1d33\",\r\n strokeThickness: 4,\r\n })\r\n .setOrigin(0.5, 1)\r\n .setDepth(50);\r\n if (kind === \"crit\") t.setScale(1.4);\r\n this.tweens.add({\r\n targets: t,\r\n y: y - 46,\r\n scale: 1,\r\n alpha: 0,\r\n duration: 750,\r\n ease: \"Cubic.easeOut\",\r\n onComplete: () => t.destroy(),\r\n });\r\n }\r\n}\r\n"
|
|
127
146
|
},
|
|
128
147
|
{
|
|
129
|
-
"path": "
|
|
130
|
-
"content": "import
|
|
148
|
+
"path": "scene.ts",
|
|
149
|
+
"content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\r\nimport { IdleRpgController } from \"./game/IdleRpgController\";\r\nimport type { ControllerBox } from \"./controller-box\";\r\nimport type { IdleSession } from \"./game/session\";\r\n\r\n// Wraps the Phaser controller as a host-driven EngineScene. Created lazily on mount (Phaser needs a\r\n// parent element), published into the shared box for the React panel, and paused/resumed by the\r\n// Mode Router via activate/suspend so a hidden mode stops ticking — and stops collecting gold, which\r\n// the next activation picks up as \"while you were away\".\r\nexport function createIdleRpgScene(\r\n box: ControllerBox<IdleRpgController>,\r\n session: IdleSession,\r\n): EngineScene {\r\n let controller: IdleRpgController | null = null;\r\n return {\r\n surface: \"fullbleed-canvas\",\r\n mount(ctx: SceneMountContext): void {\r\n controller = new IdleRpgController(ctx.host, session);\r\n box.set(controller);\r\n },\r\n activate(): void {\r\n controller?.setRunning(true);\r\n session.activate();\r\n },\r\n suspend(): void {\r\n controller?.setRunning(false);\r\n session.suspend();\r\n },\r\n capture(): Promise<Blob | null> {\r\n return controller?.capture() ?? Promise.resolve(null);\r\n },\r\n // The host destroys scenes only when it unmounts (a logout): the session goes with them — the next\r\n // login runs setup() again and builds a new one.\r\n destroy(): void {\r\n session.destroy();\r\n box.set(null);\r\n controller?.destroy();\r\n controller = null;\r\n },\r\n };\r\n}\r\n"
|
|
131
150
|
},
|
|
132
151
|
{
|
|
133
|
-
"path": "
|
|
134
|
-
"content": "import
|
|
152
|
+
"path": "ui/BattleHud.tsx",
|
|
153
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\r\nimport {\r\n Button,\r\n Icon,\r\n outlined,\r\n toneSurface,\r\n useUiKit,\r\n v,\r\n} from \"@idosgames/react/ui\";\r\nimport type { IdleSession } from \"../game/session\";\r\nimport { formatBig } from \"../game/format\";\r\nimport { stageInfo } from \"../game/stages\";\r\nimport { t } from \"../i18n\";\r\n\r\n// What sits on top of the fight: the stage title, the wave pips or the boss timer, the \"Boss!\"\r\n// button after a failed boss, and short banners when the stage turns. Everything else of the fight is\r\n// on the canvas. The top-left corner stays free — the base's \"To the lobby\" button lives there.\r\n\r\ntype Banner = { text: string; tone: \"gold\" | \"red\" | \"blue\"; key: number };\r\n\r\nexport function BattleHud({ session }: { session: IdleSession }): ReactNode {\r\n const snap = session.battle.snapshot();\r\n const info = stageInfo(snap.stage);\r\n const kit = useUiKit();\r\n const banner = useBanner(session);\r\n\r\n return (\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n inset: 0,\r\n pointerEvents: \"none\",\r\n fontFamily: v.font,\r\n color: v.text,\r\n }}\r\n >\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n top: \"calc(10px + var(--idos-safe-top, 0px))\",\r\n left: 64,\r\n right: 64,\r\n display: \"grid\",\r\n justifyItems: \"center\",\r\n gap: 4,\r\n }}\r\n >\r\n <div\r\n data-tutorial-anchor=\"idle:stage\"\r\n style={{\r\n ...outlined,\r\n fontSize: 18,\r\n padding: \"3px 14px\",\r\n borderRadius: 999,\r\n background: v.backdrop,\r\n whiteSpace: \"nowrap\",\r\n }}\r\n >\r\n <span style={{ color: v.gold }}>{t(info.difficulty)}</span>{\" \"}\r\n {info.chapter}-{info.stage}\r\n <span style={{ fontSize: 12, color: v.textDim }}>\r\n {\" \"}\r\n · {t(info.theme.id)}\r\n </span>\r\n </div>\r\n {snap.mode === \"boss\" ? (\r\n <BossBar\r\n timeLeft={snap.bossTimeLeft}\r\n hp={snap.bossHp}\r\n maxHp={snap.bossMaxHp}\r\n />\r\n ) : (\r\n <WavePips wave={snap.wave} waves={snap.waves} boss={snap.autoBoss} />\r\n )}\r\n </div>\r\n\r\n {snap.mode === \"waves\" && !snap.autoBoss && !snap.heroDown ? (\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n top: \"calc(10px + var(--idos-safe-top, 0px))\",\r\n right: 10,\r\n pointerEvents: \"auto\",\r\n }}\r\n >\r\n <Button\r\n tone=\"red\"\r\n size=\"sm\"\r\n attract\r\n onClick={() => {\r\n if (session.battle.challengeBoss()) kit.play(\"notify\");\r\n }}\r\n data-tutorial-anchor=\"idle:boss\"\r\n >\r\n <Icon glyph=\"swords\" size={18} /> {t(\"challenge\")}\r\n </Button>\r\n </div>\r\n ) : null}\r\n\r\n {banner ? (\r\n <div\r\n key={banner.key}\r\n style={{\r\n position: \"absolute\",\r\n top: \"38%\",\r\n left: 0,\r\n right: 0,\r\n display: \"grid\",\r\n justifyItems: \"center\",\r\n }}\r\n >\r\n <div\r\n style={{\r\n ...outlined,\r\n ...toneSurface(banner.tone),\r\n fontSize: 22,\r\n padding: \"6px 22px\",\r\n borderRadius: 14,\r\n animation: `idos-pop-in ${v.slow} ease-out both`,\r\n }}\r\n >\r\n {banner.text}\r\n </div>\r\n </div>\r\n ) : null}\r\n </div>\r\n );\r\n}\r\n\r\nfunction WavePips({\r\n wave,\r\n waves,\r\n boss,\r\n}: {\r\n wave: number;\r\n waves: number;\r\n boss: boolean;\r\n}): ReactNode {\r\n return (\r\n <div style={{ display: \"flex\", gap: 5, alignItems: \"center\" }}>\r\n {Array.from({ length: waves }, (_, i) => (\r\n <span\r\n key={i}\r\n style={{\r\n width: 18,\r\n height: 8,\r\n borderRadius: 4,\r\n border: `2px solid ${v.backdrop}`,\r\n background: i < wave ? v.gold : i === wave ? v.green : v.well,\r\n }}\r\n />\r\n ))}\r\n <span style={{ opacity: boss ? 1 : 0.45 }}>\r\n <Icon glyph=\"crown\" size={18} />\r\n </span>\r\n </div>\r\n );\r\n}\r\n\r\nfunction BossBar({\r\n timeLeft,\r\n hp,\r\n maxHp,\r\n}: {\r\n timeLeft: number;\r\n hp: number;\r\n maxHp: number;\r\n}): ReactNode {\r\n const share = maxHp > 0 ? hp / maxHp : 1;\r\n return (\r\n <div style={{ width: \"min(320px, 100%)\", display: \"grid\", gap: 3 }}>\r\n <div\r\n style={{\r\n ...outlined,\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n fontSize: 12,\r\n }}\r\n >\r\n <span>\r\n <Icon glyph=\"crown\" size={14} /> {t(\"bossTime\")}\r\n </span>\r\n <span style={{ color: timeLeft < 8 ? v.red : v.text }}>\r\n {Math.ceil(timeLeft)}s\r\n </span>\r\n </div>\r\n <div\r\n style={{\r\n height: 12,\r\n borderRadius: 6,\r\n background: v.well,\r\n border: `2px solid ${v.backdrop}`,\r\n overflow: \"hidden\",\r\n }}\r\n >\r\n <div\r\n style={{\r\n width: `${Math.max(0, Math.min(1, share)) * 100}%`,\r\n height: \"100%\",\r\n background: `linear-gradient(180deg, ${v.red}, ${v.redDeep})`,\r\n transition: `width ${v.fast} linear`,\r\n }}\r\n />\r\n </div>\r\n <div style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\r\n {formatBig(Math.ceil(hp))} / {formatBig(Math.ceil(maxHp))}\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\n/** A short banner when the stage turns: cleared, boss, boss gone, hero down. */\r\nfunction useBanner(session: IdleSession): Banner | null {\r\n const snap = session.battle.snapshot();\r\n const prev = useRef(snap);\r\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\r\n const [banner, setBanner] = useState<Banner | null>(null);\r\n const kit = useUiKit();\r\n\r\n useEffect(() => () => {\r\n if (timer.current) clearTimeout(timer.current);\r\n }, []);\r\n\r\n // Compared on every render (the HUD redraws a few times a second): a turn shows once.\r\n useEffect(() => {\r\n const before = prev.current;\r\n prev.current = snap;\r\n let next: Omit<Banner, \"key\"> | null = null;\r\n if (snap.stage > before.stage) {\r\n next = { text: t(\"stageClear\"), tone: \"gold\" };\r\n kit.play(\"levelUp\");\r\n } else if (snap.mode === \"boss\" && before.mode !== \"boss\")\r\n next = { text: t(\"boss\"), tone: \"red\" };\r\n else if (snap.heroDown && !before.heroDown)\r\n next = { text: t(\"heroDown\"), tone: \"blue\" };\r\n else if (!snap.autoBoss && before.autoBoss && before.mode === \"boss\")\r\n next = { text: t(\"bossEscaped\"), tone: \"blue\" };\r\n if (!next) return;\r\n setBanner({ ...next, key: Date.now() });\r\n if (timer.current) clearTimeout(timer.current);\r\n timer.current = setTimeout(() => setBanner(null), 1400);\r\n });\r\n\r\n return banner;\r\n}\r\n"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"path": "ui/BottomNav.tsx",
|
|
157
|
+
"content": "import { useSyncExternalStore, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { Badge, Icon, outlined, useUiKit, v, type Glyph } from \"@idosgames/react/ui\";\nimport { t, type Key } from \"../i18n\";\n\n// The game's bottom bar (Legend Slime's row of big icons): Enhance is this screen; the rest open the\n// systems the game brings — the hero hub (`character`), gear summons (`lootboxes`), the quests and\n// the shop — through ctx.features. A system that is missing or has nothing on this title has no button.\n\nconst ENTRIES: Array<{ feature: string; glyph: Glyph; label: Key }> = [\n { feature: \"character\", glyph: \"hero\", label: \"heroes\" },\n { feature: \"lootboxes\", glyph: \"chest\", label: \"summon\" },\n { feature: \"quests\", glyph: \"quest\", label: \"quests\" },\n { feature: \"store\", glyph: \"shop\", label: \"shop\" },\n];\n\nexport function BottomNav({ features }: { features: FeatureRegistry }): ReactNode {\n // Badges and availability change while the game runs (a reward waits, a tab appears).\n useSyncExternalStore(features.subscribe, () => featureKey(features));\n const kit = useUiKit();\n const shown = ENTRIES.flatMap((e) => {\n const entry = features.get(e.feature);\n return entry?.available ? [{ ...e, badge: entry.badge }] : [];\n });\n\n return (\n <nav\n style={{\n display: \"flex\",\n justifyContent: \"space-around\",\n gap: 4,\n padding: \"6px 6px calc(6px + var(--idos-safe-bottom, 0px))\",\n background: v.panelDeep,\n borderTop: `2px solid ${v.panelEdge}`,\n }}\n >\n <NavButton glyph=\"sword\" label={t(\"enhance\")} active onClick={() => {}} />\n {shown.map((e) => (\n <NavButton\n key={e.feature}\n glyph={e.glyph}\n label={t(e.label)}\n badge={e.badge}\n anchor={`idle:nav:${e.feature}`}\n onClick={() => {\n kit.play(\"tab\");\n features.open(e.feature);\n }}\n />\n ))}\n </nav>\n );\n}\n\nfunction NavButton({\n glyph,\n label,\n badge = 0,\n active = false,\n anchor,\n onClick,\n}: {\n glyph: Glyph;\n label: string;\n badge?: number;\n active?: boolean;\n anchor?: string;\n onClick: () => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={onClick}\n data-tutorial-anchor={anchor}\n aria-current={active ? \"page\" : undefined}\n style={{\n position: \"relative\",\n flex: \"1 1 0\",\n maxWidth: 96,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n padding: \"6px 2px 4px\",\n borderRadius: 12,\n border: `2px solid ${active ? v.gold : \"transparent\"}`,\n background: active ? v.wellSoft : \"transparent\",\n cursor: \"pointer\",\n }}\n >\n <Icon glyph={glyph} size={30} />\n <span style={{ ...outlined, fontSize: 11 }}>{label}</span>\n {badge > 0 ? (\n <span style={{ position: \"absolute\", top: 0, right: 8 }}>\n <Badge count={badge} />\n </span>\n ) : null}\n </button>\n );\n}\n\nfunction featureKey(features: FeatureRegistry): string {\n return ENTRIES.map((e) => {\n const f = features.get(e.feature);\n return f ? `${f.available ? 1 : 0}:${f.badge}` : \"-\";\n }).join(\"|\");\n}\n"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"path": "ui/EnhancePanel.tsx",
|
|
161
|
+
"content": "import { memo, useEffect, useRef, useState, type ReactNode } from \"react\";\r\nimport {\r\n Icon,\r\n ResourceCounter,\r\n buttonStyle,\r\n outlined,\r\n useCatalog,\r\n useUiKit,\r\n v,\r\n type Glyph,\r\n} from \"@idosgames/react/ui\";\r\nimport type { HeroView, IdleSession } from \"../game/session\";\r\nimport {\r\n PERCENT_ROLES,\r\n enhanceValue,\r\n statCap,\r\n statCost,\r\n type Role,\r\n type StatInfo,\r\n} from \"../game/heroStats\";\r\nimport { formatBig, formatStat } from \"../game/format\";\r\nimport { t } from \"../i18n\";\r\n\r\n// \"Enhance\" — the stats of the fighting hero, bought with gold level by level (Legend Slime's main\r\n// list). ×1 / ×10 / MAX sets how many levels one tap asks for; holding the button keeps buying. The\r\n// session sends one request per stat at a time and adds the taps in between up — see IdleSession.enhance.\r\n\r\ntype Mult = 1 | 10 | \"max\";\r\nconst MAX_LEVELS = 1000;\r\nconst HOLD_START_MS = 380;\r\nconst HOLD_EVERY_MS = 140;\r\n\r\nconst ROLE_GLYPH: Record<Role, Glyph> = {\r\n damage: \"sword\",\r\n health: \"heart\",\r\n regen: \"potion\",\r\n armor: \"shield\",\r\n attackSpeed: \"energy\",\r\n critChance: \"star\",\r\n critDamage: \"flame\",\r\n dodge: \"bulb\",\r\n multiShot: \"swords\",\r\n allMight: \"crown\",\r\n};\r\n\r\nexport function EnhancePanel({\r\n session,\r\n hero,\r\n}: {\r\n session: IdleSession;\r\n hero: HeroView;\r\n}): ReactNode {\r\n const [mult, setMult] = useState<Mult>(1);\r\n const catalog = useCatalog();\r\n const currency =\r\n session.income?.currencyID ??\r\n hero.stats.find((s) => s.price)?.price?.currencyID ??\r\n \"\";\r\n const rate = session.ratePerSecond();\r\n\r\n return (\r\n <div style={{ display: \"grid\", gap: 8, minHeight: 0 }}>\r\n <div\r\n style={{\r\n display: \"flex\",\r\n alignItems: \"center\",\r\n gap: 10,\r\n justifyContent: \"space-between\",\r\n }}\r\n >\r\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\r\n {currency ? (\r\n <ResourceCounter\r\n currencyID={currency}\r\n amount={session.balance(currency)}\r\n size={26}\r\n />\r\n ) : null}\r\n {rate > 0 ? (\r\n <span style={{ ...outlined, fontSize: 12, color: v.green }}>\r\n +{formatBig(Math.round(rate * 10) / 10)}\r\n {t(\"perSecond\")}\r\n </span>\r\n ) : null}\r\n </div>\r\n <div style={{ display: \"flex\", gap: 4 }}>\r\n {([1, 10, \"max\"] as Mult[]).map((m) => (\r\n <button\r\n key={String(m)}\r\n type=\"button\"\r\n className=\"idos-press\"\r\n onClick={() => setMult(m)}\r\n data-tutorial-anchor={`idle:mult:${m}`}\r\n style={{\r\n ...buttonStyle(mult === m ? \"gold\" : \"grey\", false, \"sm\"),\r\n padding: \"4px 10px\",\r\n minWidth: 44,\r\n }}\r\n >\r\n {m === \"max\" ? t(\"max\") : `×${m}`}\r\n </button>\r\n ))}\r\n </div>\r\n </div>\r\n <div\r\n style={{\r\n display: \"flex\",\r\n alignItems: \"baseline\",\r\n gap: 8,\r\n ...outlined,\r\n fontSize: 13,\r\n color: v.textDim,\r\n }}\r\n >\r\n <span style={{ color: v.text, fontSize: 15 }}>\r\n {catalog.localize(hero.name)}\r\n </span>\r\n <span>\r\n {t(\"rank\")} {hero.rank}\r\n </span>\r\n <span>\r\n {t(\"power\")} {formatBig(hero.power)}\r\n </span>\r\n </div>\r\n <Rows session={session} hero={hero} mult={mult} />\r\n </div>\r\n );\r\n}\r\n\r\n// The rows redraw when the hero changes (an upgrade, gear, the balance) — not with the fight.\r\nconst Rows = memo(function Rows({\r\n session,\r\n hero,\r\n mult,\r\n}: {\r\n session: IdleSession;\r\n hero: HeroView;\r\n mult: Mult;\r\n}): ReactNode {\r\n const stats = hero.stats.filter((s) => s.price);\r\n return (\r\n <div style={{ display: \"grid\", gap: 6 }}>\r\n {stats.map((stat) => (\r\n <StatRow\r\n key={stat.id}\r\n session={session}\r\n hero={hero}\r\n stat={stat}\r\n mult={mult}\r\n />\r\n ))}\r\n </div>\r\n );\r\n});\r\n\r\nfunction StatRow({\r\n session,\r\n hero,\r\n stat,\r\n mult,\r\n}: {\r\n session: IdleSession;\r\n hero: HeroView;\r\n stat: StatInfo;\r\n mult: Mult;\r\n}): ReactNode {\r\n const catalog = useCatalog();\r\n const level = Number(hero.input.model?.StatLevels?.[stat.id] ?? 0);\r\n const cap = statCap(stat, Math.max(1, hero.rank), hero.def);\r\n const capped = cap > 0 && level >= cap;\r\n const want = mult === \"max\" ? MAX_LEVELS : mult;\r\n const plan = session.plan(stat.id, want);\r\n const levels = plan.levels;\r\n const percent = stat.role ? PERCENT_ROLES.has(stat.role) : false;\r\n const value = enhanceValue(hero.input, stat.id, level);\r\n const next = enhanceValue(hero.input, stat.id, level + Math.max(1, levels));\r\n const missing = stat.requirements.filter(\r\n (r) => Number(hero.input.model?.StatLevels?.[r.statID] ?? 0) < r.level,\r\n );\r\n const blocked = capped || missing.length > 0;\r\n const can = !blocked && levels > 0;\r\n const cost = levels > 0 ? plan.cost : statCost(stat, level + 1);\r\n\r\n return (\r\n <div\r\n style={{\r\n display: \"flex\",\r\n alignItems: \"center\",\r\n gap: 10,\r\n padding: \"6px 8px\",\r\n borderRadius: 12,\r\n background: v.panel,\r\n border: `2px solid ${v.panelEdge}`,\r\n boxShadow: v.panelShadow,\r\n }}\r\n >\r\n <div\r\n style={{\r\n width: 46,\r\n height: 46,\r\n flex: \"0 0 46px\",\r\n borderRadius: 12,\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n background: v.wellSoft,\r\n border: `2px solid ${v.panelEdge}`,\r\n position: \"relative\",\r\n }}\r\n >\r\n <Icon glyph={stat.role ? ROLE_GLYPH[stat.role] : \"star\"} size={28} />\r\n <span\r\n style={{\r\n ...outlined,\r\n position: \"absolute\",\r\n bottom: -6,\r\n fontSize: 10,\r\n padding: \"0 4px\",\r\n borderRadius: 6,\r\n background: v.panelDeep,\r\n }}\r\n >\r\n Lv {formatBig(level)}\r\n </span>\r\n </div>\r\n <div style={{ flex: 1, minWidth: 0 }}>\r\n <div style={{ ...outlined, fontSize: 14 }}>{catalog.localize(stat.name)}</div>\r\n <div style={{ ...outlined, fontSize: 15, color: v.gold }}>\r\n {formatStat(value, percent)}\r\n {!blocked && levels > 0 ? (\r\n <span style={{ fontSize: 11, color: v.green }}>\r\n {\" \"}\r\n → {formatStat(next, percent)}\r\n </span>\r\n ) : null}\r\n </div>\r\n {cap > 0 ? (\r\n <div style={{ ...outlined, fontSize: 10, color: v.textDim }}>\r\n {level}/{cap}\r\n {capped ? ` · ${t(\"rankUpToRaise\")}` : \"\"}\r\n {missing.length > 0\r\n ? ` · ${t(\"needs\")}: ${missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}`\r\n : \"\"}\r\n </div>\r\n ) : null}\r\n </div>\r\n <HoldButton\r\n disabled={!can}\r\n repeat={mult !== \"max\"}\r\n onPress={() => session.enhance(stat.id, levels > 0 ? levels : 1)}\r\n anchor={`idle:enhance:${stat.id}`}\r\n >\r\n {capped ? (\r\n t(\"maxed\")\r\n ) : (\r\n <span style={{ display: \"grid\", justifyItems: \"center\", lineHeight: 1.1 }}>\r\n <span style={{ fontSize: 13 }}>\r\n {t(\"enhance\")}\r\n {levels > 1 ? ` ×${levels}` : \"\"}\r\n </span>\r\n {stat.price ? (\r\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 3, fontSize: 12 }}>\r\n <Icon\r\n glyph={catalog.iconOf({ kind: \"currency\", id: stat.price.currencyID, amount: cost })}\r\n size={14}\r\n />\r\n {formatBig(cost)}\r\n </span>\r\n ) : null}\r\n </span>\r\n )}\r\n </HoldButton>\r\n </div>\r\n );\r\n}\r\n\r\n/** A button that fires on press and keeps firing while held (after a short pause). */\r\nfunction HoldButton({\r\n children,\r\n disabled,\r\n repeat,\r\n onPress,\r\n anchor,\r\n}: {\r\n children: ReactNode;\r\n disabled: boolean;\r\n repeat: boolean;\r\n onPress: () => void;\r\n anchor: string;\r\n}): ReactNode {\r\n const kit = useUiKit();\r\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\r\n const press = useRef(onPress);\r\n press.current = onPress;\r\n const live = useRef(!disabled);\r\n live.current = !disabled;\r\n\r\n const stop = () => {\r\n if (timer.current) clearTimeout(timer.current);\r\n timer.current = null;\r\n };\r\n useEffect(() => stop, []);\r\n useEffect(() => {\r\n if (disabled) stop();\r\n }, [disabled]);\r\n\r\n const loop = () => {\r\n timer.current = setTimeout(() => {\r\n if (!live.current) return stop();\r\n press.current();\r\n loop();\r\n }, HOLD_EVERY_MS);\r\n };\r\n\r\n return (\r\n <button\r\n type=\"button\"\r\n className=\"idos-press\"\r\n disabled={disabled}\r\n data-tutorial-anchor={anchor}\r\n onPointerDown={(e) => {\r\n if (disabled || e.button !== 0) return;\r\n kit.play(\"click\");\r\n press.current();\r\n if (!repeat) return;\r\n stop();\r\n timer.current = setTimeout(loop, HOLD_START_MS);\r\n }}\r\n onPointerUp={stop}\r\n onPointerLeave={stop}\r\n onPointerCancel={stop}\r\n onContextMenu={(e) => e.preventDefault()}\r\n style={{\r\n ...buttonStyle(\"green\", disabled, \"sm\"),\r\n minWidth: 104,\r\n minHeight: 46,\r\n touchAction: \"none\",\r\n userSelect: \"none\",\r\n }}\r\n >\r\n {children}\r\n </button>\r\n );\r\n}\r\n"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"path": "ui/GamePanel.tsx",
|
|
165
|
+
"content": "import { useEffect, useSyncExternalStore, type ComponentType, type ReactNode } 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.\n\nexport function makeGamePanel(\n session: IdleSession,\n features: FeatureRegistry,\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 style={{ ...outlined, color: v.textDim, textAlign: \"center\" }}>\n {t(\"loading\")}\n </div>\n )}\n </div>\n </div>\n <BottomNav features={features} />\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 = [{ kind: \"currency\" as const, id: away.currencyID, amount: away.amount }];\n const close = () => {\n celebrate(lines);\n session.dismissAway();\n };\n return (\n <Popup title={t(\"welcomeBack\")} onClose={close} width={380} variant=\"center\">\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\", textAlign: \"center\" }}>\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
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"path": "ui/GuideQuest.tsx",
|
|
169
|
+
"content": "import { useEffect, useState, type ReactNode } from \"react\";\r\nimport type { ResourceGrant } from \"@idosgames/core\";\r\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\r\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\r\nimport {\r\n Button,\r\n Icon,\r\n ResourceList,\r\n configSection,\r\n errorText,\r\n grantLines,\r\n grantedBy,\r\n outlined,\r\n useCatalog,\r\n useCelebrate,\r\n useNotify,\r\n v,\r\n} from \"@idosgames/react/ui\";\r\nimport {\r\n pickGuide,\r\n type QuestConfigView,\r\n type QuestStateView,\r\n} from \"../game/guide\";\r\nimport { t } from \"../i18n\";\r\n\r\n/** The quest strip: name, progress, reward and \"Claim\" — a tap elsewhere opens the quests screen. */\r\nexport function GuideQuest({\r\n features,\r\n}: {\r\n features: FeatureRegistry;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const state = useUserState();\r\n const celebrate = useCelebrate();\r\n const catalog = useCatalog();\r\n const notify = useNotify();\r\n const [busy, setBusy] = useState(false);\r\n\r\n useEffect(() => {\r\n // Quest progress comes with the login; a new cycle is the only reason to ask the server.\r\n void client.cache.ensureState([\"Quest\"]);\r\n }, [client]);\r\n\r\n const guide = pickGuide(\r\n configSection<QuestConfigView>(client, \"Quest\"),\r\n state?.Quest as QuestStateView | undefined,\r\n );\r\n if (!guide) return null;\r\n\r\n const claim = async () => {\r\n setBusy(true);\r\n const res = await client.quest.claimQuestReward(guide.questID, guide.cycleID);\r\n setBusy(false);\r\n if (!res.ok)\r\n return void notify.toast({ level: \"error\", message: errorText(res.error) });\r\n celebrate(grantedBy(res.data));\r\n };\r\n const reward = grantLines(guide.grant as ResourceGrant | null | undefined);\r\n const quests = features.get(\"quests\");\r\n\r\n return (\r\n <div\r\n role={quests?.available ? \"button\" : undefined}\r\n onClick={() => {\r\n if (quests?.available && !guide.claimable) features.open(\"quests\");\r\n }}\r\n data-tutorial-anchor=\"idle:guide\"\r\n style={{\r\n display: \"flex\",\r\n alignItems: \"center\",\r\n gap: 8,\r\n padding: \"6px 8px 6px 10px\",\r\n borderRadius: 12,\r\n background: guide.claimable\r\n ? `linear-gradient(90deg, color-mix(in srgb, ${v.gold} 35%, ${v.panelDeep}), ${v.panelDeep})`\r\n : v.panelDeep,\r\n border: `2px solid ${guide.claimable ? v.gold : v.panelEdge}`,\r\n cursor: quests?.available ? \"pointer\" : \"default\",\r\n minWidth: 0,\r\n }}\r\n >\r\n <Icon glyph=\"quest\" size={22} />\r\n <div style={{ flex: 1, minWidth: 0 }}>\r\n <div\r\n style={{\r\n ...outlined,\r\n fontSize: 13,\r\n whiteSpace: \"nowrap\",\r\n overflow: \"hidden\",\r\n textOverflow: \"ellipsis\",\r\n }}\r\n >\r\n {catalog.localize(guide.name)}\r\n </div>\r\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\r\n {guide.current} / {guide.target}\r\n </div>\r\n </div>\r\n {reward.length > 0 ? <ResourceList lines={reward} size={16} /> : null}\r\n {guide.claimable ? (\r\n <Button size=\"sm\" tone=\"gold\" attract busy={busy} onClick={() => void claim()}>\r\n {t(\"claim\")}\r\n </Button>\r\n ) : null}\r\n </div>\r\n );\r\n}\r\n"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"path": "ui/SkillBar.tsx",
|
|
173
|
+
"content": "import type { ReactNode } from \"react\";\nimport { Icon, outlined, useUiKit, v, type Glyph } from \"@idosgames/react/ui\";\nimport type { IdleSession } from \"../game/session\";\nimport type { SkillId } from \"../game/battle\";\nimport { t } from \"../i18n\";\n\n// The skill row under the fight: three skills with a cooldown sweep and the \"Auto\" switch. With\n// Auto on the battle casts them itself (Heal waits until the hero is hurt).\n\nconst GLYPH: Record<SkillId, Glyph> = {\n meteor: \"flame\",\n frenzy: \"energy\",\n heal: \"heart\",\n};\n\nexport function SkillBar({ session }: { session: IdleSession }): ReactNode {\n const snap = session.battle.snapshot();\n const kit = useUiKit();\n return (\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n <button\n type=\"button\"\n className=\"idos-press\"\n data-tutorial-anchor=\"idle:auto\"\n onClick={() => {\n kit.play(\"tab\");\n session.battle.setAutoSkills(!snap.autoSkills);\n }}\n style={{\n ...outlined,\n width: 48,\n height: 48,\n borderRadius: \"50%\",\n border: `3px solid ${snap.autoSkills ? v.gold : v.panelEdge}`,\n background: snap.autoSkills\n ? `linear-gradient(180deg, ${v.gold}, ${v.goldDeep})`\n : v.well,\n color: snap.autoSkills ? v.onGold : v.textDim,\n fontSize: 11,\n lineHeight: 1.05,\n cursor: \"pointer\",\n }}\n >\n {t(\"auto\")}\n <br />\n {snap.autoSkills ? \"ON\" : \"OFF\"}\n </button>\n {snap.skills.map((s) => {\n const ready = s.unlocked && s.cooldownLeft <= 0 && !snap.heroDown;\n const sweep = s.cooldownLeft > 0 ? (s.cooldownLeft / s.cooldown) * 360 : 0;\n return (\n <button\n key={s.id}\n type=\"button\"\n className=\"idos-press\"\n title={t(s.id)}\n aria-label={t(s.id)}\n disabled={!ready}\n data-tutorial-anchor={`idle:skill:${s.id}`}\n onClick={() => {\n if (session.battle.castSkill(s.id)) kit.play(\"claim\");\n }}\n style={{\n position: \"relative\",\n width: 48,\n height: 48,\n borderRadius: \"50%\",\n border: `3px solid ${s.activeLeft > 0 ? v.gold : v.panelEdge}`,\n background: v.panelDeep,\n display: \"grid\",\n placeItems: \"center\",\n cursor: ready ? \"pointer\" : \"default\",\n opacity: s.unlocked ? 1 : 0.55,\n overflow: \"hidden\",\n padding: 0,\n }}\n >\n <Icon glyph={s.unlocked ? GLYPH[s.id] : \"lock\"} size={26} />\n {sweep > 0 ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n background: `conic-gradient(${v.backdrop} ${sweep}deg, transparent 0)`,\n }}\n />\n ) : null}\n {!s.unlocked ? (\n <span\n style={{\n ...outlined,\n position: \"absolute\",\n bottom: 1,\n fontSize: 9,\n }}\n >\n {t(\"opensAt\")} {s.unlockStage}\n </span>\n ) : s.cooldownLeft > 0 ? (\n <span style={{ ...outlined, position: \"absolute\", fontSize: 13 }}>\n {Math.ceil(s.cooldownLeft)}\n </span>\n ) : null}\n </button>\n );\n })}\n </div>\n );\n}\n"
|
|
135
174
|
},
|
|
136
175
|
{
|
|
137
176
|
"path": "module.meta.json",
|
|
138
|
-
"content": "{\n \"id\": \"idle-rpg\",\n \"type\": \"template\",\n \"summary\": \"Idle RPG:
|
|
177
|
+
"content": "{\n \"id\": \"idle-rpg\",\n \"type\": \"template\",\n \"uses\": [\n \"character\",\n \"lootboxes\",\n \"store\"\n ],\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.0\",\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"
|
|
139
178
|
}
|
|
140
179
|
]
|
|
141
180
|
}
|