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…

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.
![]()
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.
| Moves | Character | |
|---|---|---|
| Great sword | Charged slash, shoulder tackle; true charged slash on an opening | Slow, heavy blows |
| Long sword | Vertical combo, fade slash; spirit roundslash on an opening | Quick cuts, slides back to dodge |
| Bow | Normal shot, quick shot; power shot on an opening | Hits from range |
| Monster | Ground | Minutes |
|---|---|---|
| Great Jagras | Forest | 2 |
| Tobi-Kadachi | Forest | 3 |
| Anjanath | Forest | 4 |
| Velkhana | Snowfield, ice cave | 5 |
| Barioth | Snowfield | 5 |
| Nargacuga | Forest | 5 |
| Tigrex | Snowfield | 5 |
| Zinogre | Forest | 6 |
| Ruiner Nergigante | Crystal ruins | 7 |
![]()
/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.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.
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.
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 .hooks/register.tsx 291 lines1import { 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}
291hooks/data.ts 151 lines1import 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
151hooks/engine.ts 1162 lines1import 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}
1162hooks/i18n.ts 271 lines1import 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}
271hooks/monsters/registry.ts 32 lines1import 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 }
32hooks/raster.ts 447 lines1import { 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}
447hooks/svg.ts 232 lines1import 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}
232hooks/sprites.ts 65 lines1import { 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 }
65hooks/collision.ts 105 lines1import 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}
105hooks/collision-data.ts 698 lines1import 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}
698hooks/monsters/types.ts 137 lines1import 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}
137hooks/monsters/ruiner_nergigante.ts 153 lines1import 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