SLOPSHOPPER

pixel-hunter

An 8-bit hunter in a side pane that hunts one of nine monsters while Claude works and runs back to camp when Claude answers. Local only: no model calls, no…

newpanecommandtimer
v0.10.0MITupdated 2026-10-26s7788/pixel-hunter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-hunter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hunt ⎿ pixel-hunter: Hunt pane opened. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pixel-hunter

An 8-bit hunter that fights while Claude works.

A side pane for Claude Code. When you send Claude a prompt, your hunter leaves camp and hunts one of nine monsters. When Claude answers, the hunter runs back to camp and roasts meat until your next prompt. A hunt takes two to seven minutes of Claude's working time, depending on the monster, and a wounded monster waits for you across turns and sessions.

The hunter roasts meat at camp, then fights Tigrex with the great sword, Zinogre with the long sword and Velkhana with the bow

繁體中文

Install

At the prompt of a Claude Code terminal session:

/plugin install pixel-hunter --marketplace s7788/pixel-hunter

Answer y to add the marketplace, then pick a scope. The plugin is active right away.

From a shell:

claude plugin install pixel-hunter --marketplace s7788/pixel-hunter

In the desktop app, click + next to the prompt box, then Plugins → Add marketplace with s7788/pixel-hunter, then Add plugin.

Needs Claude Code 2.1.275 or later (tested on 2.1.304 in the desktop app). The desktop app, VS Code and mobile draw the pixel art. The terminal draws it in character cells, or as an image in terminals that show images (kitty, Ghostty, iTerm2, WezTerm, foot); a button in the pane switches between the two.

The game speaks your language: Traditional Chinese where Claude does, English everywhere else. It follows Claude's language setting, then your system locale.

How it plays

  • Your turn is the hunt. A prompt sends the hunter out; Claude's answer, or you interrupting it, brings the hunter home. Subagents don't count, only the main turn.
  • A new monster each hunt. Every new hunt draws one of nine, each on its own hunting ground with its own moves and tricks: Great Jagras swells its belly, Tobi-Kadachi charges up, Anjanath readies fire, Barioth loses its wings, Nargacuga and Tigrex rage, Zinogre supercharges, Ruiner Nergigante grows spikes.
  • The fight runs itself. The hunter reads the monster's moves: it dodges what would hit it, backs off, runs in, and only swings when the blow would land.
  • Damage is real contact. Every attack has hit boxes; a blow counts only when they touch the target's body.
  • Progress is kept. The monster's HP carries over between turns and sessions. Bring it down for a kill; run out of HP and the hunter is carted back to camp, with the monster still wounded. A strip under the picture counts your kills by monster.
  • Pick your weapon at camp. Under the picture, between turns: Great Sword, Long Sword, Bow or Random (a different weapon at once, and again for each new hunt). Each weapon has its own stance, run, dodge and moves, and they're balanced to hunt every monster at about the same pace.
MovesCharacter
Great swordCharged slash, shoulder tackle; true charged slash on an openingSlow, heavy blows
Long swordVertical combo, fade slash; spirit roundslash on an openingQuick cuts, slides back to dodge
BowNormal shot, quick shot; power shot on an openingHits from range
MonsterGroundMinutes
Great JagrasForest2
Tobi-KadachiForest3
AnjanathForest4
VelkhanaSnowfield, ice cave5
BariothSnowfield5
NargacugaForest5
TigrexSnowfield5
ZinogreForest6
Ruiner NergiganteCrystal ruins7

Bow

Commands

  • /hunt opens the pane (it also opens by itself when a session starts).
  • /hunt-demo starts or ends a hunt without a Claude turn, to try it out.
  • /hunt-demo <monster> drops the hunt under way and heads out after that monster, for example /hunt-demo zinogre.

Good to know

  • No tokens, no network. The fight is a small local state machine stepped 8 times a second. It never calls a model, never reads your files and never sends anything anywhere.
  • Light. A tick takes well under a millisecond. The first time a weapon meets a monster, the game spends about a second working out its damage scale.
  • One hunter. The save lives in the plugin's own store, so every session shares the same hunter and the same wounded monster.

Fan project

pixel-hunter is a free, non-commercial, unofficial fan project based on Monster Hunter. It is not affiliated with, endorsed by, or officially licensed by Capcom. Monster Hunter and the underlying character designs belong to their respective rights holders.

This project is intended to follow the Capcom Fan Content Guidelines. Those guidelines include games with added creative expression, restrict commercial use, and exclude direct reuse or processing of official assets and use of official logos. This statement is not a claim of individual approval or a compliance certification from Capcom.

The repository's sprite extraction tool describes its source action boards as AI-generated key-pose images. Sprites are cropped, background-removed, aligned, and palette-quantized from external reference packs; the crystal-ruins scene (S05) is generated by tools/make_s05.py. The reference packs and their generation records are not included, so the repository alone does not establish their complete provenance or whether official or third-party images were used as generation inputs. See NOTICE and the asset pipeline.

License

The source code is under the MIT License. The license does not cover the pixel art (the sprites and scenes in hooks/gen and the images in docs/media) or the names of the monsters and their moves, which come from the Monster Hunter series; those are not offered for reuse. Monster Hunter is a trademark of Capcom. See NOTICE.

The non-commercial fan-project statement applies to the Monster Hunter-based work; it does not add a non-commercial restriction to the MIT-licensed source code. MIT does not grant rights to Capcom's intellectual property or to the excluded artwork. Any reuse involving that content requires its own rights assessment under the applicable guidelines or separate permission.

Contributing

How it's built, the sprite pipeline and the known limits: docs/DEVELOPMENT.md. Design decisions and the file map: HANDOFF.md. Checks:

claude plugin validate .
claude plugin test .
Source 27 files
hooks/register.tsx 291 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { HuntEvent, HuntLang, HuntPhase, HuntWeaponChoice, HuntWorld, MonsterId } from '../types'
5import { HUNTER, TICK_MS, WEAPON_CHOICES } from './data'
6import type { Game } from './engine'
7import { beginTurn, chooseWeapon, debugHunt, endTurn, migrate, newGame, restore, step, viewOf } from './engine'
8import { pickLang, t } from './i18n'
9import { enabledMonsters } from './monsters/registry'
10import { huntCells, imageCells, huntRgba, RASTER, toBase64 } from './raster'
11import { HUNT_SIZE, huntSvg, hudText, statsSvg, statsText } from './svg'
12
13const PANE = 'pixel-hunter'
14const TITLE = 'pixel-hunter'
15const SAVE_KEY = 'save'
16const PICTURE_KEY = 'terminalPicture'
17/** Ticks between saves while the game moves (10 s). */
18const SAVE_EVERY = 80
19
20const phase = atom({ plugin: 'pixel-hunter', key: 'phase' } as const, 'camp' as HuntPhase)
21const turnStartedAt = atom({ plugin: 'pixel-hunter', key: 'turnStartedAt' } as const, null as number | null)
22const lastElapsedMs = atom({ plugin: 'pixel-hunter', key: 'lastElapsedMs' } as const, 0)
23const world = atom({ plugin: 'pixel-hunter', key: 'world' } as const, null as HuntWorld | null)
24const log = atom({ plugin: 'pixel-hunter', key: 'log' } as const, [] as HuntEvent[])
25const lang = atom({ plugin: 'pixel-hunter', key: 'lang' } as const, 'en' as HuntLang)
26/** How the terminal draws the scene: terminal cells (works anywhere) or an Image (kitty, Ghostty, iTerm2, WezTerm, foot). */
27const terminalCells = atom({ plugin: 'pixel-hunter', key: 'terminalCells' } as const, true)
28
29/** working_elapsed: MM:SS, HH:MM:SS past an hour. */
30const fmt = (ms: number): string => {
31  const s = Math.max(0, Math.floor(ms / 1000))
32  const hh = Math.floor(s / 3600)
33  const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
34  const ss = String(s % 60).padStart(2, '0')
35  return hh > 0 ? `${String(hh).padStart(2, '0')}:${mm}:${ss}` : `${mm}:${ss}`
36}
37
38async function note($: EngineInterface, text: string): Promise<void> {
39  const at = await $.clock.now()
40  await update($, log, list => [...list, { at, text }].slice(-30))
41}
42
43async function save($: EngineInterface, game: Game): Promise<void> {
44  await $.store.set(SAVE_KEY, game)
45}
46
47async function changeGame($: EngineInterface, fn: (g: Game) => Game): Promise<Game> {
48  let out: Game | undefined
49  await update($, world, w => {
50    const current = w ?? { tick: 0, now: 0, game: newGame(Date.now()) }
51    out = fn(current.game)
52    return { ...current, game: out }
53  })
54  return out!
55}
56
57/** One tick: the game steps and the pane redraws. */
58async function advance($: EngineInterface): Promise<void> {
59  const p = await read($, phase)
60  const n = await $.clock.now()
61  let saved: Game | undefined
62  let isHome = false
63  await update($, world, w => {
64    if (w === null) return w
65    const game = step(w.game, p === 'hunting')
66    if ((w.tick + 1) % SAVE_EVERY === 0 || game.mode !== w.game.mode) saved = game
67    isHome = game.mode === 'camp'
68    return { tick: w.tick + 1, now: n, game }
69  })
70  if (saved !== undefined) await save($, saved)
71  // back at camp after a turn: the HUD reads 待機 again, not 撤退
72  if (p === 'retreat' && isHome) await update($, phase, () => 'camp')
73}
74
75/**
76 * Traditional Chinese where Claude speaks it, English for everyone else:
77 * Claude's `language` setting first, then the system locale (on macOS and
78 * Linux it already follows LANG / LC_ALL, so the mod reads no environment
79 * variables of its own). A setting that cannot be read is passed over.
80 */
81async function detectLang($: EngineInterface): Promise<HuntLang> {
82  let language: unknown
83  try {
84    language = await $.settings.read({ key: 'language' })
85  } catch {
86    language = undefined
87  }
88  return pickLang([language, Intl.DateTimeFormat().resolvedOptions().locale])
89}
90
91async function pickWeapon($: EngineInterface, choice: HuntWeaponChoice): Promise<void> {
92  await save($, await changeGame($, g => chooseWeapon(g, choice)))
93  await note($, `weapon ${choice}`)
94}
95
96async function togglePicture($: EngineInterface): Promise<void> {
97  const next = !(await read($, terminalCells))
98  await update($, terminalCells, () => next)
99  await $.store.set(PICTURE_KEY, next)
100}
101
102/** A turn (or the demo) starts; `monster` drops the hunt under way for a fresh one of that species (debug). */
103async function startTurn($: EngineInterface, label: string, monster?: MonsterId): Promise<void> {
104  const now = await $.clock.now()
105  await update($, turnStartedAt, () => now)
106  await update($, phase, () => 'hunting')
107  await save($, await changeGame($, g => (monster === undefined ? beginTurn(g) : debugHunt(g, monster))))
108  await note($, label)
109}
110
111async function finishTurn($: EngineInterface, reason: string): Promise<void> {
112  const started = await read($, turnStartedAt)
113  const now = await $.clock.now()
114  const elapsed = started === null ? 0 : now - started
115  await update($, lastElapsedMs, () => elapsed)
116  await update($, turnStartedAt, () => null)
117  // answered or interrupted alike: the hunter runs back to camp, the hunt kept
118  await update($, phase, () => 'retreat')
119  await save($, await changeGame($, endTurn))
120  await note($, `turn.complete ${reason} ${fmt(elapsed)}`)
121}
122
123export const register: Register = on => {
124  let animation: Timer | undefined
125  /**
126   * Ticks, turn changes and the demo run one at a time, in arrival order, so a
127   * tick queued before a retreat can never land a hit after it.
128   */
129  let queue: Promise<void> = Promise.resolve()
130  const serial = (work: () => Promise<void>): Promise<void> => {
131    const run = queue.then(work, work)
132    queue = run.catch(() => undefined)
133    return run
134  }
135
136  on('session.start', async ($, e, next) => {
137    await $.command.register({ name: 'hunt', description: 'Open the 8-bit hunt pane' })
138    await $.command.register({
139      name: 'hunt-demo',
140      description: 'Toggle the hunt on or off without a Claude turn; with a monster id, start a fresh hunt of it (debug)',
141      argumentHint: '[monster id]',
142    })
143    const stored = (await $.store.get(SAVE_KEY)) as Game | undefined
144    const language = await detectLang($)
145    await update($, lang, () => language)
146    const cellsMode = await $.store.get(PICTURE_KEY)
147    if (typeof cellsMode === 'boolean') await update($, terminalCells, () => cellsMode)
148    const now = await $.clock.now()
149    // a hot reload keeps the session's world (migrated if it is older); a new session restores the save
150    await update($, world, w =>
151      w !== null ? { ...w, game: migrate(w.game) } : { tick: 0, now, game: stored === undefined ? newGame(now) : restore(stored) },
152    )
153    // a turn interrupted under the old pause rule (before a reload): retreat, as an interrupt does now
154    if ((await read($, phase)) === ('paused' as HuntPhase)) {
155      await update($, phase, () => 'retreat')
156      await save($, await changeGame($, endTurn))
157    }
158    void $.ui.open({ id: PANE, title: TITLE })
159    await note($, stored === undefined ? 'session.start (new hunter)' : 'session.start (save restored)')
160    animation?.cancel()
161    animation = $.clock.every(TICK_MS, () => {
162      void serial(() => advance($))
163    })
164    return next(e)
165  })
166
167  on('command.run', { command: 'hunt' }, async $ => {
168    await $.ui.open({ id: PANE, title: TITLE })
169    return { text: 'Hunt pane opened.' }
170  })
171
172  on('command.run', { command: 'hunt-demo' }, async ($, e) => {
173    const wanted = e.args.trim()
174    if (wanted !== '') {
175      const monster = enabledMonsters().find(id => id === wanted)
176      if (monster === undefined) return { text: `Unknown monster "${wanted}". One of: ${enabledMonsters().join(', ')}.` }
177      await serial(() => startTurn($, `hunt-demo -> ${monster}`, monster))
178      await $.ui.open({ id: PANE, title: TITLE })
179      return { text: `Hunt demo: a fresh ${monster} hunt (the one under way was dropped).` }
180    }
181    const isHunting = (await read($, phase)) === 'hunting'
182    await serial(() => (isHunting ? finishTurn($, 'answer') : startTurn($, 'hunt-demo -> hunting')))
183    await $.ui.open({ id: PANE, title: TITLE })
184    return { text: `Hunt demo: ${isHunting ? 'retreat' : 'hunting'}.` }
185  })
186
187  /**
188   * The pane's buttons, answered here by key rather than by their onPress: the
189   * pane redraws every tick, and a closure lives only as long as the drawing
190   * that held it, so a click landing just after a redraw used to be lost.
191   */
192  on('ui.press', { plugin: 'pixel-hunter' }, async ($, e, next) => {
193    const weapon = WEAPON_CHOICES.find(c => `weapon-${c}` === e.element)
194    if (weapon !== undefined) await serial(() => pickWeapon($, weapon))
195    else if (e.element === 'picture') await togglePicture($)
196    else return next(e)
197    return { element: e.element }
198  })
199
200  on('turn.start', async ($, e, next) => {
201    if (e.agentId !== undefined) return next(e)
202    await serial(() => startTurn($, `turn.start ${e.turnId.slice(0, 8)}`))
203    return next(e)
204  })
205
206  on('turn.complete', async ($, e, next) => {
207    if (e.agentId !== undefined) return next(e)
208    await serial(() => finishTurn($, e.reason))
209    return next(e)
210  })
211
212  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
213    const els = $.ui.resolve(e)
214    const { Box, Text, Button } = els
215    const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
216    const Image = e.surface === 'terminal' && 'Image' in els ? els.Image : undefined
217    const Raster = e.surface === 'terminal' && 'Raster' in els ? els.Raster : undefined
218    const isCells = await read($, terminalCells)
219    const p = await read($, phase)
220    const started = await read($, turnStartedAt)
221    const w = await read($, world)
222    const last = await read($, lastElapsedMs)
223    const lg = await read($, lang)
224    if (w === null) return <Text dimColor>Hunt is loading…</Text>
225    const elapsed = started === null ? last : Math.max(0, w.now - started)
226    const view = viewOf(w.game)
227    // the terminal's picture is pixels (raster.ts), the Desktop's an Svg
228    const picture = Svg === undefined && (Image !== undefined || Raster !== undefined)
229    const source = picture ? '' : huntSvg(p, view, fmt(elapsed), false, lg)
230    const g = w.game
231    const clock = fmt(elapsed)
232    // the picture's box: the pane's width, no taller than the rows it leaves for the picture
233    const box = imageCells(e.props.bodyColumns, e.props.scroll.bodyRows)
234    // pickable as soon as the turn ends, whether or not the hunter is back yet (engine.ts chooseWeapon)
235    const canChoose = p !== 'hunting'
236
237    return (
238      <Box flexDirection="column">
239        {Svg !== undefined ? (
240          <Svg source={source} alt={`8-bit hunt: ${p}`} width={HUNT_SIZE.width} height={HUNT_SIZE.height} />
241        ) : Image !== undefined || Raster !== undefined ? (
242          <Box flexDirection="column">
243            {isCells && Raster !== undefined ? (
244              <Raster key="scene-cells" {...box} cells={huntCells(view, false, box.columns, box.rows)} />
245            ) : Image !== undefined ? (
246              <Image
247                key="scene"
248                source={{ rgba: toBase64(huntRgba(view, false)), width: RASTER.width, height: RASTER.height }}
249                {...box}
250                alt={`8-bit hunt: ${p}${view.monsterInfo === null ? '' : ` — ${t(lg, view.monsterInfo.name)}`}`}
251              />
252            ) : null}
253            <Text bold>{hudText(p, view, clock, lg)}</Text>
254            <Text dimColor>
255              HP {view.hp}/{HUNTER.hpMax} ST {view.stamina}/{HUNTER.staminaMax}
256            </Text>
257            {view.label !== '' && <Text>{t(lg, view.label)}</Text>}
258            {Image !== undefined && Raster !== undefined && (
259              <Button key="picture" plain onPress={() => togglePicture($)}>
260                {t(lg, isCells ? 'pictureCells' : 'pictureImage')}
261              </Button>
262            )}
263          </Box>
264        ) : (
265          <Text dimColor>(this surface draws no Svg; hunt scene skipped)</Text>
266        )}
267        {Svg !== undefined ? (
268          <Svg source={statsSvg(g, lg)} alt={statsText(g, lg)} width={HUNT_SIZE.width} height={24} />
269        ) : (
270          <Text>{statsText(g, lg)}</Text>
271        )}
272        {canChoose ? (
273          <Box flexDirection="row">
274            {WEAPON_CHOICES.map(c => (
275              <Button key={`weapon-${c}`} plain dimColor={c !== g.weaponChoice} onPress={() => serial(() => pickWeapon($, c))}>
276                {`${c === g.weaponChoice ? '●' : '○'} ${t(lg, c)}  `}
277              </Button>
278            ))}
279          </Box>
280        ) : (
281          <Text dimColor>
282            {t(lg, 'weaponInHand', { weapon: t(lg, g.weapon) })}
283            {g.weaponChoice === 'random' ? t(lg, 'randomSuffix') : ''}
284            {t(lg, 'pickAfterTurn')}
285          </Text>
286        )}
287      </Box>
288    )
289  })
290}
291
hooks/data.ts 151 lines
1import type { HuntWeapon, HuntWeaponChoice } from '../types'
2import type { ActionId, SceneId } from './sprites'
3
4/**
5 * Tuning data for v1. Every number here is this project's pixel-adaptation
6 * design, not data measured from the original game.
7 */
8
9/** One frame per tick: the hooks advance the game and send the frame on show. */
10export const TICK_MS = 125
11
12export type Move = {
13  action: ActionId
14  /** Ticks each key pose shows; the last pose holds when the move outlasts them. */
15  ticks: readonly number[]
16  /** The pose whose first tick lands the hit (attack moves only). */
17  active?: number
18  damage?: number
19  stamina?: number
20  /** Decision weight among the attacks of the same actor. */
21  weight?: number
22  /**
23   * The action whose boxes it is hit on, when not the one drawn: a weapon's run
24   * is drawn as its stepping stance but hit as C03, the body the moves are tuned to.
25   */
26  body?: ActionId
27}
28
29export const HUNTER = {
30  hpMax: 12,
31  staminaMax: 8,
32  /** Idle ticks per stamina point regained, and per HP point regained. */
33  staminaRegenTicks: 6,
34  hpRegenTicks: 42,
35  /** Where the hunter enters a fight; from there it runs in and rolls back as needed. */
36  x: 96,
37  campX: 208,
38  /** Fight moves: run speed, roll distance (backwards), leftmost stance (far enough in that the body stays on screen). */
39  runStep: 4,
40  rollDistance: 30,
41  minX: 32,
42  /**
43   * Room the monster always leaves behind the hunter, from the left edge: it never
44   * walks, lunges or dives so close that the hunter has no space to back off or roll.
45   */
46  backRoom: 90,
47  /** Chance to roll when a monster attack is predicted to reach; drawn once per attack. */
48  dodgeRate: 0.4,
49  idleTicks: [4, 10] as const,
50  /** Chance to stand and wait instead of attacking when a normal opening comes. */
51  waitWeight: 20,
52  moves: {
53    idle: { action: 'C01', ticks: [3, 3, 3, 3] },
54    run: { action: 'C03', ticks: [2, 2, 2, 2] },
55    roll: { action: 'C08', ticks: [2, 4, 4, 2], stamina: 1 },
56    hit: { action: 'C15', ticks: [2, 3, 4, 3] },
57    down: { action: 'C17', ticks: [8, 5, 4, 3] },
58    charge: { action: 'G03', ticks: [3, 6, 3, 4], active: 2, damage: 30, stamina: 2, weight: 50 },
59    trueCharge: { action: 'G05', ticks: [4, 3, 4, 4], active: 3, damage: 55, stamina: 3 },
60    tackle: { action: 'G06', ticks: [3, 3, 3, 3], active: 2, damage: 12, stamina: 1, weight: 30 },
61    // long sword: quick cuts; the spirit roundslash for openings
62    lsChain: { action: 'L02', ticks: [3, 3, 3, 3], active: 1, damage: 16, stamina: 1, weight: 50 },
63    lsFade: { action: 'L03', ticks: [3, 3, 4, 3], active: 1, damage: 12, stamina: 1, weight: 30 },
64    lsRound: { action: 'L05', ticks: [3, 4, 3, 5], active: 1, damage: 36, stamina: 3 },
65    // bow: shots from range; the power shot for openings
66    bowShot: { action: 'B04', ticks: [3, 3, 3, 3], active: 2, damage: 10, stamina: 1, weight: 50 },
67    bowQuick: { action: 'B05', ticks: [2, 2, 3, 2], active: 2, damage: 7, stamina: 1, weight: 30 },
68    bowPower: { action: 'B06', ticks: [3, 4, 3, 4], active: 2, damage: 28, stamina: 3 },
69    // camp only: walk to the fire, kneel by it (C21 F1), eat the meat (C21 F4)
70    walk: { action: 'C02', ticks: [3, 3, 3, 3] },
71    roast: { action: 'C21', ticks: [1] },
72    eat: { action: 'C21', ticks: [0, 0, 0, 1] },
73  } satisfies Record<string, Move>,
74  /** Poses of the roll during which a hit passes through. */
75  rollInvulnerable: [1, 2] as const,
76} as const
77
78export type HunterMove = keyof typeof HUNTER.moves
79
80/** The shared moves a weapon draws its own way (C01/C02/C03/C08 show the great sword). */
81export type WeaponMove = 'idle' | 'run' | 'walk' | 'roll'
82
83type Weapon = {
84  /** In place of the shared moves, in camp too; same timing as the move they replace. */
85  moves?: Readonly<Partial<Record<WeaponMove, Move>>>
86  /** The dodge is a slide back facing the monster, not a roll turned away from it. */
87  dodgeFacesFoe?: boolean
88  /** Picked by weight when a normal opening comes. */
89  attacks: readonly HunterMove[]
90  /** On a flinch or topple: the first one the stamina allows. */
91  openers: readonly HunterMove[]
92}
93
94/**
95 * The three weapons. Each one's damage is scaled by a fixed-seed run so that
96 * every weapon takes about `targetMinutes` (engine.ts `damageScale`).
97 */
98export const WEAPONS: Readonly<Record<HuntWeapon, Weapon>> = {
99  greatsword: { attacks: ['charge', 'tackle'], openers: ['trueCharge', 'charge', 'tackle'] },
100  longsword: {
101    moves: {
102      idle: { action: 'L01', ticks: [6, 6] },
103      run: { action: 'L01', ticks: [2, 2], body: 'C03' },
104      walk: { action: 'L01', ticks: [3, 3] },
105      // L06 F1, F2 held, F4: the foresight slide back, without its counter cut
106      roll: { action: 'L06', ticks: [2, 4, 4, 2], stamina: 1 },
107    },
108    dodgeFacesFoe: true,
109    attacks: ['lsChain', 'lsFade'],
110    openers: ['lsRound', 'lsChain', 'lsFade'],
111  },
112  bow: {
113    moves: {
114      idle: { action: 'B01', ticks: [6, 0, 0, 6] },
115      run: { action: 'B01', ticks: [2, 2, 2, 2], body: 'C03' },
116      walk: { action: 'B01', ticks: [3, 3, 3, 3] },
117      roll: { action: 'B07', ticks: [2, 4, 4, 2], stamina: 1 },
118    },
119    dodgeFacesFoe: true,
120    attacks: ['bowShot', 'bowQuick'],
121    openers: ['bowPower', 'bowShot', 'bowQuick'],
122  },
123}
124
125/** The camp's buttons, in order; each id is also its text id (hooks/i18n.ts). */
126export const WEAPON_CHOICES: readonly HuntWeaponChoice[] = [...(Object.keys(WEAPONS) as HuntWeapon[]), 'random']
127
128/** The monsters' tuning lives with each species in hooks/monsters. */
129
130/** The two bodies never overlap: the gap kept between their body boxes. */
131export const BODY_GAP = 2
132
133/** Where the camp idles and where the hunts are fought. */
134export const CAMP_SCENE: SceneId = 'S03'
135
136/**
137 * The camp between turns, for looks only (HP still refills on arrival): the
138 * hunter walks to the fire, kneels to roast meat, eats it, and roasts the next.
139 * World pixels; the fire is drawn over S03's own fire.
140 */
141export const CAMP = {
142  fireX: 104,
143  fireY: 163,
144  /** Where the hunter kneels, facing the fire. */
145  seatX: 166,
146  walkStep: 2,
147  restDelayTicks: 6,
148  roastTicks: 48,
149  eatTicks: 24,
150} as const
151
hooks/engine.ts 1162 lines
1import type {
2  HuntAttack as Attack,
3  HuntContactOutcome as Outcome,
4  HuntGame as Game,
5  HunterMoveId as HunterMove,
6  HuntMode as Mode,
7  HuntRun,
8  HuntWeapon as Weapon,
9  HuntWeaponChoice as WeaponChoice,
10  MonsterId,
11  MonsterMoveId as MonsterMove,
12} from '../types'
13import type { Aabb, Placement } from './collision'
14import { contactPart, hitboxes, hitDefs, hurtboxes, reaches, shifted, toWorld, touchesAny } from './collision'
15import { MOVE_BOUNDS } from './collision-data'
16import type { Move } from './data'
17import { BODY_GAP, CAMP, CAMP_SCENE, HUNTER, TICK_MS, WEAPONS } from './data'
18import type { TextId } from './i18n'
19import { enabledMonsters, monsterDef, VELKHANA } from './monsters/registry'
20import type { Fx, MonsterCtx, MonsterDef, MonsterMove as MonsterMoveDef } from './monsters/types'
21import { RANGE_BANDS } from './monsters/types'
22import type { ActionId, SceneId } from './sprites'
23import { SCENES } from './sprites'
24
25/**
26 * The local hunt state machine: one pure step per tick, no model calls.
27 * Randomness comes from a seed kept in the state, so a saved game resumes
28 * exactly and a simulation is reproducible. Its state types live in the
29 * mod's state contract (types/index.d.ts).
30 *
31 * Damage lands only on contact: an attack's boxes on its active pose must
32 * touch the target's hurt boxes that tick (hooks/collision.ts), and each
33 * attack settles each target once.
34 *
35 * The monster is whichever species the hunt was drawn with (hooks/monsters):
36 * its moves, tuning and own mechanic come from that definition.
37 */
38
39export type { Game }
40
41/** The hunter's move as drawn: the weapon's own stance, run, walk and dodge where it has them. */
42function hunterMove(g: Game, move: HunterMove): Move {
43  const own: Move | undefined = (WEAPONS[g.weapon].moves as Partial<Record<string, Move>> | undefined)?.[move]
44  return own ?? HUNTER.moves[move]
45}
46
47/** The hunt's species; a hunt is always started with a registered one. */
48const defOf = (g: Game): MonsterDef => monsterDef(g.hunt!.monsterId)!
49const monsterMove = (g: Game, move: MonsterMove): MonsterMoveDef => defOf(g).moves[move]!
50const idleKey = (g: Game): MonsterMove => defOf(g).mechanic?.idleMove(g) ?? 'idle'
51const walkKey = (g: Game): MonsterMove => defOf(g).mechanic?.walkMove(g) ?? 'walk'
52/** Down on the ground: an opening that gets up again (never left for idle on retreat). */
53const isDown = (g: Game): boolean => {
54  const move = monsterMove(g, g.monster.move)
55  return move.opening === true && move.then !== undefined
56}
57
58/**
59 * `hitShift`/`hitSpread`: local pixels the live hit boxes have flown (a
60 * projectile, a spread volley), for the debug boxes; `lift`: the root's height
61 * in a leap (negative = up).
62 */
63export type ActorView = {
64  action: ActionId
65  frame: number
66  x: number
67  facing: 1 | -1
68  hitShift?: number
69  hitSpread?: number[]
70  lift?: number
71}
72
73export type View = {
74  scene: SceneId
75  hunter: ActorView
76  monster: ActorView | null
77  /** The hunt's species for the HUD: name, kind, and its state when it has one to show. */
78  monsterInfo: { name: string; species: string; status: string | null } | null
79  hp: number
80  stamina: number
81  weapon: Weapon
82  isRandomWeapon: boolean
83  label: string
84  monsterHpPercent: number | null
85  /** Effects drawn on live hit boxes (a projectile in flight). */
86  effects: { fx: Fx; box: Aabb }[]
87  /** Debug: where recent hits met (the last MARK_TICKS ticks). */
88  marks: { x: number; y: number; outcome: Outcome }[]
89  /** Camp props: the fire's flicker frame, and the meat on the spit (0 raw .. 1 done; null while eaten). */
90  camp: { flicker: number; cook: number | null } | null
91}
92
93const RUN_TICKS = 14
94const CAMP_DELAY = 8
95const SLAIN_HOLD = 32
96const LABEL_TICKS = 16
97
98const sum = (list: readonly number[]): number => list.reduce((a, b) => a + b, 0)
99
100export function poseAt(move: Move, t: number): number {
101  let local = t
102  for (let i = 0; i < move.ticks.length; i += 1) {
103    const span = move.ticks[i] ?? 1
104    if (local < span) return i
105    local -= span
106  }
107  return move.ticks.length - 1
108}
109
110const loopPose = (move: Move, t: number): number => poseAt(move, t % sum(move.ticks))
111const isOver = (move: Move, t: number): boolean => t >= sum(move.ticks)
112
113const SAVE_SCHEMA = 5
114const SCENE_SCALE = 3
115const HITS_KEPT = 30
116const MARK_TICKS = 8
117const HUNTER_LOOPING = new Set<string>(['idle', 'run', 'walk'])
118const hunterPoseOf = (key: string, move: Move, t: number): number =>
119  HUNTER_LOOPING.has(key) ? loopPose(move, t) : poseAt(move, t)
120/** The tick segment a monster move is in (its pose index); `frameOf` maps it to the sprite frame shown. */
121const monsterPoseOf = (move: MonsterMoveDef, t: number): number => (move.loop === true ? loopPose(move, t) : poseAt(move, t))
122const frameOf = (move: MonsterMoveDef, seg: number): number => move.frames?.[seg] ?? seg
123/** A move's active segments (a combo has several, each its own group). */
124const activesOf = (move: MonsterMoveDef): readonly number[] => move.actives ?? (move.active === undefined ? [] : [move.active])
125const spanOf = (move: Move, seg: number): [number, number] => {
126  const start = sum(move.ticks.slice(0, seg))
127  return [start, start + (move.ticks[seg] ?? 0)]
128}
129/** The active span a projectile flies from: the latest one started by tick `t` (the first before any). */
130function launchOf(move: MonsterMoveDef, t: number): [number, number] {
131  const spans = activesOf(move).map(seg => spanOf(move, seg))
132  return spans.filter(([start]) => start <= t).at(-1) ?? spans[0] ?? [0, 0]
133}
134
135/** The root's height at tick `t` of a move: linear between its lift keys, 0 outside them or without any. */
136export function liftAt(move: MonsterMoveDef, t: number): number {
137  const keys = move.lift
138  if (keys === undefined || keys.length === 0 || t <= keys[0]![0] || t >= keys[keys.length - 1]![0]) return 0
139  let i = 1
140  while (keys[i]![0] < t) i += 1
141  const [t0, y0] = keys[i - 1]!
142  const [t1, y1] = keys[i]!
143  return Math.round(y0 + ((y1 - y0) * (t - t0)) / (t1 - t0))
144}
145
146/** mulberry32 over the state's seed. */
147function rand(g: Game): number {
148  g.seed = (g.seed + 0x6d2b79f5) | 0
149  let r = Math.imul(g.seed ^ (g.seed >>> 15), 1 | g.seed)
150  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r
151  return ((r ^ (r >>> 14)) >>> 0) / 4294967296
152}
153
154const between = (g: Game, [lo, hi]: readonly [number, number]): number => lo + Math.floor(rand(g) * (hi - lo + 1))
155
156function pick<K extends string>(g: Game, weights: readonly (readonly [K, number])[]): K {
157  let roll = rand(g) * sum(weights.map(w => w[1]))
158  for (const [key, w] of weights) {
159    if (roll < w) return key
160    roll -= w
161  }
162  return weights[weights.length - 1]![0]
163}
164
165/**
166 * A callout over the fight: a text id (hooks/i18n.ts) for the engine's own
167 * words, or a species' own text as its definition writes it; the drawing
168 * picks the language either way.
169 */
170function say(g: Game, text: TextId | string): void {
171  g.label = text
172  g.labelT = 0
173}
174
175export function newGame(seed: number): Game {
176  return {
177    saveSchemaVersion: SAVE_SCHEMA,
178    simTick: 0,
179    nextAttackSerial: 0,
180    mode: 'camp',
181    modeT: 0,
182    huntNo: 0,
183    hunt: null,
184    isResting: false,
185    weapon: 'greatsword',
186    weaponChoice: 'greatsword',
187    hunter: {
188      move: 'idle',
189      t: 0,
190      x: HUNTER.campX,
191      wait: 0,
192      attack: null,
193      hp: HUNTER.hpMax,
194      stamina: HUNTER.staminaMax,
195      facing: 1,
196      rollIn: -1,
197      intent: null,
198      dodgeFor: null,
199    },
200    monster: { move: 'idle', t: 0, x: VELKHANA.x, wait: 0, attack: null },
201    seed,
202    label: '',
203    labelT: LABEL_TICKS,
204    slain: 0,
205    slainBy: {},
206    carts: 0,
207    lastOutcome: null,
208    hits: [],
209  }
210}
211
212/**
213 * Monster HP that lasts about the species' `targetMinutes` of hunting, from a
214 * fixed-seed run against an unkillable one. A species whose parts scale with
215 * max HP runs a second pass at the first estimate, so its breaks happen.
216 */
217const calibrated = new Map<MonsterId, number>()
218export function monsterHpForTarget(id: MonsterId): number {
219  let hp = calibrated.get(id)
220  if (hp === undefined) {
221    const def = monsterDef(id)!
222    const pass = (maxHp: number): number => {
223      let g = newGame(20261016)
224      startHunt(g, id, 1e9, maxHp)
225      g = beginTurn(g)
226      const ticks = 6000
227      for (let i = 0; i < ticks; i += 1) g = step(g, true)
228      const perTick = (1e9 - g.hunt!.monsterHp) / ticks
229      // no hit landed: a box or stance is off; never make a monster with no HP
230      if (!(perTick > 0)) throw new Error(`pixel-hunter: calibration of ${id} landed no hits; check the collision boxes and stances`)
231      return Math.round(perTick * ((def.targetMinutes * 60_000) / TICK_MS))
232    }
233    hp = pass(1e9)
234    if (def.calibrateTwice === true) hp = pass(hp)
235    calibrated.set(id, hp)
236  }
237  return hp
238}
239
240/**
241 * Damage per tick a weapon deals to a species in a fixed-seed run, with its
242 * damage times `scale` (while it runs that is every weapon's scale:
243 * `calibrating`). The species has its real max HP, so its parts and meters
244 * behave as in a hunt, and the HP itself never runs out.
245 */
246let calibrating: number | undefined
247function damagePerTick(weapon: Weapon, id: MonsterId, scale: number): number {
248  const maxHp = monsterHpForTarget(id)
249  calibrating = scale
250  try {
251    let g = newGame(20261016)
252    g.weapon = weapon
253    g.weaponChoice = weapon
254    startHunt(g, id, 1e9, maxHp)
255    g = beginTurn(g)
256    const ticks = 6000
257    for (let i = 0; i < ticks; i += 1) g = step(g, true)
258    const out = (1e9 - g.hunt!.monsterHp) / ticks
259    if (!(out > 0)) throw new Error(`pixel-hunter: calibration of ${weapon} on ${id} landed no hits; check the collision boxes and stances`)
260    return out
261  } finally {
262    calibrating = undefined
263  }
264}
265
266/**
267 * The factor on a weapon's damage that hunts this species as fast as the
268 * great sword does. Bigger blows also flinch, topple and break parts sooner,
269 * so it is refined over a few runs with it applied. Worked out the first time
270 * the pair meets (a hitch of about a second), then kept for the session.
271 */
272const scales = new Map<string, number>()
273export function damageScale(weapon: Weapon, id: MonsterId): number {
274  if (calibrating !== undefined) return calibrating
275  if (weapon === 'greatsword') return 1
276  const key = `${weapon}:${id}`
277  let scale = scales.get(key)
278  if (scale === undefined) {
279    const target = damagePerTick('greatsword', id, 1)
280    scale = 1
281    for (let i = 0; i < 4; i += 1) scale *= target / damagePerTick(weapon, id, scale)
282    scales.set(key, scale)
283  }
284  return scale
285}
286
287/** Out on a hunt: the weapon in hand stays until the hunter is on the way home. */
288const isOut = (g: Game): boolean => g.mode === 'leaving' || g.mode === 'arriving' || g.mode === 'fight'
289
290/** A random weapon, never the one in hand: every draw changes what the hunter holds. */
291function drawWeapon(g: Game): Weapon {
292  const others = (Object.keys(WEAPONS) as Weapon[]).filter(w => w !== g.weapon)
293  return others[Math.floor(rand(g) * others.length)]!
294}
295
296/**
297 * The weapon pick, taken up at once unless the hunter is out (then on the
298 * way home, `toCamp`, or the next trip out). `random` draws one now, like a
299 * fixed pick, and draws again for every new hunt (`takeWeapon`).
300 */
301export function chooseWeapon(game: Game, choice: WeaponChoice): Game {
302  const g = clone(game)
303  g.weaponChoice = choice
304  if (!isOut(g)) g.weapon = choice === 'random' ? drawWeapon(g) : choice
305  return g
306}
307
308/** Leaving camp: put the pick in hand; `random` draws again only for a new hunt. */
309function takeWeapon(g: Game, isNewHunt: boolean): void {
310  const choice = g.weaponChoice
311  if (choice !== 'random') g.weapon = choice
312  else if (isNewHunt) g.weapon = drawWeapon(g)
313}
314
315/** The species of a new hunt: an enabled one, from the seed (the draw leaves the seed as it is). */
316function drawMonster(g: Game): MonsterId {
317  const pool = enabledMonsters()
318  // murmur3's finalizer: a bare multiply leaves the low bits patterned, and with six species only three came up
319  let x = g.seed ^ Math.imul(g.huntNo + 1, 0x9e3779b1)
320  x = Math.imul(x ^ (x >>> 16), 0x85ebca6b)
321  x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35)
322  return pool[((x ^ (x >>> 16)) >>> 0) % pool.length]!
323}
324
325function startHunt(g: Game, id: MonsterId = drawMonster(g), hp: number = monsterHpForTarget(id), maxHp: number = hp): void {
326  const def = monsterDef(id)!
327  const scene = def.scenes[g.huntNo % def.scenes.length]!
328  const own = def.mechanic?.initial(maxHp) ?? { state: 'normal', parts: {} }
329  g.hunt = {
330    monsterId: id,
331    definitionVersion: def.definitionVersion,
332    scene,
333    monsterHp: hp,
334    monsterHpMax: maxHp,
335    stagger: 0,
336    topple: 0,
337    fightTicks: 0,
338    isFresh: true,
339    state: own.state,
340    stateT: 0,
341    parts: own.parts,
342    cooldowns: {},
343  }
344  g.huntNo += 1
345}
346
347/** The saved hunt's species is registered: otherwise it waits at camp, kept as it was. */
348const isReady = (g: Game): boolean => g.hunt === null || monsterDef(g.hunt.monsterId) !== undefined
349
350function setMode(g: Game, mode: Mode): void {
351  g.mode = mode
352  g.modeT = 0
353}
354
355/** A new attack instance when the move has a hit; a new move always ends the old one. */
356function startAttack(g: Game, source: 'hunter' | 'monster', move: Move): Attack | null {
357  if (move.active === undefined) return null
358  const id = `${g.huntNo}:${source}:${g.nextAttackSerial}`
359  g.nextAttackSerial += 1
360  return { id, contacts: [] }
361}
362
363function hunterDo(g: Game, move: HunterMove): void {
364  g.hunter.move = move
365  g.hunter.t = 0
366  g.hunter.stamina = Math.max(0, g.hunter.stamina - (hunterMove(g, move).stamina ?? 0))
367  g.hunter.attack = startAttack(g, 'hunter', hunterMove(g, move))
368}
369
370function monsterDo(g: Game, move: MonsterMove): void {
371  g.monster.move = move
372  g.monster.t = 0
373  g.monster.attack = startAttack(g, 'monster', monsterMove(g, move))
374}
375
376/** The hunt's species counters, created on first use (saves from before them have none). */
377const meters = (g: Game): Record<string, number> => (g.hunt!.meters ??= {})
378
379const ctxOf = (g: Game): MonsterCtx => ({ g, act: move => monsterDo(g, move), say: text => say(g, text) })
380
381function leaveCamp(g: Game, monster?: MonsterId): void {
382  const isNewHunt = g.hunt === null
383  if (isNewHunt) startHunt(g, monster)
384  if (!isReady(g)) {
385    say(g, 'notReady')
386    return
387  }
388  takeWeapon(g, isNewHunt)
389  damageScale(g.weapon, g.hunt!.monsterId) // calibrate now, not on the first hit
390  setMode(g, 'leaving')
391  g.hunter.facing = 1
392  hunterDo(g, 'run')
393}
394
395/** A main turn started: leave camp (new hunt or the saved one). `monster` picks the species of a new hunt (tests, calibration). */
396export function beginTurn(game: Game, monster?: MonsterId): Game {
397  const g = clone(game)
398  g.isResting = false
399  if (g.mode === 'camp') leaveCamp(g, monster)
400  return g
401}
402
403/**
404 * Debug (`/hunt-demo <monsterId>`): drop the hunt under way and head out at
405 * once after a fresh one of this species. Tallies and the seed stay.
406 */
407export function debugHunt(game: Game, monster: MonsterId): Game {
408  const g = clone(game)
409  if (g.mode !== 'camp') toCamp(g)
410  g.hunt = null
411  g.isResting = false
412  leaveCamp(g, monster)
413  return g
414}
415
416/** The main turn answered: save the fight and run back to camp. */
417export function endTurn(game: Game): Game {
418  const g = clone(game)
419  if (g.mode === 'fight' || g.mode === 'arriving') {
420    setMode(g, 'retreating')
421    g.hunter.facing = -1
422    g.hunter.intent = null
423    g.hunter.rollIn = -1
424    hunterDo(g, 'run')
425    if (!isDown(g)) monsterDo(g, idleKey(g))
426    say(g, 'retreat')
427  } else if (g.mode === 'leaving') {
428    toCamp(g)
429  }
430  return g
431}
432
433/** The fields a hunt saved before the registry lacks: it was Velkhana, in no state of its own. */
434function upgradeHunt(hunt: HuntRun): HuntRun {
435  return {
436    ...hunt,
437    monsterId: hunt.monsterId ?? 'velkhana',
438    definitionVersion: hunt.definitionVersion ?? 1,
439    state: hunt.state ?? 'normal',
440    stateT: hunt.stateT ?? 0,
441    parts: hunt.parts ?? {},
442    cooldowns: hunt.cooldowns ?? {},
443  }
444}
445
446/**
447 * An older save. Before collision (no schema 2) attacks under way had no
448 * contact record, so they are dropped and the hunter goes back to camp; the
449 * hunt's HP, seed and tallies stay. Schema 3 came in two lines that never met
450 * (the monster registry, and the weapons), so from 2 to 4 each save gains what
451 * it lacks, field by field: a hunt without species fields was Velkhana, a
452 * hunter without a weapon held the great sword, and every kill without a
453 * per-species tally is counted as Velkhana's. A current game comes back unchanged.
454 */
455export function migrate(saved: Game): Game {
456  if (saved.saveSchemaVersion === SAVE_SCHEMA) return saved
457  const g = migrateHunt(saved)
458  g.slainBy ??= g.slain > 0 ? { velkhana: g.slain } : {}
459  g.weapon ??= 'greatsword'
460  g.weaponChoice ??= 'greatsword'
461  g.saveSchemaVersion = SAVE_SCHEMA
462  return g
463}
464
465function migrateHunt(saved: Game): Game {
466  const old = clone(saved)
467  if (old.saveSchemaVersion >= 2) return { ...old, hunt: old.hunt === null ? null : upgradeHunt(old.hunt) }
468  const g: Game = {
469    ...old,
470    saveSchemaVersion: SAVE_SCHEMA,
471    simTick: 0,
472    nextAttackSerial: 0,
473    lastOutcome: null,
474    hits: [],
475    hunt: old.hunt === null ? null : upgradeHunt(old.hunt),
476    hunter: { ...old.hunter, attack: null, intent: null, rollIn: -1, dodgeFor: null },
477    monster: { ...old.monster, attack: null },
478  }
479  if (g.mode === 'slain') g.hunt = null
480  if (g.mode !== 'camp') toCamp(g)
481  return g
482}
483
484/** A save from an earlier session: whatever was under way, the hunter is back at camp. */
485export function restore(saved: Game): Game {
486  const g = migrate(clone(saved))
487  if (g.mode === 'slain') g.hunt = null
488  if (g.mode !== 'camp') toCamp(g)
489  // a hunt never fought keeps isFresh: its monster still walks in, not from an old x
490  g.isResting = false
491  return g
492}
493
494/** Between turns: walk to the fire, kneel and roast, eat, roast the next. */
495function campRest(g: Game): void {
496  const h = g.hunter
497  if (h.move === 'idle' && g.modeT >= CAMP.restDelayTicks) {
498    h.facing = -1
499    hunterDo(g, 'walk')
500  } else if (h.move === 'walk') {
501    h.x = Math.max(CAMP.seatX, h.x - CAMP.walkStep)
502    if (h.x <= CAMP.seatX) hunterDo(g, 'roast')
503  } else if (h.move === 'roast' && h.t >= CAMP.roastTicks) hunterDo(g, 'eat')
504  else if (h.move === 'eat' && h.t >= CAMP.eatTicks) hunterDo(g, 'roast')
505}
506
507function toCamp(g: Game): void {
508  if (g.weaponChoice !== 'random') g.weapon = g.weaponChoice
509  setMode(g, 'camp')
510  g.hunter = { ...g.hunter, move: 'idle', t: 0, x: HUNTER.campX, facing: 1, hp: HUNTER.hpMax, rollIn: -1, intent: null, attack: null }
511  g.monster.attack = null
512}
513
514function clone(g: Game): Game {
515  return JSON.parse(JSON.stringify(g)) as Game
516}
517
518/** One tick. `working`: a main turn is running (new trips out of camp only then). */
519export function step(game: Game, working: boolean): Game {
520  const g = clone(game)
521  g.simTick += 1
522  g.modeT += 1
523  g.labelT += 1
524  g.hunter.t += 1
525  g.monster.t += 1
526  switch (g.mode) {
527    case 'camp':
528      if (working && !g.isResting && g.modeT >= CAMP_DELAY && isReady(g)) leaveCamp(g)
529      else campRest(g)
530      break
531    case 'leaving': {
532      // from wherever the hunter was resting, off the right edge in RUN_TICKS
533      g.hunter.x += (420 - g.hunter.x) / Math.max(1, RUN_TICKS - g.modeT + 1)
534      if (g.modeT >= RUN_TICKS) {
535        const def = defOf(g)
536        setMode(g, 'arriving')
537        g.hunter.x = -40
538        if (g.hunt!.isFresh) {
539          g.monster = { move: walkKey(g), t: 0, x: def.enterFromX, wait: 0, attack: null }
540        } else {
541          g.monster = { move: idleKey(g), t: 0, x: g.monster.x, wait: between(g, def.idleTicks), attack: null }
542        }
543      }
544      break
545    }
546    case 'arriving': {
547      const def = defOf(g)
548      const span = g.hunt!.isFresh ? RUN_TICKS * 2 : RUN_TICKS
549      g.hunter.x = Math.min(HUNTER.x, hunterMaxX(g), -40 + ((HUNTER.x + 40) * g.modeT) / RUN_TICKS)
550      if (g.hunt!.isFresh) g.monster.x = def.enterFromX + ((def.x - def.enterFromX) * g.modeT) / span
551      if (g.modeT >= RUN_TICKS && g.hunter.move === 'run') {
552        hunterDo(g, 'idle')
553        g.hunter.wait = between(g, HUNTER.idleTicks)
554      }
555      if (g.modeT >= span) {
556        g.monster = {
557          move: idleKey(g),
558          t: 0,
559          x: g.hunt!.isFresh ? def.x : g.monster.x,
560          wait: between(g, def.idleTicks),
561          attack: null,
562        }
563        g.hunt!.isFresh = false
564        setMode(g, 'fight')
565        say(g, g.hunt!.monsterHp === g.hunt!.monsterHpMax ? 'huntStart' : 'huntResume')
566      }
567      break
568    }
569    case 'fight':
570      fight(g)
571      break
572    case 'retreating':
573      g.hunter.x -= (HUNTER.x + 60) / RUN_TICKS
574      if (g.modeT >= RUN_TICKS) toCamp(g)
575      break
576    case 'carted':
577      if (g.modeT === sum(HUNTER.moves.down.ticks)) {
578        g.hunter.facing = -1
579        hunterDo(g, 'run')
580      }
581      if (g.modeT > sum(HUNTER.moves.down.ticks)) g.hunter.x -= (HUNTER.x + 60) / RUN_TICKS
582      if (g.modeT >= sum(HUNTER.moves.down.ticks) + RUN_TICKS) toCamp(g)
583      break
584    case 'slain':
585      if (g.modeT === SLAIN_HOLD) {
586        g.hunter.facing = -1
587        hunterDo(g, 'run')
588      }
589      if (g.modeT > SLAIN_HOLD) g.hunter.x -= (HUNTER.x + 60) / RUN_TICKS
590      if (g.modeT >= SLAIN_HOLD + RUN_TICKS) {
591        g.hunt = null
592        toCamp(g)
593        say(g, 'huntDone')
594      }
595      break
596  }
597  return g
598}
599
600const groundOf = (g: Game): number => SCENES[g.hunt!.scene].ground * SCENE_SCALE
601const hunterAt = (g: Game): Placement => ({ x: g.hunter.x, groundY: groundOf(g), renderFlip: g.hunter.facing })
602const monsterAt = (g: Game): Placement => ({
603  x: g.monster.x,
604  groundY: groundOf(g) + liftAt(monsterMove(g, g.monster.move), g.monster.t),
605  renderFlip: 1,
606})
607/** The hunter's body this tick: the action it is hit (and hits) on, and its pose. */
608const hunterPose = (g: Game): [ActionId, number] => {
609  const move = hunterMove(g, g.hunter.move)
610  return [move.body ?? move.action, hunterPoseOf(g.hunter.move, move, g.hunter.t)]
611}
612const monsterPose = (g: Game): [ActionId, number] => {
613  const move = monsterMove(g, g.monster.move)
614  return [move.action, frameOf(move, monsterPoseOf(move, g.monster.t))]
615}
616
617/** The monster faces the other way in this segment of its move (a turn-back dash). */
618const isTurned = (g: Game): boolean => {
619  const move = monsterMove(g, g.monster.move)
620  return move.turned?.includes(monsterPoseOf(move, g.monster.t)) === true
621}
622
623/** The monster's push box now (its state may widen it; turned round it mirrors). */
624const pushBox = (g: Game): readonly [number, number] => {
625  const own = defOf(g).mechanic?.bounds?.(g) ?? defOf(g).bounds
626  return isTurned(g) ? [-own[1], -own[0]] : own
627}
628
629/** Rightmost the monster's root goes: its push box stays on the screen. */
630const ARENA_RIGHT = 368
631const monsterMaxX = (g: Game): number => ARENA_RIGHT - pushBox(g)[1]
632
633/** The bodies never overlap: the hunter stays left of the monster's push box. */
634const hunterMaxX = (g: Game): number => g.monster.x + pushBox(g)[0] - MOVE_BOUNDS.hunter[1] - BODY_GAP
635/** Room the hunter has between the left edge and the monster's push box (tests hold it to `backRoom`). */
636export const hunterRoom = (g: Game): number => hunterMaxX(g) - HUNTER.minX
637
638/** Leftmost root that still leaves the hunter `backRoom` to back off and roll in, for the push box now. */
639const roomFloor = (g: Game): number => HUNTER.minX + HUNTER.backRoom + MOVE_BOUNDS.hunter[1] - pushBox(g)[0] + BODY_GAP
640
641/**
642 * Leftmost the monster's root goes: its own limit, never into the hunter, and
643 * never so far that the hunter is pinned to the left edge (`backRoom` stays free).
644 */
645const monsterMinX = (g: Game): number =>
646  Math.max(defOf(g).minX, g.hunter.x + MOVE_BOUNDS.hunter[1] - pushBox(g)[0] + BODY_GAP, roomFloor(g))
647
648/** Local pixels a projectile has flown forward by this tick of its move (forward = the art's facing). */
649function flight(g: Game, move: MonsterMoveDef, t: number): number {
650  if (move.travel === undefined) return 0
651  return defOf(g).assetFacing * move.travel * Math.max(0, t - launchOf(move, t)[0])
652}
653
654/** Pixels each box of a spread volley has drifted down by this tick (none without a spread). */
655function drift(move: MonsterMoveDef, t: number): number[] | undefined {
656  if (move.spread === undefined) return undefined
657  const since = Math.max(0, t - launchOf(move, t)[0])
658  return move.spread.map(v => v * since)
659}
660
661/** The monster's live hit boxes this tick (a projectile where it has flown to). */
662function monsterHits(g: Game): Aabb[] {
663  const move = monsterMove(g, g.monster.move)
664  const [action, pose] = monsterPose(g)
665  return hitboxes(action, pose, monsterAt(g), flight(g, move, g.monster.t), drift(move, g.monster.t))
666}
667
668/** Everywhere this monster attack could land from where it stands: lunge and flight included. */
669function monsterReach(g: Game, key: MonsterMove): Aabb[] {
670  const move = monsterMove(g, key)
671  const [start, end] = launchOf(move, 0)
672  const forward = (move.advance ?? 0) + (move.travel ?? 0) * Math.max(0, end - start - 1)
673  const along = defOf(g).assetFacing * forward
674  return hitDefs(move.action).flatMap(h => h.boxes).flatMap(b => {
675    const [x0, y0, x1, y1] = shifted(b, along)
676    return [toWorld(forward === 0 ? b : [Math.min(b[0], x0), y0, Math.max(b[2], x1), y1], monsterAt(g))]
677  })
678}
679
680const isInvulnerable = (g: Game): boolean =>
681  g.hunter.move === 'roll' && (HUNTER.rollInvulnerable as readonly number[]).includes(poseAt(hunterMove(g, 'roll'), g.hunter.t))
682
683/** The hunter's blow, scaled so every weapon hunts each species about as fast. */
684const hunterDamage = (g: Game): number => {
685  const raw = hunterMove(g, g.hunter.move).damage ?? 0
686  return Math.round(raw * damageScale(g.weapon, g.hunt!.monsterId) * 100) / 100
687}
688
689type Contact = { source: 'hunter' | 'monster'; attack: Attack; part: string; x: number; y: number; outcome: Outcome; damage: number }
690
691/** This tick's contacts, judged on the state before any of them applies. */
692function findContacts(g: Game): Contact[] {
693  const out: Contact[] = []
694  const [hAction, hPose] = hunterPose(g)
695  const [mAction, mPose] = monsterPose(g)
696  const m = g.monster
697  const h = g.hunter
698  if (m.attack !== null && !m.attack.contacts.some(c => c.target === 'hunter')) {
699    const touch = contactPart(monsterHits(g), hurtboxes(hAction, hPose, hunterAt(g)))
700    if (touch !== undefined) {
701      const evaded = isInvulnerable(g)
702      out.push({
703        source: 'monster',
704        attack: m.attack,
705        part: touch.part,
706        ...touch.point,
707        outcome: evaded ? 'evaded' : 'damaged',
708        damage: evaded ? 0 : monsterMove(g, m.move).damage ?? 0,
709      })
710    }
711  }
712  if (h.attack !== null && !h.attack.contacts.some(c => c.target === 'monster')) {
713    const touch = contactPart(hitboxes(hAction, hPose, hunterAt(g)), hurtboxes(mAction, mPose, monsterAt(g)))
714    if (touch !== undefined) {
715      out.push({ source: 'hunter', attack: h.attack, part: touch.part, ...touch.point, outcome: 'damaged', damage: hunterDamage(g) })
716    }
717  }
718  return out
719}
720
721/**
722 * Settle this tick's contacts together (an exchange of blows is allowed), then
723 * the reactions. Returns true when the fight ended this tick.
724 */
725function settle(g: Game): boolean {
726  const found = findContacts(g)
727  if (found.length === 0) return false
728  const def = defOf(g)
729  const hunt = g.hunt!
730  const h = g.hunter
731  const m = g.monster
732  const monsterAttack = m.move
733  let toHunter = 0
734  let toMonster = 0
735  for (const c of found) {
736    const target: 'hunter' | 'monster' = c.source === 'hunter' ? 'monster' : 'hunter'
737    c.attack.contacts.push({ target, outcome: c.outcome, tick: g.simTick })
738    const hit = { attackId: c.attack.id, target, part: c.part, outcome: c.outcome, damage: c.damage, tick: g.simTick, x: c.x, y: c.y }
739    g.hits = [...g.hits, hit].slice(-HITS_KEPT)
740    if (target === 'hunter') toHunter += c.damage
741    else toMonster += c.damage
742  }
743  h.hp = Math.max(0, h.hp - toHunter)
744  hunt.monsterHp = Math.max(0, hunt.monsterHp - toMonster)
745  hunt.stagger += toMonster
746  hunt.topple += toMonster
747  const hunterFell = toHunter > 0 && h.hp === 0
748  const monsterFell = toMonster > 0 && hunt.monsterHp === 0
749
750  if (monsterFell) {
751    // the next hunt waits for the next turn; set now, so a turn begun during the hold clears it
752    g.isResting = true
753    g.slain += 1
754    g.slainBy[hunt.monsterId] = (g.slainBy[hunt.monsterId] ?? 0) + 1
755    g.lastOutcome = hunterFell ? 'mutual_ko' : null
756    if (hunterFell) g.carts += 1
757    setMode(g, 'slain')
758    monsterDo(g, def.slainMove)
759    hunterDo(g, hunterFell ? 'down' : 'idle')
760    h.rollIn = -1
761    h.intent = null
762    say(g, hunterFell ? 'mutualKo' : 'slain')
763    return true
764  }
765  if (hunterFell) {
766    g.carts += 1
767    setMode(g, 'carted')
768    hunterDo(g, 'down')
769    h.rollIn = -1
770    h.intent = null
771    if (!isDown(g)) monsterDo(g, idleKey(g))
772    say(g, 'carted')
773    return true
774  }
775  if (toHunter > 0) {
776    hunterDo(g, 'hit')
777    h.facing = 1
778    h.rollIn = -1
779    h.intent = null
780    say(g, monsterMove(g, monsterAttack).hitLabel ?? 'hit')
781  } else if (found.some(c => c.outcome === 'evaded')) {
782    say(g, 'dodged')
783  }
784  if (toMonster > 0) {
785    // the species' own reaction (a part broke) wins over a flinch or topple; a filled meter is spent with it
786    let tookOver = false
787    for (const c of found) if (c.source === 'hunter' && c.damage > 0 && def.mechanic?.damaged(ctxOf(g), c.part, c.damage) === true) tookOver = true
788    if (tookOver) {
789      if (hunt.topple >= def.toppleEvery) hunt.topple = 0
790      if (hunt.stagger >= def.flinchEvery) hunt.stagger = 0
791    } else if (hunt.topple >= def.toppleEvery && !isDown(g) && m.move !== 'getup') {
792      hunt.topple = 0
793      hunt.stagger = 0
794      monsterDo(g, 'topple')
795      say(g, `${def.name}倒地`)
796    } else if (hunt.stagger >= def.flinchEvery && !isDown(g) && m.move !== 'getup') {
797      hunt.stagger = 0
798      monsterDo(g, 'flinch')
799      say(g, `${def.name}硬直`)
800    }
801  }
802  return false
803}
804
805function fight(g: Game): void {
806  const def = defOf(g)
807  const hunt = g.hunt!
808  const h = g.hunter
809  const m = g.monster
810  hunt.fightTicks += 1
811  hunt.stateT += 1
812  // a combo's next active segment is a new group: it may land once more
813  const before = monsterMove(g, m.move)
814  if (before.actives !== undefined && m.t > 0) {
815    const at = monsterPoseOf(before, m.t)
816    if (at !== monsterPoseOf(before, m.t - 1) && at !== before.actives[0] && before.actives.includes(at)) {
817      m.attack = startAttack(g, 'monster', before)
818    }
819  }
820  if (settle(g)) return
821  def.mechanic?.tick?.(ctxOf(g))
822
823  // monster: finish moves, lunge, walk in, then decide
824  const mNow = monsterMove(g, m.move)
825  const idle = idleKey(g)
826  const walk = walkKey(g)
827  // a dive locks its landing spot once: the nearest legal spot to the hunter, within its reach
828  if (mNow.seek !== undefined && m.t === mNow.seek) {
829    meters(g).seekX = Math.round(Math.max(monsterMinX(g), m.x + def.assetFacing * (mNow.advance ?? 0)))
830  }
831  const seg = monsterPoseOf(mNow, m.t)
832  if (mNow.advance !== undefined && activesOf(mNow).includes(seg)) {
833    const [start, end] = spanOf(mNow, seg)
834    if (mNow.seek !== undefined) {
835      const to = meters(g).seekX ?? m.x
836      m.x = Math.max(monsterMinX(g), m.x + (to - m.x) / Math.max(1, end - m.t))
837    } else {
838      const stride = ((isTurned(g) ? -1 : 1) * def.assetFacing * mNow.advance) / Math.max(1, end - start)
839      m.x = stride < 0 ? Math.max(monsterMinX(g), m.x + stride) : Math.min(monsterMaxX(g), m.x + stride)
840    }
841  }
842  if (m.move !== idle && m.move !== walk && isOver(mNow, m.t)) {
843    if (mNow.then !== undefined) monsterDo(g, mNow.then)
844    else {
845      monsterDo(g, idleKey(g))
846      m.wait = between(g, def.idleTicks)
847    }
848  } else if (m.move === walk) {
849    const speed = def.mechanic?.speed(g) ?? 1
850    const next = Math.max(monsterMinX(g), m.x - (def.walkStep * speed) / sum(mNow.ticks))
851    if (isOver(mNow, m.t) || next >= m.x) {
852      monsterDo(g, idle)
853      m.wait = between(g, def.idleTicks)
854    } else m.x = next
855  } else if (m.move === idle) {
856    m.wait -= 1
857    if (m.wait <= 0) monsterDecide(g)
858  }
859
860  // a state that widens the monster (a swollen belly) moves it off the hunter's room, as a push would
861  if (m.x < roomFloor(g)) m.x = Math.min(monsterMaxX(g), roomFloor(g))
862  // a lunge or a state that widens the monster pushes the hunter back, never through it
863  if (h.x > hunterMaxX(g)) h.x = Math.max(HUNTER.minX, hunterMaxX(g))
864
865  // hunter: finish moves, roll travel, pending dodge, run in, regen, then decide
866  const hNow: Move = hunterMove(g, h.move)
867  if (h.move === 'roll') h.x = Math.max(HUNTER.minX, h.x - HUNTER.rollDistance / sum(hNow.ticks))
868  if (h.move !== 'idle' && h.move !== 'run' && isOver(hNow, h.t)) {
869    h.facing = 1
870    hunterDo(g, 'idle')
871    h.wait = between(g, HUNTER.idleTicks)
872  }
873  if (h.rollIn >= 0) {
874    h.rollIn -= 1
875    if (h.rollIn < 0 && (h.move === 'idle' || h.move === 'run')) {
876      h.intent = null
877      h.facing = WEAPONS[g.weapon].dodgeFacesFoe === true ? 1 : -1
878      hunterDo(g, 'roll')
879    }
880    if (h.move === 'idle') return
881  }
882  // a monster attack on its way that would reach: never swing into it
883  if ((h.move === 'idle' || h.move === 'run') && h.rollIn < 0 && watch(g)) return
884  if (h.move === 'run') {
885    if (h.facing === -1) stopBackingOff(g)
886    else runIn(g)
887    return
888  }
889  if (h.move !== 'idle') return
890  if (h.t % HUNTER.staminaRegenTicks === 0) h.stamina = Math.min(HUNTER.staminaMax, h.stamina + 1)
891  if (hunt.fightTicks % HUNTER.hpRegenTicks === 0) h.hp = Math.min(HUNTER.hpMax, h.hp + 1)
892  h.wait -= 1
893  if (h.wait > 0) return
894  hunterDecide(g)
895}
896
897const can = (g: Game, move: HunterMove): boolean => g.hunter.stamina >= (hunterMove(g, move).stamina ?? 0)
898
899/** Would this hunter attack touch the monster from where the hunter stands? */
900function hunterReaches(g: Game, move: HunterMove): boolean {
901  const [mAction, mPose] = monsterPose(g)
902  return reaches(hunterMove(g, move).action, { ...hunterAt(g), renderFlip: 1 }, mAction, mPose, monsterAt(g))
903}
904
905/** The monster is open to the strongest swing: a reaction, or the tail end of a move (`openFrom`). */
906function isOpening(g: Game): boolean {
907  const move = monsterMove(g, g.monster.move)
908  return move.opening === true || (move.openFrom !== undefined && g.monster.t >= move.openFrom)
909}
910
911function hunterDecide(g: Game): void {
912  const h = g.hunter
913  const opening = isOpening(g)
914  let choice: HunterMove | 'wait'
915  const weapon = WEAPONS[g.weapon]
916  if (opening) choice = weapon.openers.find(move => can(g, move)) ?? 'wait'
917  else {
918    const options: [HunterMove | 'wait', number][] = [['wait', HUNTER.waitWeight]]
919    for (const move of weapon.attacks) if (can(g, move)) options.push([move, hunterMove(g, move).weight ?? 0])
920    choice = pick(g, options)
921  }
922  if (choice === 'wait') {
923    h.wait = between(g, HUNTER.idleTicks)
924    return
925  }
926  if (hunterReaches(g, choice)) swing(g, choice)
927  else {
928    // out of reach: run in first, swing once the blade would land
929    h.intent = choice
930    h.facing = 1
931    hunterDo(g, 'run')
932  }
933}
934
935/** Swing, unless the monster's attack under way would catch the swinging body: then stand and let it pass. */
936function swing(g: Game, move: HunterMove): void {
937  if (threat(g, move) !== null) {
938    g.hunter.intent = null
939    if (g.hunter.move !== 'idle') hunterDo(g, 'idle')
940    g.hunter.wait = 1
941    return
942  }
943  attack(g, move)
944}
945
946function attack(g: Game, move: HunterMove): void {
947  g.hunter.facing = 1
948  g.hunter.intent = null
949  hunterDo(g, move)
950  // every attack move has its callout in hooks/i18n.ts (a test holds both tables to it)
951  say(g, move as HunterMove & TextId)
952}
953
954/** One running step toward the monster; swing when in reach, give up when blocked. */
955function runIn(g: Game): void {
956  const h = g.hunter
957  const intent = h.intent
958  if (intent === null || !can(g, intent)) {
959    h.intent = null
960    hunterDo(g, 'idle')
961    h.wait = between(g, HUNTER.idleTicks)
962    return
963  }
964  if (hunterReaches(g, intent)) {
965    swing(g, intent)
966    return
967  }
968  const next = Math.min(hunterMaxX(g), h.x + HUNTER.runStep)
969  if (next <= h.x) {
970    h.intent = null
971    hunterDo(g, 'idle')
972    h.wait = between(g, HUNTER.idleTicks)
973  } else h.x = next
974}
975
976/** Horizontal gap between the hunter's body and the monster's torso (negative when they would overlap). */
977const bodyGap = (g: Game): number => g.monster.x + (defOf(g).torso ?? defOf(g).bounds)[0] - (g.hunter.x + MOVE_BOUNDS.hunter[1])
978
979/** A band test: near takes anything closer than its far edge; mid and far are [from, to). */
980function inRange(g: Game, ranges: readonly ('near' | 'mid' | 'far')[]): boolean {
981  const gap = bodyGap(g)
982  return ranges.some(r => {
983    const [from, to] = RANGE_BANDS[r]
984    return (r === 'near' || gap >= from) && (r === 'far' ? gap <= to : gap < to)
985  })
986}
987
988/** The move fits the species' state, its own rules, and is off cooldown. */
989function isAllowed(g: Game, key: MonsterMove): boolean {
990  const move = monsterMove(g, key)
991  const hunt = g.hunt!
992  if (move.state !== undefined && move.state !== hunt.state) return false
993  if (defOf(g).mechanic?.allows(g, key) === false) return false
994  return (hunt.cooldowns[key] ?? 0) <= hunt.fightTicks
995}
996
997/**
998 * A safe decision point. The species' mechanic may act first (a state
999 * change); otherwise it attacks with what may be chosen now: by its decision
1000 * band, or, for moves with none, only when its boxes would reach. With nothing
1001 * to throw it walks in, or waits.
1002 */
1003function monsterDecide(g: Game): void {
1004  const def = defOf(g)
1005  const m = g.monster
1006  if (def.mechanic?.decide(ctxOf(g)) === true) return
1007  const [hAction, hPose] = hunterPose(g)
1008  const options: [MonsterMove, number][] = []
1009  for (const key of def.attacks) {
1010    if (!isAllowed(g, key)) continue
1011    const move = monsterMove(g, key)
1012    const fits = move.range !== undefined ? inRange(g, move.range) : touchesAny(monsterReach(g, key), hAction, hPose, hunterAt(g))
1013    if (fits && (move.hazard !== true || hazardFits(g, key))) options.push([key, move.weight ?? 1])
1014  }
1015  const walk = walkKey(g)
1016  const walksIn = def.mechanic?.walksIn?.(g) !== false
1017  if (options.length > 0 && def.approachWeight !== undefined && walksIn && m.x > monsterMinX(g)) options.push([walk, def.approachWeight])
1018  if (options.length === 0) {
1019    if (m.x > monsterMinX(g)) monsterDo(g, walk)
1020    else m.wait = between(g, def.idleTicks)
1021    return
1022  }
1023  const key = pick(g, options)
1024  monsterDo(g, key)
1025  if (key === walk) return
1026  const move = monsterMove(g, key)
1027  if (move.cooldown !== undefined) g.hunt!.cooldowns[key] = g.hunt!.fightTicks + sum(move.ticks) + move.cooldown
1028  if (move.label !== undefined) say(g, move.label)
1029}
1030
1031/** A ground hazard is placed only where all of it is on the arena (spec 3.4). */
1032function hazardFits(g: Game, key: MonsterMove): boolean {
1033  return monsterReach(g, key).every(b => b.minX >= HUNTER.minX && b.maxX <= ARENA_RIGHT)
1034}
1035
1036/**
1037 * Ticks until the monster's attack can first touch the hunter standing here in
1038 * `as` (any of its poses), while it can still touch; null when no threat.
1039 * Judged on the body the hunter will have, not the one it has: standing still
1040 * after backing off, or swinging (a swing's body reaches further than idle).
1041 */
1042function threat(g: Game, as: HunterMove): number | null {
1043  const m = g.monster
1044  const move = monsterMove(g, m.move)
1045  if (m.attack === null || move.active === undefined || m.attack.contacts.some(c => c.target === 'hunter')) return null
1046  const next = activesOf(move)
1047    .map(seg => spanOf(move, seg))
1048    .find(([, end]) => m.t < end)
1049  if (next === undefined) return null
1050  const [start] = next
1051  const body = hunterMove(g, as)
1052  // facing the monster, as the hunter stands once it stops or swings
1053  const at = { ...hunterAt(g), renderFlip: 1 as const }
1054  const reach = monsterReach(g, m.move)
1055  const hit = body.ticks.some((_, pose) => touchesAny(reach, body.body ?? body.action, pose, at))
1056  return hit ? Math.max(0, start - m.t) : null
1057}
1058
1059/**
1060 * The free hunter (idle or running) watches the monster. An attack that would
1061 * reach gets one dodge draw (per attack, the first time the hunter is free to
1062 * see it); a roll is timed to meet it, otherwise the hunter backs off. Backed
1063 * against the left edge there is nowhere left to run, so it rolls whenever it
1064 * has the stamina. Returns true when this tick went to dodging.
1065 */
1066function watch(g: Game): boolean {
1067  const h = g.hunter
1068  const coming = threat(g, 'idle')
1069  if (coming === null) return false
1070  const id = g.monster.attack!.id
1071  const timeRoll = (): boolean => {
1072    const safeFrom = sum(hunterMove(g, 'roll').ticks.slice(0, HUNTER.rollInvulnerable[0]))
1073    h.rollIn = Math.max(0, coming - safeFrom - 3)
1074    h.intent = null
1075    if (h.move === 'run') hunterDo(g, 'idle')
1076    return true
1077  }
1078  if (h.dodgeFor !== id) {
1079    h.dodgeFor = id
1080    if (can(g, 'roll') && rand(g) < HUNTER.dodgeRate) return timeRoll()
1081  }
1082  const isCornered = h.x - HUNTER.minX < HUNTER.runStep
1083  if (isCornered && can(g, 'roll')) return timeRoll()
1084  backOff(g)
1085  return true
1086}
1087
1088/** Run away from the monster until its attack would no longer reach. */
1089function backOff(g: Game): void {
1090  const h = g.hunter
1091  h.intent = null
1092  if (h.move !== 'run' || h.facing !== -1) {
1093    h.facing = -1
1094    hunterDo(g, 'run')
1095  }
1096  h.x = Math.max(HUNTER.minX, h.x - HUNTER.runStep)
1097}
1098
1099function stopBackingOff(g: Game): void {
1100  g.hunter.facing = 1
1101  hunterDo(g, 'idle')
1102  g.hunter.wait = between(g, HUNTER.idleTicks)
1103}
1104
1105const actorView = (move: Move, frame: number, a: { x: number }, facing: 1 | -1): ActorView => ({
1106  action: move.action,
1107  frame,
1108  x: Math.round(a.x),
1109  facing,
1110})
1111
1112/** Effects on the live hit boxes, and a dive's landing mark from the lock until it lands. */
1113function effectsOf(g: Game, move: MonsterMoveDef): View['effects'] {
1114  const out: View['effects'] = move.fx === undefined ? [] : monsterHits(g).map(box => ({ fx: move.fx!, box }))
1115  const to = g.hunt?.meters?.seekX
1116  if (move.seek !== undefined && to !== undefined && g.monster.t >= move.seek && g.monster.t < launchOf(move, 0)[1]) {
1117    const at = { x: to, groundY: groundOf(g), renderFlip: 1 as const }
1118    const land = hitDefs(move.action).flatMap(h => h.boxes.map(b => toWorld(b, at)))
1119    const minX = Math.min(...land.map(b => b.minX))
1120    const maxX = Math.max(...land.map(b => b.maxX))
1121    out.push({ fx: 'mark', box: { minX, maxX, minY: groundOf(g) - 3, maxY: groundOf(g) } })
1122  }
1123  return out
1124}
1125
1126export function viewOf(g: Game): View {
1127  const inCamp = g.mode === 'camp' || g.mode === 'leaving'
1128  const h = g.hunter
1129  const def = g.hunt === null ? undefined : monsterDef(g.hunt.monsterId)
1130  const shown = !inCamp && g.hunt !== null && def !== undefined
1131  const mMove = shown ? monsterMove(g, g.monster.move) : undefined
1132  return {
1133    scene: inCamp || g.hunt === null ? CAMP_SCENE : g.hunt.scene,
1134    hunter: actorView(hunterMove(g, h.move), hunterPoseOf(h.move, hunterMove(g, h.move), h.t), h, h.facing),
1135    monster:
1136      mMove === undefined
1137        ? null
1138        : {
1139            ...actorView(mMove, frameOf(mMove, monsterPoseOf(mMove, g.monster.t)), g.monster, 1),
1140            hitShift: flight(g, mMove, g.monster.t),
1141            ...(mMove.spread === undefined ? {} : { hitSpread: drift(mMove, g.monster.t)! }),
1142            ...(mMove.lift === undefined ? {} : { lift: liftAt(mMove, g.monster.t) }),
1143          },
1144    monsterInfo: def === undefined || inCamp ? null : { name: def.name, species: def.species, status: def.mechanic?.status(g) ?? null },
1145    hp: h.hp,
1146    stamina: h.stamina,
1147    weapon: g.weapon,
1148    isRandomWeapon: g.weaponChoice === 'random',
1149    label: g.labelT < LABEL_TICKS ? g.label : '',
1150    monsterHpPercent: g.hunt === null ? null : Math.round((100 * g.hunt.monsterHp) / g.hunt.monsterHpMax),
1151    effects: mMove === undefined || g.mode !== 'fight' ? [] : effectsOf(g, mMove),
1152    marks: inCamp
1153      ? []
1154      : g.hits.flatMap(hit =>
1155          hit.x !== undefined && hit.y !== undefined && g.simTick - hit.tick < MARK_TICKS ? [{ x: hit.x, y: hit.y, outcome: hit.outcome }] : [],
1156        ),
1157    camp: inCamp
1158      ? { flicker: g.simTick % 4, cook: h.move === 'eat' ? null : h.move === 'roast' ? Math.min(1, h.t / CAMP.roastTicks) : 0 }
1159      : null,
1160  }
1161}
1162
hooks/i18n.ts 271 lines
1import type { HuntLang } from '../types'
2
3/**
4 * Every word the pane draws, in Traditional Chinese and in English. The game
5 * keeps ids (a HUD callout is an id in the save), and the drawing looks them up.
6 */
7const ZH = {
8  // the turn side, in the HUD box
9  phaseCamp: '待機',
10  phaseHunting: '狩獵中',
11  phaseRetreat: '撤退',
12  // weapons and the camp's pick
13  greatsword: '大劍',
14  longsword: '太刀',
15  bow: '弓',
16  random: '隨機',
17  weaponInHand: '武器 {weapon}',
18  randomSuffix: '(隨機)',
19  pickAfterTurn: '・回合結束後可換',
20  // the engine's own words
21  hit: '被擊中',
22  notReady: '魔物資料未就緒',
23  // the terminal's words under the picture, and the tally
24  hudWho: '・{name}({kind})',
25  hudSep: '・',
26  slainCount: '討伐 {n}',
27  listSep: ':',
28  pictureCells: '畫面:字元(點此改圖片)',
29  pictureImage: '畫面:圖片(點此改字元)',
30  // callouts over the fight
31  retreat: '撤退',
32  huntStart: '狩獵開始',
33  huntResume: '繼續狩獵',
34  huntDone: '討伐完成',
35  slain: '討伐完成!',
36  mutualKo: '雙方倒下・討伐完成',
37  carted: '力竭…回營',
38  dodged: '翻滾閃避',
39  // the hunter's attacks, by move id
40  charge: '蓄力斬',
41  trueCharge: '真蓄力斬',
42  tackle: '肩撞',
43  lsChain: '縱斬連段',
44  lsFade: '袈裟斬',
45  lsRound: '氣刃大迴旋斬',
46  bowShot: '通常射擊',
47  bowQuick: '快速射擊',
48  bowPower: '剛射',
49} as const
50
51export type TextId = keyof typeof ZH
52
53const EN: Readonly<Record<TextId, string>> = {
54  phaseCamp: 'Camp',
55  phaseHunting: 'Hunting',
56  phaseRetreat: 'Retreat',
57  greatsword: 'Great Sword',
58  longsword: 'Long Sword',
59  bow: 'Bow',
60  random: 'Random',
61  weaponInHand: 'Weapon: {weapon}',
62  randomSuffix: ' (random)',
63  pickAfterTurn: ' · pick after the turn',
64  hit: 'Hit!',
65  notReady: 'Monster data not ready',
66  hudWho: ' · {name} ({kind})',
67  hudSep: ' · ',
68  slainCount: 'Slain {n}',
69  listSep: ': ',
70  pictureCells: 'Picture: cells (press for image)',
71  pictureImage: 'Picture: image (press for cells)',
72  retreat: 'Retreat',
73  huntStart: 'Hunt start!',
74  huntResume: 'Hunt resumes',
75  huntDone: 'Hunt complete',
76  slain: 'Slain!',
77  mutualKo: 'Both down · Slain',
78  carted: 'Carted… back to camp',
79  dodged: 'Dodged!',
80  charge: 'Charged Slash',
81  trueCharge: 'True Charged Slash',
82  tackle: 'Tackle',
83  lsChain: 'Overhead Combo',
84  lsFade: 'Fade Slash',
85  lsRound: 'Spirit Roundslash',
86  bowShot: 'Shot',
87  bowQuick: 'Quick Shot',
88  bowPower: 'Power Shot',
89}
90
91const TABLES: Readonly<Record<HuntLang, Readonly<Record<TextId, string>>>> = { 'zh-TW': ZH, en: EN }
92
93/**
94 * The species' own words in English, by the Chinese their definitions
95 * (hooks/monsters) are written in: names, kinds, moves, hits, states and
96 * reactions. A test runs every species in English and finds no Chinese left.
97 */
98const MONSTER_EN: Readonly<Record<string, string>> = {
99  // names and kinds
100  冰呪龍: 'Velkhana',
101  大凶豺龍: 'Great Jagras',
102  飛雷龍: 'Tobi-Kadachi',
103  蠻顎龍: 'Anjanath',
104  冰牙龍: 'Barioth',
105  迅龍: 'Nargacuga',
106  轟龍: 'Tigrex',
107  雷狼龍: 'Zinogre',
108  殲世滅盡龍: 'Ruiner Nergigante',
109  古龍: 'Elder Dragon',
110  古龍種: 'Elder Dragon',
111  牙獸種: 'Fanged Beast',
112  牙龍種: 'Fanged Wyvern',
113  獸龍種: 'Brute Wyvern',
114  飛龍種: 'Flying Wyvern',
115  // Velkhana
116  尾刺: 'Tail stab',
117  冰吐息: 'Ice breath',
118  被尾刺擊中: 'Hit by tail stab',
119  被冰吐息擊中: 'Hit by ice breath',
120  // Great Jagras
121  前咬: 'Bite',
122  腹部重壓: 'Belly slam',
123  側身翻壓: 'Body roll',
124  吐出胃液團: 'Bile spit',
125  被前咬擊中: 'Hit by bite',
126  被腹部重壓擊中: 'Hit by belly slam',
127  被翻壓擊中: 'Hit by body roll',
128  被胃液團擊中: 'Hit by bile',
129  膨腹: 'Inflated',
130  大凶豺龍膨腹: 'Great Jagras inflates',
131  大凶豺龍消腹: 'Great Jagras deflates',
132  '腹部破壞!': 'Belly broken!',
133  // Tobi-Kadachi
134  躍身撲擊: 'Leaping pounce',
135  迴身尾掃: 'Spinning tail sweep',
136  帶電尾砸: 'Charged tail slam',
137  被撲擊擊中: 'Hit by pounce',
138  被尾掃擊中: 'Hit by tail sweep',
139  被帶電尾砸擊中: 'Hit by charged slam',
140  帶電: 'Charged',
141  蓄電中: 'Charging',
142  蓄電中斷: 'Charge broken',
143  飛雷龍蓄電: 'Tobi-Kadachi charging',
144  飛雷龍帶電: 'Tobi-Kadachi charged',
145  飛雷龍放電: 'Tobi-Kadachi discharges',
146  // Anjanath
147  俯身咬擊: 'Lunging bite',
148  低頭衝撞: 'Head-down charge',
149  回身尾掃: 'Turning tail sweep',
150  火焰吐息: 'Fire breath',
151  被咬擊擊中: 'Hit by bite',
152  被衝撞擊中: 'Hit by charge',
153  被火焰吐息擊中: 'Hit by fire breath',
154  準備中: 'Readying',
155  火焰準備: 'Fire ready',
156  火焰準備中斷: 'Fire broken',
157  火焰準備結束: 'Fire fades',
158  蠻顎龍抬起鼻冠: 'Anjanath raises its nose',
159  蠻顎龍火焰準備: 'Anjanath readies fire',
160  // Barioth
161  飛撲: 'Pounce',
162  側身撞擊: 'Side slam',
163  長尾掃擊: 'Long tail sweep',
164  冰息旋風: 'Ice whirlwind',
165  被飛撲擊中: 'Hit by pounce',
166  被側身撞擊擊中: 'Hit by side slam',
167  被冰旋風擊中: 'Hit by whirlwind',
168  '翼部破壞!': 'Wing broken!',
169  // Nargacuga
170  刃翼飛撲: 'Bladewing pounce',
171  橫向尾掃: 'Side tail sweep',
172  重尾砸地: 'Tail slam',
173  二連尾砸: 'Double tail slam',
174  尾棘散射: 'Tail spike volley',
175  被刃翼飛撲擊中: 'Hit by bladewing',
176  被尾砸擊中: 'Hit by tail slam',
177  被尾棘擊中: 'Hit by tail spikes',
178  憤怒: 'Enraged',
179  怒氣解除: 'Rage ends',
180  // Tigrex
181  連身咬擊: 'Lunging bites',
182  折返衝刺: 'Turnback charge',
183  三連衝刺: 'Triple charge',
184  原地迴旋: 'Spin',
185  近身咆哮: 'Close roar',
186  被衝刺撞中: 'Hit by charge',
187  被迴旋擊中: 'Hit by spin',
188  被咆哮衝擊震飛: 'Blown back by the roar',
189  疲勞: 'Exhausted',
190  轟龍疲勞: 'Tigrex is exhausted',
191  // Zinogre
192  前爪三連拍: 'Triple claw slam',
193  背身重壓: 'Back slam',
194  迴旋尾擊: 'Spinning tail',
195  雷電爆發: 'Thunder burst',
196  被前爪拍中: 'Hit by claws',
197  被重壓擊中: 'Hit by slam',
198  被尾擊擊中: 'Hit by tail',
199  被雷電爆發擊中: 'Hit by thunder burst',
200  超帶電: 'Supercharged',
201  超帶電解除: 'Supercharge ends',
202  雷狼龍超帶電: 'Zinogre supercharged',
203  雷狼龍蓄電中: 'Zinogre charging',
204  // Ruiner Nergigante
205  前爪橫掃: 'Claw sweep',
206  肩部衝撞: 'Shoulder charge',
207  金屬棘散射: 'Spike burst',
208  空中俯衝: 'Dive bomb',
209  被前爪掃中: 'Hit by claw sweep',
210  被肩撞擊中: 'Hit by shoulder charge',
211  被金屬棘擊中: 'Hit by spikes',
212  被俯衝重壓: 'Crushed by the dive',
213  棘硬化: 'Spikes harden',
214  棘再生: 'Spikes regrow',
215  '白棘破壞!': 'White spikes broken!',
216  '黑棘破壞!': 'Black spikes broken!',
217}
218
219/** Words built at run time: a species' name with a reaction, or a counter. */
220const MONSTER_EN_PATTERNS: readonly [RegExp, (m: RegExpMatchArray) => string][] = [
221  [/^(.+)倒地$/, m => `${MONSTER_EN[m[1]!] ?? m[1]} toppled`],
222  [/^(.+)硬直$/, m => `${MONSTER_EN[m[1]!] ?? m[1]} flinched`],
223  [/^(.+)憤怒$/, m => `${MONSTER_EN[m[1]!] ?? m[1]} enraged`],
224  [/^破翼 (\d+)\/(\d+)$/, m => `Wings ${m[1]}/${m[2]}`],
225  [/^白棘 (\d+)$/, m => `White ${m[1]}`],
226  [/^黑棘 (\d+)$/, m => `Black ${m[1]}`],
227  [/^雷狼龍蓄電 (.+)$/, m => `Zinogre charge ${m[1]}`],
228  [/^蓄電 (.+)$/, m => `Charge ${m[1]}`],
229]
230
231/** A species' text in English; states joined with ・ are put one by one. */
232function monsterEnglish(text: string): string | undefined {
233  const own = MONSTER_EN[text]
234  if (own !== undefined) return own
235  for (const [pattern, put] of MONSTER_EN_PATTERNS) {
236    const m = text.match(pattern)
237    if (m !== null) return put(m)
238  }
239  if (text.includes('・')) {
240    const parts = text.split('・').map(part => monsterEnglish(part))
241    if (parts.every(part => part !== undefined)) return parts.join(' ')
242  }
243  return undefined
244}
245
246/**
247 * The text for an id. A string that is no id is a species' own text (or a
248 * callout saved before ids): in Chinese it is drawn as it is, in English
249 * through the species' words.
250 */
251export function t(lang: HuntLang, id: string, vars: Readonly<Record<string, string>> = {}): string {
252  const text = (TABLES[lang] as Readonly<Record<string, string>>)[id] ?? (lang === 'en' ? monsterEnglish(id) : undefined) ?? id
253  return text.replace(/\{(\w+)\}/g, (_, key: string) => vars[key] ?? '')
254}
255
256/** Traditional Chinese, however it is written: 繁體中文, Traditional Chinese, zh-TW, zh_Hant, zh-HK. */
257const TRADITIONAL = /繁|正體|臺灣|台灣|traditional|\bzh[-_](tw|hk|mo|hant)\b/i
258
259/**
260 * The pane's language from the first source that says anything, in order:
261 * Claude's `language` setting, then the locale variables, then the system
262 * locale. Traditional Chinese draws Chinese; anything else, Simplified
263 * Chinese included, draws English.
264 */
265export function pickLang(sources: readonly unknown[]): HuntLang {
266  for (const source of sources) {
267    if (typeof source === 'string' && source.trim() !== '') return TRADITIONAL.test(source) ? 'zh-TW' : 'en'
268  }
269  return 'en'
270}
271
hooks/monsters/registry.ts 32 lines
1import type { MonsterId } from '../../types'
2import type { MonsterDef } from './types'
3import { RUINER_NERGIGANTE } from './ruiner_nergigante'
4import { ZINOGRE } from './zinogre'
5import { TIGREX } from './tigrex'
6import { NARGACUGA } from './nargacuga'
7import { BARIOTH } from './barioth'
8import { ANJANATH } from './anjanath'
9import { GREAT_JAGRAS } from './great_jagras'
10import { TOBI_KADACHI } from './tobi_kadachi'
11import { VELKHANA } from './velkhana'
12
13/** Every species by id. Only `enabled` ones are drawn for new hunts. */
14export const MONSTERS: Readonly<Partial<Record<MonsterId, MonsterDef>>> = {
15  velkhana: VELKHANA,
16  great_jagras: GREAT_JAGRAS,
17  tobi_kadachi: TOBI_KADACHI,
18  anjanath: ANJANATH,
19  barioth: BARIOTH,
20  nargacuga: NARGACUGA,
21  tigrex: TIGREX,
22  zinogre: ZINOGRE,
23  ruiner_nergigante: RUINER_NERGIGANTE,
24}
25
26export const monsterDef = (id: MonsterId): MonsterDef | undefined => MONSTERS[id]
27
28export const enabledMonsters = (): MonsterId[] =>
29  (Object.keys(MONSTERS) as MonsterId[]).filter(id => MONSTERS[id]?.enabled === true)
30
31export { ANJANATH, BARIOTH, GREAT_JAGRAS, NARGACUGA, RUINER_NERGIGANTE, TIGREX, TOBI_KADACHI, VELKHANA, ZINOGRE }
32
hooks/raster.ts 447 lines
1import { HUNTER } from './data'
2import type { View } from './engine'
3import type { Rect } from './shapes'
4import { barRects, campRects, effectRects, placeActor } from './shapes'
5import { ACTIONS, SCENES } from './sprites'
6import type { RleRow, SceneId } from './sprites'
7
8/**
9 * The hunt as pixels for the terminal (its `Image` shows an RGBA picture), with
10 * no Svg in between: the scene's grid at 3x, the actors' RLE frames placed as
11 * svg.ts places them (shared `placeActor`), the same camp props, effects and
12 * bars from shapes.ts. Text (names, clock, labels) is the pane's own Text rows,
13 * and the debug boxes are Desktop only.
14 */
15export const RASTER = { width: 384, height: 216 } as const
16const W = RASTER.width
17const H = RASTER.height
18const SCENE_SCALE = 3
19
20const colorCache = new Map<string, readonly [number, number, number]>()
21function rgb(hex: string): readonly [number, number, number] {
22  let c = colorCache.get(hex)
23  if (c === undefined) {
24    c = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]
25    colorCache.set(hex, c)
26  }
27  return c
28}
29
30/** A row of RLE as palette letters (or '.') one per pixel, visited without building strings. */
31function eachRun(row: RleRow, fn: (ch: string, x: number, len: number) => void): void {
32  let x = 0
33  let i = 0
34  while (i < row.length) {
35    const ch = row.charAt(i)
36    i += 1
37    let n = 0
38    let has = false
39    while (i < row.length && row.charCodeAt(i) >= 48 && row.charCodeAt(i) <= 57) {
40      n = n * 10 + (row.charCodeAt(i) - 48)
41      has = true
42      i += 1
43    }
44    const len = has ? n : 1
45    fn(ch, x, len)
46    x += len
47  }
48}
49
50/** An RGBA buffer the size of the picture (or smaller, for a decoded frame). */
51type Pixels = { w: number; h: number; data: Uint8Array }
52
53function decode(rows: readonly RleRow[], palette: Readonly<Record<string, string>>): Pixels {
54  let w = 0
55  for (const row of rows) eachRun(row, (_, x, len) => (w = Math.max(w, x + len)))
56  const data = new Uint8Array(w * rows.length * 4)
57  rows.forEach((row, y) =>
58    eachRun(row, (ch, x, len) => {
59      const hex = palette[ch]
60      if (ch === '.' || hex === undefined) return
61      const [r, g, b] = rgb(hex)
62      for (let k = 0; k < len; k += 1) {
63        const o = (y * w + x + k) * 4
64        data[o] = r
65        data[o + 1] = g
66        data[o + 2] = b
67        data[o + 3] = 255
68      }
69    }),
70  )
71  return { w, h: rows.length, data }
72}
73
74const sceneCache = new Map<SceneId, Uint8Array>()
75/** The scene at 3x, opaque. */
76function sceneBase(id: SceneId): Uint8Array {
77  let base = sceneCache.get(id)
78  if (base === undefined) {
79    const s = SCENES[id]
80    const small = decode(s.grid, s.palette)
81    base = new Uint8Array(W * H * 4)
82    for (let y = 0; y < H; y += 1) {
83      const sy = Math.min(small.h - 1, Math.floor(y / SCENE_SCALE))
84      for (let x = 0; x < W; x += 1) {
85        const sx = Math.min(small.w - 1, Math.floor(x / SCENE_SCALE))
86        const from = (sy * small.w + sx) * 4
87        const to = (y * W + x) * 4
88        base[to] = small.data[from]!
89        base[to + 1] = small.data[from + 1]!
90        base[to + 2] = small.data[from + 2]!
91        base[to + 3] = 255
92      }
93    }
94    sceneCache.set(id, base)
95  }
96  return base
97}
98
99const frameCache = new Map<string, Pixels>()
100function frame(action: keyof typeof ACTIONS, index: number): Pixels {
101  const key = `${action}:${index}`
102  let px = frameCache.get(key)
103  if (px === undefined) {
104    const sprite = ACTIONS[action]
105    px = decode(sprite.frames[index] ?? sprite.frames[0] ?? [], sprite.palette)
106    frameCache.set(key, px)
107  }
108  return px
109}
110
111/**
112 * Source-over one pixel; the destination is opaque. On a layer (transparent
113 * until painted) a pixel is painted whole and marked opaque, or left alone
114 * when mostly see-through (alpha under a half).
115 */
116function put(dst: Uint8Array, x: number, y: number, r: number, g: number, b: number, a: number, layer = false): void {
117  if (x < 0 || y < 0 || x >= W || y >= H) return
118  const o = (y * W + x) * 4
119  if (layer) {
120    if (a < 0.5) return
121    dst[o] = r
122    dst[o + 1] = g
123    dst[o + 2] = b
124    dst[o + 3] = 255
125  } else if (a >= 1) {
126    dst[o] = r
127    dst[o + 1] = g
128    dst[o + 2] = b
129  } else {
130    dst[o] = Math.round(dst[o]! + (r - dst[o]!) * a)
131    dst[o + 1] = Math.round(dst[o + 1]! + (g - dst[o + 1]!) * a)
132    dst[o + 2] = Math.round(dst[o + 2]! + (b - dst[o + 2]!) * a)
133  }
134}
135
136function fillRects(dst: Uint8Array, list: readonly Rect[], layer = false): void {
137  for (const q of list) {
138    const [r, g, b] = rgb(q.fill)
139    const a = q.alpha ?? 1
140    const x1 = Math.round(q.x + q.w)
141    const y1 = Math.round(q.y + q.h)
142    for (let y = Math.max(0, Math.round(q.y)); y < Math.min(H, y1); y += 1) {
143      for (let x = Math.max(0, Math.round(q.x)); x < Math.min(W, x1); x += 1) put(dst, x, y, r, g, b, a, layer)
144    }
145  }
146}
147
148function drawActor(dst: Uint8Array, a: Parameters<typeof placeActor>[0], ground: number, layer = false): void {
149  const at = placeActor(a, ground)
150  const px = frame(a.action, a.frame)
151  for (let y = 0; y < px.h; y += 1) {
152    const dy = at.top + y
153    if (dy < 0 || dy >= H) continue
154    for (let x = 0; x < px.w; x += 1) {
155      const from = (y * px.w + x) * 4
156      if (px.data[from + 3] === 0) continue
157      put(dst, at.flip ? at.tx - x - 1 : at.tx + x, dy, px.data[from]!, px.data[from + 1]!, px.data[from + 2]!, 1, layer)
158    }
159  }
160}
161
162/** The picture at one tick: W x H x 4 bytes of RGBA; `isFrozen` lays the pause veil over it. */
163export function huntRgba(view: View, isFrozen: boolean): Uint8Array {
164  const ground = SCENES[view.scene].ground * SCENE_SCALE
165  const out = sceneBase(view.scene).slice()
166  if (view.camp !== null) fillRects(out, campRects(view.camp))
167  if (view.monster !== null) drawActor(out, view.monster, ground + (view.monster.lift ?? 0))
168  drawActor(out, view.hunter, ground)
169  fillRects(out, effectRects(view.effects))
170  const low = view.hp <= HUNTER.hpMax / 4
171  fillRects(out, [
172    ...barRects(34, 9, HUNTER.hpMax, view.hp, low ? '#e8402a' : '#2ee05a', '#2a3550'),
173    ...barRects(34, 25, HUNTER.staminaMax, view.stamina, '#ffd21f', '#2a3550'),
174  ])
175  if (isFrozen) fillRects(out, [{ x: 0, y: 0, w: W, h: H, fill: '#0b1a3a', alpha: 0.55 }])
176  return out
177}
178
179/** Everything but the scene on a transparent layer: camp props, the actors, the effects. */
180function actorLayer(view: View): Uint8Array {
181  const ground = SCENES[view.scene].ground * SCENE_SCALE
182  const out = new Uint8Array(W * H * 4)
183  if (view.camp !== null) fillRects(out, campRects(view.camp), true)
184  if (view.monster !== null) drawActor(out, view.monster, ground + (view.monster.lift ?? 0), true)
185  drawActor(out, view.hunter, ground, true)
186  fillRects(out, effectRects(view.effects), true)
187  return out
188}
189
190/**
191 * The part of the picture the terminal cells show: the fight (the hunter and
192 * the monster's head-to-hips, or the hunter and the fire at camp), at least
193 * MIN_WINDOW wide, in the cells' shape, its foot a little under the ground.
194 */
195// the whole scene (the person chose it over closer framings); a smaller minimum zooms in on the fight
196const MIN_WINDOW: number = W
197export function cameraWindow(view: View, columns: number, rows: number, minWidth = MIN_WINDOW): { x: number; y: number; w: number; h: number } {
198  const ground = SCENES[view.scene].ground * SCENE_SCALE
199  const hx = view.hunter.x
200  const [from, to] =
201    view.monster !== null ? [Math.min(hx - 30, view.monster.x - 110), Math.max(hx + 40, view.monster.x + 115)] : [Math.min(hx - 40, 70), Math.max(hx + 40, 140)]
202  const shape = (rows * 2) / columns
203  let w = Math.min(W, Math.max(minWidth, to - from))
204  let h = w * shape
205  if (h > H) {
206    h = H
207    w = h / shape
208  }
209  const x = Math.max(0, Math.min(W - w, (from + to) / 2 - w / 2))
210  const bottom = Math.min(H, ground + 14)
211  const y = Math.max(0, bottom - h)
212  return { x, y, w, h: Math.min(h, H - y) }
213}
214
215/**
216 * The picture as terminal cells (the `Raster` element) for terminals that draw
217 * no images, made to stay crisp at a few dozen columns: the camera window
218 * sampled to `columns * 2` x `rows * 2` pixels (2 x 2 a cell), each pixel the
219 * most common colour under it (never a blend), an actor or effect winning
220 * wherever it covers a quarter of the pixel, a dark rim round the actors, the
221 * scene behind dimmed so the fight stands out. Each cell then becomes the
222 * quadrant glyph (▘▝▀▖▌▞▛▗▚▐▜▄▙▟█) splitting its four pixels between the two
223 * of their colours that fit them best. Returns the element's `cells`: base64
224 * of little-endian u32 triplets [code point, foreground, background], 0x00RRGGBB.
225 */
226/**
227 * How the cells draw: `minWidth` of the camera window (the whole scene by
228 * default), `backdrop` the scene itself or two flat bands (sky and ground, the
229 * scene's own most common colours), `sceneDim` how far it is pulled to black,
230 * `boost` extra saturation and light on the actors (0 = as drawn).
231 */
232export type CellStyle = { minWidth?: number; backdrop?: 'scene' | 'flat'; sceneDim?: number; boost?: number }
233
234const flatCache = new Map<SceneId, { sky: number; ground: number; line: number }>()
235/** A scene as two flat bands: the most common colour above its ground line and below it. */
236function flatBands(id: SceneId): { sky: number; ground: number; line: number } {
237  let bands = flatCache.get(id)
238  if (bands === undefined) {
239    const base = sceneBase(id)
240    const groundY = SCENES[id].ground * SCENE_SCALE
241    const most = (y0: number, y1: number): number => {
242      const n = new Map<number, number>()
243      for (let y = y0; y < y1; y += 3) {
244        for (let x = 0; x < W; x += 3) {
245          const o = (y * W + x) * 4
246          const c = (base[o]! << 16) | (base[o + 1]! << 8) | base[o + 2]!
247          n.set(c, (n.get(c) ?? 0) + 1)
248        }
249      }
250      return [...n.entries()].sort((a, b) => b[1] - a[1])[0]![0]
251    }
252    const ground = most(groundY, H)
253    bands = { sky: most(0, groundY), ground, line: lighten(ground, 0.35) }
254    flatCache.set(id, bands)
255  }
256  return bands
257}
258
259const lighten = (c: number, k: number): number =>
260  (Math.round(((c >> 16) & 255) + (255 - ((c >> 16) & 255)) * k) << 16) |
261  (Math.round(((c >> 8) & 255) + (255 - ((c >> 8) & 255)) * k) << 8) |
262  Math.round((c & 255) + (255 - (c & 255)) * k)
263
264/** More saturated and a little lighter by `k` (0 = unchanged). */
265function boosted(c: number, k: number): number {
266  if (k === 0) return c
267  const ch = [(c >> 16) & 255, (c >> 8) & 255, c & 255]
268  const mean = (ch[0]! + ch[1]! + ch[2]!) / 3
269  const [r, g, b] = ch.map(v => Math.max(0, Math.min(255, Math.round((mean + (v - mean) * (1 + k)) * (1 + k / 3)))))
270  return (r! << 16) | (g! << 8) | b!
271}
272
273export function huntCells(view: View, isFrozen: boolean, columns: number, rows: number, style: CellStyle = {}): string {
274  const scene = sceneBase(view.scene)
275  const layer = actorLayer(view)
276  const cam = cameraWindow(view, columns, rows, style.minWidth ?? MIN_WINDOW)
277  const sceneDim = style.sceneDim ?? SCENE_DIM
278  const boost = style.boost ?? 0
279  const flat = style.backdrop === 'flat' ? flatBands(view.scene) : null
280  const groundY = SCENES[view.scene].ground * SCENE_SCALE
281  const gw = columns * 2
282  const gh = rows * 2
283  const color = new Uint32Array(gw * gh)
284  const actor = new Uint8Array(gw * gh)
285  const counts = new Map<number, number>()
286  const mode = (): number => {
287    let best = 0
288    let most = -1
289    for (const [c, n] of counts) {
290      if (n > most) {
291        most = n
292        best = c
293      }
294    }
295    return best
296  }
297  for (let py = 0; py < gh; py += 1) {
298    const y0 = Math.floor(cam.y + (py * cam.h) / gh)
299    const y1 = Math.min(H, Math.max(y0 + 1, Math.floor(cam.y + ((py + 1) * cam.h) / gh)))
300    for (let px = 0; px < gw; px += 1) {
301      const x0 = Math.floor(cam.x + (px * cam.w) / gw)
302      const x1 = Math.min(W, Math.max(x0 + 1, Math.floor(cam.x + ((px + 1) * cam.w) / gw)))
303      // the most common actor colour when actors cover a quarter, else the most common scene colour, dimmed
304      let covered = 0
305      counts.clear()
306      for (let y = y0; y < y1; y += 1) {
307        for (let x = x0; x < x1; x += 1) {
308          const o = (y * W + x) * 4
309          if (layer[o + 3] === 0) continue
310          covered += 1
311          const c = (layer[o]! << 16) | (layer[o + 1]! << 8) | layer[o + 2]!
312          counts.set(c, (counts.get(c) ?? 0) + 1)
313        }
314      }
315      const k = py * gw + px
316      if (covered * 4 >= (x1 - x0) * (y1 - y0)) {
317        actor[k] = 1
318        color[k] = boosted(mode(), boost)
319        continue
320      }
321      if (flat !== null) {
322        const mid = (y0 + y1) / 2
323        color[k] = darken(y1 <= groundY ? flat.sky : y0 <= groundY && mid >= groundY - 1 ? flat.line : flat.ground, sceneDim)
324        continue
325      }
326      counts.clear()
327      for (let y = y0; y < y1; y += 1) {
328        for (let x = x0; x < x1; x += 1) {
329          const o = (y * W + x) * 4
330          const c = (scene[o]! << 16) | (scene[o + 1]! << 8) | scene[o + 2]!
331          counts.set(c, (counts.get(c) ?? 0) + 1)
332        }
333      }
334      color[k] = darken(mode(), sceneDim)
335    }
336  }
337  // a dark rim on the scene round every actor pixel (left, right, above, below)
338  for (let py = 0; py < gh; py += 1) {
339    for (let px = 0; px < gw; px += 1) {
340      const k = py * gw + px
341      if (actor[k] === 1) continue
342      const near =
343        (px > 0 && actor[k - 1] === 1) || (px < gw - 1 && actor[k + 1] === 1) || (py > 0 && actor[k - gw] === 1) || (py < gh - 1 && actor[k + gw] === 1)
344      if (near) color[k] = RIM
345    }
346  }
347  if (isFrozen) for (let k = 0; k < color.length; k += 1) color[k] = darken(color[k]!, 0.55)
348  const words = new Uint32Array(columns * rows * 3)
349  for (let row = 0; row < rows; row += 1) {
350    for (let col = 0; col < columns; col += 1) {
351      const k = row * 2 * gw + col * 2
352      const [glyph, fg, bg] = quadrant(color[k]!, color[k + 1]!, color[k + gw]!, color[k + gw + 1]!)
353      const o = (row * columns + col) * 3
354      words[o] = glyph
355      words[o + 1] = fg
356      words[o + 2] = bg
357    }
358  }
359  return toBase64(new Uint8Array(words.buffer))
360}
361
362/** The rim drawn round the actors in the cells. */
363const RIM = 0x0a0c14
364/** Quadrant glyphs by which of the four pixels (bit 0 upper left, 1 upper right, 2 lower left, 3 lower right) take the foreground. */
365const QUADRANTS = [0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588]
366
367const distance = (a: number, b: number): number => {
368  const dr = ((a >> 16) & 255) - ((b >> 16) & 255)
369  const dg = ((a >> 8) & 255) - ((b >> 8) & 255)
370  const db = (a & 255) - (b & 255)
371  return dr * dr + dg * dg + db * db
372}
373
374/**
375 * One cell from its four pixels (upper left, upper right, lower left, lower
376 * right): the two of their colours that fit them best, each pixel taking the
377 * nearer; returns [glyph, foreground, background]. Exact when they have at most
378 * two colours.
379 */
380export function quadrant(ul: number, ur: number, ll: number, lr: number): [number, number, number] {
381  const px = [ul, ur, ll, lr]
382  const colors = [...new Set(px)]
383  if (colors.length === 1) return [0x2588, ul, ul]
384  let best: [number, number, number] = [0x2580, ul, ll]
385  let bestErr = Infinity
386  for (let i = 0; i < colors.length; i += 1) {
387    for (let j = i + 1; j < colors.length; j += 1) {
388      const fg = colors[i]!
389      const bg = colors[j]!
390      let mask = 0
391      let err = 0
392      px.forEach((c, b) => {
393        const df = distance(c, fg)
394        const dbg = distance(c, bg)
395        if (df <= dbg) mask |= 1 << b
396        err += Math.min(df, dbg)
397      })
398      if (err < bestErr) {
399        bestErr = err
400        best = [QUADRANTS[mask]!, fg, bg]
401      }
402    }
403  }
404  return best
405}
406
407/** How much the scene behind the fight is pulled toward black in the cells. */
408const SCENE_DIM = 0.4
409const darken = (c: number, k: number): number =>
410  (Math.round(((c >> 16) & 255) * (1 - k)) << 16) | (Math.round(((c >> 8) & 255) * (1 - k)) << 8) | Math.round((c & 255) * (1 - k))
411
412const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
413/** Base64 of bytes (the runtime's own `toBase64` when it has one). */
414export function toBase64(bytes: Uint8Array): string {
415  const native = (bytes as unknown as { toBase64?: () => string }).toBase64
416  if (typeof native === 'function') return native.call(bytes)
417  let out = ''
418  const n = bytes.length
419  for (let i = 0; i < n; i += 3) {
420    const a = bytes[i]!
421    const b = i + 1 < n ? bytes[i + 1]! : 0
422    const c = i + 2 < n ? bytes[i + 2]! : 0
423    out += B64[a >> 2]! + B64[((a & 3) << 4) | (b >> 4)]! + (i + 1 < n ? B64[((b & 15) << 2) | (c >> 6)]! : '=') + (i + 2 < n ? B64[c & 63]! : '=')
424  }
425  return out
426}
427
428/** Rows the pane keeps under the picture for its words (status, HP, label, picture button, tally, debug). */
429export const TEXT_ROWS = 7
430
431/**
432 * The terminal's box of cells for the picture: as many columns as the pane has
433 * (to 255), rows from the 384:216 shape with cells about twice as tall as wide;
434 * when that is taller than the pane leaves room for (its rows less TEXT_ROWS,
435 * at least 4), the rows stop there and the columns shrink to keep the shape.
436 */
437export function imageCells(bodyColumns: number, bodyRows = Infinity): { columns: number; rows: number } {
438  let columns = Math.max(1, Math.min(255, Math.floor(bodyColumns)))
439  let rows = Math.max(1, Math.min(255, Math.round((columns * H) / W / 2)))
440  const room = Math.max(4, Math.floor(bodyRows) - TEXT_ROWS)
441  if (rows > room) {
442    rows = room
443    columns = Math.max(1, Math.min(columns, Math.round((rows * 2 * W) / H)))
444  }
445  return { columns, rows }
446}
447
hooks/svg.ts 232 lines
1import type { HuntGame, HuntLang, HuntPhase, MonsterId } from '../types'
2import { HUNTER } from './data'
3import { attackReach, hitboxes, hurtboxes } from './collision'
4import type { Aabb, Placement } from './collision'
5import type { ActorView, View } from './engine'
6import type { TextId } from './i18n'
7import { t } from './i18n'
8import type { RleRow, SceneId } from './sprites'
9import { ACTIONS, SCENES } from './sprites'
10import { HEADS } from './gen/heads'
11import type { Rect } from './shapes'
12import { barRects, campRects, effectRects, placeActor } from './shapes'
13import { MONSTERS } from './monsters/registry'
14
15type Palette = Readonly<Record<string, string>>
16
17/** Scenes are 128x72 grids shown at 3x; actors are 1:1 board pixels. */
18const SCENE_SCALE = 3
19const W = 128 * SCENE_SCALE
20const H = 72 * SCENE_SCALE
21
22const PHASE_TEXT: Record<HuntPhase, TextId> = {
23  camp: 'phaseCamp',
24  hunting: 'phaseHunting',
25  retreat: 'phaseRetreat',
26}
27
28/**
29 * One stroked path per color: each pixel run is a 1px-tall horizontal line,
30 * later runs on the same row moved to relatively to keep the source short.
31 * Rows sit on whole y: the group that holds the paths moves them down half a
32 * pixel (`HALF`) onto the pixel centres, which spares ".5" on every row start.
33 */
34const HALF = ' translate(0 .5)'
35function gridToPaths(rows: readonly RleRow[], pal: Palette): string {
36  const runs = new Map<string, { d: string; row: number; end: number }>()
37  rows.forEach((row, y) => {
38    let x = 0
39    let i = 0
40    while (i < row.length) {
41      const ch = row.charAt(i)
42      i += 1
43      let digits = ''
44      while (i < row.length && row.charCodeAt(i) >= 48 && row.charCodeAt(i) <= 57) {
45        digits += row.charAt(i)
46        i += 1
47      }
48      const len = digits === '' ? 1 : Number(digits)
49      const color = pal[ch]
50      if (ch !== '.' && color !== undefined) {
51        const run = runs.get(color) ?? { d: '', row: -1, end: 0 }
52        run.d += run.row === y ? `m${x - run.end} 0h${len}` : `M${x} ${y}h${len}`
53        run.row = y
54        run.end = x + len
55        runs.set(color, run)
56      }
57      x += len
58    }
59  })
60  let out = ''
61  for (const [color, run] of runs) out += `<path stroke="${color}" d="${run.d}"/>`
62  return out
63}
64
65const sceneCache = new Map<string, string>()
66function scene(id: SceneId): string {
67  let out = sceneCache.get(id)
68  if (out === undefined) {
69    const s = SCENES[id]
70    out = `<g transform="scale(${SCENE_SCALE})${HALF}">${gridToPaths(s.grid, s.palette)}</g>`
71    sceneCache.set(id, out)
72  }
73  return out
74}
75
76/** An actor's frame in local coordinates, placed (and mirrored) per tick. */
77const frameCache = new Map<string, string>()
78function actor(a: ActorView, ground: number): string {
79  const sprite = ACTIONS[a.action]
80  const rows = sprite.frames[a.frame] ?? sprite.frames[0] ?? []
81  const key = `${a.action}:${a.frame}`
82  let paths = frameCache.get(key)
83  if (paths === undefined) {
84    paths = gridToPaths(rows, sprite.palette)
85    frameCache.set(key, paths)
86  }
87  const at = placeActor(a, ground)
88  return `<g transform="translate(${at.tx} ${at.top})${at.flip ? ' scale(-1 1)' : ''}${HALF}">${paths}</g>`
89}
90
91/** Flat rectangles as Svg. */
92const rects = (list: readonly Rect[]): string =>
93  list
94    .map(q => `<rect x="${q.x}" y="${q.y}" width="${q.w}" height="${q.h}" fill="${q.fill}"${q.alpha === undefined ? '' : ` fill-opacity="${q.alpha}"`}/>`)
95    .join('')
96
97/**
98 * The weapon in hand, as plain text: drawn boxes here read as buttons, but the
99 * picture takes no clicks (the camp's buttons under it pick).
100 */
101const weapon = (view: View, lang: HuntLang): string =>
102  `<text x="8" y="${H - 11}" class="w">${t(lang, 'weaponInHand', { weapon: t(lang, view.weapon) })}${view.isRandomWeapon ? t(lang, 'randomSuffix') : ''}</text>`
103
104function hud(phase: HuntPhase, view: View, clock: string, lang: HuntLang): string {
105  const low = view.hp <= HUNTER.hpMax / 4
106  return (
107    `<text x="8" y="17" class="t">HP</text>${rects(barRects(34, 9, HUNTER.hpMax, view.hp, low ? '#e8402a' : '#2ee05a', '#2a3550'))}` +
108    `<text x="8" y="33" class="t">ST</text>${rects(barRects(34, 25, HUNTER.staminaMax, view.stamina, '#ffd21f', '#2a3550'))}` +
109    `<rect x="284" y="6" width="94" height="58" fill="#10183a" stroke="#f0a020" stroke-width="2"/>` +
110    `<text x="331" y="21" class="n">${view.monsterInfo === null ? '—' : t(lang, view.monsterInfo.name)}</text>` +
111    `<text x="331" y="31" class="p">${t(lang, view.monsterInfo?.status ?? view.monsterInfo?.species ?? '')}</text>` +
112    `<text x="331" y="45" class="c">${clock}</text>` +
113    `<text x="331" y="59" class="s">${t(lang, PHASE_TEXT[phase])}</text>` +
114    (view.label === '' ? '' : `<text x="192" y="${H - 34}" class="a">${t(lang, view.label)}</text>`) +
115    weapon(view, lang)
116  )
117}
118
119const rect = (b: Aabb, cls: string): string =>
120  `<rect class="${cls}" x="${b.minX}" y="${b.minY}" width="${b.maxX - b.minX}" height="${b.maxY - b.minY}"/>`
121
122/** Debug overlay: hurt (green), live hit (red), wind-up reach (grey dashed), foot anchor (cyan). */
123function boxes(a: ActorView, ground: number, assetFacing: 1 | -1): string {
124  const at: Placement = { x: a.x, groundY: ground, renderFlip: a.facing }
125  let out = hurtboxes(a.action, a.frame, at).map(p => rect(p.box, 'bh')).join('')
126  const live = hitboxes(a.action, a.frame, at, a.hitShift ?? 0, a.hitSpread)
127  out += (live.length > 0 ? live.map(b => rect(b, 'bx')) : attackReach(a.action, at).map(b => rect(b, 'bw'))).join('')
128  // the way the actor really faces: the hunter is drawn facing right, the monster left
129  const dir = assetFacing * a.facing
130  const tip = a.x + dir * 12
131  out += `<path class="bd" d="M${a.x} ${ground + 5}H${tip}M${tip - dir * 3} ${ground + 2}L${tip} ${ground + 5}L${tip - dir * 3} ${ground + 8}"/>`
132  return out + `<path class="ba" d="M${a.x - 3} ${ground}h6M${a.x} ${ground - 3}v6"/>`
133}
134
135const effects = (list: View['effects']): string => rects(effectRects(list))
136
137/** Debug: a white cross where a hit met (grey when rolled through). */
138const marks = (list: View['marks']): string =>
139  list
140    .map(m => `<path class="${m.outcome === 'evaded' ? 'bm bv' : 'bm'}" d="M${m.x - 4} ${m.y - 4}l8 8M${m.x + 4} ${m.y - 4}l-8 8"/>`)
141    .join('')
142
143const CSS =
144  `.t{font:bold 13px monospace;fill:#f4f8ff}` +
145  `.n{font:bold 14px sans-serif;fill:#f4f8ff;text-anchor:middle}` +
146  `.p{font:10px sans-serif;fill:#a9c8ef;text-anchor:middle}` +
147  `.c{font:bold 14px monospace;fill:#ffd21f;text-anchor:middle}` +
148  `.s{font:bold 12px sans-serif;fill:#ffc23a;text-anchor:middle}` +
149  `.w{font:bold 11px sans-serif;fill:#f4f8ff;paint-order:stroke;stroke:#10183a;stroke-width:3px}` +
150  `.bh{fill:#2ee05a;fill-opacity:.2;stroke:#2ee05a}` +
151  `.bx{fill:#ff3030;fill-opacity:.3;stroke:#ff3030}` +
152  `.bw{fill:none;stroke:#9aa3b5;stroke-dasharray:2 2}` +
153  `.ba{stroke:#28e0ff}` +
154  `.bd{stroke:#28e0ff;fill:none}` +
155  `.bm{stroke:#ffffff;stroke-width:2}` +
156  `.bv{stroke:#9aa3b5}` +
157  `.a{font:bold 13px sans-serif;fill:#f4f8ff;text-anchor:middle;paint-order:stroke;stroke:#10183a;stroke-width:3px}`
158
159/** The picture at one tick, in `lang`; `showBoxes` draws the collision overlay. */
160export function huntSvg(phase: HuntPhase, view: View, clock: string, showBoxes = false, lang: HuntLang = 'en'): string {
161  const ground = SCENES[view.scene].ground * SCENE_SCALE
162  let actors =
163    (view.camp === null ? '' : rects(campRects(view.camp))) +
164    (view.monster === null ? '' : actor(view.monster, ground + (view.monster.lift ?? 0))) +
165    actor(view.hunter, ground) +
166    effects(view.effects)
167  if (showBoxes) actors += (view.monster === null ? '' : boxes(view.monster, ground + (view.monster.lift ?? 0), -1)) + boxes(view.hunter, ground, 1) + marks(view.marks)
168  return (
169    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" shape-rendering="crispEdges">` +
170    `<style>${CSS}</style><g fill="none" stroke-width="1">${scene(view.scene)}${actors}</g>` +
171    `${hud(phase, view, clock, lang)}</svg>`
172  )
173}
174
175export const HUNT_SIZE = { width: W, height: H }
176
177/**
178 * The words the terminal shows under the picture, where the Svg's HUD draws
179 * them: phase, the species (and its state, or its kind), the clock.
180 */
181export function hudText(phase: HuntPhase, view: View, clock: string, lang: HuntLang = 'en'): string {
182  const info = view.monsterInfo
183  const who = info === null ? '' : t(lang, 'hudWho', { name: t(lang, info.name), kind: t(lang, info.status ?? info.species) })
184  return `${t(lang, PHASE_TEXT[phase])}${who}${t(lang, 'hudSep')}${clock}`
185}
186
187/** The monsters in the order the tally lists them (the registry's). */
188const roster = (): MonsterId[] => Object.keys(MONSTERS) as MonsterId[]
189
190/** One line of text for a surface without Svg: 討伐 7:冰呪龍×3 大凶豺龍×4 (only species slain). */
191export function statsText(g: HuntGame, lang: HuntLang = 'en'): string {
192  const parts = roster()
193    .filter(id => (g.slainBy[id] ?? 0) > 0)
194    .map(id => `${t(lang, MONSTERS[id]!.name)}×${g.slainBy[id]}`)
195  return `${t(lang, 'slainCount', { n: String(g.slain) })}${parts.length > 0 ? t(lang, 'listSep') + parts.join(' ') : ''}`
196}
197
198const STATS_H = 24
199const STATS_CELL = 36
200const headCache = new Map<string, string>()
201function head(id: MonsterId): string {
202  let paths = headCache.get(id)
203  if (paths === undefined) {
204    const h = HEADS[id as keyof typeof HEADS]
205    paths = gridToPaths(h.rows, h.palette)
206    headCache.set(id, paths)
207  }
208  return paths
209}
210
211/**
212 * The tally strip under the scene: 討伐 N, then each species' 16x16 head and
213 * ×count in registry order, a species not yet slain dimmed. Its own small Svg,
214 * so the scene's source limit is untouched.
215 */
216export function statsSvg(g: HuntGame, lang: HuntLang = 'en'): string {
217  let cells = ''
218  roster().forEach((id, i) => {
219    const n = g.slainBy[id] ?? 0
220    const x = 54 + i * STATS_CELL
221    cells +=
222      `<g transform="translate(${x} 4)${HALF}"${n === 0 ? ' opacity=".3"' : ''}><g fill="none" stroke-width="1">${head(id)}</g></g>` +
223      `<text x="${x + 17}" y="16" class="k${n === 0 ? ' d' : ''}">×${n}</text>`
224  })
225  return (
226    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${STATS_H}" width="${W}" height="${STATS_H}" shape-rendering="crispEdges">` +
227    `<style>.k{font:bold 11px monospace;fill:#f4f8ff}.d{fill:#6f7c99}.t{font:bold 12px sans-serif;fill:#ffc23a}</style>` +
228    `<rect width="${W}" height="${STATS_H}" fill="#10183a"/>` +
229    `<text x="4" y="16" class="t">${t(lang, 'slainCount', { n: String(g.slain) })}</text>${cells}</svg>`
230  )
231}
232
hooks/sprites.ts 65 lines
1import { ANJANATH } from './gen/anjanath'
2import { BARIOTH } from './gen/barioth'
3import { BOW } from './gen/bow'
4import { NARGA } from './gen/narga'
5import { TIGREX } from './gen/tigrex'
6import { ZINOGRE } from './gen/zinogre'
7import { NERGI } from './gen/nergi'
8import { CAMP } from './gen/camp'
9import { HUNTER } from './gen/hunter'
10import { JAGRAS } from './gen/jagras'
11import { LS } from './gen/ls'
12import { SCENES } from './gen/scenes'
13import { TOBI } from './gen/tobi'
14import { VEL } from './gen/vel'
15
16/** A frame row run-length encoded: a palette letter or '.', then its count when over 1. */
17export type RleRow = string
18
19export type Sprite = {
20  palette: Readonly<Record<string, string>>
21  anchorX: number
22  frames: readonly (readonly RleRow[])[]
23}
24
25export type ActionId =
26  | keyof typeof HUNTER.actions
27  | keyof typeof CAMP.actions
28  | keyof typeof LS.actions
29  | keyof typeof BOW.actions
30  | keyof typeof VEL.actions
31  | keyof typeof JAGRAS.actions
32  | keyof typeof TOBI.actions
33  | keyof typeof ANJANATH.actions
34  | keyof typeof BARIOTH.actions
35  | keyof typeof NARGA.actions
36  | keyof typeof TIGREX.actions
37  | keyof typeof ZINOGRE.actions
38  | keyof typeof NERGI.actions
39export type SceneId = keyof typeof SCENES
40
41function withPalette(group: typeof HUNTER | typeof CAMP | typeof LS | typeof BOW | typeof VEL | typeof JAGRAS | typeof TOBI | typeof ANJANATH | typeof BARIOTH | typeof NARGA | typeof TIGREX | typeof ZINOGRE | typeof NERGI): Record<string, Sprite> {
42  const out: Record<string, Sprite> = {}
43  for (const [id, action] of Object.entries(group.actions)) {
44    out[id] = { palette: group.palette, anchorX: action.anchorX, frames: action.frames }
45  }
46  return out
47}
48
49export const ACTIONS = {
50  ...withPalette(HUNTER),
51  ...withPalette(CAMP),
52  ...withPalette(LS),
53  ...withPalette(BOW),
54  ...withPalette(VEL),
55  ...withPalette(JAGRAS),
56  ...withPalette(TOBI),
57  ...withPalette(ANJANATH),
58  ...withPalette(BARIOTH),
59  ...withPalette(NARGA),
60  ...withPalette(TIGREX),
61  ...withPalette(ZINOGRE),
62  ...withPalette(NERGI),
63} as Record<ActionId, Sprite>
64export { SCENES }
65
hooks/collision.ts 105 lines
1import type { HitDef, LocalBox, Part } from './collision-data'
2import { BOXES } from './collision-data'
3import type { ActionId } from './sprites'
4
5/**
6 * Pure geometry in the 384x216 world: local boxes placed at an actor's foot
7 * anchor, mirrored by its render flip. No game state is read or changed here.
8 */
9
10export type Aabb = { minX: number; minY: number; maxX: number; maxY: number }
11export type PlacedBox = { part: Part; box: Aabb }
12
13/** Where an actor stands and how its sprite is flipped (1 = as drawn). */
14export type Placement = { x: number; groundY: number; renderFlip: 1 | -1 }
15
16export function toWorld([x0, y0, x1, y1]: LocalBox, at: Placement): Aabb {
17  const a = at.x + at.renderFlip * x0
18  const b = at.x + at.renderFlip * x1
19  return { minX: Math.min(a, b), minY: at.groundY + y0, maxX: Math.max(a, b), maxY: at.groundY + y1 }
20}
21
22/** Edges and corners touching count as contact. */
23export const overlaps = (a: Aabb, b: Aabb): boolean =>
24  a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY
25
26export function hurtboxes(action: ActionId, pose: number, at: Placement): PlacedBox[] {
27  const hurt = BOXES[action].hurt
28  const set = hurt[pose] ?? hurt[0] ?? {}
29  return (Object.entries(set) as [Part, LocalBox][]).map(([part, box]) => ({ part, box: toWorld(box, at) }))
30}
31
32/** A local box moved `dx` pixels along the local x axis. */
33export const shifted = ([x0, y0, x1, y1]: LocalBox, dx: number): LocalBox => [x0 + dx, y0, x1 + dx, y1]
34
35/** Every attack pose of an action: the first, then a combo's others. */
36export const hitDefs = (action: ActionId): HitDef[] => {
37  const own = BOXES[action]
38  return own.hit === undefined ? [] : [own.hit, ...(own.more ?? [])]
39}
40
41/**
42 * The attack's boxes on this pose, moved `dx` local pixels (a projectile in
43 * flight) and box i `dys[i]` pixels down (a spread volley); empty off its attack poses.
44 */
45export function hitboxes(action: ActionId, pose: number, at: Placement, dx = 0, dys?: readonly number[]): Aabb[] {
46  const hit = hitDefs(action).find(h => h.pose === pose)
47  if (hit === undefined) return []
48  return hit.boxes.map((b, i) => {
49    const moved = dx === 0 ? b : shifted(b, dx)
50    const dy = dys?.[i] ?? 0
51    return toWorld(dy === 0 ? moved : [moved[0], moved[1] + dy, moved[2], moved[3] + dy], at)
52  })
53}
54
55/** Every box of an attack action regardless of pose, for the wind-up preview. */
56export function attackReach(action: ActionId, at: Placement): Aabb[] {
57  return hitDefs(action).flatMap(h => h.boxes.map(b => toWorld(b, at)))
58}
59
60export const activePose = (action: ActionId): number | undefined => BOXES[action].hit?.pose
61
62/** Would this attack, thrown from here, touch the target as it stands now? */
63export function reaches(attack: ActionId, from: Placement, target: ActionId, pose: number, at: Placement): boolean {
64  return touchesAny(attackReach(attack, from), target, pose, at)
65}
66
67/** Do any of these (world) boxes touch the target as it stands now? */
68export function touchesAny(boxes: Aabb[], target: ActionId, pose: number, at: Placement): boolean {
69  const hurt = hurtboxes(target, pose, at)
70  return boxes.some(a => hurt.some(h => overlaps(a, h.box)))
71}
72
73/** Special parts first, then head, body, tail (spec 4.9). */
74const PRIORITY: Record<Part, number> = {
75  belly: -1,
76  wing_near: -1,
77  wing_far: -1,
78  spike_head: -1,
79  spike_arm_near: -1,
80  spike_arm_far: -1,
81  hunter_body: 0,
82  head: 0,
83  body: 1,
84  tail: 2,
85}
86
87export type Touch = { part: Part; point: { x: number; y: number } }
88
89/**
90 * The part this tick's live hit boxes touch first by priority, and the centre
91 * of where they meet (the first touching hit box), or undefined.
92 */
93export function contactPart(hits: Aabb[], hurt: PlacedBox[]): Touch | undefined {
94  const touched = hurt.filter(h => hits.some(a => overlaps(a, h.box)))
95  touched.sort((a, b) => PRIORITY[a.part] - PRIORITY[b.part] || a.part.localeCompare(b.part))
96  const first = touched[0]
97  if (first === undefined) return undefined
98  const a = hits.find(b => overlaps(b, first.box))!
99  const minX = Math.max(a.minX, first.box.minX)
100  const maxX = Math.min(a.maxX, first.box.maxX)
101  const minY = Math.max(a.minY, first.box.minY)
102  const maxY = Math.min(a.maxY, first.box.maxY)
103  return { part: first.part, point: { x: Math.round((minX + maxX) / 2), y: Math.round((minY + maxY) / 2) } }
104}
105
hooks/collision-data.ts 698 lines
1import type { ActionId } from './sprites'
2
3/**
4 * Hand-measured collision boxes, in each action's local frame: x = u - anchorX
5 * on the unmirrored sprite, y = v - height (feet at 0, body at negative y).
6 * Each box is [minX, minY, maxX, maxY]. Measured from the extracted frames
7 * (hooks/gen) on a grid; they are this project's adaptation, not game data.
8 *
9 * `hurt` lists, per pose, the parts that can be hit; one entry stands for
10 * every pose. `hit` is the attack's box on its active pose (data.ts `active`).
11 *
12 * Great Jagras (JG_*) boxes are this project's grey-box values (spec 3.4),
13 * fitted to each extracted pose on a 10px grid; `belly` exists only on the
14 * poses drawn inflated. Its spit (JG04) is a projectile: the box is where the
15 * ball leaves the mouth, moved by the engine as it flies.
16 *
17 * Tobi-Kadachi (TK_*) boxes start from the same grey-box table, fitted the
18 * same way. TK02 is a leap: the art stays on the ground line and the engine
19 * lifts the root (moves' `lift`), so these boxes rise with it. TK03 sweeps
20 * the tail round both sides; TK04's box covers the slam's splash in front.
21 *
22 * Anjanath (AN_*) likewise. AN03 has no head box while it turns away (the
23 * head is hidden behind the body); AN04's breath is three boxes of one group
24 * straight ahead of the mouth, so it settles once.
25 *
26 * Barioth (BA_*) boxes are proposed from each extracted frame (head at the
27 * front, tail at the back, spec 3.4 widths) and checked on the grid preview;
28 * its two wings (near and far side, spec 3.4 allows that naming) are boxes on
29 * the back of every pose. BA04's box is the whirlwind 100 px ahead (ticks 12..27).
30 *
31 * Nargacuga (NA_*) boxes are proposed from each frame the same way (body
32 * top capped below the raised tail); NA02 F2 and NA04 F2 are drawn turned
33 * round, head at the right. NA03X is the enraged double slam from the combo
34 * board: its two slams are `hit` and `more`. NA04's three boxes are the spike
35 * volley at the tail tip, moved by the engine as it flies and spreads.
36 *
37 * Tigrex (TI_*) likewise. TI01X is the turn-back dash from the combo board:
38 * left dash, brake turning right, right dash, brake turning left, left dash,
39 * recover; its frames 1 and 2 are drawn facing right (head at the right) and
40 * each dash is its own hit pose (`hit`, then `more`).
41 *
42 * Zinogre (ZI_*) likewise. ZI01X is the three-paw combo: six poses from the
43 * combo board and a seventh (recover) from its own board; each paw is a hit
44 * pose (`hit`, then `more`). ZI04's box is the thunder ring as one AABB.
45 *
46 * The object below is plain JSON so tools can read it without TypeScript.
47 */
48export type LocalBox = readonly [number, number, number, number]
49export type Part =
50  | 'hunter_body'
51  | 'head'
52  | 'body'
53  | 'tail'
54  | 'belly'
55  | 'wing_near'
56  | 'wing_far'
57  | 'spike_head'
58  | 'spike_arm_near'
59  | 'spike_arm_far'
60export type PoseHurt = Readonly<Partial<Record<Part, LocalBox>>>
61export type HitDef = { pose: number; boxes: readonly LocalBox[] }
62/** `hit` is the first (or only) attack pose; `more` the other poses of a combo, each its own group. */
63export type ActionBoxes = { hurt: readonly PoseHurt[]; hit?: HitDef; more?: readonly HitDef[] }
64
65/** The hunter's simplified body width [minX, maxX], for spacing only (each monster has its own `bounds`). */
66export const MOVE_BOUNDS = { hunter: [-18, 22] } as const
67
68export const BOXES: Readonly<Record<ActionId, ActionBoxes>> = {
69  "C01": { "hurt": [{ "hunter_body": [-18, -64, 22, 0] }] },
70  "C02": { "hurt": [{}] },
71  "C03": { "hurt": [{ "hunter_body": [0, -70, 45, 0] }] },
72  "C08": { "hurt": [
73    { "hunter_body": [-15, -58, 25, 0] },
74    { "hunter_body": [-10, -52, 45, 0] },
75    { "hunter_body": [-25, -50, 40, 0] },
76    { "hunter_body": [-8, -68, 32, 0] }
77  ] },
78  "C15": { "hurt": [{ "hunter_body": [-30, -70, 18, 0] }] },
79  "C17": { "hurt": [{}] },
80  "C21": { "hurt": [{}] },
81  "G03": { "hurt": [{ "hunter_body": [-22, -55, 32, 0] }], "hit": { "pose": 2, "boxes": [[8, -72, 40, -45], [35, -92, 65, -65]] } },
82  "G05": { "hurt": [
83    { "hunter_body": [-25, -55, 30, 0] },
84    { "hunter_body": [-25, -55, 30, 0] },
85    { "hunter_body": [-25, -85, 60, -25] },
86    { "hunter_body": [-25, -55, 30, 0] }
87  ], "hit": { "pose": 3, "boxes": [[42, -34, 80, -2]] } },
88  "G06": { "hurt": [{ "hunter_body": [-20, -58, 40, 0] }], "hit": { "pose": 2, "boxes": [[25, -60, 55, -15]] } },
89  "L01": { "hurt": [{ "hunter_body": [-20, -60, 22, 0] }] },
90  "L02": { "hurt": [{ "hunter_body": [-20, -62, 24, 0] }], "hit": { "pose": 1, "boxes": [[40, -100, 78, -50], [25, -50, 78, -2]] } },
91  "L03": { "hurt": [{ "hunter_body": [-20, -60, 24, 0] }], "hit": { "pose": 1, "boxes": [[20, -34, 62, -8]] } },
92  "L05": { "hurt": [
93    { "hunter_body": [-18, -62, 24, 0] },
94    { "hunter_body": [-10, -52, 30, 0] },
95    { "hunter_body": [-10, -58, 32, 0] },
96    { "hunter_body": [-12, -72, 24, 0] }
97  ], "hit": { "pose": 1, "boxes": [[-60, -92, 92, -8]] } },
98  "L06": { "hurt": [{ "hunter_body": [-20, -60, 24, 0] }] },
99  "B07": { "hurt": [{ "hunter_body": [-22, -72, 22, 0] }] },
100  "B01": { "hurt": [{ "hunter_body": [-22, -72, 22, 0] }] },
101  "B04": { "hurt": [{ "hunter_body": [-22, -72, 22, 0] }], "hit": { "pose": 2, "boxes": [[55, -50, 150, -34]] } },
102  "B05": { "hurt": [{ "hunter_body": [-22, -72, 22, 0] }], "hit": { "pose": 2, "boxes": [[55, -76, 142, -12]] } },
103  "B06": { "hurt": [{ "hunter_body": [-22, -72, 22, 0] }], "hit": { "pose": 2, "boxes": [[50, -80, 152, -8]] } },
104  "V01": { "hurt": [{ "head": [-108, -85, -60, -22], "body": [-62, -80, 25, 0], "tail": [25, -36, 92, 0] }] },
105  "V02": { "hurt": [{ "head": [-115, -82, -75, -20], "body": [-75, -88, 15, 0], "tail": [15, -40, 97, 0] }] },
106  "V14": { "hurt": [
107    { "head": [-100, -75, -60, -25], "body": [-60, -90, 30, 0], "tail": [30, -30, 88, 0] },
108    { "head": [-100, -75, -60, -25], "body": [-60, -90, 40, 0], "tail": [40, -62, 98, -40] },
109    { "head": [-90, -75, -55, -45], "body": [-55, -92, 40, 0], "tail": [-100, -44, 40, -26] },
110    { "head": [-95, -80, -55, -25], "body": [-55, -92, 40, 0], "tail": [50, -45, 82, 0] }
111  ], "hit": { "pose": 2, "boxes": [[-135, -44, -75, -26]] } },
112  "V17": { "hurt": [{ "head": [-105, -72, -50, -15], "body": [-55, -90, 40, 0], "tail": [40, -32, 88, 0] }],
113    "hit": { "pose": 2, "boxes": [[-172, -52, -90, -20]] } },
114  "V23": { "hurt": [
115    { "head": [-110, -88, -70, -45], "body": [-75, -112, 30, 0], "tail": [30, -45, 85, 0] },
116    { "head": [-110, -88, -70, -45], "body": [-75, -112, 30, 0], "tail": [30, -45, 85, 0] },
117    { "head": [-125, -95, -75, -55], "body": [-75, -112, 30, 0], "tail": [30, -55, 95, 0] },
118    { "head": [-110, -88, -70, -45], "body": [-75, -112, 30, 0], "tail": [30, -45, 85, 0] }
119  ] },
120  "V25": { "hurt": [
121    { "head": [-110, -78, -70, -40], "body": [-65, -98, 50, 0], "tail": [50, -45, 95, 0] },
122    { "head": [-118, -65, -75, -15], "body": [-75, -95, 40, 0], "tail": [40, -45, 100, 0] },
123    { "head": [-125, -60, -60, 0], "body": [-60, -90, 85, 0] },
124    { "head": [-125, -60, -60, 0], "body": [-60, -90, 85, 0] }
125  ] },
126  "V26": { "hurt": [
127    { "head": [-145, -50, -90, 0], "body": [-90, -82, 55, 0], "tail": [55, -45, 90, 0] },
128    { "head": [-120, -70, -85, -20], "body": [-85, -100, 15, 0], "tail": [15, -45, 90, 0] },
129    { "head": [-120, -70, -85, -20], "body": [-85, -100, 15, 0], "tail": [15, -45, 90, 0] },
130    { "head": [-120, -70, -85, -20], "body": [-85, -100, 15, 0], "tail": [15, -45, 90, 0] }
131  ] },
132  "JG_IDLE": { "hurt": [{ "head": [-72, -52, -30, -16], "body": [-36, -64, 46, 0], "tail": [44, -34, 72, -8] }] },
133  "JG_WALK": { "hurt": [{ "head": [-72, -52, -30, -16], "body": [-36, -64, 46, 0], "tail": [44, -34, 74, -8] }] },
134  "JG01": { "hurt": [
135    { "head": [-66, -40, -30, -8], "body": [-34, -58, 48, 0], "tail": [46, -30, 76, -6] },
136    { "head": [-56, -82, -22, -46], "body": [-34, -70, 44, 0], "tail": [42, -34, 74, -6] },
137    { "head": [-62, -60, -26, -18], "body": [-30, -62, 48, 0], "tail": [46, -32, 72, -6] },
138    { "head": [-60, -58, -24, -20], "body": [-30, -64, 52, 0], "tail": [50, -34, 76, -6] }
139  ], "hit": { "pose": 2, "boxes": [[-78, -52, -40, -14]] } },
140  "JG02": { "hurt": [
141    { "belly": [-30, -36, 10, 0], "head": [-62, -44, -30, -10], "body": [-30, -66, 48, 0], "tail": [46, -34, 76, -6] },
142    { "belly": [-58, -46, -10, 0], "head": [-58, -88, -26, -58], "body": [-30, -80, 40, 0], "tail": [40, -34, 74, -6] },
143    { "belly": [-10, -30, 40, 0], "head": [-70, -40, -40, -8], "body": [-40, -66, 50, 0], "tail": [52, -58, 70, -20] },
144    { "belly": [-4, -30, 38, 0], "head": [-70, -46, -40, -14], "body": [-40, -66, 50, 0], "tail": [50, -40, 72, -12] }
145  ], "hit": { "pose": 2, "boxes": [[-74, -30, 60, 0]] } },
146  "JG03": { "hurt": [
147    { "head": [-70, -44, -34, -12], "body": [-36, -70, 48, 0], "tail": [44, -34, 72, -6] },
148    { "head": [-66, -50, -30, -14], "body": [-30, -74, 40, 0], "tail": [36, -50, 70, -6] },
149    { "belly": [-40, -62, 16, -6], "head": [-70, -54, -40, -10], "body": [-60, -62, 40, 0], "tail": [40, -76, 66, -20] },
150    { "head": [-62, -56, -26, -18], "body": [-28, -70, 50, 0], "tail": [48, -36, 74, -6] }
151  ], "hit": { "pose": 2, "boxes": [[-68, -52, 60, 0]] } },
152  "JG04": { "hurt": [
153    { "head": [-72, -58, -32, -20], "body": [-36, -68, 44, 0], "tail": [42, -30, 70, -6] },
154    { "head": [-62, -84, -28, -48], "body": [-32, -72, 40, 0], "tail": [40, -34, 66, -6] },
155    { "head": [-54, -70, -24, -40], "body": [-30, -64, 40, 0], "tail": [40, -36, 64, -8] },
156    { "head": [-72, -56, -34, -18], "body": [-36, -70, 48, 0], "tail": [46, -34, 66, -6] }
157  ], "hit": { "pose": 2, "boxes": [[-64, -58, -48, -42]] } },
158  "JG_INFLATE": { "hurt": [
159    { "head": [-70, -46, -32, -12], "body": [-36, -68, 46, 0], "tail": [44, -32, 74, -6] },
160    { "belly": [-34, -34, 0, 0], "head": [-70, -60, -34, -24], "body": [-34, -74, 50, 0], "tail": [46, -34, 74, -6] },
161    { "belly": [-60, -60, -4, 0], "head": [-62, -100, -30, -70], "body": [-34, -98, 40, 0], "tail": [40, -38, 76, -6] },
162    { "belly": [-62, -60, -4, 0], "head": [-56, -100, -22, -70], "body": [-30, -98, 40, 0], "tail": [40, -40, 72, -6] }
163  ] },
164  "JG_DEFLATE": { "hurt": [
165    { "belly": [-60, -62, -4, 0], "head": [-58, -100, -24, -72], "body": [-30, -98, 40, 0], "tail": [40, -36, 74, -6] },
166    { "belly": [-46, -44, 0, 0], "head": [-70, -74, -36, -44], "body": [-36, -90, 46, 0], "tail": [46, -38, 72, -6] },
167    { "belly": [-36, -40, 0, 0], "head": [-62, -94, -28, -62], "body": [-30, -90, 44, 0], "tail": [44, -40, 74, -6] },
168    { "head": [-62, -60, -26, -22], "body": [-28, -74, 52, 0], "tail": [50, -36, 74, -6] }
169  ] },
170  "JG_FLINCH": { "hurt": [
171    { "head": [-72, -54, -32, -16], "body": [-36, -72, 46, 0], "tail": [44, -34, 70, -6] },
172    { "head": [-62, -74, -28, -36], "body": [-28, -80, 44, 0], "tail": [44, -34, 62, -6] },
173    { "head": [-72, -40, -34, -6], "body": [-34, -70, 50, 0], "tail": [48, -38, 72, -6] },
174    { "head": [-70, -56, -32, -20], "body": [-32, -72, 46, 0], "tail": [46, -34, 66, -6] }
175  ] },
176  "JG_TOPPLE": { "hurt": [
177    { "head": [-74, -56, -36, -20], "body": [-36, -76, 46, 0], "tail": [44, -38, 68, -6] },
178    { "head": [-80, -58, -46, -26], "body": [-46, -74, 30, 0], "tail": [30, -72, 60, -20] },
179    { "head": [-80, -36, -46, 0], "body": [-60, -60, 40, 0], "tail": [40, -38, 66, -8] },
180    { "head": [-66, -30, -30, 0], "body": [-32, -58, 50, 0], "tail": [50, -26, 74, -4] }
181  ] },
182  "JG_GETUP": { "hurt": [
183    { "head": [-66, -30, -30, 0], "body": [-30, -58, 50, 0], "tail": [50, -26, 76, -4] },
184    { "head": [-66, -46, -30, -12], "body": [-30, -68, 54, 0], "tail": [54, -44, 70, -8] },
185    { "head": [-72, -54, -34, -16], "body": [-36, -80, 46, 0], "tail": [46, -44, 68, -6] },
186    { "head": [-70, -54, -32, -16], "body": [-34, -80, 46, 0], "tail": [46, -40, 70, -6] }
187  ] },
188  "JG_DEATH": { "hurt": [
189    { "head": [-74, -56, -36, -16], "body": [-36, -74, 44, 0], "tail": [44, -38, 68, -6] },
190    { "head": [-74, -52, -36, -12], "body": [-36, -72, 44, 0], "tail": [44, -36, 68, -6] },
191    { "head": [-74, -30, -36, 0], "body": [-36, -50, 56, 0], "tail": [56, -22, 74, 0] },
192    { "head": [-74, -30, -36, 0], "body": [-36, -50, 56, 0], "tail": [56, -22, 74, 0] }
193  ] },
194  "TK_IDLE": { "hurt": [{ "head": [-72, -50, -40, -18], "body": [-46, -64, 58, 0], "tail": [40, -94, 104, -40] }] },
195  "TK_WALK": { "hurt": [{ "head": [-74, -58, -50, -24], "body": [-50, -73, 56, 0], "tail": [40, -97, 104, -44] }] },
196  "TK01": { "hurt": [
197    { "head": [-66, -50, -44, -18], "body": [-44, -64, 58, -4], "tail": [40, -92, 96, -40] },
198    { "head": [-58, -56, -38, -12], "body": [-40, -64, 60, 0], "tail": [40, -96, 104, -42] },
199    { "head": [-82, -50, -52, -18], "body": [-52, -62, 58, -4], "tail": [40, -90, 96, -38] },
200    { "head": [-66, -48, -42, -20], "body": [-42, -63, 60, 0], "tail": [40, -94, 104, -42] }
201  ], "hit": { "pose": 2, "boxes": [[-96, -56, -48, -16]] } },
202  "TK02": { "hurt": [
203    { "head": [-66, -46, -44, -14], "body": [-44, -61, 60, 0], "tail": [40, -82, 104, -34] },
204    { "head": [-66, -60, -36, -17], "body": [-40, -74, 60, 0], "tail": [48, -88, 104, -42] },
205    { "head": [-66, -40, -36, 0], "body": [-36, -66, 64, 0], "tail": [64, -98, 112, -50] },
206    { "head": [-66, -44, -40, -16], "body": [-42, -60, 60, 0], "tail": [40, -97, 104, -36] }
207  ], "hit": { "pose": 2, "boxes": [[-80, -58, 42, -4]] } },
208  "TK03": { "hurt": [
209    { "head": [-90, -56, -62, -30], "body": [-64, -72, 24, -4], "tail": [24, -98, 80, -20] },
210    { "head": [-56, -80, -24, -56], "body": [-72, -78, 16, 0], "tail": [16, -102, 80, -30] },
211    { "head": [-66, -70, -40, -30], "body": [-64, -90, 40, -2], "tail": [40, -80, 104, -20] },
212    { "head": [-82, -66, -56, -30], "body": [-60, -75, 36, -2], "tail": [36, -88, 88, -34] }
213  ], "hit": { "pose": 2, "boxes": [[-108, -48, 108, -4]] } },
214  "TK04": { "hurt": [
215    { "head": [-66, -46, -42, -14], "body": [-44, -67, 56, -2], "tail": [24, -98, 104, -44] },
216    { "head": [-66, -56, -42, -24], "body": [-44, -77, 56, 0], "tail": [24, -132, 96, -60] },
217    { "head": [8, -66, 40, -36], "body": [16, -70, 104, -5], "tail": [-56, -120, 40, -5] },
218    { "head": [-56, -60, -30, -20], "body": [-32, -76, 32, -4], "tail": [32, -83, 104, -4] }
219  ], "hit": { "pose": 2, "boxes": [[-104, -80, -16, 0]] } },
220  "TK_CHARGE": { "hurt": [{ "head": [-74, -60, -50, -24], "body": [-50, -74, 58, 0], "tail": [40, -97, 104, -44] }] },
221  "TK_DISCHARGE": { "hurt": [{ "head": [-66, -64, -44, -20], "body": [-48, -82, 58, 0], "tail": [40, -104, 104, -40] }] },
222  "TK_FLINCH": { "hurt": [
223    { "head": [-72, -52, -44, -18], "body": [-48, -64, 56, 0], "tail": [40, -92, 96, -40] },
224    { "head": [-72, -58, -48, -30], "body": [-48, -74, 56, 0], "tail": [40, -92, 96, -40] },
225    { "head": [-64, -40, -40, -4], "body": [-40, -62, 48, -4], "tail": [40, -90, 96, -34] },
226    { "head": [-72, -52, -44, -20], "body": [-48, -63, 56, 0], "tail": [40, -93, 96, -40] }
227  ] },
228  "TK_TOPPLE": { "hurt": [
229    { "head": [-72, -56, -48, -20], "body": [-48, -66, 60, 0], "tail": [40, -96, 96, -40] },
230    { "head": [-80, -50, -48, -10], "body": [-48, -80, 40, -2], "tail": [40, -96, 88, -4] },
231    { "head": [-88, -48, -60, -6], "body": [-60, -58, 40, -4], "tail": [40, -54, 88, -8] },
232    { "head": [-88, -42, -60, -4], "body": [-60, -50, 32, -4], "tail": [32, -50, 80, -4] }
233  ] },
234  "TK_GETUP": { "hurt": [
235    { "head": [-88, -42, -60, -4], "body": [-60, -50, 32, -4], "tail": [32, -50, 88, -4] },
236    { "head": [-82, -86, -50, -50], "body": [-64, -84, 8, -4], "tail": [8, -46, 80, -4] },
237    { "head": [-58, -64, -36, -30], "body": [-40, -75, 56, 0], "tail": [48, -104, 112, -50] },
238    { "head": [-58, -62, -36, -30], "body": [-40, -74, 56, 0], "tail": [48, -101, 112, -50] }
239  ] },
240  "TK_DEATH": { "hurt": [
241    { "head": [-82, -56, -56, -20], "body": [-60, -79, 56, 0], "tail": [56, -76, 88, -12] },
242    { "head": [-90, -50, -60, -8], "body": [-60, -68, 40, -4], "tail": [40, -64, 80, -4] },
243    { "head": [-90, -50, -60, -6], "body": [-60, -58, 32, -2], "tail": [32, -48, 80, -4] },
244    { "head": [-90, -36, -60, -6], "body": [-60, -53, 32, -4], "tail": [32, -45, 80, -6] }
245  ] },
246  "AN_IDLE": { "hurt": [
247    { "head": [-96, -84, -62, -30], "body": [-62, -100, 60, 0], "tail": [60, -74, 120, -34] },
248    { "head": [-88, -97, -56, -44], "body": [-56, -100, 56, 0], "tail": [56, -66, 88, -30] },
249    { "head": [-96, -77, -58, -24], "body": [-58, -98, 52, 0], "tail": [52, -75, 80, -38] },
250    { "head": [-80, -88, -56, -36], "body": [-56, -101, 48, 0], "tail": [44, -92, 64, -38] }
251  ] },
252  "AN_WALK": { "hurt": [
253    { "head": [-96, -79, -60, -28], "body": [-60, -100, 64, 0], "tail": [64, -80, 112, -44] },
254    { "head": [-88, -83, -56, -28], "body": [-56, -101, 60, 0], "tail": [60, -77, 88, -48] },
255    { "head": [-80, -77, -48, -28], "body": [-48, -99, 64, 0], "tail": [64, -88, 88, -52] },
256    { "head": [-72, -78, -48, -30], "body": [-48, -101, 64, 0], "tail": [60, -70, 80, -52] }
257  ] },
258  "AN01": { "hurt": [
259    { "head": [-88, -76, -54, -18], "body": [-54, -100, 72, 0], "tail": [72, -70, 136, -36] },
260    { "head": [-64, -109, -30, -48], "body": [-34, -109, 72, 0], "tail": [72, -66, 104, -28] },
261    { "head": [-64, -97, -40, -24], "body": [-40, -102, 72, 0], "tail": [72, -74, 104, -38] },
262    { "head": [-56, -92, -28, -38], "body": [-30, -100, 64, 0], "tail": [64, -70, 88, -32] }
263  ], "hit": { "pose": 2, "boxes": [[-92, -96, -40, -20]] } },
264  "AN02": { "hurt": [
265    { "head": [-80, -70, -48, -16], "body": [-48, -102, 88, -6], "tail": [88, -99, 128, -60] },
266    { "head": [-80, -75, -48, -18], "body": [-48, -100, 96, 0], "tail": [96, -89, 112, -60] },
267    { "head": [-72, -71, -44, -20], "body": [-48, -96, 72, -6], "tail": [72, -92, 96, -50] },
268    { "head": [-56, -79, -32, -24], "body": [-32, -115, 72, -6], "tail": [72, -81, 96, -60] }
269  ], "hit": { "pose": 2, "boxes": [[-108, -84, 40, -2]] } },
270  "AN03": { "hurt": [
271    { "head": [-96, -87, -62, -37], "body": [-62, -108, 64, 0], "tail": [64, -78, 120, -38] },
272    { "body": [-64, -106, 48, 0], "tail": [48, -89, 96, -30] },
273    { "body": [-64, -108, 40, 0], "tail": [40, -89, 96, -29] },
274    { "head": [-80, -89, -48, -37], "body": [-48, -108, 48, 0], "tail": [48, -63, 72, -26] }
275  ], "hit": { "pose": 2, "boxes": [[-104, -62, 104, -8]] } },
276  "AN04": { "hurt": [
277    { "head": [-104, -115, -64, -42], "body": [-64, -127, 64, 0], "tail": [64, -79, 112, -40] },
278    { "head": [-88, -113, -56, -44], "body": [-56, -125, 48, 0], "tail": [48, -84, 64, -56] },
279    { "head": [-96, -134, -48, -30], "body": [-48, -121, 40, 0], "tail": [40, -68, 56, -28] },
280    { "head": [-96, -97, -64, -36], "body": [-64, -128, 48, 0], "tail": [40, -87, 56, -63] }
281  ], "hit": { "pose": 2, "boxes": [[-120, -66, -78, -34], [-160, -74, -118, -26], [-200, -82, -158, -18]] } },
282  "AN_FIRE_READY": { "hurt": [
283    { "head": [-96, -90, -62, -38], "body": [-62, -112, 72, 0], "tail": [72, -83, 120, -49] },
284    { "head": [-80, -119, -50, -47], "body": [-56, -121, 48, 0], "tail": [48, -79, 80, -41] },
285    { "head": [-88, -129, -56, -55], "body": [-56, -129, 48, 0], "tail": [48, -77, 80, -35] },
286    { "head": [-80, -135, -48, -55], "body": [-56, -135, 48, 0], "tail": [48, -77, 64, -50] }
287  ] },
288  "AN_COOL_DOWN": { "hurt": [
289    { "head": [-96, -126, -56, -47], "body": [-56, -131, 64, 0], "tail": [64, -88, 104, -47] },
290    { "head": [-88, -110, -56, -44], "body": [-56, -127, 48, 0], "tail": [48, -91, 80, -56] },
291    { "head": [-96, -87, -60, -32], "body": [-60, -115, 64, 0], "tail": [64, -82, 80, -61] },
292    { "head": [-80, -105, -50, -51], "body": [-56, -118, 48, -1], "tail": [44, -80, 64, -49] }
293  ] },
294  "AN_FLINCH": { "hurt": [
295    { "head": [-96, -82, -62, -29], "body": [-62, -98, 64, 0], "tail": [64, -73, 120, -36] },
296    { "head": [-80, -104, -48, -57], "body": [-56, -104, 56, 0], "tail": [56, -65, 88, -30] },
297    { "head": [-64, -69, -44, -16], "body": [-48, -95, 64, -1], "tail": [64, -68, 96, -32] },
298    { "head": [-72, -82, -44, -30], "body": [-44, -93, 56, -1], "tail": [56, -65, 80, -31] }
299  ] },
300  "AN_TOPPLE": { "hurt": [
301    { "head": [-112, -107, -72, -32], "body": [-72, -101, 64, 0], "tail": [64, -66, 88, -3] },
302    { "head": [-80, -63, -48, 0], "body": [-48, -88, 40, 0], "tail": [40, -102, 64, -40] },
303    { "head": [-88, -32, -56, 0], "body": [-56, -67, 48, 0], "tail": [48, -32, 80, 0] },
304    { "head": [-80, -57, -48, 0], "body": [-48, -57, 32, 0], "tail": [32, -37, 72, 0] }
305  ] },
306  "AN_GETUP": { "hurt": [
307    { "head": [-96, -56, -56, 0], "body": [-56, -60, 64, 0], "tail": [64, -36, 112, 0] },
308    { "head": [-104, -89, -72, -35], "body": [-72, -98, 32, -1], "tail": [32, -57, 72, -35] },
309    { "head": [-112, -111, -80, -60], "body": [-88, -111, 24, 0], "tail": [24, -57, 56, -28] },
310    { "head": [-96, -89, -64, -37], "body": [-64, -100, 32, -1], "tail": [32, -68, 64, -33] }
311  ] },
312  "AN_DEATH": { "hurt": [
313    { "head": [-88, -84, -56, -22], "body": [-56, -121, 72, 0], "tail": [72, -86, 120, -38] },
314    { "head": [-72, -97, -40, -20], "body": [-48, -97, 56, -5], "tail": [56, -74, 96, -5] },
315    { "head": [-88, -52, -56, 0], "body": [-56, -76, 48, 0], "tail": [48, -31, 88, 0] },
316    { "head": [-88, -43, -56, 0], "body": [-56, -62, 40, 0], "tail": [40, -35, 72, 0] }
317  ] },
318  "BA_IDLE": { "hurt": [
319    { "wing_near": [-47, -79, -7, -43], "wing_far": [-35, -91, 9, -69], "head": [-83, -83, -43, -39], "body": [-43, -91, 34, -4], "tail": [34, -73, 94, -4] },
320    { "wing_near": [-42, -84, -2, -48], "wing_far": [-30, -96, 14, -74], "head": [-78, -89, -38, -45], "body": [-38, -96, 34, 0], "tail": [34, -73, 94, 0] },
321    { "wing_near": [-48, -87, -8, -51], "wing_far": [-36, -99, 8, -77], "head": [-84, -88, -44, -44], "body": [-44, -99, 40, -4], "tail": [40, -93, 100, -4] },
322    { "wing_near": [-46, -87, -6, -51], "wing_far": [-34, -99, 10, -77], "head": [-82, -93, -42, -49], "body": [-42, -99, 35, -4], "tail": [35, -75, 95, -4] }
323  ] },
324  "BA_WALK": { "hurt": [
325    { "wing_near": [-54, -78, -14, -42], "wing_far": [-42, -90, 2, -68], "head": [-90, -83, -50, -39], "body": [-50, -90, 28, -3], "tail": [28, -74, 88, -3] },
326    { "wing_near": [-53, -81, -13, -45], "wing_far": [-41, -93, 3, -71], "head": [-89, -84, -49, -40], "body": [-49, -93, 28, 0], "tail": [28, -75, 88, 0] },
327    { "wing_near": [-55, -79, -15, -43], "wing_far": [-43, -91, 1, -69], "head": [-91, -82, -51, -38], "body": [-51, -91, 37, -1], "tail": [37, -73, 97, -1] },
328    { "wing_near": [-58, -77, -18, -41], "wing_far": [-46, -89, -2, -67], "head": [-94, -81, -54, -37], "body": [-54, -89, 33, -4], "tail": [33, -69, 93, -4] }
329  ] },
330  "BA01": { "hurt": [
331    { "wing_near": [-50, -75, -10, -39], "wing_far": [-38, -87, 6, -65], "head": [-86, -80, -46, -36], "body": [-46, -87, 35, -8], "tail": [35, -58, 95, -8] },
332    { "wing_near": [-52, -105, -12, -69], "wing_far": [-40, -117, 4, -95], "head": [-88, -88, -48, -44], "body": [-48, -117, 33, -10], "tail": [33, -132, 93, -3] },
333    { "wing_near": [-51, -144, -11, -108], "wing_far": [-39, -156, 5, -134], "head": [-87, -133, -47, -89], "body": [-47, -156, 35, -6], "tail": [35, -135, 95, 0] },
334    { "wing_near": [-46, -85, -6, -49], "wing_far": [-34, -97, 10, -75], "head": [-82, -84, -42, -40], "body": [-42, -97, 39, -7], "tail": [39, -90, 99, -7] }
335  ], "hit": { "pose": 2, "boxes": [[-100, -72, 40, -4]] } },
336  "BA02": { "hurt": [
337    { "wing_near": [-53, -88, -13, -52], "wing_far": [-41, -100, 3, -78], "head": [-89, -91, -49, -47], "body": [-49, -100, 30, -9], "tail": [30, -60, 90, -9] },
338    { "wing_near": [-41, -92, -1, -56], "wing_far": [-29, -104, 15, -82], "head": [-77, -103, -37, -59], "body": [-37, -104, 20, -1], "tail": [20, -87, 80, -1] },
339    { "wing_near": [-67, -89, -27, -53], "wing_far": [-55, -101, -11, -79], "head": [-103, -94, -63, -50], "body": [-63, -101, 28, -6], "tail": [28, -69, 88, 0] },
340    { "wing_near": [-53, -85, -13, -49], "wing_far": [-41, -97, 3, -75], "head": [-89, -89, -49, -45], "body": [-49, -97, 35, -8], "tail": [35, -78, 95, -8] }
341  ], "hit": { "pose": 2, "boxes": [[-92, -68, 80, -2]] } },
342  "BA03": { "hurt": [
343    { "wing_near": [-47, -91, -7, -55], "wing_far": [-35, -103, 9, -81], "head": [-83, -92, -43, -48], "body": [-43, -103, 33, -9], "tail": [33, -76, 93, -9] },
344    { "wing_near": [-38, -116, 2, -80], "wing_far": [-26, -128, 18, -106], "head": [-74, -101, -34, -57], "body": [-34, -128, 41, -4], "tail": [41, -128, 101, -4] },
345    { "wing_near": [-43, -100, -3, -64], "wing_far": [-31, -112, 13, -90], "head": [-79, -102, -39, -58], "body": [-39, -112, 48, 0], "tail": [48, -101, 108, -23] },
346    { "wing_near": [-47, -88, -7, -52], "wing_far": [-35, -100, 9, -78], "head": [-83, -93, -43, -49], "body": [-43, -100, 34, -10], "tail": [34, -59, 94, -10] }
347  ], "hit": { "pose": 2, "boxes": [[-120, -52, 132, -4]] } },
348  "BA04": { "hurt": [
349    { "wing_near": [-51, -84, -11, -48], "wing_far": [-39, -96, 5, -74], "head": [-87, -87, -47, -43], "body": [-47, -96, 24, 0], "tail": [24, -53, 84, 0] },
350    { "wing_near": [-60, -98, -20, -62], "wing_far": [-48, -110, -9, -88], "head": [-96, -106, -56, -62], "body": [-56, -110, -9, 0], "tail": [-9, -90, 51, 0] },
351    { "wing_near": [-24, -83, -3, -47], "wing_far": [-12, -95, -3, -73], "head": [-60, -95, -20, -51], "body": [-20, -95, -3, 0], "tail": [-3, -82, 57, 0] },
352    { "wing_near": [-30, -72, -6, -36], "wing_far": [-18, -84, -6, -62], "head": [-66, -80, -26, -36], "body": [-26, -84, -6, -6], "tail": [-6, -80, 54, -6] }
353  ], "hit": { "pose": 2, "boxes": [[-114, -64, -86, 0]] } },
354  "BA_STUMBLE": { "hurt": [
355    { "wing_near": [-70, -132, -30, -96], "wing_far": [-58, -144, -14, -122], "head": [-106, -155, -66, -111], "body": [-66, -144, 16, -5], "tail": [16, -153, 76, 0] },
356    { "wing_near": [-75, -89, -35, -53], "wing_far": [-63, -101, -19, -79], "head": [-111, -93, -71, -49], "body": [-71, -101, 11, -4], "tail": [11, -134, 71, -4] },
357    { "wing_near": [-82, -86, -42, -50], "wing_far": [-70, -98, -26, -76], "head": [-118, -84, -78, -40], "body": [-78, -98, 8, -11], "tail": [8, -122, 68, -11] },
358    { "wing_near": [-67, -95, -27, -59], "wing_far": [-55, -107, -11, -85], "head": [-103, -96, -63, -52], "body": [-63, -107, 17, -12], "tail": [17, -96, 77, -12] }
359  ] },
360  "BA_FLINCH": { "hurt": [
361    { "wing_near": [-52, -80, -12, -44], "wing_far": [-40, -92, 4, -70], "head": [-88, -82, -48, -38], "body": [-48, -92, 31, -3], "tail": [31, -60, 91, -3] },
362    { "wing_near": [-58, -114, -18, -78], "wing_far": [-46, -126, -2, -104], "head": [-94, -121, -54, -77], "body": [-54, -126, 20, 0], "tail": [20, -112, 80, 0] },
363    { "wing_near": [-54, -87, -14, -51], "wing_far": [-42, -99, 2, -77], "head": [-90, -85, -50, -41], "body": [-50, -99, 31, -3], "tail": [31, -68, 91, -3] },
364    { "wing_near": [-52, -80, -12, -44], "wing_far": [-40, -92, 4, -70], "head": [-88, -85, -48, -41], "body": [-48, -92, 33, -3], "tail": [33, -58, 93, -3] }
365  ] },
366  "BA_TOPPLE": { "hurt": [
367    { "wing_near": [-54, -112, -14, -76], "wing_far": [-42, -124, 2, -102], "head": [-90, -124, -50, -80], "body": [-50, -124, 25, 0], "tail": [25, -99, 85, 0] },
368    { "wing_near": [-53, -113, -13, -77], "wing_far": [-41, -125, 3, -103], "head": [-89, -120, -49, -76], "body": [-49, -125, 29, 0], "tail": [29, -103, 89, 0] },
369    { "wing_near": [-66, -80, -26, -44], "wing_far": [-54, -92, -10, -70], "head": [-102, -84, -62, -40], "body": [-62, -92, 26, 0], "tail": [26, -103, 86, 0] },
370    { "wing_near": [-57, -43, -17, -7], "wing_far": [-45, -55, -1, -33], "head": [-93, -44, -53, 0], "body": [-53, -55, 37, 0], "tail": [37, -33, 97, 0] }
371  ] },
372  "BA_GETUP": { "hurt": [
373    { "wing_near": [-49, -49, -9, -13], "wing_far": [-37, -61, 7, -39], "head": [-85, -57, -45, -13], "body": [-45, -61, 40, 0], "tail": [40, -32, 100, 0] },
374    { "wing_near": [-50, -87, -10, -51], "wing_far": [-38, -99, 6, -77], "head": [-86, -88, -46, -44], "body": [-46, -99, 29, -8], "tail": [29, -104, 89, -8] },
375    { "wing_near": [-49, -96, -9, -60], "wing_far": [-37, -108, 7, -86], "head": [-85, -100, -45, -56], "body": [-45, -108, 42, 0], "tail": [42, -134, 102, 0] },
376    { "wing_near": [-49, -90, -9, -54], "wing_far": [-37, -102, 7, -80], "head": [-85, -92, -45, -48], "body": [-45, -102, 35, 0], "tail": [35, -68, 95, 0] }
377  ] },
378  "BA_DEATH": { "hurt": [
379    { "wing_near": [-52, -99, -12, -63], "wing_far": [-40, -111, 4, -89], "head": [-88, -92, -48, -48], "body": [-48, -111, 31, -2], "tail": [31, -86, 91, -2] },
380    { "wing_near": [-51, -127, -11, -91], "wing_far": [-39, -139, 5, -117], "head": [-87, -104, -47, -60], "body": [-47, -139, 29, 0], "tail": [29, -119, 89, 0] },
381    { "wing_near": [-60, -87, -20, -51], "wing_far": [-48, -99, -4, -77], "head": [-96, -72, -56, -28], "body": [-56, -99, 35, -4], "tail": [35, -99, 95, -4] },
382    { "wing_near": [-54, -49, -14, -13], "wing_far": [-42, -61, 2, -39], "head": [-90, -47, -50, -3], "body": [-50, -61, 38, 0], "tail": [38, -42, 98, 0] }
383  ] },
384  "NA_IDLE": { "hurt": [
385    { "head": [-80, -75, -36, -35], "body": [-36, -90, 42, 0], "tail": [42, -116, 106, 0] },
386    { "head": [-72, -78, -28, -38], "body": [-28, -90, 33, -3], "tail": [33, -119, 97, -3] },
387    { "head": [-74, -79, -30, -39], "body": [-30, -90, 33, 0], "tail": [33, -122, 97, 0] },
388    { "head": [-73, -80, -29, -40], "body": [-29, -90, 34, -1], "tail": [34, -116, 98, -1] }
389  ] },
390  "NA_WALK": { "hurt": [
391    { "head": [-81, -76, -37, -36], "body": [-37, -90, 41, -1], "tail": [41, -119, 105, -1] },
392    { "head": [-77, -79, -33, -39], "body": [-33, -90, 36, -2], "tail": [36, -124, 100, -2] },
393    { "head": [-77, -83, -33, -43], "body": [-33, -90, 40, -1], "tail": [40, -126, 104, -1] },
394    { "head": [-73, -86, -29, -46], "body": [-29, -90, 35, 0], "tail": [35, -124, 99, 0] }
395  ] },
396  "NA01": { "hurt": [
397    { "head": [-91, -76, -47, -36], "body": [-47, -90, 59, -9], "tail": [59, -122, 123, -9] },
398    { "head": [-50, -98, -6, -58], "body": [-6, -90, 22, -13], "tail": [22, -132, 86, -13] },
399    { "head": [-83, -95, -39, -55], "body": [-39, -90, 66, -22], "tail": [66, -107, 130, 0] },
400    { "head": [-74, -89, -30, -49], "body": [-30, -90, 17, -13], "tail": [17, -132, 81, -13] }
401  ], "hit": { "pose": 2, "boxes": [[-100, -62, 48, -8]] } },
402  "NA02": { "hurt": [
403    { "head": [-83, -72, -39, -32], "body": [-39, -90, 45, 0], "tail": [45, -127, 109, 0] },
404    { "head": [36, -80, 80, -36], "body": [-18, -92, 36, 0], "tail": [-62, -130, -18, -4] },
405    { "head": [-136, -87, -92, -47], "body": [-92, -90, -5, 0], "tail": [-5, -90, 59, 0] },
406    { "head": [-60, -74, -16, -34], "body": [-16, -90, 43, 0], "tail": [43, -118, 107, 0] }
407  ], "hit": { "pose": 2, "boxes": [[-124, -44, 136, -4]] } },
408  "NA03": { "hurt": [
409    { "head": [-84, -76, -40, -36], "body": [-40, -90, 40, 0], "tail": [40, -123, 104, 0] },
410    { "head": [-71, -149, -27, -109], "body": [-27, -90, 14, -4], "tail": [14, -149, 78, -4] },
411    { "head": [-79, -98, -35, -58], "body": [-35, -90, 41, 0], "tail": [41, -106, 105, 0] },
412    { "head": [-64, -94, -20, -54], "body": [-20, -90, 37, -2], "tail": [37, -113, 101, -2] }
413  ], "hit": { "pose": 2, "boxes": [[-124, -92, -28, 0]] } },
414  "NA03X": { "hurt": [
415    { "head": [-89, -71, -45, -31], "body": [-45, -90, 39, 0], "tail": [39, -113, 103, 0] },
416    { "head": [-86, -69, -42, -29], "body": [-42, -90, 34, 0], "tail": [34, -150, 98, 0] },
417    { "head": [-93, -68, -49, -28], "body": [-49, -90, 36, 0], "tail": [36, -110, 100, 0] },
418    { "head": [-83, -133, -39, -93], "body": [-39, -90, 33, -1], "tail": [33, -141, 97, -1] },
419    { "head": [-93, -63, -49, -23], "body": [-49, -90, 35, 0], "tail": [35, -113, 99, 0] },
420    { "head": [-92, -57, -48, -17], "body": [-48, -75, 34, -1], "tail": [34, -111, 98, -1] }
421  ], "hit": { "pose": 2, "boxes": [[-124, -92, -28, 0]] }, "more": [{ "pose": 4, "boxes": [[-124, -92, -28, 0]] }] },
422  "NA04": { "hurt": [
423    { "head": [-80, -78, -36, -38], "body": [-36, -90, 43, 0], "tail": [43, -139, 107, 0] },
424    { "head": [28, -80, 72, -36], "body": [-35, -92, 28, 0], "tail": [-79, -130, -35, -4] },
425    { "head": [-27, -77, 17, -37], "body": [17, -90, 39, -4], "tail": [39, -133, 103, -4] },
426    { "head": [-66, -95, -22, -55], "body": [-22, -90, 36, 0], "tail": [36, -129, 100, 0] }
427  ], "hit": { "pose": 2, "boxes": [[-69, -53, -59, -43], [-69, -53, -59, -43], [-69, -53, -59, -43]] } },
428  "NA_ROAR": { "hurt": [
429    { "head": [-79, -78, -35, -38], "body": [-35, -90, 37, -1], "tail": [37, -127, 101, -1] },
430    { "head": [-72, -109, -28, -69], "body": [-28, -90, 29, -1], "tail": [29, -129, 93, -1] },
431    { "head": [-79, -132, -35, -92], "body": [-35, -90, 37, 0], "tail": [37, -134, 101, 0] },
432    { "head": [-76, -85, -32, -45], "body": [-32, -90, 32, -1], "tail": [32, -125, 96, -1] }
433  ] },
434  "NA_FLINCH": { "hurt": [
435    { "head": [-92, -69, -48, -29], "body": [-48, -90, 55, -1], "tail": [55, -126, 119, -1] },
436    { "head": [-50, -97, -6, -57], "body": [-6, -90, 48, 0], "tail": [48, -123, 112, 0] },
437    { "head": [-51, -83, -7, -43], "body": [-7, -90, 46, -4], "tail": [46, -109, 110, -4] },
438    { "head": [-52, -79, -8, -39], "body": [-8, -90, 49, -1], "tail": [49, -125, 113, -1] }
439  ] },
440  "NA_TOPPLE": { "hurt": [
441    { "head": [-88, -92, -44, -52], "body": [-44, -90, 38, 0], "tail": [38, -124, 102, 0] },
442    { "head": [-79, -78, -35, -38], "body": [-35, -90, 34, -1], "tail": [34, -128, 98, -1] },
443    { "head": [-68, -93, -24, -53], "body": [-24, -90, 40, 0], "tail": [40, -108, 104, 0] },
444    { "head": [-76, -40, -32, 0], "body": [-32, -47, 31, 0], "tail": [31, -31, 95, 0] }
445  ] },
446  "NA_GETUP": { "hurt": [
447    { "head": [-94, -49, -50, -9], "body": [-50, -61, 38, 0], "tail": [38, -48, 102, 0] },
448    { "head": [-80, -84, -36, -44], "body": [-36, -90, 29, -2], "tail": [29, -127, 93, -2] },
449    { "head": [-80, -85, -36, -45], "body": [-36, -90, 29, -2], "tail": [29, -127, 93, -2] },
450    { "head": [-82, -78, -38, -38], "body": [-38, -90, 25, 0], "tail": [25, -127, 89, 0] }
451  ] },
452  "NA_DEATH": { "hurt": [
453    { "head": [-81, -86, -37, -46], "body": [-37, -90, 34, 0], "tail": [34, -129, 98, 0] },
454    { "head": [-84, -82, -40, -42], "body": [-40, -90, 38, 0], "tail": [38, -130, 102, 0] },
455    { "head": [-77, -100, -33, -60], "body": [-33, -90, 36, -11], "tail": [36, -113, 100, -11] },
456    { "head": [-87, -39, -43, 0], "body": [-43, -49, 24, 0], "tail": [24, -35, 88, 0] }
457  ] },
458  "TI_IDLE": { "hurt": [
459    { "head": [-97, -87, -49, -43], "body": [-49, -100, 44, -2], "tail": [44, -114, 100, -2] },
460    { "head": [-101, -87, -53, -43], "body": [-53, -100, 49, -2], "tail": [49, -113, 105, -2] },
461    { "head": [-98, -89, -50, -45], "body": [-50, -100, 48, 0], "tail": [48, -123, 104, 0] },
462    { "head": [-95, -90, -47, -46], "body": [-47, -100, 44, -1], "tail": [44, -122, 100, -1] }
463  ] },
464  "TI_WALK": { "hurt": [
465    { "head": [-103, -102, -55, -58], "body": [-55, -100, 40, 0], "tail": [40, -130, 96, -3] },
466    { "head": [-109, -108, -61, -64], "body": [-61, -100, 39, -11], "tail": [39, -132, 95, -11] },
467    { "head": [-104, -106, -56, -62], "body": [-56, -100, 41, -10], "tail": [41, -135, 97, -10] },
468    { "head": [-102, -108, -54, -64], "body": [-54, -100, 38, -10], "tail": [38, -131, 94, -10] }
469  ] },
470  "TI02": { "hurt": [
471    { "head": [-90, -76, -42, -32], "body": [-42, -100, 53, -3], "tail": [53, -113, 109, -3] },
472    { "head": [-98, -121, -50, -77], "body": [-50, -100, 49, 0], "tail": [49, -118, 105, 0] },
473    { "head": [-92, -108, -44, -64], "body": [-44, -100, 52, -2], "tail": [52, -112, 108, -2] },
474    { "head": [-86, -82, -38, -38], "body": [-38, -100, 53, -2], "tail": [53, -128, 109, -2] }
475  ], "hit": { "pose": 2, "boxes": [[-124, -76, -52, -18]] } },
476  "TI03": { "hurt": [
477    { "head": [-92, -82, -44, -38], "body": [-44, -99, 51, -4], "tail": [51, -115, 107, -4] },
478    { "head": [-87, -117, -39, -73], "body": [-39, -100, 59, 0], "tail": [59, -122, 115, 0] },
479    { "head": [-85, -117, -37, -73], "body": [-37, -100, 57, 0], "tail": [57, -134, 113, 0] },
480    { "head": [-82, -100, -34, -56], "body": [-34, -100, 52, -4], "tail": [52, -113, 108, -4] }
481  ], "hit": { "pose": 2, "boxes": [[-132, -68, 136, -2]] } },
482  "TI04": { "hurt": [
483    { "head": [-92, -82, -44, -38], "body": [-44, -100, 49, 0], "tail": [49, -122, 105, 0] },
484    { "head": [-99, -146, -51, -102], "body": [-51, -100, 49, 0], "tail": [49, -111, 105, 0] },
485    { "head": [-88, -151, -40, -107], "body": [-40, -100, 46, 0], "tail": [46, -136, 102, 0] },
486    { "head": [-87, -80, -39, -36], "body": [-39, -100, 46, 0], "tail": [46, -131, 102, 0] }
487  ], "hit": { "pose": 2, "boxes": [[-108, -108, 96, 0]] } },
488  "TI01X": { "hurt": [
489    { "head": [-104, -94, -56, -50], "body": [-56, -100, 43, -2], "tail": [43, -145, 99, -2] },
490    { "head": [51, -100, 99, -50], "body": [-45, -100, 51, 0], "tail": [-101, -140, -45, -2] },
491    { "head": [51, -100, 99, -50], "body": [-49, -100, 51, 0], "tail": [-105, -140, -49, -2] },
492    { "head": [-93, -100, -45, -56], "body": [-45, -100, 55, -9], "tail": [55, -137, 111, -9] },
493    { "head": [-104, -110, -56, -66], "body": [-56, -100, 45, -1], "tail": [45, -144, 101, -3] },
494    { "head": [-103, -101, -55, -57], "body": [-55, -100, 42, -1], "tail": [42, -139, 98, -3] }
495  ], "hit": { "pose": 0, "boxes": [[-108, -76, 68, -2]] }, "more": [{ "pose": 2, "boxes": [[-68, -76, 108, -2]] }, { "pose": 4, "boxes": [[-108, -76, 68, -2]] }] },
496  "TI_TIRED": { "hurt": [
497    { "head": [-102, -104, -54, -60], "body": [-54, -100, 40, 0], "tail": [40, -129, 96, -1] },
498    { "head": [-109, -116, -61, -72], "body": [-61, -100, 38, -10], "tail": [38, -129, 94, -10] },
499    { "head": [-103, -115, -55, -71], "body": [-55, -100, 38, -10], "tail": [38, -140, 94, -10] },
500    { "head": [-103, -113, -55, -69], "body": [-55, -100, 34, -10], "tail": [34, -140, 90, -10] }
501  ] },
502  "TI_ROAR": { "hurt": [
503    { "head": [-98, -97, -50, -53], "body": [-50, -100, 44, -5], "tail": [44, -119, 100, -5] },
504    { "head": [-103, -153, -55, -109], "body": [-55, -100, 44, -4], "tail": [44, -120, 100, -4] },
505    { "head": [-100, -149, -52, -105], "body": [-52, -100, 45, 0], "tail": [45, -132, 101, 0] },
506    { "head": [-92, -94, -44, -50], "body": [-44, -100, 44, -5], "tail": [44, -120, 100, -5] }
507  ] },
508  "TI_FLINCH": { "hurt": [
509    { "head": [-94, -78, -46, -34], "body": [-46, -98, 49, -3], "tail": [49, -112, 105, -3] },
510    { "head": [-85, -123, -37, -79], "body": [-37, -100, 45, 0], "tail": [45, -124, 101, 0] },
511    { "head": [-79, -91, -31, -47], "body": [-31, -100, 49, -3], "tail": [49, -117, 105, -3] },
512    { "head": [-92, -79, -44, -35], "body": [-44, -97, 44, -4], "tail": [44, -112, 100, -4] }
513  ] },
514  "TI_TOPPLE": { "hurt": [
515    { "head": [-102, -100, -54, -56], "body": [-54, -100, 37, 0], "tail": [37, -116, 93, 0] },
516    { "head": [-99, -93, -51, -49], "body": [-51, -100, 49, 0], "tail": [49, -130, 105, 0] },
517    { "head": [-109, -80, -61, -36], "body": [-61, -100, 40, -6], "tail": [40, -89, 96, -6] },
518    { "head": [-102, -44, -54, 0], "body": [-54, -60, 39, 0], "tail": [39, -71, 95, 0] }
519  ] },
520  "TI_GETUP": { "hurt": [
521    { "head": [-97, -61, -49, -17], "body": [-49, -68, 45, 0], "tail": [45, -94, 101, 0] },
522    { "head": [-98, -71, -50, -27], "body": [-50, -100, 48, 0], "tail": [48, -127, 104, 0] },
523    { "head": [-99, -113, -51, -69], "body": [-51, -100, 43, 0], "tail": [43, -122, 99, 0] },
524    { "head": [-86, -88, -38, -44], "body": [-38, -100, 50, -4], "tail": [50, -128, 106, -4] }
525  ] },
526  "TI_DEATH": { "hurt": [
527    { "head": [-101, -97, -53, -53], "body": [-53, -100, 39, 0], "tail": [39, -121, 95, 0] },
528    { "head": [-121, -91, -73, -47], "body": [-73, -100, 34, 0], "tail": [34, -114, 90, 0] },
529    { "head": [-120, -62, -72, -18], "body": [-72, -78, 36, 0], "tail": [36, -85, 92, 0] },
530    { "head": [-118, -50, -70, -6], "body": [-70, -63, 23, 0], "tail": [23, -69, 79, 0] }
531  ] },
532  "ZI_IDLE": { "hurt": [
533    { "head": [-94, -115, -50, -71], "body": [-50, -100, 36, 0], "tail": [36, -140, 96, 0] },
534    { "head": [-95, -101, -51, -57], "body": [-51, -100, 34, -1], "tail": [34, -139, 94, -1] },
535    { "head": [-91, -111, -47, -67], "body": [-47, -100, 32, -1], "tail": [32, -141, 92, -1] },
536    { "head": [-95, -102, -51, -58], "body": [-51, -100, 34, 0], "tail": [34, -144, 94, 0] }
537  ] },
538  "ZI_WALK": { "hurt": [
539    { "head": [-95, -116, -51, -72], "body": [-51, -100, 39, -1], "tail": [39, -145, 99, -1] },
540    { "head": [-92, -115, -48, -71], "body": [-48, -100, 40, -1], "tail": [40, -145, 100, -1] },
541    { "head": [-91, -112, -47, -68], "body": [-47, -100, 37, -1], "tail": [37, -143, 97, -1] },
542    { "head": [-92, -112, -48, -68], "body": [-48, -100, 39, 0], "tail": [39, -144, 99, 0] }
543  ] },
544  "ZI01X": { "hurt": [
545    { "head": [-92, -101, -48, -57], "body": [-48, -100, 35, 0], "tail": [35, -143, 95, 0] },
546    { "head": [-95, -91, -51, -47], "body": [-51, -100, 31, -7], "tail": [31, -144, 91, -7] },
547    { "head": [-86, -111, -42, -67], "body": [-42, -100, 35, 0], "tail": [35, -141, 95, 0] },
548    { "head": [-90, -94, -46, -50], "body": [-46, -100, 32, 0], "tail": [32, -140, 92, 0] },
549    { "head": [-96, -143, -52, -99], "body": [-52, -100, 28, 0], "tail": [28, -130, 88, 0] },
550    { "head": [-90, -91, -46, -47], "body": [-46, -100, 35, -1], "tail": [35, -138, 95, -1] },
551    { "head": [-93, -98, -49, -54], "body": [-49, -100, 28, -17], "tail": [28, -131, 88, -17] }
552  ], "hit": { "pose": 1, "boxes": [[-108, -88, -20, 0]] }, "more": [{ "pose": 3, "boxes": [[-108, -88, -20, 0]] }, { "pose": 5, "boxes": [[-108, -88, -20, 0]] }] },
553  "ZI02": { "hurt": [
554    { "head": [-89, -102, -45, -58], "body": [-45, -100, 40, -2], "tail": [40, -120, 100, -2] },
555    { "head": [-69, -109, -25, -65], "body": [-25, -100, 21, -6], "tail": [21, -136, 81, -6] },
556    { "head": [-94, -100, -50, -56], "body": [-50, -100, 40, 0], "tail": [40, -113, 100, -5] },
557    { "head": [-81, -98, -37, -54], "body": [-37, -100, 41, -2], "tail": [41, -116, 101, -2] }
558  ], "hit": { "pose": 2, "boxes": [[-120, -64, 112, -4]] } },
559  "ZI03": { "hurt": [
560    { "head": [-83, -101, -39, -57], "body": [-39, -100, 42, -2], "tail": [42, -125, 102, -2] },
561    { "head": [-80, -113, -36, -69], "body": [-36, -100, 41, -8], "tail": [41, -116, 101, -26] },
562    { "head": [-91, -78, -47, -34], "body": [-47, -100, 44, -9], "tail": [44, -105, 104, -9] },
563    { "head": [-87, -93, -43, -49], "body": [-43, -100, 36, 0], "tail": [36, -121, 96, 0] }
564  ], "hit": { "pose": 2, "boxes": [[-96, -80, 88, 0]] } },
565  "ZI04": { "hurt": [
566    { "head": [-87, -96, -43, -52], "body": [-43, -100, 39, -4], "tail": [39, -118, 99, -4] },
567    { "head": [-88, -96, -44, -52], "body": [-44, -100, 32, -4], "tail": [32, -141, 92, -4] },
568    { "head": [-97, -122, -53, -78], "body": [-53, -100, 37, -2], "tail": [37, -139, 97, -2] },
569    { "head": [-83, -108, -39, -64], "body": [-39, -100, 38, 0], "tail": [38, -132, 98, 0] }
570  ], "hit": { "pose": 2, "boxes": [[-112, -112, 112, 0]] } },
571  "ZI_CHARGE": { "hurt": [
572    { "head": [-89, -103, -45, -59], "body": [-45, -100, 34, 0], "tail": [34, -121, 94, 0] },
573    { "head": [-91, -100, -47, -56], "body": [-47, -100, 34, 0], "tail": [34, -121, 94, 0] },
574    { "head": [-90, -109, -46, -65], "body": [-46, -100, 32, 0], "tail": [32, -127, 92, 0] },
575    { "head": [-93, -109, -49, -65], "body": [-49, -100, 30, 0], "tail": [30, -127, 90, 0] }
576  ] },
577  "ZI_SUPER": { "hurt": [
578    { "head": [-92, -113, -48, -69], "body": [-48, -100, 30, 0], "tail": [30, -131, 90, 0] },
579    { "head": [-94, -107, -50, -63], "body": [-50, -100, 30, -1], "tail": [30, -129, 90, -1] },
580    { "head": [-91, -113, -47, -69], "body": [-47, -100, 28, -2], "tail": [28, -133, 88, -2] },
581    { "head": [-93, -104, -49, -60], "body": [-49, -100, 31, -2], "tail": [31, -125, 91, -2] }
582  ] },
583  "ZI_DISCHARGE": { "hurt": [
584    { "head": [-92, -118, -48, -74], "body": [-48, -100, 31, 0], "tail": [31, -134, 91, 0] },
585    { "head": [-86, -115, -42, -71], "body": [-42, -100, 34, 0], "tail": [34, -132, 94, 0] },
586    { "head": [-93, -99, -49, -55], "body": [-49, -100, 31, -4], "tail": [31, -129, 91, -4] },
587    { "head": [-92, -94, -48, -50], "body": [-48, -100, 34, 0], "tail": [34, -119, 94, 0] }
588  ] },
589  "ZI_BREAK": { "hurt": [
590    { "head": [-93, -122, -49, -78], "body": [-49, -100, 36, -2], "tail": [36, -140, 96, -2] },
591    { "head": [-87, -128, -43, -84], "body": [-43, -100, 38, 0], "tail": [38, -140, 98, 0] },
592    { "head": [-91, -104, -47, -60], "body": [-47, -100, 36, -5], "tail": [36, -134, 96, -5] },
593    { "head": [-91, -109, -47, -65], "body": [-47, -100, 34, -5], "tail": [34, -126, 94, -5] }
594  ] },
595  "ZI_FLINCH": { "hurt": [
596    { "head": [-90, -105, -46, -61], "body": [-46, -100, 34, 0], "tail": [34, -127, 94, 0] },
597    { "head": [-86, -109, -42, -65], "body": [-42, -100, 34, 0], "tail": [34, -126, 94, 0] },
598    { "head": [-89, -95, -45, -51], "body": [-45, -100, 30, -5], "tail": [30, -122, 90, -5] },
599    { "head": [-88, -101, -44, -57], "body": [-44, -100, 32, 0], "tail": [32, -127, 92, 0] }
600  ] },
601  "ZI_TOPPLE": { "hurt": [
602    { "head": [-94, -124, -50, -80], "body": [-50, -100, 34, 0], "tail": [34, -152, 94, 0] },
603    { "head": [-70, -99, -26, -55], "body": [-26, -100, 52, -4], "tail": [52, -139, 112, -4] },
604    { "head": [-82, -90, -38, -46], "body": [-38, -100, 50, -12], "tail": [50, -90, 110, -12] },
605    { "head": [-99, -71, -55, -27], "body": [-55, -74, 32, 0], "tail": [32, -59, 92, 0] }
606  ] },
607  "ZI_GETUP": { "hurt": [
608    { "head": [-96, -74, -52, -30], "body": [-52, -79, 37, 0], "tail": [37, -61, 97, 0] },
609    { "head": [-99, -97, -55, -53], "body": [-55, -100, 25, 0], "tail": [25, -136, 85, 0] },
610    { "head": [-102, -128, -58, -84], "body": [-58, -100, 17, -5], "tail": [17, -138, 77, -5] },
611    { "head": [-99, -115, -55, -71], "body": [-55, -100, 26, -5], "tail": [26, -140, 86, -5] }
612  ] },
613  "ZI_DEATH": { "hurt": [
614    { "head": [-88, -103, -44, -59], "body": [-44, -100, 38, 0], "tail": [38, -131, 98, 0] },
615    { "head": [-85, -81, -41, -37], "body": [-41, -100, 41, -4], "tail": [41, -125, 101, -4] },
616    { "head": [-77, -80, -33, -36], "body": [-33, -90, 56, -8], "tail": [56, -61, 116, -8] },
617    { "head": [-78, -64, -34, -20], "body": [-34, -68, 47, 0], "tail": [47, -63, 107, 0] }
618  ] },
619  "RN_IDLE": { "hurt": [
620    { "spike_head": [-83, -102, -41, -74], "spike_arm_near": [-51, -64, -19, -8], "spike_arm_far": [-25, -72, 7, -12], "head": [-89, -92, -45, -48], "body": [-45, -100, 38, 0], "tail": [38, -104, 88, 0] },
621    { "spike_head": [-84, -107, -42, -79], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -97, -46, -53], "body": [-46, -100, 36, 0], "tail": [36, -107, 86, 0] },
622    { "spike_head": [-82, -108, -40, -80], "spike_arm_near": [-50, -64, -18, -8], "spike_arm_far": [-24, -72, 8, -12], "head": [-88, -98, -44, -54], "body": [-44, -100, 35, 0], "tail": [35, -105, 85, 0] },
623    { "spike_head": [-82, -107, -40, -79], "spike_arm_near": [-50, -64, -18, -8], "spike_arm_far": [-24, -72, 8, -12], "head": [-88, -97, -44, -53], "body": [-44, -100, 34, 0], "tail": [34, -102, 84, 0] }
624  ] },
625  "RN_WALK": { "hurt": [
626    { "spike_head": [-82, -105, -40, -77], "spike_arm_near": [-50, -64, -18, -8], "spike_arm_far": [-24, -72, 8, -12], "head": [-88, -95, -44, -51], "body": [-44, -100, 40, 0], "tail": [40, -101, 90, 0] },
627    { "spike_head": [-84, -104, -42, -76], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -94, -46, -50], "body": [-46, -100, 40, 0], "tail": [40, -101, 90, 0] },
628    { "spike_head": [-84, -101, -42, -73], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -91, -46, -47], "body": [-46, -100, 40, 0], "tail": [40, -101, 90, 0] },
629    { "spike_head": [-81, -102, -39, -74], "spike_arm_near": [-49, -64, -17, -8], "spike_arm_far": [-23, -72, 9, -12], "head": [-87, -92, -43, -48], "body": [-43, -100, 38, -3], "tail": [38, -101, 88, -3] }
630  ] },
631  "RN01": { "hurt": [
632    { "spike_head": [-84, -104, -42, -76], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -94, -46, -50], "body": [-46, -100, 37, -1], "tail": [37, -115, 87, -1] },
633    { "spike_head": [-81, -128, -39, -100], "spike_arm_near": [-49, -64, -17, -8], "spike_arm_far": [-23, -72, 9, -12], "head": [-87, -118, -43, -74], "body": [-43, -100, 32, 0], "tail": [32, -110, 82, 0] },
634    { "spike_head": [-99, -112, -57, -84], "spike_arm_near": [-67, -64, -35, -8], "spike_arm_far": [-41, -72, -9, -12], "head": [-105, -102, -61, -58], "body": [-61, -100, 29, -1], "tail": [29, -112, 79, -1] },
635    { "spike_head": [-84, -101, -42, -73], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -91, -46, -47], "body": [-46, -100, 32, -1], "tail": [32, -103, 82, -1] }
636  ], "hit": { "pose": 2, "boxes": [[-112, -86, -4, -4]] } },
637  "RN02": { "hurt": [
638    { "spike_head": [-84, -106, -42, -78], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -96, -46, -52], "body": [-46, -100, 37, 0], "tail": [37, -109, 87, 0] },
639    { "spike_head": [-86, -111, -44, -83], "spike_arm_near": [-54, -64, -22, -8], "spike_arm_far": [-28, -72, 4, -12], "head": [-92, -101, -48, -57], "body": [-48, -100, 42, 0], "tail": [42, -103, 92, 0] },
640    { "spike_head": [-102, -108, -60, -80], "spike_arm_near": [-70, -64, -38, -8], "spike_arm_far": [-44, -72, -12, -12], "head": [-108, -98, -64, -54], "body": [-64, -100, 26, 0], "tail": [26, -113, 76, 0] },
641    { "spike_head": [-81, -100, -39, -72], "spike_arm_near": [-49, -64, -17, -8], "spike_arm_far": [-23, -72, 9, -12], "head": [-87, -90, -43, -46], "body": [-43, -100, 30, -4], "tail": [30, -108, 80, -4] }
642  ], "hit": { "pose": 2, "boxes": [[-104, -90, 54, -2]] } },
643  "RN03": { "hurt": [
644    { "spike_head": [-83, -108, -41, -80], "spike_arm_near": [-51, -64, -19, -8], "spike_arm_far": [-25, -72, 7, -12], "head": [-89, -98, -45, -54], "body": [-45, -100, 36, 0], "tail": [36, -122, 86, 0] },
645    { "spike_head": [-82, -116, -40, -88], "spike_arm_near": [-50, -64, -18, -8], "spike_arm_far": [-24, -72, 8, -12], "head": [-88, -106, -44, -62], "body": [-44, -100, 42, 0], "tail": [42, -111, 92, 0] },
646    { "spike_head": [-64, -119, -22, -91], "spike_arm_near": [-32, -64, 0, -8], "spike_arm_far": [-6, -72, 26, -12], "head": [-70, -109, -26, -65], "body": [-26, -100, 3, 0], "tail": [3, -124, 53, 0] },
647    { "spike_head": [-81, -105, -39, -77], "spike_arm_near": [-49, -64, -17, -8], "spike_arm_far": [-23, -72, 9, -12], "head": [-87, -95, -43, -51], "body": [-43, -100, 36, 0], "tail": [36, -116, 86, 0] }
648  ], "hit": { "pose": 2, "boxes": [[-70, -63, -59, -52], [-70, -63, -59, -52], [-70, -63, -59, -52]] } },
649  "RN04": { "hurt": [
650    { "spike_head": [-79, -142, -37, -114], "spike_arm_near": [-47, -64, -15, -8], "spike_arm_far": [-21, -72, 11, -12], "head": [-85, -132, -41, -88], "body": [-41, -100, 41, 0], "tail": [41, -148, 91, 0] },
651    { "spike_head": [-82, -164, -40, -136], "spike_arm_near": [-50, -64, -18, -8], "spike_arm_far": [-24, -72, 8, -12], "head": [-88, -154, -44, -110], "body": [-44, -100, 41, -6], "tail": [41, -162, 91, 0] },
652    { "spike_head": [-46, -193, -4, -165], "spike_arm_near": [-14, -64, 18, -8], "spike_arm_far": [12, -72, 44, -12], "head": [-52, -183, -8, -139], "body": [-8, -100, 62, 0], "tail": [62, -43, 112, 0] },
653    { "spike_head": [-79, -127, -37, -99], "spike_arm_near": [-47, -64, -15, -8], "spike_arm_far": [-21, -72, 11, -12], "head": [-85, -117, -41, -73], "body": [-41, -100, 42, 0], "tail": [42, -122, 92, 0] }
654  ], "hit": { "pose": 2, "boxes": [[-97, -101, 86, 0]] } },
655  "RN_GROW": { "hurt": [
656    { "spike_head": [-83, -118, -41, -90], "spike_arm_near": [-51, -64, -19, -8], "spike_arm_far": [-25, -72, 7, -12], "head": [-89, -108, -45, -64], "body": [-45, -100, 36, -1], "tail": [36, -125, 86, -1] },
657    { "spike_head": [-85, -122, -43, -94], "spike_arm_near": [-53, -64, -21, -8], "spike_arm_far": [-27, -72, 5, -12], "head": [-91, -112, -47, -68], "body": [-47, -100, 36, -1], "tail": [36, -136, 86, -1] },
658    { "spike_head": [-85, -128, -43, -100], "spike_arm_near": [-53, -64, -21, -8], "spike_arm_far": [-27, -72, 5, -12], "head": [-91, -118, -47, -74], "body": [-47, -100, 36, 0], "tail": [36, -128, 86, 0] },
659    { "spike_head": [-83, -131, -41, -103], "spike_arm_near": [-51, -64, -19, -8], "spike_arm_far": [-25, -72, 7, -12], "head": [-89, -121, -45, -77], "body": [-45, -100, 35, -1], "tail": [35, -127, 85, -1] }
660  ] },
661  "RN_HARDEN": { "hurt": [
662    { "spike_head": [-84, -127, -42, -99], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -117, -46, -73], "body": [-46, -100, 37, -1], "tail": [37, -145, 87, -1] },
663    { "spike_head": [-85, -130, -43, -102], "spike_arm_near": [-53, -64, -21, -8], "spike_arm_far": [-27, -72, 5, -12], "head": [-91, -120, -47, -76], "body": [-47, -100, 38, 0], "tail": [38, -144, 88, 0] },
664    { "spike_head": [-84, -131, -42, -103], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -121, -46, -77], "body": [-46, -100, 39, 0], "tail": [39, -139, 89, 0] },
665    { "spike_head": [-83, -135, -41, -107], "spike_arm_near": [-51, -64, -19, -8], "spike_arm_far": [-25, -72, 7, -12], "head": [-89, -125, -45, -81], "body": [-45, -100, 38, 0], "tail": [38, -138, 88, 0] }
666  ] },
667  "RN_BREAK": { "hurt": [
668    { "spike_head": [-84, -137, -42, -109], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -127, -46, -83], "body": [-46, -100, 37, 0], "tail": [37, -147, 87, 0] },
669    { "spike_head": [-85, -136, -43, -108], "spike_arm_near": [-53, -64, -21, -8], "spike_arm_far": [-27, -72, 5, -12], "head": [-91, -126, -47, -82], "body": [-47, -100, 38, 0], "tail": [38, -155, 88, 0] },
670    { "spike_head": [-82, -131, -40, -103], "spike_arm_near": [-50, -64, -18, -8], "spike_arm_far": [-24, -72, 8, -12], "head": [-88, -121, -44, -77], "body": [-44, -100, 37, 0], "tail": [37, -146, 87, 0] },
671    { "spike_head": [-83, -135, -41, -107], "spike_arm_near": [-51, -64, -19, -8], "spike_arm_far": [-25, -72, 7, -12], "head": [-89, -125, -45, -81], "body": [-45, -100, 36, 0], "tail": [36, -155, 86, 0] }
672  ] },
673  "RN_FLINCH": { "hurt": [
674    { "spike_head": [-85, -112, -43, -84], "spike_arm_near": [-53, -64, -21, -8], "spike_arm_far": [-27, -72, 5, -12], "head": [-91, -102, -47, -58], "body": [-47, -100, 40, 0], "tail": [40, -115, 90, 0] },
675    { "spike_head": [-84, -140, -42, -112], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -130, -46, -86], "body": [-46, -100, 32, 0], "tail": [32, -121, 82, 0] },
676    { "spike_head": [-86, -108, -44, -80], "spike_arm_near": [-54, -64, -22, -8], "spike_arm_far": [-28, -72, 4, -12], "head": [-92, -98, -48, -54], "body": [-48, -100, 35, 0], "tail": [35, -111, 85, 0] },
677    { "spike_head": [-84, -111, -42, -83], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -101, -46, -57], "body": [-46, -100, 36, 0], "tail": [36, -118, 86, 0] }
678  ] },
679  "RN_TOPPLE": { "hurt": [
680    { "spike_head": [-83, -120, -41, -92], "spike_arm_near": [-51, -64, -19, -8], "spike_arm_far": [-25, -72, 7, -12], "head": [-89, -110, -45, -66], "body": [-45, -100, 42, 0], "tail": [42, -122, 92, 0] },
681    { "spike_head": [-81, -141, -39, -113], "spike_arm_near": [-49, -64, -17, -8], "spike_arm_far": [-23, -72, 9, -12], "head": [-87, -131, -43, -87], "body": [-43, -100, 46, 0], "tail": [46, -115, 96, 0] },
682    { "spike_head": [-86, -97, -44, -69], "spike_arm_near": [-54, -64, -22, -8], "spike_arm_far": [-28, -72, 4, -12], "head": [-92, -87, -48, -43], "body": [-48, -100, 42, -5], "tail": [42, -106, 92, -5] },
683    { "spike_head": [-80, -85, -38, -57], "spike_arm_near": [-48, -64, -16, -8], "spike_arm_far": [-22, -72, 10, -12], "head": [-86, -75, -42, -31], "body": [-42, -86, 44, 0], "tail": [44, -64, 94, 0] }
684  ] },
685  "RN_GETUP": { "hurt": [
686    { "spike_head": [-78, -80, -36, -52], "spike_arm_near": [-46, -64, -14, -8], "spike_arm_far": [-20, -72, 12, -12], "head": [-84, -70, -40, -26], "body": [-40, -92, 48, 0], "tail": [48, -62, 98, 0] },
687    { "spike_head": [-80, -106, -38, -78], "spike_arm_near": [-48, -64, -16, -8], "spike_arm_far": [-22, -72, 10, -12], "head": [-86, -96, -42, -52], "body": [-42, -100, 46, 0], "tail": [46, -134, 96, 0] },
688    { "spike_head": [-80, -135, -38, -107], "spike_arm_near": [-48, -64, -16, -8], "spike_arm_far": [-22, -72, 10, -12], "head": [-86, -125, -42, -81], "body": [-42, -100, 46, -4], "tail": [46, -130, 96, -4] },
689    { "spike_head": [-75, -124, -33, -96], "spike_arm_near": [-43, -64, -11, -8], "spike_arm_far": [-17, -72, 15, -12], "head": [-81, -114, -37, -70], "body": [-37, -100, 46, 0], "tail": [46, -126, 96, 0] }
690  ] },
691  "RN_DEATH": { "hurt": [
692    { "spike_head": [-84, -120, -42, -92], "spike_arm_near": [-52, -64, -20, -8], "spike_arm_far": [-26, -72, 6, -12], "head": [-90, -110, -46, -66], "body": [-46, -100, 38, 0], "tail": [38, -121, 88, 0] },
693    { "spike_head": [-74, -138, -32, -110], "spike_arm_near": [-42, -64, -10, -8], "spike_arm_far": [-16, -72, 16, -12], "head": [-80, -128, -36, -84], "body": [-36, -100, 51, 0], "tail": [51, -107, 101, 0] },
694    { "spike_head": [-89, -97, -47, -69], "spike_arm_near": [-57, -64, -25, -8], "spike_arm_far": [-31, -72, 1, -12], "head": [-95, -87, -51, -43], "body": [-51, -95, 39, 0], "tail": [39, -63, 89, 0] },
695    { "spike_head": [-69, -76, -27, -48], "spike_arm_near": [-37, -64, -5, -8], "spike_arm_far": [-11, -72, 21, -12], "head": [-75, -66, -31, -22], "body": [-31, -71, 55, 0], "tail": [55, -43, 105, 0] }
696  ] }
697}
698
hooks/monsters/types.ts 137 lines
1import type { HuntGame as Game, HuntRun, MonsterId } from '../../types'
2import type { Move } from '../data'
3import type { SceneId } from '../sprites'
4
5/**
6 * A monster species as data: its moves, attacks and tuning, and (optionally)
7 * its own mechanic. The engine reads only this; nothing species-specific is
8 * hard-coded there. Every number is this project's adaptation, not game data.
9 */
10
11/** Decision bands on the horizontal gap between the two body boxes (spec 3.3). */
12export type Range = 'near' | 'mid' | 'far'
13export const RANGE_BANDS: Readonly<Record<Range, readonly [number, number]>> = { near: [0, 32], mid: [32, 96], far: [96, 160] }
14
15/** Effects drawn by the mod on live hit boxes; `mark` is a dive's locked landing spot. */
16export type Fx = 'spit' | 'flame' | 'tornado' | 'spikes' | 'mark'
17
18export type MonsterMove = Move & {
19  /** Shown when the move starts. */
20  label?: string
21  /** Shown when it hits the hunter. */
22  hitLabel?: string
23  /** Pixels the root moves forward, spread over the active pose; stops at the hunter's body. */
24  advance?: number
25  /**
26   * A leap: [tick, y] keys of the root's height (negative = up), linear between
27   * them and 0 outside; the drawing and every box follow it (spec 3.3).
28   */
29  lift?: readonly (readonly [number, number])[]
30  /** A projectile: pixels per tick its hit box flies forward from the active pose's first tick. */
31  travel?: number
32  /** Decision bands it may be chosen in; without them, only when its boxes would reach. */
33  range?: readonly Range[]
34  /** The species state it needs (e.g. 'inflated'); without it, any state the species allows. */
35  state?: string
36  /** Ticks after the move ends before it may be chosen again. */
37  cooldown?: number
38  /** The move that follows when this one ends (default: back to idle). */
39  then?: string
40  /** The hunter treats it as an opening for its strongest swing. */
41  opening?: boolean
42  /** Idle and walk moves loop their poses; every other move plays once. */
43  loop?: boolean
44  /** An effect drawn on the live hit box (kept out of the monster's frames). */
45  fx?: Fx
46  /**
47   * A combo: the sprite frame each tick segment shows (default: segment i shows
48   * frame i), so key poses can repeat or come from a combo board.
49   */
50  frames?: readonly number[]
51  /**
52   * Every active segment, each its own attack group (a fresh contact record, so
53   * each may land once); default `[active]`. `active` stays the first.
54   */
55  actives?: readonly number[]
56  /** Segments where the monster faces the other way as drawn: it moves backwards and its push box mirrors. */
57  turned?: readonly number[]
58  /** Projectile spread: vertical pixels per tick for each hit box (one volley, one group). */
59  spread?: readonly number[]
60  /** A dive: at this tick the landing spot is locked (the nearest legal spot to the hunter), then never follows. */
61  seek?: number
62  /** A ground hazard placed ahead: chosen only when all its boxes fit the arena. */
63  hazard?: boolean
64  /** From this tick on the move is an opening for the hunter (e.g. a tail stuck in the ground). */
65  openFrom?: number
66}
67
68/** The engine's hands, lent to a mechanic so it never imports the engine. */
69export type MonsterCtx = {
70  g: Game
71  act(move: string): void
72  say(text: string): void
73}
74
75/** A species' own rules; every hook is called only during a fight. */
76export type Mechanic = {
77  /** State and parts of a new hunt. */
78  initial(maxHp: number): { state: string; parts: HuntRun['parts'] }
79  /** The idle and walk moves for the current state. */
80  idleMove(g: Game): string
81  walkMove(g: Game): string
82  /** Walk speed factor for the current state. */
83  speed(g: Game): number
84  /** The push box for the current state, when it differs from the species' own (e.g. an inflated belly). */
85  bounds?(g: Game): readonly [number, number]
86  /** Every fight tick, after the contacts settled and before the monster moves on (e.g. a move just finished). */
87  tick?(c: MonsterCtx): void
88  /** At a safe decision point (monster idle, wait over): true when it started a move. */
89  decide(c: MonsterCtx): boolean
90  /** False when it should not walk in while it has attacks to choose (its `approachWeight` is skipped). */
91  walksIn?(g: Game): boolean
92  /** A move may be chosen in the current state. */
93  allows(g: Game, move: string): boolean
94  /** After damage landed on `part`: true when it took over the reaction (e.g. a part broke). */
95  damaged(c: MonsterCtx, part: string, damage: number): boolean
96  /** HUD status text for the current state, or null. */
97  status(g: Game): string | null
98}
99
100export type MonsterDef = {
101  id: MonsterId
102  definitionVersion: number
103  name: string
104  species: string
105  /** Only enabled species are drawn for new hunts. */
106  enabled: boolean
107  /** New hunts take these grounds in turn; a hunt never changes ground. */
108  scenes: readonly SceneId[]
109  /** Minutes of fighting one hunt is tuned to last; HP is calibrated to it. */
110  targetMinutes: number
111  /** The way the art faces as drawn: -1 = left. */
112  assetFacing: -1
113  x: number
114  /** Leftmost the monster walks in to reach the hunter. */
115  minX: number
116  enterFromX: number
117  walkStep: number
118  idleTicks: readonly [number, number]
119  /** Damage taken between flinches, and between topples. */
120  flinchEvery: number
121  toppleEvery: number
122  /** Push box [minX, maxX] in the unmirrored local frame: the hunter never stands inside it. Spacing only. */
123  bounds: readonly [number, number]
124  /** Torso width for the decision bands (spec 3.3), when narrower than the push box; default `bounds`. */
125  torso?: readonly [number, number]
126  moves: Readonly<Record<string, MonsterMove>>
127  /** The moves it attacks with, in decision order. */
128  attacks: readonly string[]
129  /** Weight of walking in when attacks are also possible; without it, it walks only when none is. */
130  approachWeight?: number
131  /** The move played when slain (held until the hunter leaves). */
132  slainMove: string
133  /** Calibration runs a second pass at the first estimate (part durability scales with max HP). */
134  calibrateTwice?: boolean
135  mechanic?: Mechanic
136}
137
hooks/monsters/ruiner_nergigante.ts 153 lines
1import type { HuntGame as Game } from '../../types'
2import { durability, metersOf } from './common'
3import type { Mechanic, MonsterCtx, MonsterDef } from './types'
4
5/**
6 * 殲世滅盡龍 (spec 4.8): the boss. A claw sweep, a shoulder charge, a volley of
7 * metal spikes and, with at least one black spike, a dive. Its head and both
8 * forearms (near and far side) each carry spikes: bare, white or black. A fight
9 * starts white; a white spot hardens black after 20 s of fighting (at a safe
10 * decision point); a bare spot grows white again after 12 s. Hits on a spot
11 * wear its spikes (white 8%, black 12% of max HP): breaking white puts it down
12 * for 24 ticks, breaking black staggers it for 8; either leaves the spot bare.
13 * The dive locks its landing spot at tick 10, falls on [14,17) (body and shock
14 * wave one group, 4 damage at most) and spends every black spike when it ends.
15 *
16 * Spike stages live in `meters` (`<part>:stage` 0 bare / 1 white / 2 black,
17 * `<part>:t` its age in fight ticks); the wear in `parts`.
18 */
19
20const SPOTS = ['spike_head', 'spike_arm_near', 'spike_arm_far'] as const
21const BARE = 0
22const WHITE = 1
23const BLACK = 2
24const HARDEN_TICKS = 160
25const REGROW_TICKS = 96
26const WHITE_SHARE = 0.08
27const BLACK_SHARE = 0.12
28const DIVE_TICKS = 37
29
30const stage = (g: Game, spot: string): number => metersOf(g)[`${spot}:stage`] ?? WHITE
31const blacks = (g: Game): number => SPOTS.filter(s => stage(g, s) === BLACK).length
32const whites = (g: Game): number => SPOTS.filter(s => stage(g, s) === WHITE).length
33
34function setStage(g: Game, spot: string, to: number): void {
35  const m = metersOf(g)
36  m[`${spot}:stage`] = to
37  m[`${spot}:t`] = 0
38  const share = to === WHITE ? WHITE_SHARE : to === BLACK ? BLACK_SHARE : 0
39  g.hunt!.parts[spot] = { ...durability(g.hunt!.monsterHpMax, share), broken: to === BARE }
40}
41
42const mechanic: Mechanic = {
43  initial: maxHp => ({
44    state: 'normal',
45    parts: Object.fromEntries(SPOTS.map(s => [s, durability(maxHp, WHITE_SHARE)])),
46  }),
47  idleMove: g => (blacks(g) > 0 ? 'idleBlack' : 'idle'),
48  walkMove: () => 'walk',
49  speed: () => 1,
50  tick(c: MonsterCtx): void {
51    const m = metersOf(c.g)
52    for (const s of SPOTS) m[`${s}:t`] = (m[`${s}:t`] ?? 0) + 1
53    // the dive spends every black spike when it ends (not when it is cut short)
54    if (c.g.monster.move === 'dive' && c.g.monster.t >= DIVE_TICKS) for (const s of SPOTS) if (stage(c.g, s) === BLACK) setStage(c.g, s, BARE)
55  },
56  decide(c: MonsterCtx): boolean {
57    const m = metersOf(c.g)
58    const hardening = SPOTS.filter(s => stage(c.g, s) === WHITE && (m[`${s}:t`] ?? 0) >= HARDEN_TICKS)
59    if (hardening.length > 0) {
60      for (const s of hardening) setStage(c.g, s, BLACK)
61      c.act('harden')
62      c.say('棘硬化')
63      return true
64    }
65    const growing = SPOTS.filter(s => stage(c.g, s) === BARE && (m[`${s}:t`] ?? 0) >= REGROW_TICKS)
66    if (growing.length > 0) {
67      for (const s of growing) setStage(c.g, s, WHITE)
68      c.act('grow')
69      c.say('棘再生')
70      return true
71    }
72    return false
73  },
74  // with a black spike to dive on it holds its ground (the dive is a mid-range move; walking in would crowd it out)
75  walksIn: g => blacks(g) === 0,
76  allows: (g, move) => move !== 'dive' || blacks(g) > 0,
77  damaged(c: MonsterCtx, part: string, damage: number): boolean {
78    const was = (SPOTS as readonly string[]).includes(part) ? stage(c.g, part) : BARE
79    const own = c.g.hunt!.parts[part]
80    if (was === BARE || own === undefined) return false
81    own.durability = Math.max(0, own.durability - damage)
82    if (own.durability > 0) return false
83    setStage(c.g, part, BARE)
84    // a white break puts it down (once, however many break this tick); a black one staggers it
85    if (was === WHITE) {
86      if (c.g.monster.move !== 'spikeDown') c.act('spikeDown')
87      c.say('白棘破壞!')
88    } else {
89      if (c.g.monster.move !== 'spikeDown') c.act('spikeStagger')
90      c.say('黑棘破壞!')
91    }
92    return true
93  },
94  status: g => [whites(g) > 0 ? `白棘 ${whites(g)}` : '', blacks(g) > 0 ? `黑棘 ${blacks(g)}` : ''].filter(s => s !== '').join('・') || null,
95}
96
97export const RUINER_NERGIGANTE = {
98  id: 'ruiner_nergigante',
99  definitionVersion: 1,
100  enabled: true,
101  name: '殲世滅盡龍',
102  species: '古龍種',
103  targetMinutes: 7,
104  scenes: ['S05'],
105  assetFacing: -1,
106  x: 272,
107  minX: 140,
108  enterFromX: 460,
109  walkStep: 14,
110  idleTicks: [12, 28],
111  flinchEvery: 70,
112  toppleEvery: 200,
113  // the push box reaches the horns (head -90..-46); the decision torso takes in the neck (to -68)
114  bounds: [-88, 38],
115  torso: [-68, 38],
116  moves: {
117    idle: { action: 'RN_IDLE', ticks: [3, 3, 3, 3], loop: true },
118    walk: { action: 'RN_WALK', ticks: [3, 3, 3, 3], loop: true },
119    // with a black spike: the hardened look held
120    idleBlack: { action: 'RN_HARDEN', ticks: [0, 0, 0, 12], loop: true },
121    claw: {
122      action: 'RN01', ticks: [4, 3, 4, 12], active: 2, damage: 2, weight: 4, cooldown: 20, advance: 16,
123      range: ['near'], label: '前爪橫掃', hitLabel: '被前爪掃中',
124    },
125    shoulder: {
126      action: 'RN02', ticks: [5, 5, 5, 14], active: 2, damage: 3, weight: 3, cooldown: 28, advance: 96,
127      range: ['mid'], label: '肩部衝撞', hitLabel: '被肩撞擊中',
128    },
129    spikes: {
130      action: 'RN03', ticks: [5, 5, 12, 10], active: 2, damage: 2, weight: 2, cooldown: 32, travel: 28, spread: [-5, 0, 5],
131      fx: 'spikes', range: ['mid', 'far'], label: '金屬棘散射', hitLabel: '被金屬棘擊中',
132    },
133    // take off, hang (the landing spot locked at tick 10, marked on the ground), fall on [14,17), recover
134    dive: {
135      action: 'RN04', ticks: [6, 8, 3, 20], active: 2, damage: 4, weight: 1, cooldown: 48, advance: 96, seek: 10,
136      lift: [[4, 0], [8, -56], [14, -56], [17, 0]], range: ['mid'], label: '空中俯衝', hitLabel: '被俯衝重壓',
137    },
138    grow: { action: 'RN_GROW', ticks: [3, 3, 3, 3] },
139    harden: { action: 'RN_HARDEN', ticks: [3, 3, 3, 3] },
140    spikeDown: { action: 'RN_TOPPLE', ticks: [2, 2, 4, 16], then: 'getup', opening: true },
141    spikeStagger: { action: 'RN_BREAK', ticks: [2, 2, 2, 2], opening: true },
142    flinch: { action: 'RN_FLINCH', ticks: [3, 3, 3, 3], opening: true },
143    topple: { action: 'RN_TOPPLE', ticks: [3, 3, 3, 30], then: 'getup', opening: true },
144    getup: { action: 'RN_GETUP', ticks: [3, 3, 3, 3] },
145    death: { action: 'RN_DEATH', ticks: [3, 3, 4, 40] },
146  },
147  attacks: ['claw', 'shoulder', 'spikes', 'dive'],
148  approachWeight: 2,
149  slainMove: 'death',
150  calibrateTwice: true,
151  mechanic,
152} as const satisfies MonsterDef
153