Compare commits

..
4 Commits
Author SHA1 Message Date
Warren H 956c9e32f9 Android build v1.1.22 2026-07-05 12:08:11 -04:00
Warren H 547044892d Android build v1.1.21 2026-07-04 21:15:29 -04:00
Warren H 81d65bc381 Android build v1.1.20 2026-07-04 21:15:04 -04:00
Warren H bdae0007a1 Android build v1.1.19 2026-07-04 20:50:33 -04:00
77 changed files with 9938 additions and 427 deletions
+33
View File
@@ -35,9 +35,42 @@
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
- Every IWT2 boss must have a boss pet collection-log reward. Add the pet to IWT2 boss reward metadata when adding the boss, and keep boss kill pet drops at a rare 1 in 500 chance.
- IWT2 boss and party movement must be built with crowding and hazard escape in mind from the first implementation. New bosses must not rely on straight-line chase or single-vector hazard avoidance if the boss can create ground effects, wall pressure, summons, or multi-boss overlap.
- IWT2 AI-controlled party members must be able to route around nearby ground effects and telegraphs using deterministic local steering/path scoring. Avoidance should consider hazard damage radius, telegraph danger, boss collision radius, nearby party members, wall clearance, and a center-safe bias when escaping danger.
- IWT2 bosses must not be allowed to pin the tank, DPS, or themselves in arena corners. Any boss that chases, charges, pounces, relocates, or creates persistent hazards needs wall-disengage/relocation behavior and must avoid sustained wall contact.
- IWT2 collision resolution must run after both party movement and boss movement so party-vs-boss and party-vs-party overlaps do not persist into damage, projectile, or hazard ticks. When resolving boss overlap near walls, prefer pushing party members toward arena center instead of clamping them deeper into the wall/corner.
- Any new IWT2 boss or boss-pair change must be verified with forced simulation for that boss alone and in at least one two-boss pairing. Sim checks should look for sustained boss wall contact, live party overlap, party members stuck near hazards with near-zero movement, and deaths caused by corner pileups rather than intended mechanics.
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
## IWT2 Boss Asset Requirements
- IWT2 gameplay boss sprites should use side-view cel-shaded PNG art by default, not top-down token art.
- Boss sprites should face right in source art. Renderer may flip sprites horizontally for left-facing movement; avoid 360-degree rotation for side-view sprites.
- Use original creature designs only. Do not copy franchise art, silhouettes, armor layouts, or exact monster designs.
- Generate full-body sprites with generous padding, crisp inked outlines, simple shaded color blocks, and strong silhouettes readable on the Thor main viewport.
- Keep gameplay sprites transparent after processing, cropped to content plus padding, and sized appropriately for Thor. Prefer final PNGs around 512 px wide unless a smaller asset remains crisp.
- Store IWT2 boss gameplay sprites under `public/iwt2/bosses/` using `<boss-id>-side-cel.png`.
- In boss metadata, set `spriteUrl`, `spriteView: 'side'`, `spriteWidthScale`, `spriteHeightScale`, and `spriteYOffsetScale` so the rendered sprite preserves its source aspect ratio and reads well near party members.
- Use a flat chroma-key source background for generated sprites, then remove it locally. Use `#00ff00` unless the boss has green/teal body colors; use `#ff00ff` for green/teal bosses. The key color must not appear in the boss.
- Validate every final sprite has transparent corners and renders nonblank in the 960 x 540 Thor main viewport.
Base prompt for future IWT2 boss sprites:
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original <boss fantasy> boss, facing right, full body visible
Subject: <distinct silhouette, body plan, role/mechanic cues, readable combat posture>; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: <boss palette and accent colors>
Scene/backdrop: perfectly flat solid <#00ff00 or #ff00ff> chroma-key background for background removal
Constraints: background must be one uniform <key color> with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use <key color> anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Performance Requirements
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 97
versionName "1.1.18"
versionCode 101
versionName "1.1.22"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

+183
View File
@@ -0,0 +1,183 @@
# IWT2 Future Boss Concept Prompts
These are exploration-only concepts. They are not implemented encounters.
## Rimebastion
- Hook: Frost leviathan turtle-crab siege boss; plants jagged ice wall segments, boxes party into lanes, then shatters walls into cone shards.
- Chroma key: `#00ff00`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original frost leviathan turtle-crab siege boss, facing right, full body visible
Subject: massive turtle-crab hybrid with a low armored glacier shell, four heavy crab legs, broad claw-like siege forelimbs, frost-rimmed tusk mandibles, ice wall crystal growths along the shell edges, compact battering-ram posture, readable tanky wall-builder silhouette; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: deep blue shell plates, pale cyan ice crystals, white frost edges, dark slate claws, cold violet shadow accents
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Ember Mantis Duelist
- Hook: Fast side-stepping mantis swordsman. Blade arms draw glowing line-slash telegraphs, then carve straight arena cuts that force party repositioning.
- Chroma key: `#00ff00`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original ember mantis duelist boss, facing right, full body visible
Subject: tall predatory mantis warrior with two long scythe-like blade arms, ember-cracked chitin plates, narrow duelist stance, swept horn-like antennae, glowing slash scars along forearms, agile line-slash mechanic cues; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: charcoal black chitin, deep crimson armor plates, molten orange cracks, pale bone blade edges, small yellow ember accents
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Obsidian Ram Golem
- Hook: Living volcanic-stone ram construct. Charge cracks armor plates, quake sends fracture waves, shatter armor drops broken obsidian slabs as hazards.
- Chroma key: `#00ff00`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original obsidian ram golem boss, facing right, full body visible
Subject: massive quadruped ram-shaped stone construct with jagged obsidian armor plates, curled basalt horns, glowing ember cracks, heavy hooves, lowered charging posture, fractured shoulder plates suggesting shatter armor mechanics, quake-impact weight in stance; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: black obsidian, charcoal basalt, dark gray stone, lava-orange crack glow, muted red-hot horn accents
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Stormcoil Wyrm
- Hook: Serpentine sky-eel boss. Loops body into charged rings, leaves arcing storm trails, chains lightning through clustered party members.
- Chroma key: `#ff00ff`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original storm eel wyrm boss, facing right, full body visible
Subject: long serpentine eel-wyrm with raised crested head, ribbon-like looping body forming two open coils, jagged fin spines, glowing storm nodes along its sides, forked whiskers acting as lightning conductors, readable chain-lightning mechanic cues, aggressive hovering combat posture; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: deep navy body, pale silver belly, electric cyan and white lightning accents, violet storm-glow details
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background for background removal
Constraints: background must be one uniform #ff00ff with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #ff00ff anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Venom Orchid Hydra
- Hook: Three blossom-serpent heads sweep staggered poison cones while buried roots snare lanes and blooming pods leave toxic zones.
- Chroma key: `#ff00ff`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original venom orchid hydra boss, facing right, full body visible
Subject: massive root-bodied hydra with three long orchid-serpent heads, poisonous blossom crowns, thorny vine necks, exposed twisting roots used for snares, swollen toxin pods, readable cone-attack posture with heads angled at different heights; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: deep violet petals, pale bone-yellow stamens, dark bark roots, black-purple thorns, toxic lime-yellow venom accents; do not use magenta
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background for background removal
Constraints: background must be one uniform #ff00ff with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #ff00ff anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Sandglass Scorpion
- Hook: Burrowing scorpion boss that leaves moving sand trails, plants delayed stinger eruptions, and flips hourglass sand zones between safe glass and sinking hazard.
- Chroma key: `#00ff00`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original sandglass scorpion boss, facing right, full body visible
Subject: massive desert scorpion with hourglass-shaped torso shell, glassy sand chambers in claws and tail, segmented legs built for burrowing, curved raised stinger with dripping golden sand, ridged shovel-like pincers, readable combat posture suggesting delayed underground strikes and arena sand zones; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: warm ochre sand armor, dark obsidian joints, amber glass chambers, pale bone claws, bright gold sand accents
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Crystal Bat Matriarch
- Hook: Giant jeweled cave-bat queen. Sonic ring pulses force party spacing, mirror shards split/reflect danger lanes, swoop dives punish clustered players.
- Chroma key: `#00ff00`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original crystal bat matriarch boss, facing right, full body visible
Subject: towering bat queen with wide angular wings, faceted amethyst-and-obsidian crystal growths along crown, shoulders, wing fingers, and tail; open fanged mouth emitting visible pale sonic ring motifs around the head; several floating broken mirror shards orbiting near the wings; poised in a low forward swoop posture with claws extended; original creature design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: deep violet body, black wing membranes, icy blue crystal highlights, silver mirror shards, pale cyan sonic accents
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Cinderback Ricochet
- Hook: Molten armadillo boss curls into plated lava wheel, ricochets off arena walls, leaves burning trails, then uncurls for armor-plate slam windows.
- Chroma key: `#00ff00`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original molten armadillo boss, facing right, full body visible
Subject: bulky low-slung armadillo-like beast with layered volcanic armor plates, glowing lava cracks, heavy clawed feet, curled rolling-combat silhouette cues, scorched tail, defensive plated brow, readable ricochet boss posture; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: charcoal obsidian plates, ember orange lava seams, dark red underbody, pale hot-yellow highlights
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Hollowcrown Revenant
- Hook: Spectral stag boss leaves cursed hoof zones and fading dash trails; ghost antlers flare before line charges.
- Chroma key: `#ff00ff`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original spectral stag revenant boss, facing right, full body visible
Subject: gaunt undead stag spirit with cracked bone mask, tall translucent ghost antlers, torn shadowy hide, glowing pale blue curse runes on ribs and hooves, wispy dash-trail ribbons streaming backward, combat-ready lowered stance; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, eerie revenant glow, readable over dark arena grid
Color palette: bone white, charcoal black, cold blue, pale cyan, muted violet accents
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background for background removal
Constraints: background must be one uniform #ff00ff with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #ff00ff anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
## Tidejaw Behemoth
- Hook: Massive reef-backed crocodile boss. Blasts pressurized water jets, uses heavy tail sweeps to knock party members sideways, leaves spinning whirlpool puddles that pull players inward.
- Chroma key: `#ff00ff`
```text
Use case: stylized-concept
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
Primary request: side-view cel-shaded sprite of an original tide crocodile behemoth boss, facing right, full body visible
Subject: enormous low-slung crocodilian sea monster with a broad armored skull, jagged coral-like back plates, heavy sweeping tail, webbed claws, pressure-jet water vents along shoulders and flanks, swirling puddle motifs around feet without shadows; original design, not copied from any franchise
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
Color palette: deep swamp green body, dark navy shell plates, pale bone claws and teeth, bright cyan water accents, coral-orange warning marks
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background for background removal
Constraints: background must be one uniform #ff00ff with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #ff00ff anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Barroth boss sprite">
<path d="M20 80c6-31 35-49 68-42 31 6 48 27 43 52-5 26-34 41-68 34-30-6-48-24-43-44Z" fill="#8d714d"/>
<path d="M44 49c16-15 49-17 72-1-28 0-53 6-76 20Z" fill="#d6bd80"/>
<path d="M56 29h48l17 24-23 17H55L35 52Z" fill="#6a5137"/>
<path d="M63 37h33l10 14-12 9H62L50 51Z" fill="#a48a61"/>
<path d="M35 69 8 54l18 42 33 2Z" fill="#6a5137"/>
<path d="m102 68 33-13-14 42-34 2Z" fill="#6a5137"/>
<path d="M67 65c14 0 26 10 26 23s-12 23-26 23-26-10-26-23 12-23 26-23Z" fill="#5a412e"/>
<path d="m94 82 32 7-31 10Z" fill="#3a2b21"/>
<path d="M54 77h7v8h-7z" fill="#f3e2a5"/>
<path d="M44 111h16l-8 18ZM90 111h16l-8 18Z" fill="#3a2b21"/>
</svg>

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Bulldrome boss sprite">
<path d="M22 80c4-31 32-50 64-43 26 6 42 23 43 45 2 23-16 39-43 41-34 2-62-14-64-43Z" fill="#7a4b35"/>
<path d="M42 91c11 15 42 22 67 10-9 16-28 24-52 18-13-4-23-13-28-24l13-4Z" fill="#3b2d28" opacity=".55"/>
<path d="M42 51c11-12 31-17 52-12 14 3 25 10 31 21-25-9-53-11-83-9Z" fill="#b48762"/>
<path d="M30 62 9 48l8 31 20-5Z" fill="#d6c8aa"/>
<path d="m104 55 30-12-13 28-20-2Z" fill="#d6c8aa"/>
<path d="M40 34 24 18h25l7 17Z" fill="#ad5632"/>
<path d="m99 36 15-18 13 24-20 8Z" fill="#ad5632"/>
<path d="M72 58c17 0 31 11 31 25S89 108 72 108 41 97 41 83s14-25 31-25Z" fill="#5b3b31"/>
<path d="M73 63c9 0 17 7 17 16s-8 17-17 17-17-8-17-17 8-16 17-16Z" fill="#2c2422"/>
<path d="M68 72h9v20h-9z" fill="#e8b068"/>
<path d="M61 55c5-9 17-9 22 0-6-2-16-2-22 0Z" fill="#261b17"/>
<path d="M42 110h16l-8 16ZM90 109h16l-8 16Z" fill="#2a211e"/>
</svg>

After

Width:  |  Height:  |  Size: 981 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Great Jaggi boss sprite">
<path d="M21 78c8-28 38-42 69-32 24 8 36 27 27 49-9 23-38 32-66 20-22-9-35-21-30-37Z" fill="#3f8f73"/>
<path d="M54 48c15-15 38-14 55 1-21 0-39 6-54 19Z" fill="#b8f0aa"/>
<path d="M28 79 7 58l33 5Z" fill="#2a6e58"/>
<path d="M97 66 137 46l-20 36Z" fill="#2a6e58"/>
<path d="M92 91c16 2 30 9 41 22-24-5-43-8-58-6Z" fill="#326d59"/>
<path d="M70 62c12 0 22 9 22 21S82 105 70 105s-22-10-22-22 10-21 22-21Z" fill="#245848"/>
<path d="M60 32 48 15l26 13ZM82 32l14-17 6 27Z" fill="#b8f0aa"/>
<circle cx="60" cy="74" r="4" fill="#f4ffd0"/>
<circle cx="60" cy="74" r="2" fill="#111"/>
<path d="m93 82 31 4-29 12Z" fill="#1f4539"/>
<path d="M44 108h13l-7 18ZM80 111h13l-7 18Z" fill="#1f4539"/>
</svg>

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Khezu boss sprite">
<path d="M27 82c2-28 27-43 55-40 25 3 45 20 45 44 0 23-21 39-50 39-30 0-52-18-50-43Z" fill="#d8d7c9"/>
<path d="M42 55c14-13 47-13 66 0-23-2-45 2-68 16Z" fill="#fff4df"/>
<path d="M37 70 10 55l15 40 30 2Z" fill="#b9b7aa"/>
<path d="m102 68 31-15-13 40-30 4Z" fill="#b9b7aa"/>
<path d="M49 72c8-16 32-21 45-8 13 14 8 39-13 46-21 7-42-4-43-21 0-6 4-12 11-17Z" fill="#efe9da"/>
<path d="M64 61c17-32 40-43 57-32-22 10-33 27-34 51Z" fill="#d8d7c9"/>
<path d="M89 35c8-18 23-24 40-21-14 8-22 18-25 31Z" fill="#fff4df"/>
<path d="M60 84c11-5 26-4 36 4-9 8-27 8-36-4Z" fill="#7d4b55"/>
<path d="M55 112h14l-8 17ZM88 112h14l-8 17Z" fill="#8d8a80"/>
<path d="M105 69c9 1 15 5 19 13-10-3-17-3-25 2Z" fill="#77d9ff"/>
</svg>

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Rathian boss sprite">
<path d="M26 77c6-30 35-48 66-40 27 7 42 30 36 55-6 24-34 38-64 29-26-8-43-25-38-44Z" fill="#4d9a58"/>
<path d="M48 51c13-17 40-22 64-4-25 0-44 6-59 20Z" fill="#f0c24d"/>
<path d="M35 69 5 38l13 54 36 10Z" fill="#3f7c4a"/>
<path d="m100 65 38-28-15 56-35 9Z" fill="#3f7c4a"/>
<path d="M30 63 6 37l31 14 18 36Z" fill="#78b861"/>
<path d="m112 58 28-22-20 31-27 22Z" fill="#78b861"/>
<path d="M77 22c17 8 30 21 38 39-18-12-37-18-59-18 5-10 12-17 21-21Z" fill="#2d693f"/>
<path d="M70 61c13 0 24 10 24 23s-11 24-24 24-24-11-24-24 11-23 24-23Z" fill="#2f5f3c"/>
<path d="m96 82 34 7-33 12Z" fill="#244c32"/>
<path d="M57 75h6v8h-6z" fill="#f7ffd2"/>
<path d="M44 110h15l-8 19ZM88 111h15l-8 19Z" fill="#244c32"/>
</svg>

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Tobi Kadachi boss sprite">
<path d="M25 79c7-27 34-42 65-34 24 6 38 23 35 45-3 23-28 37-58 30-27-6-47-24-42-41Z" fill="#6a79bd"/>
<path d="M49 50c13-14 38-18 61-3-21 1-40 8-56 22Z" fill="#a8f3ff"/>
<path d="M40 69 8 49l20 46 34 5Z" fill="#515d9d"/>
<path d="m95 66 38-18-20 48-34 4Z" fill="#515d9d"/>
<path d="M92 89c20 3 34 14 41 32-22-10-42-14-62-12Z" fill="#dfeaff"/>
<path d="M68 61c13 0 23 10 23 22s-10 23-23 23-23-11-23-23 10-22 23-22Z" fill="#414b87"/>
<path d="M58 32 44 12l28 16ZM83 32l19-17 4 29Z" fill="#dfeaff"/>
<path d="m82 55 7 12 13-4-11 11 7 13-14-7-11 11 3-15-14-7 15-2Z" fill="#a8f3ff"/>
<circle cx="59" cy="74" r="4" fill="#f6ffff"/>
<circle cx="59" cy="74" r="2" fill="#101425"/>
<path d="M45 108h14l-8 18ZM82 110h14l-8 18Z" fill="#303869"/>
</svg>

After

Width:  |  Height:  |  Size: 877 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Yian Kut Ku boss sprite">
<path d="M28 84c3-29 28-45 56-38 24 6 39 24 35 48-3 22-24 34-51 31-25-3-42-18-40-41Z" fill="#d66a35"/>
<path d="M52 51c9-14 29-18 49-5-16 0-31 5-43 16Z" fill="#ffd166"/>
<path d="M38 67 9 45l12 45 29 5Z" fill="#f28a3e"/>
<path d="m99 71 36-20-16 45-30-1Z" fill="#f28a3e"/>
<path d="M33 66 7 42l20 9 17 24Z" fill="#ffd166"/>
<path d="m112 63 25-16-16 20-20 12Z" fill="#ffd166"/>
<path d="M80 26c18 7 29 21 31 40-13-15-28-21-47-22 4-8 9-14 16-18Z" fill="#b6462d"/>
<path d="M69 58c12 0 22 10 22 22s-10 22-22 22-22-10-22-22 10-22 22-22Z" fill="#812f25"/>
<path d="m93 79 31 5-28 11Z" fill="#61241d"/>
<circle cx="60" cy="73" r="5" fill="#fff2a5"/>
<circle cx="60" cy="73" r="2" fill="#1f1511"/>
<path d="M52 112h13l-8 18ZM86 111h14l-8 18Z" fill="#58251c"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+88 -3
View File
@@ -145,6 +145,8 @@
}
.iwt2-boss-hud {
display: grid;
gap: 6px;
left: 50%;
min-width: 310px;
padding: 8px 12px 10px;
@@ -155,7 +157,7 @@
z-index: 4;
}
.iwt2-boss-hud > div:first-child {
.iwt2-boss-hud-row > div:first-child {
align-items: baseline;
display: flex;
gap: 12px;
@@ -723,6 +725,21 @@
outline-color: #e5b95f;
}
.iwt2-dungeon-list {
min-height: 0;
overflow: hidden;
}
.iwt2-dungeon-list .iwt2-action-list {
min-height: 0;
overflow: hidden;
}
.iwt2-dungeon-list .iwt2-action-row {
min-height: 56px;
padding: 10px 12px;
}
.iwt2-controller-preview {
align-content: center;
background: #141922;
@@ -956,6 +973,16 @@
padding: 8px 10px;
}
.iwt2-dungeon-list .iwt2-action-list {
gap: 6px;
overflow: hidden;
}
.iwt2-dungeon-list .iwt2-action-row {
min-height: 42px;
padding: 6px 9px;
}
.iwt2-action-row strong,
.iwt2-info-row strong {
font-size: 0.78rem;
@@ -2137,7 +2164,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
}
.pvp-opponent-bottom-display {
grid-template-rows: auto auto minmax(0, 1fr) auto;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
}
.dual-controls-header,
@@ -2172,6 +2199,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
}
.dual-opponent-progress,
.dual-opponent-arena,
.dual-opponent-effects {
background: var(--panel);
border: 3px solid #0c0d11;
@@ -2195,6 +2223,53 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
height: 22px;
}
.dual-opponent-arena {
padding: 7px;
}
.dual-opponent-arena-field {
background:
linear-gradient(rgba(23, 29, 36, 0.96), rgba(23, 29, 36, 0.96)),
repeating-linear-gradient(0deg, transparent 0 39px, rgba(90, 108, 126, 0.28) 40px),
repeating-linear-gradient(90deg, transparent 0 39px, rgba(90, 108, 126, 0.28) 40px);
border: 2px solid #3c4a59;
border-radius: 6px;
max-height: 138px;
min-height: 112px;
overflow: hidden;
position: relative;
width: 100%;
}
.dual-opponent-arena-entity {
align-items: center;
border: 2px solid #0a0c10;
border-radius: 999px;
color: #fff7df;
display: flex;
font-family: ui-monospace, Consolas, monospace;
font-size: 10px;
font-weight: 900;
justify-content: center;
line-height: 1;
min-height: 12px;
min-width: 12px;
position: absolute;
transform: translate(-50%, -50%);
}
.dual-opponent-arena-entity.boss {
border-color: #fff0b8;
border-radius: 45%;
font-size: 12px;
min-height: 18px;
min-width: 24px;
}
.dual-opponent-arena-entity.healer {
box-shadow: 0 0 0 3px rgba(255, 244, 168, 0.84);
}
.dual-opponent-party-grid {
background: var(--panel);
border: 3px solid #0c0d11;
@@ -2567,7 +2642,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
}
.pvp-opponent-bottom-display {
grid-template-rows: auto auto minmax(0, 1fr) auto;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
}
.dual-opponent-progress {
@@ -2590,6 +2665,16 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
height: 16px;
}
.dual-opponent-arena {
border-width: 2px;
padding: 5px;
}
.dual-opponent-arena-field {
max-height: 112px;
min-height: 92px;
}
.dual-opponent-party-grid {
border-width: 2px;
gap: 6px;
+71 -3
View File
@@ -1,7 +1,12 @@
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { AuthScreen } from './components/AuthScreen'
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
import { useGameAction } from './input'
import {
loadAuthSession,
type AuthSession,
} from './profile'
type GameVersion = 'iwt1' | 'iwt2'
@@ -31,9 +36,46 @@ const GAME_OPTIONS: Array<{
function App() {
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [serverMessage, setServerMessage] = useState('')
useEffect(() => {
let cancelled = false
loadAuthSession()
.then((session) => {
if (cancelled) return
setAuthSession(session.account && session.profile ? session : null)
})
.catch((reason: unknown) => {
if (cancelled) return
setServerMessage(
reason instanceof Error
? `${reason.message} Offline play is still available.`
: 'Unable to reach the server. Offline play is still available.',
)
})
.finally(() => {
if (!cancelled) setAuthChecked(true)
})
return () => {
cancelled = true
}
}, [])
const acceptSession = useCallback((session: AuthSession) => {
setAuthSession(session)
setSelectedVersion(null)
setServerMessage('')
}, [])
const clearAuthSession = useCallback(() => {
setAuthSession(null)
setSelectedVersion(null)
}, [])
useGameAction((action, device) => {
if (selectedVersion || device !== 'controller') return
if (!authSession || selectedVersion || device !== 'controller') return
if (action === 'navigateLeft' || action === 'navigateUp') {
setSelectedIndex((current) => Math.max(0, current - 1))
} else if (action === 'navigateRight' || action === 'navigateDown') {
@@ -43,8 +85,34 @@ function App() {
}
})
if (!authChecked) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
if (!authSession) {
return (
<AuthScreen
onAuthenticated={acceptSession}
serverMessage={serverMessage}
/>
)
}
if (selectedVersion === 'iwt1') {
return <IWantToHeal1App onBackToGameSelect={() => setSelectedVersion(null)} />
return (
<IWantToHeal1App
initialSession={authSession}
onAuthenticationCleared={clearAuthSession}
onBackToGameSelect={() => setSelectedVersion(null)}
/>
)
}
if (selectedVersion === 'iwt2') {
+47
View File
@@ -49,6 +49,11 @@ export type DualScreenCombatState = {
opponentClassName?: string
opponentParty?: PartyMember[]
opponentEnemyHealth?: number
opponentArena?: {
bounds: { width: number, height: number }
bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }>
party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }>
}
opponentResource?: number
opponentMaxResource?: number
opponentResourceName?: string
@@ -638,6 +643,48 @@ export function DualScreenBottomDisplay() {
</section>
)}
{state.opponentArena && (
<section className="dual-opponent-arena" aria-label="Opponent arena">
<div
className="dual-opponent-arena-field"
style={{ aspectRatio: `${state.opponentArena.bounds.width} / ${state.opponentArena.bounds.height}` }}
>
{state.opponentArena.bosses.map((boss) => (
<div
className="dual-opponent-arena-entity boss"
key={boss.id}
style={{
backgroundColor: boss.color,
height: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.height) * 100)}%`,
left: `${(boss.x / state.opponentArena!.bounds.width) * 100}%`,
top: `${(boss.y / state.opponentArena!.bounds.height) * 100}%`,
width: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.width) * 100)}%`,
}}
title={`${boss.name} ${Math.ceil(boss.health)} / ${boss.maxHealth}`}
>
{boss.icon}
</div>
))}
{state.opponentArena.party.map((member) => (
<div
className={`dual-opponent-arena-entity party ${member.isHealer ? 'healer' : ''}`}
key={member.id}
style={{
backgroundColor: member.color,
height: `${Math.max(5, (member.radius / state.opponentArena!.bounds.height) * 100)}%`,
left: `${(member.x / state.opponentArena!.bounds.width) * 100}%`,
opacity: member.health > 0 ? 1 : 0.35,
top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`,
width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`,
}}
>
{member.icon}
</div>
))}
</div>
</section>
)}
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
{state.opponentParty.map((member) => (
<PartyMemberFrame
+25 -20
View File
@@ -1,5 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import { AuthScreen } from '../../components/AuthScreen'
import {
loadCpuPvpLeaderboard,
type CpuPvpLeaderboardEntry,
@@ -138,11 +137,21 @@ function ScreenLoading() {
type RoguelikeVariant = 'pve' | 'pvp'
function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
type IWantToHeal1AppProps = {
initialSession: AuthSession
onAuthenticationCleared: () => void
onBackToGameSelect: () => void
}
function IWantToHeal1App({
initialSession,
onAuthenticationCleared,
onBackToGameSelect,
}: IWantToHeal1AppProps) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Screen>('menu')
const [account, setAccount] = useState<Account | null>(null)
const [profile, setProfile] = useState<CharacterProfile | null>(null)
const [account, setAccount] = useState<Account | null>(initialSession.account)
const [profile, setProfile] = useState<CharacterProfile | null>(initialSession.profile)
const [authChecked, setAuthChecked] = useState(false)
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
const [serverMessage, setServerMessage] = useState('')
@@ -186,6 +195,10 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
.finally(() => setAuthChecked(true))
}, [])
useEffect(() => {
if (authChecked && (!account || !profile)) onAuthenticationCleared()
}, [account, authChecked, onAuthenticationCleared, profile])
useEffect(() => {
const handleModeChange = (event: Event) => {
const nextMode = (event as CustomEvent<GameMode>).detail
@@ -350,18 +363,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
)
function acceptSession(session: AuthSession) {
setAccount(session.account)
setProfile(session.profile)
setGameMode(getGameMode())
setScreen('menu')
setError('')
setServerMessage('')
window.requestAnimationFrame(() => {
focusFirstControl()
})
}
async function signOut() {
try {
await logoutAccount()
@@ -371,6 +372,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
setScreen('menu')
setSyncMessage('')
setSyncComparison(null)
onAuthenticationCleared()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
}
@@ -832,10 +834,13 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
if (!account || !profile) {
return (
<AuthScreen
onAuthenticated={acceptSession}
serverMessage={serverMessage}
/>
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Returning to Sign In...</h1>
{serverMessage && <p>{serverMessage}</p>}
</section>
</main>
)
}
+78 -60
View File
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useGameAction } from '../../input'
import { getGameMode } from '../../gameRepository'
import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle'
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
import {
useDualScreen,
@@ -54,6 +56,7 @@ const IWT2_MENU_COLUMNS = 4
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
type Iwt2RoguelikeRunState = {
bossIds: Iwt2BossId[]
buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[]
@@ -78,7 +81,7 @@ const MENU_ITEMS: Array<{
{
screen: 'dungeons',
title: 'Dungeons',
description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.',
description: 'Queue into seven modular IWT2 boss arenas.',
glyph: 'D',
},
{
@@ -129,11 +132,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => {
writeIwt2Save(save)
}, [save])
useEffect(() => () => {
cancelPvpQueueRef.current?.()
}, [])
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
[save, screen, selectedBossId],
@@ -182,6 +191,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
return (
<BossArenaScreen
bossId={selectedBossId}
key={`arena-${selectedBossId}-${arenaModeLabel}`}
modeLabel={arenaModeLabel}
save={save}
onBack={() => setScreen('menu')}
@@ -193,7 +203,9 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
if (screen === 'roguelike-arena' && roguelikeRun) {
return (
<BossArenaScreen
bossIds={roguelikeRun.bossIds}
bossId={selectedBossId}
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
roguelikeRun={{
buffs: roguelikeRun.buffs,
contentType: roguelikeRun.contentType,
@@ -233,7 +245,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
onChoose={(buffId, debuffId) => {
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
setRoguelikeRun(nextRun)
setSelectedBossId(bossForRoguelike(nextRun.variant, nextRun.contentType, nextRun.stage))
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
setScreen('roguelike-arena')
}}
/>
@@ -267,17 +279,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
onBack={() => setScreen('menu')}
onContentTypeChange={setRoguelikeContentType}
onStart={() => {
const nextRun = createRoguelikeRun(save, roguelikeVariant, roguelikeContentType)
setRoguelikeRun(nextRun)
setSelectedBossId(bossForRoguelike(roguelikeVariant, roguelikeContentType, nextRun.stage))
setScreen('roguelike-arena')
startIwt2RoguelikeRun()
}}
onVariantChange={(nextVariant) => {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
setRoguelikeVariant(nextVariant)
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
setRoguelikeContentType('dungeon')
}
}}
queueMessage={pvpQueueMessage}
/>
</main>
)
@@ -303,7 +316,6 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
if (screen === 'hunter-profile') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
@@ -359,6 +371,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<button
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={`${item.screen}-${item.title}`}
onClick={() => openMenuItem(item)}
onPointerDown={() => setSelectedIndex(index)}
@@ -376,24 +389,58 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
)}
</main>
)
function startIwt2RoguelikeRun() {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
if (roguelikeVariant !== 'pvp') {
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
return
}
const startStage = 1
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
contentType: roguelikeContentType,
startStage,
gameMode: getGameMode(),
liveMatchActive: () => false,
onSearching: setPvpQueueMessage,
onCpuMatch: (_difficulty, message) => {
cancelPvpQueueRef.current = null
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
onLiveMatch: (...liveMatchArgs) => {
const message = liveMatchArgs[2]
cancelPvpQueueRef.current = null
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
messages: {
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`,
searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`,
notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`,
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`,
},
})
}
function beginIwt2RoguelikeArena(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
) {
const nextRun = createRoguelikeRun(save, variant, contentType)
setRoguelikeRun(nextRun)
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
setScreen('roguelike-arena')
}
}
function bossForRoguelike(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
stage = 1,
): Iwt2BossId {
const dungeonBosses: Iwt2BossId[] = variant === 'pvp'
? ['yian-kut-ku', 'great-jaggi', 'bulldrome', 'khezu']
: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
const raidBosses: Iwt2BossId[] = ['khezu', 'great-jaggi', 'yian-kut-ku', 'bulldrome']
const stadiumBosses: Iwt2BossId[] = ['great-jaggi', 'yian-kut-ku', 'khezu', 'bulldrome']
const pool = contentType === 'raid'
? raidBosses
: contentType === 'stadium'
? stadiumBosses
: dungeonBosses
return pool[(stage - 1) % pool.length]
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
if (contentType === 'raid') return 'Raid'
if (contentType === 'stadium') return 'Stadium'
return 'Dungeon'
}
function createRoguelikeRun(
@@ -402,6 +449,7 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState {
return {
bossIds: createRandomRoguelikeBossPair(),
buffs: [],
contentType,
debuffs: [],
@@ -462,11 +510,17 @@ function applyRoguelikeChoice(
return {
...run,
...nextBase,
bossIds: createRandomRoguelikeBossPair(),
...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1,
}
}
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
return chooseRunChoices(pool, 2)
}
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
const pool = [...items]
const choices: T[] = []
@@ -531,35 +585,6 @@ function buildIwt2WorkshopDualScreenState(
screen: Iwt2Screen,
save: Iwt2Save,
): DualScreenWorkshopState | null {
if (screen === 'hunter-profile') {
return {
items: [
{
glyph: 'H',
meta: `${save.character.experience} XP`,
status: `Level ${save.character.level}`,
title: save.character.name,
},
{
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
status: 'Class',
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
},
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
glyph: IWT2_BOSS_METADATA[bossId].icon,
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
title: IWT2_BOSS_METADATA[bossId].name,
})),
],
mode: 'collection',
subtitle: 'IWT2 inventory and collection log',
summary: `${save.inventory.length} inventory slots`,
title: 'Hunter Profile',
}
}
if (screen === 'customize-character') {
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
return {
@@ -578,10 +603,3 @@ function buildIwt2WorkshopDualScreenState(
return null
}
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
if (bossId === 'khezu') return 'khezu-pearl'
return 'raw-bulldrome-coin'
}
+17 -8
View File
@@ -2,16 +2,25 @@ import type { Iwt2BossEntityState } from '../sim'
import { ArenaBar } from './ArenaBars'
import { IWT2_BOSS_METADATA } from '../content/bosses'
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
const entries = bosses ?? (boss ? [boss] : [])
return (
<div className="iwt2-boss-hud">
<div>
<strong>{metadata.name}</strong>
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
</div>
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
{entries.map((entry) => {
const metadata = IWT2_BOSS_METADATA[entry.bossId]
return (
<div className="iwt2-boss-hud-row" key={entry.id}>
<div>
<strong>{metadata.name}</strong>
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
{entry.maxArmor > 0 && <small>{Math.ceil(entry.armor)} / {entry.maxArmor} Armor</small>}
{entry.mechanicEnergyMax > 0 && <small>{Math.ceil(entry.mechanicEnergy)} / {entry.mechanicEnergyMax} Static</small>}
</div>
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
</div>
)
})}
</div>
)
}
@@ -27,6 +27,7 @@ export function PartyFrames({
<button
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected ? 'true' : undefined}
key={member.id}
onClick={() => onTarget(member.id)}
type="button"
+59
View File
@@ -0,0 +1,59 @@
import type { Iwt2BossId } from './bosses'
export const IWT2_BOSS_PET_DROP_RATE = 1 / 500
export type Iwt2BossMaterialReward = {
id: string
name: string
}
export type Iwt2BossPetReward = {
id: string
name: string
}
const IWT2_BOSS_MATERIAL_REWARDS: Record<Iwt2BossId, Iwt2BossMaterialReward> = {
'barroth': { id: 'barroth-shell', name: 'Barroth Shell' },
'bulldrome': { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' },
'cinderback-ricochet': { id: 'cinderback-plate', name: 'Cinderback Plate' },
'crystal-bat-matriarch': { id: 'crystal-bat-fang', name: 'Crystal Bat Fang' },
'ember-mantis-duelist': { id: 'ember-mantis-blade', name: 'Ember Mantis Blade' },
'great-jaggi': { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' },
'hollowcrown-revenant': { id: 'hollowcrown-antler', name: 'Hollowcrown Antler' },
'khezu': { id: 'khezu-pearl', name: 'Khezu Pearl' },
'obsidian-ram-golem': { id: 'obsidian-ram-core', name: 'Obsidian Ram Core' },
'rathian': { id: 'rathian-scale', name: 'Rathian Scale' },
'rimebastion': { id: 'rimebastion-core', name: 'Rimebastion Core' },
'sandglass-scorpion': { id: 'sandglass-stinger', name: 'Sandglass Stinger' },
'stormcoil-wyrm': { id: 'stormcoil-conductor', name: 'Stormcoil Conductor' },
'tobi-kadachi': { id: 'tobi-kadachi-pelt', name: 'Tobi Kadachi Pelt' },
'venom-orchid-hydra': { id: 'venom-orchid-pod', name: 'Venom Orchid Pod' },
'yian-kut-ku': { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' },
}
const IWT2_BOSS_PET_REWARDS: Record<Iwt2BossId, Iwt2BossPetReward> = {
'barroth': { id: 'barroth-pet', name: 'Barroth Pet' },
'bulldrome': { id: 'bulldrome-pet', name: 'Bulldrome Pet' },
'cinderback-ricochet': { id: 'cinderback-ricochet-pet', name: 'Cinderback Ricochet Pet' },
'crystal-bat-matriarch': { id: 'crystal-bat-matriarch-pet', name: 'Crystal Bat Matriarch Pet' },
'ember-mantis-duelist': { id: 'ember-mantis-duelist-pet', name: 'Ember Mantis Duelist Pet' },
'great-jaggi': { id: 'great-jaggi-pet', name: 'Great Jaggi Pet' },
'hollowcrown-revenant': { id: 'hollowcrown-revenant-pet', name: 'Hollowcrown Revenant Pet' },
'khezu': { id: 'khezu-pet', name: 'Khezu Pet' },
'obsidian-ram-golem': { id: 'obsidian-ram-golem-pet', name: 'Obsidian Ram Golem Pet' },
'rathian': { id: 'rathian-pet', name: 'Rathian Pet' },
'rimebastion': { id: 'rimebastion-pet', name: 'Rimebastion Pet' },
'sandglass-scorpion': { id: 'sandglass-scorpion-pet', name: 'Sandglass Scorpion Pet' },
'stormcoil-wyrm': { id: 'stormcoil-wyrm-pet', name: 'Stormcoil Wyrm Pet' },
'tobi-kadachi': { id: 'tobi-kadachi-pet', name: 'Tobi Kadachi Pet' },
'venom-orchid-hydra': { id: 'venom-orchid-hydra-pet', name: 'Venom Orchid Hydra Pet' },
'yian-kut-ku': { id: 'yian-kut-ku-pet', name: 'Yian Kut Ku Pet' },
}
export function iwt2BossMaterialRewardFor(bossId: Iwt2BossId): Iwt2BossMaterialReward {
return IWT2_BOSS_MATERIAL_REWARDS[bossId]
}
export function iwt2BossPetRewardFor(bossId: Iwt2BossId): Iwt2BossPetReward {
return IWT2_BOSS_PET_REWARDS[bossId]
}
+581 -1
View File
@@ -1,6 +1,22 @@
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' | 'great-jaggi' | 'khezu'
export type Iwt2BossId =
| 'bulldrome'
| 'yian-kut-ku'
| 'great-jaggi'
| 'khezu'
| 'rathian'
| 'barroth'
| 'tobi-kadachi'
| 'rimebastion'
| 'ember-mantis-duelist'
| 'cinderback-ricochet'
| 'obsidian-ram-golem'
| 'stormcoil-wyrm'
| 'venom-orchid-hydra'
| 'sandglass-scorpion'
| 'crystal-bat-matriarch'
| 'hollowcrown-revenant'
export type Iwt2BalanceOverrides = {
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
@@ -10,6 +26,11 @@ export type Iwt2BossMetadata = {
id: Iwt2BossId
name: string
icon: string
spriteUrl: string
spriteView?: 'topDown' | 'side'
spriteWidthScale?: number
spriteHeightScale?: number
spriteYOffsetScale?: number
color: string
accentColor: string
maxHealth: number
@@ -64,12 +85,82 @@ export type Iwt2BossMetadata = {
lightningStrikeDamage?: number
lightningStrikeStunSeconds?: number
lightningStrikeCount?: number
poisonTailCooldown?: number
tailSweepWindup?: number
tailSweepRecover?: number
tailSweepInnerRadius?: number
tailSweepOuterRadius?: number
tailSweepAngleRadians?: number
tailSweepDamage?: number
tailSweepStunSeconds?: number
poisonSpitCooldown?: number
poisonSpitWindup?: number
poisonPuddleRadius?: number
poisonPuddleDamage?: number
poisonPuddleSeconds?: number
mudArmor?: number
mudSprayCooldown?: number
mudSprayWindup?: number
mudSprayRange?: number
mudSprayAngleRadians?: number
mudSprayDamage?: number
mudSprayStunSeconds?: number
mudPuddleRadius?: number
mudPuddleSeconds?: number
staticEnergyMax?: number
staticEnergyPerSecond?: number
staticEnergyPerMelee?: number
staticPounceWindup?: number
staticPounceSpeed?: number
staticPounceDamage?: number
staticPounceStunSeconds?: number
staticPounceLength?: number
staticPounceWidth?: number
chainShockCooldown?: number
chainShockWindup?: number
chainShockRadius?: number
chainShockDamage?: number
chainShockStunSeconds?: number
iceWallCooldown?: number
iceWallWindup?: number
iceWallActiveSeconds?: number
iceWallCount?: number
iceWallLength?: number
iceWallWidth?: number
iceWallDamage?: number
iceShardDamage?: number
iceShardStunSeconds?: number
slashCooldown?: number
slashWindup?: number
slashDamage?: number
slashStunSeconds?: number
slashWidth?: number
crossSlashAngleRadians?: number
sidestepDistance?: number
ricochetCooldown?: number
ricochetWindup?: number
ricochetSpeed?: number
ricochetDamage?: number
ricochetStunSeconds?: number
ricochetSeconds?: number
lavaTrailRadius?: number
lavaTrailDamage?: number
lavaTrailSeconds?: number
armorSlamWindup?: number
armorSlamRadius?: number
armorSlamDamage?: number
armorSlamStunSeconds?: number
}
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
id: 'bulldrome',
name: 'Bulldrome',
icon: 'B',
spriteUrl: '/iwt2/bosses/bulldrome-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.2,
spriteHeightScale: 4.5,
spriteYOffsetScale: -0.14,
color: '#8f5a3c',
accentColor: '#e6b17f',
maxHealth: 950,
@@ -96,6 +187,11 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'yian-kut-ku',
name: 'Yian Kut Ku',
icon: 'Y',
spriteUrl: '/iwt2/bosses/yian-kut-ku-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
spriteHeightScale: 4.7,
spriteYOffsetScale: -0.1,
color: '#d66a35',
accentColor: '#ffd166',
maxHealth: 400,
@@ -138,6 +234,11 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
id: 'great-jaggi',
name: 'Great Jaggi',
icon: 'J',
spriteUrl: '/iwt2/bosses/great-jaggi-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
spriteHeightScale: 4.1,
spriteYOffsetScale: -0.09,
color: '#3f8f73',
accentColor: '#b8f0aa',
maxHealth: 620,
@@ -170,6 +271,11 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'khezu',
name: 'Khezu',
icon: 'K',
spriteUrl: '/iwt2/bosses/khezu-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
spriteHeightScale: 4.4,
spriteYOffsetScale: -0.12,
color: '#d8d7c9',
accentColor: '#77d9ff',
maxHealth: 760,
@@ -204,6 +310,456 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
lightningStrikeCount: 2,
}
const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
id: 'rathian',
name: 'Rathian',
icon: 'R',
spriteUrl: '/iwt2/bosses/rathian-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.9,
spriteHeightScale: 4.35,
spriteYOffsetScale: -0.12,
color: '#4d9a58',
accentColor: '#f0c24d',
maxHealth: 1120,
radius: 34,
moveSpeed: 118,
meleeRange: 62,
meleeDamage: 11,
meleeCooldown: 1.08,
chargeCooldown: 0,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
poisonTailCooldown: 5.4,
tailSweepWindup: 0.72,
tailSweepRecover: 0.48,
tailSweepInnerRadius: 24,
tailSweepOuterRadius: 118,
tailSweepAngleRadians: Math.PI * 1.25,
tailSweepDamage: 23,
tailSweepStunSeconds: 0.55,
poisonSpitCooldown: 6.2,
poisonSpitWindup: 0.7,
poisonPuddleRadius: 46,
poisonPuddleDamage: 6,
poisonPuddleSeconds: 8,
}
const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
id: 'barroth',
name: 'Barroth',
icon: 'A',
spriteUrl: '/iwt2/bosses/barroth-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.3,
spriteHeightScale: 3.35,
spriteYOffsetScale: -0.07,
color: '#8d714d',
accentColor: '#d6bd80',
maxHealth: 1280,
radius: 35,
moveSpeed: 104,
meleeRange: 60,
meleeDamage: 12,
meleeCooldown: 1.18,
chargeCooldown: 0,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
mudArmor: 280,
mudSprayCooldown: 5.8,
mudSprayWindup: 0.82,
mudSprayRange: 172,
mudSprayAngleRadians: Math.PI * 0.58,
mudSprayDamage: 13,
mudSprayStunSeconds: 0.25,
mudPuddleRadius: 40,
mudPuddleSeconds: 7.5,
}
const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
id: 'tobi-kadachi',
name: 'Tobi Kadachi',
icon: 'T',
spriteUrl: '/iwt2/bosses/tobi-kadachi-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.7,
spriteHeightScale: 3.85,
spriteYOffsetScale: -0.08,
color: '#6a79bd',
accentColor: '#a8f3ff',
maxHealth: 920,
radius: 28,
moveSpeed: 160,
meleeRange: 53,
meleeDamage: 8,
meleeCooldown: 0.78,
chargeCooldown: 2.2,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
staticEnergyMax: 100,
staticEnergyPerSecond: 16,
staticEnergyPerMelee: 15,
staticPounceWindup: 0.58,
staticPounceSpeed: 560,
staticPounceDamage: 24,
staticPounceStunSeconds: 0.6,
staticPounceLength: 520,
staticPounceWidth: 46,
chainShockCooldown: 5.2,
chainShockWindup: 0.85,
chainShockRadius: 86,
chainShockDamage: 15,
chainShockStunSeconds: 0.45,
}
const DEFAULT_RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = {
id: 'rimebastion',
name: 'Rimebastion',
icon: 'I',
spriteUrl: '/iwt2/bosses/rimebastion-side-cel.png',
spriteView: 'side',
spriteWidthScale: 7.2,
spriteHeightScale: 4.5,
spriteYOffsetScale: -0.08,
color: '#5f9fd6',
accentColor: '#b8f3ff',
maxHealth: 1380,
radius: 37,
moveSpeed: 86,
meleeRange: 62,
meleeDamage: 12,
meleeCooldown: 1.2,
chargeCooldown: 5.6,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
iceWallCooldown: 6.2,
iceWallWindup: 0.9,
iceWallActiveSeconds: 0.7,
iceWallCount: 3,
iceWallLength: 180,
iceWallWidth: 22,
iceWallDamage: 10,
iceShardDamage: 22,
iceShardStunSeconds: 0.55,
}
const DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = {
id: 'ember-mantis-duelist',
name: 'Ember Mantis Duelist',
icon: 'M',
spriteUrl: '/iwt2/bosses/ember-mantis-duelist-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.6,
spriteHeightScale: 4.6,
spriteYOffsetScale: -0.1,
color: '#b94831',
accentColor: '#ffb24a',
maxHealth: 1040,
radius: 29,
moveSpeed: 168,
meleeRange: 54,
meleeDamage: 9,
meleeCooldown: 0.78,
chargeCooldown: 3.8,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
slashCooldown: 4.2,
slashWindup: 0.48,
slashDamage: 21,
slashStunSeconds: 0.35,
slashWidth: 24,
crossSlashAngleRadians: Math.PI * 0.32,
sidestepDistance: 94,
}
const DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = {
id: 'cinderback-ricochet',
name: 'Cinderback Ricochet',
icon: 'C',
spriteUrl: '/iwt2/bosses/cinderback-ricochet-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.6,
spriteHeightScale: 3.8,
spriteYOffsetScale: -0.06,
color: '#9a4f2f',
accentColor: '#ff8a3d',
maxHealth: 1250,
radius: 34,
moveSpeed: 106,
meleeRange: 60,
meleeDamage: 11,
meleeCooldown: 1.05,
chargeCooldown: 5.4,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
ricochetCooldown: 6.6,
ricochetWindup: 0.65,
ricochetSpeed: 440,
ricochetDamage: 24,
ricochetStunSeconds: 0.45,
ricochetSeconds: 1.45,
lavaTrailRadius: 34,
lavaTrailDamage: 6,
lavaTrailSeconds: 5.5,
armorSlamWindup: 0.42,
armorSlamRadius: 86,
armorSlamDamage: 18,
armorSlamStunSeconds: 0.5,
}
const DEFAULT_OBSIDIAN_RAM_GOLEM_BOSS_METADATA: Iwt2BossMetadata = {
id: 'obsidian-ram-golem',
name: 'Obsidian Ram Golem',
icon: 'O',
spriteUrl: '/iwt2/bosses/obsidian-ram-golem-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.5,
spriteHeightScale: 4.25,
spriteYOffsetScale: -0.08,
color: '#2f3034',
accentColor: '#ff7b32',
maxHealth: 1420,
radius: 36,
moveSpeed: 98,
meleeRange: 62,
meleeDamage: 9,
meleeCooldown: 1.12,
chargeCooldown: 5.7,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
mudArmor: 240,
}
const DEFAULT_STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = {
id: 'stormcoil-wyrm',
name: 'Stormcoil Wyrm',
icon: 'S',
spriteUrl: '/iwt2/bosses/stormcoil-wyrm-side-cel.png',
spriteView: 'side',
spriteWidthScale: 7.1,
spriteHeightScale: 4.15,
spriteYOffsetScale: -0.1,
color: '#273b75',
accentColor: '#78ecff',
maxHealth: 1120,
radius: 30,
moveSpeed: 142,
meleeRange: 58,
meleeDamage: 8,
meleeCooldown: 0.95,
chargeCooldown: 5.1,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
chainShockCooldown: 5,
chainShockWindup: 0.78,
chainShockRadius: 94,
chainShockDamage: 13,
chainShockStunSeconds: 0.38,
}
const DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = {
id: 'venom-orchid-hydra',
name: 'Venom Orchid Hydra',
icon: 'V',
spriteUrl: '/iwt2/bosses/venom-orchid-hydra-side-cel.png',
spriteView: 'side',
spriteWidthScale: 7.2,
spriteHeightScale: 4.6,
spriteYOffsetScale: -0.1,
color: '#5e316c',
accentColor: '#d8f05b',
maxHealth: 1320,
radius: 35,
moveSpeed: 84,
meleeRange: 64,
meleeDamage: 8,
meleeCooldown: 1.15,
chargeCooldown: 5.4,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
poisonSpitCooldown: 5.6,
poisonSpitWindup: 0.75,
poisonPuddleRadius: 42,
poisonPuddleDamage: 5,
poisonPuddleSeconds: 7,
}
const DEFAULT_SANDGLASS_SCORPION_BOSS_METADATA: Iwt2BossMetadata = {
id: 'sandglass-scorpion',
name: 'Sandglass Scorpion',
icon: 'G',
spriteUrl: '/iwt2/bosses/sandglass-scorpion-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.4,
spriteHeightScale: 4.25,
spriteYOffsetScale: -0.07,
color: '#9d7430',
accentColor: '#ffd15a',
maxHealth: 1180,
radius: 33,
moveSpeed: 126,
meleeRange: 58,
meleeDamage: 8,
meleeCooldown: 0.98,
chargeCooldown: 5.2,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
mudPuddleRadius: 44,
mudPuddleSeconds: 6,
}
const DEFAULT_CRYSTAL_BAT_MATRIARCH_BOSS_METADATA: Iwt2BossMetadata = {
id: 'crystal-bat-matriarch',
name: 'Crystal Bat Matriarch',
icon: 'Q',
spriteUrl: '/iwt2/bosses/crystal-bat-matriarch-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.8,
spriteHeightScale: 4.75,
spriteYOffsetScale: -0.12,
color: '#4e2d76',
accentColor: '#8eeaff',
maxHealth: 1050,
radius: 31,
moveSpeed: 150,
meleeRange: 56,
meleeDamage: 7,
meleeCooldown: 0.9,
chargeCooldown: 5.1,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
}
const DEFAULT_HOLLOWCROWN_REVENANT_BOSS_METADATA: Iwt2BossMetadata = {
id: 'hollowcrown-revenant',
name: 'Hollowcrown Revenant',
icon: 'H',
spriteUrl: '/iwt2/bosses/hollowcrown-revenant-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.6,
spriteHeightScale: 4.35,
spriteYOffsetScale: -0.1,
color: '#3c3f5e',
accentColor: '#9be8ff',
maxHealth: 1080,
radius: 30,
moveSpeed: 158,
meleeRange: 56,
meleeDamage: 8,
meleeCooldown: 0.9,
chargeCooldown: 4.8,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
}
function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
if (!override) return metadata
@@ -217,10 +773,34 @@ export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFA
export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA)
export const GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_GREAT_JAGGI_BOSS_METADATA)
export const KHEZU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_KHEZU_BOSS_METADATA)
export const RATHIAN_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_RATHIAN_BOSS_METADATA)
export const BARROTH_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BARROTH_BOSS_METADATA)
export const TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_TOBI_KADACHI_BOSS_METADATA)
export const RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_RIMEBASTION_BOSS_METADATA)
export const EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA)
export const CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA)
export const OBSIDIAN_RAM_GOLEM_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_OBSIDIAN_RAM_GOLEM_BOSS_METADATA)
export const STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_STORMCOIL_WYRM_BOSS_METADATA)
export const VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA)
export const SANDGLASS_SCORPION_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_SANDGLASS_SCORPION_BOSS_METADATA)
export const CRYSTAL_BAT_MATRIARCH_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_CRYSTAL_BAT_MATRIARCH_BOSS_METADATA)
export const HOLLOWCROWN_REVENANT_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_HOLLOWCROWN_REVENANT_BOSS_METADATA)
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
bulldrome: BULLDROME_BOSS_METADATA,
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
'great-jaggi': GREAT_JAGGI_BOSS_METADATA,
khezu: KHEZU_BOSS_METADATA,
rathian: RATHIAN_BOSS_METADATA,
barroth: BARROTH_BOSS_METADATA,
'tobi-kadachi': TOBI_KADACHI_BOSS_METADATA,
rimebastion: RIMEBASTION_BOSS_METADATA,
'ember-mantis-duelist': EMBER_MANTIS_DUELIST_BOSS_METADATA,
'cinderback-ricochet': CINDERBACK_RICOCHET_BOSS_METADATA,
'obsidian-ram-golem': OBSIDIAN_RAM_GOLEM_BOSS_METADATA,
'stormcoil-wyrm': STORMCOIL_WYRM_BOSS_METADATA,
'venom-orchid-hydra': VENOM_ORCHID_HYDRA_BOSS_METADATA,
'sandglass-scorpion': SANDGLASS_SCORPION_BOSS_METADATA,
'crystal-bat-matriarch': CRYSTAL_BAT_MATRIARCH_BOSS_METADATA,
'hollowcrown-revenant': HOLLOWCROWN_REVENANT_BOSS_METADATA,
}
+4 -4
View File
@@ -64,8 +64,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
radius: 14,
attackRange: 245,
attackDamage: 8,
attackCooldown: 0.62,
castTime: 0.32,
attackCooldown: 0.31,
castTime: 0.16,
projectileSpeed: 410,
},
mage: {
@@ -80,8 +80,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
radius: 14,
attackRange: 220,
attackDamage: 11,
attackCooldown: 0.78,
castTime: 0.42,
attackCooldown: 0.39,
castTime: 0.21,
projectileSpeed: 330,
},
rogue: {
@@ -1,6 +1,6 @@
import Phaser from 'phaser'
import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA } from '../../content/bosses'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
import { IWT2_CLASS_METADATA } from '../../content/classes'
import type {
Iwt2ArenaIndicator,
@@ -24,9 +24,11 @@ type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAd
export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps
private arenaGraphics?: Phaser.GameObjects.Graphics
private bossUnderlayGraphics?: Phaser.GameObjects.Graphics
private entityGraphics?: Phaser.GameObjects.Graphics
private telegraphGraphics?: Phaser.GameObjects.Graphics
private labels = new Map<string, Phaser.GameObjects.Text>()
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
private lastHudPublish = 0
constructor(deps: SceneDeps) {
@@ -34,11 +36,22 @@ export class BulldromeArenaScene extends Phaser.Scene {
this.deps = deps
}
preload() {
for (const metadata of Object.values(IWT2_BOSS_METADATA)) {
if (metadata.spriteUrl.endsWith('.svg')) {
this.load.svg(bossSpriteKey(metadata.id), metadata.spriteUrl, { width: 144, height: 144 })
} else {
this.load.image(bossSpriteKey(metadata.id), metadata.spriteUrl)
}
}
}
create() {
this.cameras.main.setBackgroundColor('#10141b')
this.arenaGraphics = this.add.graphics()
this.telegraphGraphics = this.add.graphics()
this.entityGraphics = this.add.graphics()
this.arenaGraphics = this.add.graphics().setDepth(0)
this.telegraphGraphics = this.add.graphics().setDepth(10)
this.bossUnderlayGraphics = this.add.graphics().setDepth(20)
this.entityGraphics = this.add.graphics().setDepth(35)
this.drawState(this.deps.getState())
}
@@ -80,22 +93,29 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
private drawEntities(state: Iwt2ArenaState) {
const bossUnderlay = this.bossUnderlayGraphics!
const graphics = this.entityGraphics!
bossUnderlay.clear()
graphics.clear()
for (const hazard of state.hazards) drawHazard(graphics, hazard)
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss]
const liveIds = new Set<string>(entities.map((entity) => entity.id))
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
const liveLabelIds = new Set<string>(entities.filter((entity) => entity.kind !== 'boss').map((entity) => entity.id))
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
for (const entity of entities.sort(entitySort)) {
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
const color = entityColor(entity)
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(entity.kind === 'party' && entity.aiRole === 'player' ? 4 : 2, entity.kind === 'boss' ? 0xfff0b8 : 0x0a0c10, 1)
if (entity.kind === 'boss') {
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
if (this.textures.exists(bossSpriteKey(entity.bossId))) {
drawBossUnderlay(bossUnderlay, entity, color, alpha)
this.drawBossSprite(entity, alpha)
} else {
drawBossFallback(graphics, entity, color, alpha)
}
} else if (entity.kind === 'hostileAdd') {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(2, 0x0a0c10, 1)
const wing = entity.radius * 1.55
graphics.fillTriangle(
entity.position.x + entity.facing.x * wing,
@@ -114,6 +134,8 @@ export class BulldromeArenaScene extends Phaser.Scene {
entity.position.y + entity.radius,
)
} else {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(entity.aiRole === 'player' ? 4 : 2, 0x0a0c10, 1)
if (entity.id === this.deps.getSelectedPartyId()) {
graphics.lineStyle(4, 0xfff4a8, 0.95)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
@@ -121,12 +143,19 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
}
this.drawLabel(entity)
if (entity.kind !== 'boss') this.drawLabel(entity)
this.drawHealthBar(graphics, entity)
}
for (const [id, sprite] of this.bossSprites) {
if (!liveBossIds.has(id)) {
sprite.destroy()
this.bossSprites.delete(id)
}
}
for (const [id, label] of this.labels) {
if (!liveIds.has(id)) {
if (!liveLabelIds.has(id)) {
label.destroy()
this.labels.delete(id)
}
@@ -144,6 +173,30 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
}
private drawBossSprite(entity: Iwt2BossEntityState, alpha: number) {
const key = bossSpriteKey(entity.bossId)
const metadata = IWT2_BOSS_METADATA[entity.bossId]
let sprite = this.bossSprites.get(entity.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5).setDepth(25)
this.bossSprites.set(entity.id, sprite)
}
const isSideView = metadata.spriteView === 'side'
const facingLength = Math.hypot(entity.facing.x, entity.facing.y)
const rotation = !isSideView && facingLength > 0.001 ? Math.atan2(entity.facing.y, entity.facing.x) : 0
const width = entity.radius * (metadata.spriteWidthScale ?? 3.35)
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
sprite
.setTexture(key)
.setPosition(entity.position.x, entity.position.y + yOffset)
.setDisplaySize(width, height)
.setRotation(rotation)
.setFlipX(isSideView && entity.facing.x < -0.05)
.setAlpha(alpha)
}
private drawLabel(entity: DrawableEntity) {
let label = this.labels.get(entity.id)
const icon = entityIcon(entity)
@@ -166,7 +219,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) {
const width = entity.kind === 'boss' ? 92 : 34
const height = entity.kind === 'boss' ? 8 : 7
const y = entity.position.y + entity.radius + 8
const y = entity.position.y + (entity.kind === 'boss' ? entity.radius * 1.55 : entity.radius) + 8
const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth))
graphics.fillStyle(0x050608, 0.9)
graphics.fillRect(entity.position.x - width / 2, y, width, height)
@@ -177,6 +230,20 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.fillStyle(0x58a8ff, 0.92)
graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height)
}
if (entity.kind === 'boss' && entity.maxArmor > 0) {
const armorRatio = Math.max(0, Math.min(1, entity.armor / entity.maxArmor))
graphics.fillStyle(0x21180e, 0.9)
graphics.fillRect(entity.position.x - width / 2, y + height + 3, width, 4)
graphics.fillStyle(0xd6bd80, 0.95)
graphics.fillRect(entity.position.x - width / 2, y + height + 3, width * armorRatio, 4)
}
if (entity.kind === 'boss' && entity.mechanicEnergyMax > 0) {
const energyRatio = Math.max(0, Math.min(1, entity.mechanicEnergy / entity.mechanicEnergyMax))
graphics.fillStyle(0x07171b, 0.9)
graphics.fillRect(entity.position.x - width / 2, y + height + 8, width, 4)
graphics.fillStyle(0xa8f3ff, 0.95)
graphics.fillRect(entity.position.x - width / 2, y + height + 8, width * energyRatio, 4)
}
}
}
@@ -197,11 +264,42 @@ function entityIcon(entity: DrawableEntity): string {
return IWT2_CLASS_METADATA[entity.classId].icon
}
function bossSpriteKey(bossId: Iwt2BossId): string {
return `iwt2-boss-${bossId}`
}
function drawBossUnderlay(
graphics: Phaser.GameObjects.Graphics,
entity: Iwt2BossEntityState,
color: string,
alpha: number,
) {
const colorValue = Number.parseInt(color.slice(1), 16)
graphics.fillStyle(0x050608, 0.42 * alpha)
graphics.fillEllipse(entity.position.x, entity.position.y + entity.radius * 0.68, entity.radius * 2.45, entity.radius * 0.74)
graphics.lineStyle(2, colorValue, 0.45 * alpha)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
}
function drawBossFallback(
graphics: Phaser.GameObjects.Graphics,
entity: Iwt2BossEntityState,
color: string,
alpha: number,
) {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(2, 0xfff0b8, 1)
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
}
function drawHazard(graphics: Phaser.GameObjects.Graphics, hazard: Iwt2GroundHazardState) {
const ratio = Math.max(0, Math.min(1, hazard.remainingSeconds / Math.max(0.001, hazard.fadeSeconds)))
const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34
graphics.fillStyle(0xff7a2f, alpha)
graphics.lineStyle(2, 0xffc15a, 0.55)
const color = hazard.hazardKind === 'poisonPuddle' ? 0x7bd84f : hazard.hazardKind === 'mudPuddle' ? 0x9a6a3a : 0xff7a2f
const lineColor = hazard.hazardKind === 'poisonPuddle' ? 0xc9ff8f : hazard.hazardKind === 'mudPuddle' ? 0xd6bd80 : 0xffc15a
graphics.fillStyle(color, alpha)
graphics.lineStyle(2, lineColor, 0.55)
graphics.fillCircle(hazard.position.x, hazard.position.y, hazard.radius)
graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius)
}
@@ -237,12 +335,55 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
return
}
if (indicator.kind === 'arc') {
const points = arcDangerPolygon(
indicator.position,
indicator.direction,
indicator.innerRadius,
indicator.outerRadius,
indicator.angleRadians,
)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return
}
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
graphics.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6)
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
}
function arcDangerPolygon(
position: Iwt2Vec2,
direction: Iwt2Vec2,
innerRadius: number,
outerRadius: number,
angleRadians: number,
) {
const baseAngle = Math.atan2(direction.y, direction.x)
const halfAngle = angleRadians / 2
const segmentCount = 18
const points: Phaser.Math.Vector2[] = []
for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
position.x + Math.cos(angle) * outerRadius,
position.y + Math.sin(angle) * outerRadius,
))
}
for (let index = segmentCount; index >= 0; index -= 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
position.x + Math.cos(angle) * innerRadius,
position.y + Math.sin(angle) * innerRadius,
))
}
return points
}
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
const dx = end.x - start.x
const dy = end.y - start.y
+51 -10
View File
@@ -1,4 +1,6 @@
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
import { IWT2_BOSS_PET_DROP_RATE, iwt2BossMaterialRewardFor, iwt2BossPetRewardFor } from '../content/bossRewards'
import type { Iwt2BossId } from '../content/bosses'
export type Iwt2InventoryItem = {
id: string
@@ -11,6 +13,7 @@ export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moo
export type Iwt2CollectionLog = {
bossKills: Record<string, number>
bossPets: Record<string, number>
dropsFound: Record<string, number>
}
@@ -29,6 +32,20 @@ export type Iwt2CloudSlot = {
collectionLog: Iwt2CollectionLog
}
export type Iwt2BossPetAward = {
bossId: Iwt2BossId
petId: string
petName: string
quantity: number
duplicate: boolean
quantityAfter: number
}
export type Iwt2BossKillReward = {
save: Iwt2Save
petAwarded: Iwt2BossPetAward | null
}
export type Iwt2Save = {
version: 1
updatedAt: number
@@ -54,6 +71,7 @@ export function createDefaultIwt2Save(): Iwt2Save {
inventory: [],
collectionLog: {
bossKills: {},
bossPets: {},
dropsFound: {},
},
}
@@ -76,6 +94,7 @@ function normalizeSave(value: unknown): Iwt2Save {
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
collectionLog: {
bossKills: candidate.collectionLog?.bossKills ?? {},
bossPets: candidate.collectionLog?.bossPets ?? {},
dropsFound: candidate.collectionLog?.dropsFound ?? {},
},
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
@@ -97,9 +116,16 @@ export function writeIwt2Save(save: Iwt2Save) {
}))
}
export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save {
const drop = bossDropFor(bossId)
return {
export function recordIwt2BossKill(save: Iwt2Save, bossId: Iwt2BossId): Iwt2Save {
return recordIwt2BossKillReward(save, bossId).save
}
export function recordIwt2BossKillReward(save: Iwt2Save, bossId: Iwt2BossId): Iwt2BossKillReward {
const drop = iwt2BossMaterialRewardFor(bossId)
const pet = iwt2BossPetRewardFor(bossId)
const awardedPet = Math.random() < IWT2_BOSS_PET_DROP_RATE
const previousPetQuantity = save.collectionLog.bossPets[pet.id] ?? 0
const updatedSave: Iwt2Save = {
...save,
updatedAt: Date.now(),
character: {
@@ -122,12 +148,31 @@ export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save {
...save.collectionLog.bossKills,
[bossId]: (save.collectionLog.bossKills[bossId] ?? 0) + 1,
},
bossPets: awardedPet
? {
...save.collectionLog.bossPets,
[pet.id]: (save.collectionLog.bossPets[pet.id] ?? 0) + 1,
}
: save.collectionLog.bossPets,
dropsFound: {
...save.collectionLog.dropsFound,
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + 1,
},
},
}
return {
save: updatedSave,
petAwarded: awardedPet
? {
bossId,
duplicate: previousPetQuantity > 0,
petId: pet.id,
petName: pet.name,
quantity: 1,
quantityAfter: previousPetQuantity + 1,
}
: null,
}
}
export function updateIwt2CharacterSettings(
@@ -157,6 +202,7 @@ export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
inventory: save.inventory.map((item) => ({ ...item })),
collectionLog: {
bossKills: { ...save.collectionLog.bossKills },
bossPets: { ...save.collectionLog.bossPets },
dropsFound: { ...save.collectionLog.dropsFound },
},
},
@@ -172,18 +218,12 @@ export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
inventory: save.cloudSlot.inventory.map((item) => ({ ...item })),
collectionLog: {
bossKills: { ...save.cloudSlot.collectionLog.bossKills },
bossPets: { ...save.cloudSlot.collectionLog.bossPets },
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
},
}
}
function bossDropFor(bossId: string): { id: string, name: string } {
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
if (bossId === 'great-jaggi') return { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' }
if (bossId === 'khezu') return { id: 'khezu-pearl', name: 'Khezu Pearl' }
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
}
function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId {
return value === 'sun_gold'
|| value === 'ember_red'
@@ -214,6 +254,7 @@ function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined {
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
collectionLog: {
bossKills: candidate.collectionLog.bossKills ?? {},
bossPets: candidate.collectionLog.bossPets ?? {},
dropsFound: candidate.collectionLog.dropsFound ?? {},
},
}
+116 -40
View File
@@ -16,7 +16,8 @@ import type { Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKill,
recordIwt2BossKillReward,
type Iwt2BossPetAward,
type Iwt2Save,
} from '../save/iwt2Repository'
import { AbilityBar } from '../components/AbilityBar'
@@ -47,6 +48,7 @@ const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
type BossArenaScreenProps = {
bossId: Iwt2BossId
bossIds?: Iwt2BossId[]
modeLabel?: string
save: Iwt2Save
onBack: () => void
@@ -61,24 +63,26 @@ type BossArenaScreenProps = {
}
}
export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null
pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const [petAwards, setPetAwards] = useState<Iwt2BossPetAward[]>([])
const stateRef = useRef(arenaState)
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
const statusRef = useRef(status)
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
const saveRef = useRef(save)
const killRecordedRef = useRef(false)
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
const lastPublishTimeRef = useRef(0)
const lastHudSignatureRef = useRef('')
const abilityCooldownsRef = useRef<Record<string, number>>({})
@@ -100,12 +104,6 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
opponentStateRef.current = opponentArenaState
}, [opponentArenaState])
useEffect(() => {
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null
opponentStateRef.current = nextOpponentState
setOpponentArenaState(nextOpponentState)
}, [bossId, pvpRoguelike])
useEffect(() => {
statusRef.current = status
}, [status])
@@ -123,9 +121,9 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
}, [save])
const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId)
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null
killRecordedRef.current = false
const next = createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale)
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next
@@ -135,7 +133,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
setAbilityCooldowns({})
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossId, pvpRoguelike])
}, [bossHealthScale, bossId, bossIds, pvpRoguelike])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
@@ -211,7 +209,6 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
}
if (action === 'back') {
if (statusRef.current === 'paused') setStatus('playing')
else onBack()
return
}
}
@@ -272,11 +269,24 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
opponentStateRef.current = nextOpponentState
}
if (next.boss.health <= 0 && !killRecordedRef.current) {
killRecordedRef.current = true
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
if (newlyDefeatedBosses.length > 0) {
const nextRecordedIds = new Set(recordedKillIdsRef.current)
let updatedSave = saveRef.current
const newPetAwards: Iwt2BossPetAward[] = []
for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId)
updatedSave = reward.save
if (reward.petAwarded) newPetAwards.push(reward.petAwarded)
}
recordedKillIdsRef.current = nextRecordedIds
saveRef.current = updatedSave
if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards])
onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
showOverlay('victory')
} else if (next.party.every((member) => member.health <= 0)) {
showOverlay('defeat')
@@ -286,7 +296,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
if (
hudSignature !== lastHudSignatureRef.current
|| next.time - lastPublishTimeRef.current >= 0.08
|| next.boss.health <= 0
|| next.bosses.some((boss) => boss.health <= 0)
) {
lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time
@@ -295,7 +305,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
setAbilityCooldowns(abilityCooldownsRef.current)
}
return next
}, [bossId, onSaveUpdated, pvpRoguelike, showOverlay])
}, [onSaveUpdated, pvpRoguelike, showOverlay])
const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
@@ -303,13 +313,15 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
const overlayPrimaryLabel = status === 'paused'
? 'Resume'
: status === 'victory' && roguelikeRun
? 'Choose Upgrade'
: 'Restart'
const overlayTitle = status === 'victory'
? `${bossMetadata.name} Down`
? `${bossTitle} Down`
: status === 'defeat'
? 'Party Defeated'
: 'Arena Paused'
@@ -337,7 +349,6 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
abilities,
arenaState,
bindings: activeBindings,
bossMetadata,
cooldowns: abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
@@ -353,7 +364,6 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
activeBindings,
arenaState,
abilityCooldowns,
bossMetadata,
controllerIconStyle,
directPartyTargeting,
modeLabel,
@@ -375,7 +385,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
>
<section className="iwt2-arena-layout">
<div className="iwt2-arena-stage">
<BossHud boss={arenaState.boss} />
<BossHud bosses={arenaState.bosses} />
<PartyFrames
controllerIconStyle={controllerIconStyle}
onTarget={setSelectedPartyId}
@@ -399,6 +409,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
<button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
@@ -408,6 +419,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
<button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
@@ -422,7 +434,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
<div className="iwt2-result-panel">
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
<span>{bossMetadata.icon}</span>
<span>{arenaState.bosses.map((boss) => IWT2_BOSS_METADATA[boss.bossId].icon).join('')}</span>
</div>
<p className="eyebrow">{overlayEyebrow}</p>
<h1>{overlayTitle}</h1>
@@ -440,6 +452,10 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
Standing
</span>
<span>
<strong>{defeatedBossCount}/{arenaState.bosses.length}</strong>
Bosses
</span>
<span>
<strong>{Math.round(totalPartyDamage)}</strong>
Damage
@@ -447,9 +463,14 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
</div>
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+125 XP</span>
<span>Common carve</span>
<span>Log +1</span>
<span>+{arenaState.bosses.length * 125} XP</span>
<span>{arenaState.bosses.length} carves</span>
<span>Log +{arenaState.bosses.length}</span>
{petAwards.map((award) => (
<span key={`${award.petId}:${award.quantityAfter}`}>
{award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''}
</span>
))}
</div>
)}
<div className="iwt2-overlay-actions">
@@ -471,7 +492,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
</button>
</div>
{lastDevice === 'controller' && (
<small className="iwt2-result-hint">D-pad selects, A confirms, B exits</small>
<small className="iwt2-result-hint">D-pad selects, A confirms</small>
)}
</div>
</div>
@@ -496,11 +517,17 @@ function formatArenaTime(seconds: number): string {
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
function roguelikeBossHealthScale(stage: number): number {
return 0.5 + Math.max(0, stage - 1) * 0.1
}
function arenaHudSignature(state: Iwt2ArenaState): string {
return [
state.boss.id,
Math.ceil(state.boss.health),
state.boss.attackPhase,
...state.bosses.map((boss) => [
boss.id,
Math.ceil(boss.health),
boss.attackPhase,
].join(':')),
state.hostileAdds.length,
state.hazards.length,
...state.party.map((member) => [
@@ -514,6 +541,12 @@ function arenaHudSignature(state: Iwt2ArenaState): string {
].join('|')
}
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
return bosses
.map((boss) => IWT2_BOSS_METADATA[boss.bossId].name)
.join(' + ')
}
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
const living = state.party.filter((member) => member.health > 0)
const targets = living.length > 0 ? living : state.party
@@ -586,7 +619,6 @@ function buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings,
bossMetadata,
cooldowns,
controllerIconStyle,
directPartyTargeting,
@@ -600,7 +632,6 @@ function buildIwt2DualScreenCombatState({
abilities: Iwt2HealerAbility[]
arenaState: Iwt2ArenaState
bindings: DualScreenCombatState['bindings']
bossMetadata: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
cooldowns: Record<string, number>
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
directPartyTargeting: boolean
@@ -612,12 +643,15 @@ function buildIwt2DualScreenCombatState({
status: ArenaStatus
}): DualScreenCombatState {
const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer')
const arenaBossHealth = totalBossHealth(arenaState)
const arenaBossMaxHealth = totalBossMaxHealth(arenaState)
const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
? {
opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
opponentEnemyHealth: opponentArenaState.boss.health,
opponentArena: toDualScreenOpponentArena(opponentArenaState),
opponentEnemyHealth: totalBossHealth(opponentArenaState),
opponentMaxResource: opponentHealer?.maxMana ?? 100,
opponentName: 'CPU Rival',
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
@@ -632,14 +666,14 @@ function buildIwt2DualScreenCombatState({
controllerIconStyle,
difficultyName: 'IWT2',
directPartyTargeting,
dungeonName: `${bossMetadata.name} Arena`,
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
encounterCount: 1,
encounterDescription: arenaState.boss.attackPhase,
encounterHealth: arenaState.boss.health,
encounterHealth: arenaBossHealth,
encounterIndex: 0,
encounterIsBoss: true,
encounterMaxHealth: arenaState.boss.maxHealth,
encounterName: bossMetadata.name,
encounterMaxHealth: arenaBossMaxHealth,
encounterName: formatBossEncounterTitle(arenaState.bosses),
floatingTexts: [],
maxResource: 100,
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
@@ -656,6 +690,48 @@ function buildIwt2DualScreenCombatState({
}
}
function totalBossHealth(state: Iwt2ArenaState): number {
return state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
}
function totalBossMaxHealth(state: Iwt2ArenaState): number {
return state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
}
function toDualScreenOpponentArena(state: Iwt2ArenaState): NonNullable<DualScreenCombatState['opponentArena']> {
return {
bounds: state.bounds,
bosses: state.bosses.map((boss) => {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return {
id: boss.id,
name: metadata.name,
icon: metadata.icon,
color: metadata.color,
x: boss.position.x,
y: boss.position.y,
radius: boss.radius,
health: boss.health,
maxHealth: boss.maxHealth,
}
}),
party: state.party.map((member) => {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
id: member.id,
icon: metadata.icon,
color: metadata.color,
x: member.position.x,
y: member.position.y,
radius: member.radius,
health: member.health,
maxHealth: member.maxHealth,
isHealer: member.id === 'player-healer',
}
}),
}
}
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
+623 -102
View File
@@ -1,6 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ControllerStylePreview } from '../../../components/ControllerIcons'
import { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
import {
useGameAction,
useInput,
type ControllerIconStyle,
type InputAction,
} from '../../../input'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
import { getGameMode } from '../../../gameRepository'
import {
restoreIwt2CloudSlot,
@@ -9,6 +15,7 @@ import {
type Iwt2Save,
} from '../save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { iwt2BossMaterialRewardFor, iwt2BossPetRewardFor } from '../content/bossRewards'
import {
abilitiesForHealer,
IWT2_HEALER_METADATA,
@@ -62,6 +69,39 @@ type Iwt2UpgradeNavEntry =
| { kind: 'upgradeContinue', row: number, column: number, disabled?: boolean }
| { kind: 'upgradeLeave', row: number, column: number }
type Iwt2HunterProfileTab = 'stats' | 'collection'
type Iwt2HunterProfileNavEntry =
| { kind: 'back', key: string, row: number, column: number }
| { kind: 'tab', key: string, row: number, column: number, tab: Iwt2HunterProfileTab }
| { kind: 'stat', key: string, row: number, column: number, index: number }
| { kind: 'boss', key: string, row: number, column: number, bossId: Iwt2BossId }
| { kind: 'item', key: string, row: number, column: number, itemKey: string }
type Iwt2HunterProfileBossEntry = {
bossId: Iwt2BossId
dropId: string
dropName: string
encounter: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
kills: number
petId: string
petName: string
pets: number
drops: number
}
type Iwt2HunterCollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
function withBackAction(actions: Iwt2NavAction[], onBack: () => void): Iwt2NavAction[] {
return [
{
@@ -122,10 +162,83 @@ const MODE_ACTIONS: Record<Iwt2Mode, Array<{
detail: 'Shared arena entry with raid-role notes: watch lightning markers, rotate through safe bands, and recover stunned allies fast.',
value: 'Start',
},
{
bossId: 'rathian',
label: 'Rathian Raid Assignment',
detail: 'Shared arena entry with raid-role notes: dodge tail arcs, keep poison puddles out of the party path, and recover after sweeps.',
value: 'Start',
},
{
bossId: 'barroth',
label: 'Barroth Raid Assignment',
detail: 'Shared arena entry with raid-role notes: break mud armor, sidestep mud cones, and reposition through slow zones.',
value: 'Start',
},
{
bossId: 'tobi-kadachi',
label: 'Tobi Kadachi Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track static charge, bait line pounces, and spread before chain shock.',
value: 'Start',
},
{
bossId: 'rimebastion',
label: 'Rimebastion Raid Assignment',
detail: 'Shared arena entry with raid-role notes: read ice wall lanes, dodge shard cones, and keep the tank away from corners.',
value: 'Start',
},
{
bossId: 'ember-mantis-duelist',
label: 'Ember Mantis Duelist Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track sidesteps, cut through slash lanes, and spread before crossed blades.',
value: 'Start',
},
{
bossId: 'cinderback-ricochet',
label: 'Cinderback Ricochet Raid Assignment',
detail: 'Shared arena entry with raid-role notes: bait ricochet lanes, avoid lava trails, and recover after armor slam.',
value: 'Start',
},
{
bossId: 'obsidian-ram-golem',
label: 'Obsidian Ram Golem Raid Assignment',
detail: 'Shared arena entry with raid-role notes: bait charge lines, avoid quake fractures, and break armor safely.',
value: 'Start',
},
{
bossId: 'stormcoil-wyrm',
label: 'Stormcoil Wyrm Raid Assignment',
detail: 'Shared arena entry with raid-role notes: spread for chain lightning and rotate through charged ring gaps.',
value: 'Start',
},
{
bossId: 'venom-orchid-hydra',
label: 'Venom Orchid Hydra Raid Assignment',
detail: 'Shared arena entry with raid-role notes: dodge staggered poison cones and keep root lanes off the party.',
value: 'Start',
},
{
bossId: 'sandglass-scorpion',
label: 'Sandglass Scorpion Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track burrows, wait out stinger eruptions, and avoid sinking sand.',
value: 'Start',
},
{
bossId: 'crystal-bat-matriarch',
label: 'Crystal Bat Matriarch Raid Assignment',
detail: 'Shared arena entry with raid-role notes: space for sonic rings, watch mirror lanes, and dodge swoops.',
value: 'Start',
},
{
bossId: 'hollowcrown-revenant',
label: 'Hollowcrown Revenant Raid Assignment',
detail: 'Shared arena entry with raid-role notes: avoid cursed hoof zones and sidestep ghost-antler charges.',
value: 'Start',
},
],
}
const CONTROLLER_ICON_OPTIONS: ControllerIconStyle[] = ['playstation', 'xbox', 'nintendo']
const IWT2_DUNGEON_BOSS_PAGE_SIZE = 5
const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
xbox: 'Xbox',
@@ -133,6 +246,121 @@ const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
nintendo: 'Nintendo',
}
const IWT2_DUNGEON_ACTIONS: Array<Omit<Iwt2NavAction, 'onConfirm'> & { bossId: Iwt2BossId }> = [
{
bossId: 'bulldrome',
key: 'bulldrome',
label: 'Bulldrome Arena',
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
value: 'Ready',
},
{
bossId: 'yian-kut-ku',
key: 'yian-kut-ku',
label: 'Yian Kut Ku Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
},
{
bossId: 'great-jaggi',
key: 'great-jaggi',
label: 'Great Jaggi Arena',
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
value: 'Ready',
},
{
bossId: 'khezu',
key: 'khezu',
label: 'Khezu Arena',
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
value: 'Ready',
},
{
bossId: 'rathian',
key: 'rathian',
label: 'Rathian Arena',
detail: 'Poison tail arcs and targeted poison puddles punish stacked positioning.',
value: 'Ready',
},
{
bossId: 'barroth',
key: 'barroth',
label: 'Barroth Arena',
detail: 'Breakable mud armor softens incoming damage while mud spray creates slow zones.',
value: 'Ready',
},
{
bossId: 'tobi-kadachi',
key: 'tobi-kadachi',
label: 'Tobi Kadachi Arena',
detail: 'Static builds into line pounces while chain shock punishes grouped allies.',
value: 'Ready',
},
{
bossId: 'rimebastion',
key: 'rimebastion',
label: 'Rimebastion Arena',
detail: 'Ice walls split the room, then shard lanes and cones punish trapped routes.',
value: 'Ready',
},
{
bossId: 'ember-mantis-duelist',
key: 'ember-mantis-duelist',
label: 'Ember Mantis Duelist Arena',
detail: 'Fast sidesteps lead into line slashes and crossed lane attacks.',
value: 'Ready',
},
{
bossId: 'cinderback-ricochet',
key: 'cinderback-ricochet',
label: 'Cinderback Ricochet Arena',
detail: 'Wall-bounce rolls leave lava trails before a heavy armor slam.',
value: 'Ready',
},
{
bossId: 'obsidian-ram-golem',
key: 'obsidian-ram-golem',
label: 'Obsidian Ram Golem Arena',
detail: 'Volcanic charge lines crack armor, then quake fractures split the floor.',
value: 'Ready',
},
{
bossId: 'stormcoil-wyrm',
key: 'stormcoil-wyrm',
label: 'Stormcoil Wyrm Arena',
detail: 'Charged rings and chain lightning punish clustered party movement.',
value: 'Ready',
},
{
bossId: 'venom-orchid-hydra',
key: 'venom-orchid-hydra',
label: 'Venom Orchid Hydra Arena',
detail: 'Three heads sweep poison cones while roots and pods pressure escape paths.',
value: 'Ready',
},
{
bossId: 'sandglass-scorpion',
key: 'sandglass-scorpion',
label: 'Sandglass Scorpion Arena',
detail: 'Burrows, delayed stinger eruptions, and shifting sand zones control space.',
value: 'Ready',
},
{
bossId: 'crystal-bat-matriarch',
key: 'crystal-bat-matriarch',
label: 'Crystal Bat Matriarch Arena',
detail: 'Sonic rings, mirror shard lanes, and swoop dives punish bad spacing.',
value: 'Ready',
},
{
bossId: 'hollowcrown-revenant',
key: 'hollowcrown-revenant',
label: 'Hollowcrown Revenant Arena',
detail: 'Cursed hoof zones and ghost-antler dash trails cut across the arena.',
value: 'Ready',
},
]
export function Iwt2ScreenShell({
children,
eyebrow = 'I Want To Heal 2',
@@ -176,6 +404,7 @@ export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
<button
className={`iwt2-action-row ${selectedIndex === index ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
disabled={action.disabled}
key={action.key}
onClick={action.onConfirm}
@@ -221,40 +450,39 @@ export function Iwt2DungeonsScreen({
onBack: () => void
onOpenBoss: (bossId: Iwt2BossId) => void
}) {
const [page, setPage] = useState(0)
const pageCount = Math.max(1, Math.ceil(IWT2_DUNGEON_ACTIONS.length / IWT2_DUNGEON_BOSS_PAGE_SIZE))
const currentPage = Math.min(page, pageCount - 1)
const pageStart = currentPage * IWT2_DUNGEON_BOSS_PAGE_SIZE
const pageEnd = Math.min(IWT2_DUNGEON_ACTIONS.length, pageStart + IWT2_DUNGEON_BOSS_PAGE_SIZE)
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
key: 'bulldrome',
label: 'Bulldrome Arena',
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
value: 'Ready',
onConfirm: () => onOpenBoss('bulldrome'),
key: 'dungeons-page-prev',
label: 'Previous Page',
detail: `Bosses ${pageStart + 1}-${pageEnd} of ${IWT2_DUNGEON_ACTIONS.length}`,
disabled: currentPage === 0,
value: 'Prev',
onConfirm: () => setPage((activePage) => Math.max(0, activePage - 1)),
},
{
key: 'yian-kut-ku',
label: 'Yian Kut Ku Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
onConfirm: () => onOpenBoss('yian-kut-ku'),
key: 'dungeons-page-next',
label: 'Next Page',
detail: `Page ${currentPage + 1}/${pageCount}`,
disabled: currentPage >= pageCount - 1,
value: 'Next',
onConfirm: () => setPage((activePage) => Math.min(pageCount - 1, activePage + 1)),
},
{
key: 'great-jaggi',
label: 'Great Jaggi Arena',
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
value: 'Ready',
onConfirm: () => onOpenBoss('great-jaggi'),
},
{
key: 'khezu',
label: 'Khezu Arena',
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
value: 'Ready',
onConfirm: () => onOpenBoss('khezu'),
},
], onBack), [onBack, onOpenBoss])
...IWT2_DUNGEON_ACTIONS.slice(pageStart, pageEnd).map((entry) => ({
...entry,
onConfirm: () => onOpenBoss(entry.bossId),
})),
], onBack), [currentPage, onBack, onOpenBoss, pageCount, pageEnd, pageStart])
return (
<Iwt2ScreenShell title="Dungeons" onBack={onBack}>
<Iwt2ActionList actions={actions} />
<div className="iwt2-dungeon-list">
<Iwt2ActionList actions={actions} />
</div>
</Iwt2ScreenShell>
)
}
@@ -292,6 +520,7 @@ export function Iwt2RoguelikeScreen({
onContentTypeChange,
onStart,
onVariantChange,
queueMessage,
variant,
}: {
contentType: Iwt2RoguelikeContentType
@@ -299,6 +528,7 @@ export function Iwt2RoguelikeScreen({
onContentTypeChange: (contentType: Iwt2RoguelikeContentType) => void
onStart: () => void
onVariantChange: (variant: Iwt2RoguelikeVariant) => void
queueMessage?: string
variant: Iwt2RoguelikeVariant
}) {
const gameMode = getGameMode()
@@ -403,6 +633,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => select('back')}
type="button"
@@ -414,6 +645,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`text-button ${variant === 'pve' ? 'active' : ''} ${selected('variant-pve') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('variant-pve') ? 'true' : undefined}
onClick={() => open('variant-pve')}
onPointerDown={() => select('variant-pve')}
type="button"
@@ -423,6 +655,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`text-button ${variant === 'pvp' ? 'active' : ''} ${selected('variant-pvp') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('variant-pvp') ? 'true' : undefined}
onClick={() => open('variant-pvp')}
onPointerDown={() => select('variant-pvp')}
type="button"
@@ -441,6 +674,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pve-dungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pve-dungeon') ? 'true' : undefined}
onClick={() => open('pve-dungeon')}
onPointerDown={() => select('pve-dungeon')}
type="button"
@@ -450,6 +684,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pve-raid') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pve-raid') ? 'true' : undefined}
onClick={() => open('pve-raid')}
onPointerDown={() => select('pve-raid')}
type="button"
@@ -467,6 +702,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`text-button ${selected('pve-start') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pve-start') ? 'true' : undefined}
onClick={() => open('pve-start')}
onPointerDown={() => select('pve-start')}
type="button"
@@ -487,6 +723,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pvp-dungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pvp-dungeon') ? 'true' : undefined}
onClick={() => open('pvp-dungeon')}
onPointerDown={() => select('pvp-dungeon')}
type="button"
@@ -496,6 +733,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pvp-raid') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pvp-raid') ? 'true' : undefined}
onClick={() => open('pvp-raid')}
onPointerDown={() => select('pvp-raid')}
type="button"
@@ -505,6 +743,7 @@ export function Iwt2RoguelikeScreen({
<button
className={`text-button ${contentType === 'stadium' ? 'active' : ''} ${selected('pvp-stadium') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pvp-stadium') ? 'true' : undefined}
onClick={() => open('pvp-stadium')}
onPointerDown={() => select('pvp-stadium')}
type="button"
@@ -517,11 +756,12 @@ export function Iwt2RoguelikeScreen({
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
<div>
<strong>{pvpCardTitle}</strong>
<small>{pvpCardCopy}</small>
<small>{queueMessage || pvpCardCopy}</small>
</div>
<button
className={`text-button ${selected('pvp-start') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pvp-start') ? 'true' : undefined}
onClick={() => open('pvp-start')}
onPointerDown={() => select('pvp-start')}
type="button"
@@ -652,9 +892,7 @@ export function Iwt2RoguelikeUpgradeScreen({
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
return
}
if ((action === 'back' || action === 'pause') && !pvpUpgrade) {
onBack()
}
if (action === 'back' || action === 'pause') return
})
return (
@@ -682,6 +920,7 @@ export function Iwt2RoguelikeUpgradeScreen({
<button
className={`${activeBuffId === choice.id && pvpUpgrade ? 'selected-upgrade' : ''} ${entrySelected('upgradeBuff', index) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={entrySelected('upgradeBuff', index) ? 'true' : undefined}
key={choice.id}
onClick={() => {
if (pvpUpgrade) setSelectedBuffId(choice.id)
@@ -704,6 +943,7 @@ export function Iwt2RoguelikeUpgradeScreen({
<button
className={`${activeDebuffId === choice.id ? 'selected-upgrade' : ''} ${entrySelected('upgradeDebuff', index) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={entrySelected('upgradeDebuff', index) ? 'true' : undefined}
key={choice.id}
onClick={() => setSelectedDebuffId(choice.id)}
onPointerDown={() => setCursor('upgradeDebuff', index)}
@@ -731,6 +971,7 @@ export function Iwt2RoguelikeUpgradeScreen({
<button
className={`secondary-result-button ${entrySelected('upgradeContinue') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={entrySelected('upgradeContinue') ? 'true' : undefined}
disabled={!activeBuffId || !activeDebuffId}
onClick={() => {
if (activeBuffId && activeDebuffId) onChoose(activeBuffId, activeDebuffId)
@@ -744,6 +985,7 @@ export function Iwt2RoguelikeUpgradeScreen({
<button
className={`secondary-result-button ${entrySelected('upgradeLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={entrySelected('upgradeLeave') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setCursor('upgradeLeave')}
type="button"
@@ -763,63 +1005,352 @@ export function Iwt2HunterProfileScreen({
onBack: () => void
save: Iwt2Save
}) {
const bossRows = (Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
bossId,
drops: save.collectionLog.dropsFound[bossDropIdForProfile(bossId)] ?? 0,
kills: save.collectionLog.bossKills[bossId] ?? 0,
name: IWT2_BOSS_METADATA[bossId].name,
}))
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
const [activeTab, setActiveTab] = useState<Iwt2HunterProfileTab>('stats')
const [selectedIndex, setSelectedIndex] = useState(1)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const screenRef = useRef<HTMLElement | null>(null)
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<Iwt2HunterProfileBossEntry[]>(() => (
(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => {
const drop = iwt2BossMaterialRewardFor(bossId)
const pet = iwt2BossPetRewardFor(bossId)
return {
bossId,
dropId: drop.id,
dropName: drop.name,
drops: save.collectionLog.dropsFound[drop.id] ?? 0,
encounter: IWT2_BOSS_METADATA[bossId],
kills: save.collectionLog.bossKills[bossId] ?? 0,
petId: pet.id,
petName: pet.name,
pets: save.collectionLog.bossPets[pet.id] ?? 0,
}
})
), [save.collectionLog.bossKills, save.collectionLog.bossPets, save.collectionLog.dropsFound])
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>(() => bosses[0]?.bossId ?? 'bulldrome')
const selectedBoss = bosses.find((boss) => boss.bossId === selectedBossId) ?? bosses[0]
const totalBossKills = bosses.reduce((total, boss) => total + boss.kills, 0)
const totalDrops = bosses.reduce((total, boss) => total + boss.drops, 0)
const totalPets = bosses.reduce((total, boss) => total + boss.pets, 0)
const mostKilledBoss = bosses.reduce<Iwt2HunterProfileBossEntry | null>((best, boss) => {
if (!best) return boss
return boss.kills > best.kills ? boss : best
}, null)
const collectionItems = useMemo<Iwt2HunterCollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
chance: 'Defeats',
glyph: 'K',
key: `kills:${selectedBoss.bossId}`,
name: 'Boss Kills',
quantity: selectedBoss.kills,
rarity: 'stat',
source: selectedBoss.encounter.name,
},
{
chance: 'Guaranteed',
glyph: selectedBoss.encounter.icon,
key: `drop:${selectedBoss.dropId}`,
name: selectedBoss.dropName,
quantity: selectedBoss.drops,
rarity: 'common',
source: selectedBoss.encounter.name,
},
{
chance: '1 in 500',
glyph: '*',
key: `pet:${selectedBoss.petId}`,
name: selectedBoss.petName,
quantity: selectedBoss.pets,
rarity: 'legendary',
source: selectedBoss.encounter.name,
},
]
}, [selectedBoss])
const statTiles = useMemo(() => [
{
key: 'name',
label: 'Name',
value: save.character.name,
onConfirm: () => {},
key: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
key: 'level',
label: 'Level',
value: String(save.character.level),
onConfirm: () => {},
detail: totalBossKills > 0 && mostKilledBoss ? `${mostKilledBoss.kills} kills` : '0 kills',
key: 'stat:most-killed',
label: 'Most Killed Boss',
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.name : 'None',
},
{
key: 'experience',
key: 'stat:hunter-level',
label: 'Hunter Level',
value: save.character.level,
},
{
key: 'stat:experience',
label: 'Experience',
value: `${save.character.experience} XP`,
onConfirm: () => {},
},
{
key: 'class',
label: 'Healing Class',
value: IWT2_HEALER_METADATA[save.character.healerStyle].name,
onConfirm: () => {},
key: 'stat:collection-drops',
label: 'Collection Drops',
value: totalDrops + totalPets,
detail: `${totalPets} pets, ${save.inventory.length} inventory slots`,
},
...bossRows.map((row) => ({
key: `boss-${row.bossId}`,
label: row.name,
value: `${row.kills} kills / ${row.drops} drops`,
onConfirm: () => {},
})),
{
key: 'inventory',
label: 'IWT2 Inventory',
value: `${save.inventory.length} slots`,
onConfirm: () => {},
},
], onBack), [bossRows, onBack, save.character.experience, save.character.healerStyle, save.character.level, save.character.name, save.inventory.length])
], [mostKilledBoss, save.character.experience, save.character.level, save.inventory.length, totalBossKills, totalDrops, totalPets])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const navEntries = useMemo<Iwt2HunterProfileNavEntry[]>(() => {
const entries: Iwt2HunterProfileNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
]
if (activeTab === 'stats') {
entries.push(...statTiles.map((tile, index) => ({
column: index,
index,
key: tile.key,
kind: 'stat' as const,
row: 1,
})))
} else {
entries.push(
...bosses.map((boss, index) => ({
bossId: boss.bossId,
column: 0,
key: `boss:${boss.bossId}`,
kind: 'boss' as const,
row: index + 1,
})),
...collectionItems.map((item, index) => ({
column: (index % IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1,
itemKey: item.key,
key: `item:${item.key}`,
kind: 'item' as const,
row: Math.floor(index / IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1,
})),
)
}
return entries
}, [activeTab, bosses, collectionItems, statTiles])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
items: [
{
detail: focusedItem.source,
glyph: focusedItem.glyph,
meta: focusedItem.chance,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
title: 'Drop Rate',
},
{
detail: 'I Want To Heal 2',
meta: `${selectedBoss.kills}`,
status: `${selectedBoss.drops} drops`,
title: 'Boss Kills',
},
],
mode: 'collection',
subtitle: selectedBoss.encounter.name,
summary: `Owned x${focusedItem.quantity}`,
title: focusedItem.name,
}
}, [activeTab, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: Iwt2HunterProfileTab) {
setActiveTab(tab)
setSelectedIndex(tab === 'stats' ? 1 : 2)
}
function selectBoss(bossId: Iwt2BossId) {
setSelectedBossId(bossId)
setFocusedItemKey(null)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'boss') selectBoss(entry.bossId)
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
}
useEffect(() => {
if (!activeEntry) return
screenRef.current
?.querySelector<HTMLElement>('[data-game-selected="true"]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
return (
<Iwt2ScreenShell title="Hunter Profile" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
aria-selected={activeTab === 'stats'}
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:stats') ? 'true' : undefined}
onClick={() => selectTab('stats')}
onPointerDown={() => selectKey('tab:stats')}
role="tab"
type="button"
>
Stats
</button>
<button
aria-selected={activeTab === 'collection'}
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:collection') ? 'true' : undefined}
onClick={() => selectTab('collection')}
onPointerDown={() => selectKey('tab:collection')}
role="tab"
type="button"
>
Collection Log
</button>
</div>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
type="button"
>
Back
</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
{statTiles.map((tile) => (
<article
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
data-game-selected={selected(tile.key) ? 'true' : undefined}
key={tile.key}
onPointerDown={() => selectKey(tile.key)}
>
<span>{tile.label}</span>
<strong>{tile.value}</strong>
{tile.detail && <small>{tile.detail}</small>}
</article>
))}
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => (
<button
className={`collection-boss-button ${selectedBoss?.bossId === boss.bossId ? 'selected' : ''} ${selected(`boss:${boss.bossId}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`boss:${boss.bossId}`) ? 'true' : undefined}
key={boss.bossId}
onClick={() => selectBoss(boss.bossId)}
onPointerDown={() => selectKey(`boss:${boss.bossId}`)}
type="button"
>
<span>{bossInitialsForProfile(boss.encounter.name)}</span>
<strong>{boss.encounter.name}</strong>
</button>
))}
</div>
{selectedBoss && (
<article className="collection-boss-detail">
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
onPointerDown={() => selectKey(`item:${item.key}`)}
type="button"
>
<span>{item.glyph}</span>
<strong>{item.name}</strong>
<small>{item.chance}</small>
<b>x{item.quantity}</b>
</button>
))}
</div>
</article>
)}
</div>
)}
</section>
)
}
function bossDropIdForProfile(bossId: Iwt2BossId): string {
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
if (bossId === 'khezu') return 'khezu-pearl'
return 'raw-bulldrome-coin'
function bossInitialsForProfile(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
export function Iwt2CustomizeCharacterScreen({
@@ -923,8 +1454,13 @@ function clampToEnabled(actions: Iwt2NavAction[], index: number) {
if (actions.length === 0) return 0
const bounded = Math.min(Math.max(0, index), actions.length - 1)
if (!actions[bounded]?.disabled) return bounded
const firstEnabled = actions.findIndex((action) => !action.disabled)
return firstEnabled >= 0 ? firstEnabled : 0
for (let offset = 1; offset < actions.length; offset += 1) {
const previous = bounded - offset
const next = bounded + offset
if (previous >= 0 && !actions[previous]?.disabled) return previous
if (next < actions.length && !actions[next]?.disabled) return next
}
return 0
}
function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction: -1 | 1) {
@@ -939,10 +1475,8 @@ function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction:
}
function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) {
const bounded = Math.min(index, entries.length - 1)
const active = entries[bounded]
if (active && !upgradeEntryDisabled(active)) return active
return entries.find((entry) => !upgradeEntryDisabled(entry))
if (entries.length === 0) return undefined
return entries[Math.min(index, entries.length - 1)]
}
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
@@ -950,30 +1484,17 @@ function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
}
function moveUpgradeSelection(entries: Iwt2UpgradeNavEntry[], current: number, action: string) {
const enabledEntries = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => !upgradeEntryDisabled(entry))
if (enabledEntries.length === 0) return 0
if (entries.length === 0) return 0
const bounded = Math.min(current, entries.length - 1)
const candidate = entries[bounded]
const active = candidate && !upgradeEntryDisabled(candidate)
? candidate
: enabledEntries[0]?.entry
if (!active) return bounded
const candidates = enabledEntries.filter(({ entry }) => {
if (entry === active) return false
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return entries.findIndex((entry) => entry === active)
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || a.index - b.index
})
return candidates[0]?.index ?? bounded
const active = entries[bounded]
const continueIndex = entries.findIndex((entry) => entry.kind === 'upgradeContinue' || entry.kind === 'upgradeLeave')
const lastChoiceIndex = Math.max(0, continueIndex >= 0 ? continueIndex - 1 : entries.length - 1)
if (action === 'navigateRight') return Math.min(lastChoiceIndex, bounded + 1)
if (action === 'navigateLeft') return Math.max(0, bounded - 1)
if (action === 'navigateDown') return continueIndex >= 0 ? continueIndex : bounded
if (action === 'navigateUp' && active?.kind === 'upgradeContinue') return lastChoiceIndex
if (action === 'navigateUp' && active?.kind === 'upgradeLeave') return lastChoiceIndex
return bounded
}
function moveSpatialSelection(actions: Iwt2NavAction[], current: number, action: string) {
+351 -68
View File
@@ -2,8 +2,10 @@ import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
@@ -12,6 +14,7 @@ import type {
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
Iwt2Vec2,
} from './types'
import type { Iwt2PlayerClassId } from '../content/classes'
import { tickBoss } from './bossAi'
@@ -20,6 +23,7 @@ import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
import { applyPartyDamageInShape } from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
@@ -33,6 +37,7 @@ const MAX_DT = 1 / 15
const MAX_EVENTS = 80
const HEALER_MANA_REGEN_PER_SECOND = 3
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
const IWT2_ARENA_BOSS_IDS = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
type InitialPartyMember = {
id: Iwt2PartyEntityId
@@ -53,8 +58,13 @@ const INITIAL_PARTY: InitialPartyMember[] = [
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
]
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
export function createInitialIwt2ArenaState(
bossId: Iwt2BossId = 'bulldrome',
bossIds?: Iwt2BossId[],
bossHealthScale = 1,
): Iwt2ArenaState {
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
return {
schemaVersion: 1,
time: 0,
@@ -64,33 +74,8 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
hostileAdds: [],
hazards: [],
indicators: [],
boss: {
id: bossId,
kind: 'boss',
bossId,
position: { x: 640, y: 250 },
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId),
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { x: 640, y: 250 },
chargeEnd: { x: 640, y: 250 },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId),
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
},
boss: bosses[0],
bosses,
nextEventId: 1,
nextProjectileId: 1,
nextAddId: 1,
@@ -99,16 +84,87 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
}
}
function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
const remaining = IWT2_ARENA_BOSS_IDS.filter((id) => id !== primaryBossId)
const random = remaining[Math.floor(Math.random() * remaining.length)] ?? primaryBossId
return [primaryBossId, random]
}
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const position = initialBossPosition(index)
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
return {
id: bossId,
kind: 'boss',
bossId,
position,
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: maxHealth,
maxHealth,
armor: bossMetadata.mudArmor ?? 0,
maxArmor: bossMetadata.mudArmor ?? 0,
mechanicEnergy: 0,
mechanicEnergyMax: bossMetadata.staticEnergyMax ?? 0,
meleeCooldownRemaining: 0.6 + index * 0.25,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { ...position },
chargeEnd: { ...position },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
mechanicArcs: [],
mechanicLinks: [],
}
}
function initialBossPosition(index: number) {
return {
x: index === 0 ? 660 : 760,
y: index === 0 ? 190 : 345,
}
}
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4
if (bossId === 'khezu') return 3
if (bossId === 'rathian') return 2.2
if (bossId === 'barroth') return 2.1
if (bossId === 'tobi-kadachi') return 2.6
if (bossId === 'rimebastion') return 2.8
if (bossId === 'ember-mantis-duelist') return 1.4
if (bossId === 'cinderback-ricochet') return 2.5
if (bossId === 'obsidian-ram-golem') return 2.2
if (bossId === 'stormcoil-wyrm') return 2.6
if (bossId === 'venom-orchid-hydra') return 2.4
if (bossId === 'sandglass-scorpion') return 2.1
if (bossId === 'crystal-bat-matriarch') return 2.3
if (bossId === 'hollowcrown-revenant') return 1.8
return 0
}
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
if (bossId === 'yian-kut-ku') return 1.2
if (bossId === 'khezu') return 1.6
if (bossId === 'rathian') return 3
if (bossId === 'tobi-kadachi') return 2.4
if (bossId === 'ember-mantis-duelist') return 3
if (bossId === 'stormcoil-wyrm') return 3.1
if (bossId === 'venom-orchid-hydra') return 2.8
if (bossId === 'sandglass-scorpion') return 3.2
if (bossId === 'crystal-bat-matriarch') return 2.9
return 0
}
@@ -128,13 +184,24 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
}
const separatedState = {
...baseState,
party: separatePartyFromBoss(baseState.party, baseState),
party: separatePartyFromBosses(
separatePartyFromParty(separatePartyFromBosses(baseState.party, baseState), baseState),
baseState,
),
}
const bossResult = tickBoss(separatedState, step)
const bossResult = tickBosses(separatedState, step)
const postBossState = { ...separatedState, bosses: bossResult.bosses }
const postBossParty = separatePartyFromBosses(
separatePartyFromParty(
separatePartyFromBosses(bossResult.party, postBossState),
postBossState,
),
postBossState,
)
const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party,
bossResult.boss,
postBossParty,
bossResult.bosses,
bossResult.hostileAdds ?? separatedState.hostileAdds,
bossResult.hazards ?? separatedState.hazards,
bossResult.nextHazardId ?? state.nextHazardId,
@@ -151,20 +218,29 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
})
const damageResult = applyPartyAttacks(
hazardResult.party,
projectileResult.boss,
projectileResult.bosses,
projectileResult.hostileAdds,
separatedState.time,
projectileResult.nextProjectileId,
)
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
const finalParty = regeneratePartyMana(hotResult.party, step)
const regeneratedParty = regeneratePartyMana(hotResult.party, step)
const finalCollisionState = { ...separatedState, bosses: damageResult.bosses }
const finalParty = separatePartyFromBosses(
separatePartyFromParty(
separatePartyFromBosses(regeneratedParty, finalCollisionState),
finalCollisionState,
),
finalCollisionState,
)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
)
return {
...separatedState,
boss: damageResult.boss,
boss: getPrimaryBoss(damageResult.bosses),
bosses: damageResult.bosses,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
party: finalParty,
hostileAdds: damageResult.hostileAdds,
@@ -266,6 +342,7 @@ function createEmptyStatus(): Iwt2StatusState {
stunnedSeconds: 0,
knockedDownSeconds: 0,
invulnerableSeconds: 0,
slowedSeconds: 0,
}
}
@@ -274,22 +351,185 @@ function clampInputAxis(value: number): number {
return Math.min(1, Math.max(-1, value))
}
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
function tickBosses(state: Iwt2ArenaState, dt: number) {
let nextParty = state.party
let nextHostileAdds = state.hostileAdds
let nextHazards = state.hazards
let nextProjectileId = state.nextProjectileId
let nextAddId = state.nextAddId
let nextHazardId = state.nextHazardId
const bosses: Iwt2BossEntityState[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
const events: Iwt2ArenaEvent[] = []
const indicators: Iwt2ArenaIndicator[] = []
for (const boss of state.bosses) {
const bossState = {
...state,
boss,
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
nextProjectileId,
nextAddId,
nextHazardId,
}
const result = tickBoss(bossState, dt)
bosses.push(result.boss)
nextParty = result.party
nextHostileAdds = result.hostileAdds ?? nextHostileAdds
nextHazards = result.hazards ?? nextHazards
nextProjectileId = result.nextProjectileId ?? nextProjectileId
nextAddId = result.nextAddId ?? nextAddId
nextHazardId = result.nextHazardId ?? nextHazardId
projectiles.push(...(result.projectiles ?? []))
events.push(...result.events)
indicators.push(...result.indicators)
}
const separatedBosses = separateBosses(bosses, state)
return {
bosses: separatedBosses,
boss: getPrimaryBoss(separatedBosses),
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
projectiles,
events,
indicators,
nextAddId,
nextHazardId,
nextProjectileId,
}
}
function separateBosses(bosses: Iwt2BossEntityState[], state: Iwt2ArenaState): Iwt2BossEntityState[] {
const next = bosses.map((boss) => ({ ...boss, position: { ...boss.position } }))
for (let pass = 0; pass < 3; pass += 1) {
for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) {
const first = next[firstIndex]
if (!first || first.health <= 0) continue
for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) {
const second = next[secondIndex]
if (!second || second.health <= 0) continue
const minDistance = first.radius + second.radius + 4
const distance = distanceVec2(first.position, second.position)
if (distance >= minDistance) continue
const fallback = {
x: first.position.x <= second.position.x ? -1 : 1,
y: first.position.y <= second.position.y ? -0.4 : 0.4,
}
const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position))
const push = (minDistance - distance) * 0.55
const firstPosition = clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds)
const secondPosition = clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds)
next[firstIndex] = {
...first,
position: firstPosition,
velocity: scaleVec2(subtractVec2(firstPosition, first.position), 30),
}
next[secondIndex] = {
...second,
position: secondPosition,
velocity: scaleVec2(subtractVec2(secondPosition, second.position), 30),
}
}
}
}
return next
}
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
const position = separateCircles(
{ position: member.position, radius: member.radius },
{ position: state.boss.position, radius: state.boss.radius },
state.bounds,
)
let position = member.position
for (const boss of state.bosses) {
if (boss.health <= 0) continue
const separated = separateCircles(
{ position, radius: member.radius },
{ position: boss.position, radius: boss.radius },
state.bounds,
)
position = wallClearance(separated, member, state) <= 2
|| circleOverlapAmount({ position: separated, radius: member.radius }, boss) > 0.25
? separateFromBossTowardCenter(member, boss, state)
: separated
}
return { ...member, position }
})
}
function circleOverlapAmount(
moving: { position: Iwt2Vec2, radius: number },
fixed: { position: Iwt2Vec2, radius: number },
): number {
return Math.max(0, moving.radius + fixed.radius - distanceVec2(moving.position, fixed.position))
}
function separateFromBossTowardCenter(
member: Iwt2PartyEntityState,
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
): Iwt2Vec2 {
const center = { x: state.bounds.width * 0.5, y: state.bounds.height * 0.5 }
const direction = normalizeVec2(subtractVec2(center, boss.position))
const minDistance = boss.radius + member.radius + 3
return clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, minDistance)),
member.radius,
state.bounds,
)
}
function wallClearance(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
return Math.min(
position.x - member.radius,
state.bounds.width - member.radius - position.x,
position.y - member.radius,
state.bounds.height - member.radius - position.y,
)
}
function separatePartyFromParty(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
const next = party.map((member) => ({ ...member, position: { ...member.position } }))
for (let pass = 0; pass < 2; pass += 1) {
for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) {
const first = next[firstIndex]
if (!first || first.health <= 0) continue
for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) {
const second = next[secondIndex]
if (!second || second.health <= 0) continue
const minDistance = first.radius + second.radius + 2
const distance = distanceVec2(first.position, second.position)
if (distance >= minDistance) continue
const fallback = {
x: first.position.x <= second.position.x ? -1 : 1,
y: first.position.y <= second.position.y ? -0.35 : 0.35,
}
const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position))
const push = (minDistance - distance) * 0.5
next[firstIndex] = {
...first,
position: clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds),
}
next[secondIndex] = {
...second,
position: clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds),
}
}
}
}
return next
}
function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[],
hazards: Iwt2GroundHazardState[],
nextHazardId: number,
@@ -298,7 +538,7 @@ function advanceProjectiles(
time: number,
dt: number,
): {
boss: Iwt2ArenaState['boss']
bosses: Iwt2BossEntityState[]
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
@@ -308,7 +548,7 @@ function advanceProjectiles(
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextBoss = boss
let nextBosses = bosses
let nextParty = party
let nextHostileAdds = hostileAdds
let nextHazards = hazards
@@ -334,7 +574,7 @@ function advanceProjectiles(
continue
}
if (nextBoss.health <= 0 && nextHostileAdds.every((add) => add.health <= 0)) continue
if (nextBosses.every((boss) => boss.health <= 0) && nextHostileAdds.every((add) => add.health <= 0)) continue
const nextPosition = {
x: projectile.position.x + projectile.velocity.x * dt,
y: projectile.position.y + projectile.velocity.y * dt,
@@ -371,28 +611,32 @@ function advanceProjectiles(
continue
}
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
const damage = Math.min(projectile.damage, nextBoss.health)
nextBoss = {
...nextBoss,
health: Math.max(0, nextBoss.health - damage),
}
const hitBoss = nextBosses.find((boss) => (
boss.health > 0
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
))
if (hitBoss) {
const damageResult = applyDamageToBoss(hitBoss, projectile.damage)
const damage = damageResult.healthDamage
nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id
? damageResult.boss
: boss)
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
targetId: hitBoss.id,
value: damage,
})
if (nextBoss.health <= 0) {
if (damageResult.boss.health <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
targetId: hitBoss.id,
})
}
continue
@@ -405,7 +649,7 @@ function advanceProjectiles(
}
return {
boss: nextBoss,
bosses: nextBosses,
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
@@ -536,12 +780,12 @@ function advanceBossProjectile({
function applyPartyAttacks(
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[],
time: number,
nextProjectileId: number,
): {
boss: Iwt2ArenaState['boss']
bosses: Iwt2BossEntityState[]
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
projectiles: Iwt2ProjectileEntityState[]
@@ -550,13 +794,13 @@ function applyPartyAttacks(
} {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let nextBoss = boss
let nextBosses = bosses
let nextHostileAdds = hostileAdds
let projectileId = nextProjectileId
const nextParty = party.map((member) => {
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
const target = getPriorityAttackTarget(member, nextBosses, nextHostileAdds)
if (!target) return member
if (!canPartyMemberHitTarget(member, target.position)) return member
if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.projectileSpeed > 0) {
if (!member.attackReady) return member
@@ -585,9 +829,15 @@ function applyPartyAttacks(
}
}
if (member.attackCooldownRemaining > 0) return member
const damage = Math.min(metadata.attackDamage, target.health)
let damage = Math.min(metadata.attackDamage, target.health)
let defeated = target.health - damage <= 0
if (target.kind === 'boss') {
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) }
const damageResult = applyDamageToBoss(target, metadata.attackDamage)
damage = damageResult.healthDamage
defeated = damageResult.boss.health <= 0
nextBosses = nextBosses.map((boss) => boss.id === target.id
? damageResult.boss
: boss)
} else {
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
? { ...add, health: Math.max(0, add.health - damage) }
@@ -601,7 +851,7 @@ function applyPartyAttacks(
targetId: target.id,
value: damage,
})
if (target.health - damage <= 0) {
if (defeated) {
events.push({
id: 0,
time,
@@ -622,7 +872,7 @@ function applyPartyAttacks(
}
})
return {
boss: nextBoss,
bosses: nextBosses,
party: nextParty,
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
projectiles,
@@ -633,9 +883,9 @@ function applyPartyAttacks(
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
boss: Iwt2ArenaState['boss'],
bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[],
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
): (Iwt2BossEntityState | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined
const livingAdds = hostileAdds.filter((add) => add.health > 0)
if (livingAdds.length > 0) {
@@ -643,7 +893,15 @@ function getPriorityAttackTarget(
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0])
}
return boss.health > 0 ? boss : undefined
const livingBosses = bosses.filter((boss) => boss.health > 0)
if (livingBosses.length === 0) return undefined
return livingBosses.reduce((best, boss) => (
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
), livingBosses[0])
}
function getPrimaryBoss(bosses: Iwt2BossEntityState[]): Iwt2BossEntityState {
return bosses.find((boss) => boss.health > 0) ?? bosses[0]
}
function addDamageDone(
@@ -656,6 +914,31 @@ function addDamageDone(
: member)
}
function applyDamageToBoss(
boss: Iwt2BossEntityState,
rawDamage: number,
): { boss: Iwt2BossEntityState, healthDamage: number } {
if (rawDamage <= 0 || boss.health <= 0) return { boss, healthDamage: 0 }
if (boss.armor <= 0) {
const healthDamage = Math.min(rawDamage, boss.health)
return {
boss: { ...boss, health: Math.max(0, boss.health - healthDamage) },
healthDamage,
}
}
const armorDamage = Math.min(boss.armor, rawDamage * 0.75)
const healthDamage = Math.min(boss.health, rawDamage - armorDamage * 0.6)
return {
boss: {
...boss,
armor: Math.max(0, boss.armor - armorDamage),
health: Math.max(0, boss.health - healthDamage),
},
healthDamage,
}
}
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
return events.map((event, index) => ({ ...event, id: firstId + index }))
}
+7 -3
View File
@@ -54,8 +54,12 @@ export type Iwt2ArenaState = Iwt2CoreArenaState & {
telegraphs: Iwt2ArenaTelegraph[]
}
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId))
export function createInitialIwt2ArenaState(
bossId?: Iwt2BossId,
bossIds?: Iwt2BossId[],
bossHealthScale?: number,
): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale))
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
@@ -69,7 +73,7 @@ export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
entities: [
...state.party.map(toArenaEntity),
...state.hostileAdds.map(toHostileAddArenaEntity),
toBossArenaEntity(state.boss),
...state.bosses.map(toBossArenaEntity),
],
telegraphs: createTelegraphs(state.indicators),
}
+271
View File
@@ -0,0 +1,271 @@
import { BARROTH_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createConeIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickBarroth(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'mudSprayWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyMudSpray(party, boss, state.time + dt)
const mudResult = addMudSprayPuddles(hazards, boss, nextHazardId, state.time + dt, state.bounds)
party = result.party
hazards = mudResult.hazards
nextHazardId = mudResult.nextHazardId
events.push(...result.events)
boss = {
...boss,
attackPhase: 'mudSprayRecover',
phaseSecondsRemaining: 0.46,
}
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'mudSprayRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
}
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.65) {
const speed = BARROTH_BOSS_METADATA.moveSpeed * (boss.armor > 0 ? 1.18 : 1.4)
boss = moveBossTowardCenter(boss, state, speed, dt)
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'mudSprayWindup',
chargeCooldownRemaining: BARROTH_BOSS_METADATA.mudSprayCooldown!,
phaseSecondsRemaining: BARROTH_BOSS_METADATA.mudSprayWindup!,
velocity: { x: 0, y: 0 },
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
const speed = BARROTH_BOSS_METADATA.moveSpeed * (boss.armor > 0 ? 0.9 : 1.08)
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, speed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(moveToward(boss.position, center, speed * dt), boss.radius, state.bounds)
return {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + 58
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function applyMudSpray(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'cone',
angleRadians: BARROTH_BOSS_METADATA.mudSprayAngleRadians!,
direction: boss.facing,
origin: boss.position,
range: BARROTH_BOSS_METADATA.mudSprayRange!,
}, {
damage: BARROTH_BOSS_METADATA.mudSprayDamage!,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: BARROTH_BOSS_METADATA.mudSprayStunSeconds!,
time,
})
return { party: result.party, events: result.events }
}
function addMudSprayPuddles(
hazards: Iwt2GroundHazardState[],
boss: Iwt2BossEntityState,
nextHazardId: number,
time: number,
bounds: Iwt2ArenaState['bounds'],
): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
const offsets = [-0.34, 0, 0.34]
for (let index = 0; index < offsets.length; index += 1) {
const point = clampVec2ToArena(
conePoint(boss.position, boss.facing, BARROTH_BOSS_METADATA.mudSprayRange! * (0.46 + index * 0.17), offsets[index]),
BARROTH_BOSS_METADATA.mudPuddleRadius!,
bounds,
)
const puddle = addMudPuddle({
duration: BARROTH_BOSS_METADATA.mudPuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextId,
position: point,
radius: BARROTH_BOSS_METADATA.mudPuddleRadius!,
sourceId: boss.id,
time,
})
nextHazards = puddle.hazards
nextId = puddle.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function conePoint(origin: Iwt2Vec2, direction: Iwt2Vec2, distance: number, angleOffset: number): Iwt2Vec2 {
const base = Math.atan2(direction.y, direction.x) + angleOffset
return addVec2(origin, {
x: Math.cos(base) * distance,
y: Math.sin(base) * distance,
})
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > BARROTH_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: BARROTH_BOSS_METADATA.meleeRange,
}, {
damage: BARROTH_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: BARROTH_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withBarrothIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createBarrothIndicators(result.boss),
}
}
function createBarrothIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
if (boss.attackPhase !== 'mudSprayWindup' && boss.attackPhase !== 'mudSprayRecover') return []
return [createConeIndicator({
angleRadians: BARROTH_BOSS_METADATA.mudSprayAngleRadians!,
color: '#b38a52',
direction: boss.facing,
id: `${boss.id}:mud-spray`,
mechanicId: 'barroth-mud-spray',
origin: boss.position,
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'mudSprayWindup',
boss.attackPhase === 'mudSprayRecover',
),
range: BARROTH_BOSS_METADATA.mudSprayRange!,
sourceId: boss.id,
})]
}
+24
View File
@@ -13,6 +13,18 @@ import type {
} from './types'
import { tickGreatJaggi } from './greatJaggiAi'
import { tickKhezu } from './khezuAi'
import { tickBarroth } from './barrothAi'
import { tickCinderbackRicochet } from './cinderbackAi'
import { tickCrystalBatMatriarch } from './crystalBatMatriarchAi'
import { tickEmberMantisDuelist } from './emberMantisAi'
import { tickHollowcrownRevenant } from './hollowcrownRevenantAi'
import { tickObsidianRamGolem } from './obsidianRamGolemAi'
import { tickRathian } from './rathianAi'
import { tickRimebastion } from './rimebastionAi'
import { tickSandglassScorpion } from './sandglassScorpionAi'
import { tickStormcoilWyrm } from './stormcoilWyrmAi'
import { tickTobiKadachi } from './tobiKadachiAi'
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
import { tickYianKutKu } from './yianKutKuAi'
import {
applyPartyDamageInShape,
@@ -48,6 +60,18 @@ export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
if (state.boss.bossId === 'great-jaggi') return tickGreatJaggi(state, dt)
if (state.boss.bossId === 'khezu') return tickKhezu(state, dt)
if (state.boss.bossId === 'rathian') return tickRathian(state, dt)
if (state.boss.bossId === 'barroth') return tickBarroth(state, dt)
if (state.boss.bossId === 'tobi-kadachi') return tickTobiKadachi(state, dt)
if (state.boss.bossId === 'rimebastion') return tickRimebastion(state, dt)
if (state.boss.bossId === 'ember-mantis-duelist') return tickEmberMantisDuelist(state, dt)
if (state.boss.bossId === 'cinderback-ricochet') return tickCinderbackRicochet(state, dt)
if (state.boss.bossId === 'obsidian-ram-golem') return tickObsidianRamGolem(state, dt)
if (state.boss.bossId === 'stormcoil-wyrm') return tickStormcoilWyrm(state, dt)
if (state.boss.bossId === 'venom-orchid-hydra') return tickVenomOrchidHydra(state, dt)
if (state.boss.bossId === 'sandglass-scorpion') return tickSandglassScorpion(state, dt)
if (state.boss.bossId === 'crystal-bat-matriarch') return tickCrystalBatMatriarch(state, dt)
if (state.boss.bossId === 'hollowcrown-revenant') return tickHollowcrownRevenant(state, dt)
return tickBulldrome(state, dt)
}
+605
View File
@@ -0,0 +1,605 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addFirePuddle } from './hazards'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const CINDERBACK_PHASE = {
armorSlamRecover: 'cinderbackArmorSlamRecover',
armorSlamWindup: 'cinderbackArmorSlamWindup',
ricochetWindup: 'cinderbackRicochetWindup',
ricocheting: 'cinderbackRicocheting',
} as const
type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE]
const CINDERBACK_TUNING = {
armorSlamDamage: 20,
armorSlamRadius: 118,
armorSlamRecover: 1.05,
armorSlamStunSeconds: 0.7,
armorSlamWindup: 0.62,
centerDisengageSpeed: 152,
firePuddleDamage: 5,
firePuddleRadius: 34,
firePuddleSeconds: 6.4,
lavaTrailInterval: 0.26,
meleeCooldown: 1.12,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 104,
ricochetCooldown: 7.2,
ricochetDamage: 18,
ricochetDuration: 3.1,
ricochetMaxBounces: 4,
ricochetSpeed: 470,
ricochetStunSeconds: 0.42,
ricochetWidth: 46,
ricochetWindup: 0.72,
wallContactLimit: 0.48,
wallMargin: 64,
}
export function tickCinderbackRicochet(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = cinderbackPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase === CINDERBACK_PHASE.ricocheting
|| incomingPhase === CINDERBACK_PHASE.armorSlamWindup
|| incomingPhase === CINDERBACK_PHASE.armorSlamRecover
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = cinderbackPhase(boss)
if (phase === CINDERBACK_PHASE.ricochetWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const facing = wallSafeRollDirection(boss, target.position, state)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.ricocheting),
chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state),
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
fireballCooldownRemaining: 0,
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.ricochetDuration,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.ricocheting) {
const roll = moveRicochet(boss, state, dt)
const hitResult = applyRicochetHits(party, boss, boss.position, roll.position, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
const trailResult = maybeAddLavaTrail({
boss,
hazards,
nextHazardId,
position: roll.position,
time: state.time + dt,
})
hazards = trailResult.hazards
nextHazardId = trailResult.nextHazardId
const bounceCount = boss.mechanicEnergy + roll.bounces
const wallContactSeconds = roll.bounces > 0 || roll.cornerBounce
? 0
: isBossNearWall({ ...boss, position: roll.position }, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2)
boss = {
...boss,
chargeEnd: nextWallImpact(roll.position, roll.facing, boss.radius, state),
chargeHitEntityIds: hitResult.hitEntityIds,
chargeStart: { ...roll.position },
facing: roll.facing,
fireballCooldownRemaining: trailResult.nextTrailInSeconds,
mechanicEnergy: bounceCount,
position: roll.position,
velocity: roll.velocity,
wallContactSeconds,
}
if (
boss.phaseSecondsRemaining <= 0
|| bounceCount >= CINDERBACK_TUNING.ricochetMaxBounces
|| wallContactSeconds >= CINDERBACK_TUNING.wallContactLimit
|| roll.cornerBounce
) {
boss = beginArmorSlam(boss, state)
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.armorSlamWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slamResult = applyArmorSlam(party, boss, state.time + dt)
const puddleResult = addArmorSlamPuddles({
boss,
hazards,
nextHazardId,
state,
time: state.time + dt,
})
party = slamResult.party
hazards = puddleResult.hazards
nextHazardId = puddleResult.nextHazardId
events.push(...slamResult.events)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamRecover),
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamRecover,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.armorSlamRecover) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.4),
phaseSecondsRemaining: 0,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.68) {
boss = moveBossTowardCenter(boss, state, CINDERBACK_TUNING.centerDisengageSpeed, dt)
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
const facing = wallSafeRollDirection(boss, target.position, state)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.ricochetWindup),
chargeCooldownRemaining: CINDERBACK_TUNING.ricochetCooldown,
chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state),
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
phaseSecondsRemaining: CINDERBACK_TUNING.ricochetWindup,
velocity: { x: 0, y: 0 },
}
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
const nextPosition = clampVec2ToArena(
moveTowardKeepingDistance(boss.position, target.position, CINDERBACK_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginArmorSlam(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
const position = clampVec2ToArena(disengageFromWall(boss.position, boss.radius, state), boss.radius, state.bounds)
return {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamWindup),
chargeEnd: { ...position },
chargeStart: { ...position },
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamWindup,
position,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
function moveRicochet(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
dt: number,
): { bounces: number, cornerBounce: boolean, facing: Iwt2Vec2, position: Iwt2Vec2, velocity: Iwt2Vec2 } {
const minX = boss.radius
const maxX = state.bounds.width - boss.radius
const minY = boss.radius
const maxY = state.bounds.height - boss.radius
let facing = withFallbackFacing(boss.facing, { x: 1, y: 0 })
let position = addVec2(boss.position, scaleVec2(facing, CINDERBACK_TUNING.ricochetSpeed * dt))
let xBounce = false
let yBounce = false
if (position.x < minX) {
position = { ...position, x: minX + (minX - position.x) }
facing = { ...facing, x: Math.abs(facing.x) }
xBounce = true
} else if (position.x > maxX) {
position = { ...position, x: maxX - (position.x - maxX) }
facing = { ...facing, x: -Math.abs(facing.x) }
xBounce = true
}
if (position.y < minY) {
position = { ...position, y: minY + (minY - position.y) }
facing = { ...facing, y: Math.abs(facing.y) }
yBounce = true
} else if (position.y > maxY) {
position = { ...position, y: maxY - (position.y - maxY) }
facing = { ...facing, y: -Math.abs(facing.y) }
yBounce = true
}
position = clampVec2ToArena(position, boss.radius, state.bounds)
const cornerBounce = xBounce && yBounce
if (cornerBounce) {
facing = withFallbackFacing(subtractVec2(arenaCenter(state), position), facing)
position = disengageFromWall(position, boss.radius, state)
}
facing = normalizeVec2(facing)
return {
bounces: Number(xBounce) + Number(yBounce),
cornerBounce,
facing,
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function maybeAddLavaTrail({
boss,
hazards,
nextHazardId,
position,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
if (boss.fireballCooldownRemaining > 0) {
return { hazards, nextHazardId, nextTrailInSeconds: boss.fireballCooldownRemaining }
}
const result = addFirePuddle({
damage: CINDERBACK_TUNING.firePuddleDamage,
duration: CINDERBACK_TUNING.firePuddleSeconds,
hazards,
nextHazardId,
position,
radius: CINDERBACK_TUNING.firePuddleRadius,
sourceId: boss.id,
time,
})
return {
hazards: result.hazards,
nextHazardId: result.nextHazardId,
nextTrailInSeconds: CINDERBACK_TUNING.lavaTrailInterval,
}
}
function addArmorSlamPuddles({
boss,
hazards,
nextHazardId,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
state: Iwt2ArenaState
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
const angles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
for (const angle of angles) {
const position = clampVec2ToArena({
x: boss.position.x + Math.cos(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72,
y: boss.position.y + Math.sin(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72,
}, CINDERBACK_TUNING.firePuddleRadius, state.bounds)
const result = addFirePuddle({
damage: CINDERBACK_TUNING.firePuddleDamage,
duration: CINDERBACK_TUNING.firePuddleSeconds,
hazards: nextHazards,
nextHazardId: nextId,
position,
radius: CINDERBACK_TUNING.firePuddleRadius,
sourceId: boss.id,
time,
})
nextHazards = result.hazards
nextId = result.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function applyRicochetHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: CINDERBACK_TUNING.ricochetWidth,
}, {
damage: CINDERBACK_TUNING.ricochetDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: CINDERBACK_TUNING.ricochetStunSeconds,
sourceId: boss.id,
stunSeconds: CINDERBACK_TUNING.ricochetStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyArmorSlam(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CINDERBACK_TUNING.armorSlamRadius,
}, {
damage: CINDERBACK_TUNING.armorSlamDamage,
knockdownSeconds: CINDERBACK_TUNING.armorSlamStunSeconds,
sourceId: boss.id,
stunSeconds: CINDERBACK_TUNING.armorSlamStunSeconds,
time,
})
return {
party: result.party,
events: [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, CINDERBACK_TUNING.armorSlamRadius),
...result.events,
],
}
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > CINDERBACK_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CINDERBACK_TUNING.meleeRange,
}, {
damage: CINDERBACK_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: CINDERBACK_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(
moveTowardKeepingDistance(boss.position, center, speed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveTowardKeepingDistance(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = Math.hypot(offset.x, offset.y)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
function nextWallImpact(
position: Iwt2Vec2,
direction: Iwt2Vec2,
radius: number,
state: Iwt2ArenaState,
): Iwt2Vec2 {
const facing = withFallbackFacing(direction, { x: 1, y: 0 })
const times: number[] = []
if (Math.abs(facing.x) > 0.0001) {
times.push(((facing.x > 0 ? state.bounds.width - radius : radius) - position.x) / facing.x)
}
if (Math.abs(facing.y) > 0.0001) {
times.push(((facing.y > 0 ? state.bounds.height - radius : radius) - position.y) / facing.y)
}
const impactDistance = Math.max(0, Math.min(...times.filter((value) => value > 0)))
return clampVec2ToArena(addVec2(position, scaleVec2(facing, impactDistance)), radius, state.bounds)
}
function wallSafeRollDirection(
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
state: Iwt2ArenaState,
): Iwt2Vec2 {
if (!isBossNearWall(boss, state)) return withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.25), scaleVec2(targetBias, 0.55)), centerBias)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + CINDERBACK_TUNING.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function disengageFromWall(position: Iwt2Vec2, radius: number, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const margin = radius + CINDERBACK_TUNING.wallMargin * 0.55
const clamped = {
x: Math.min(state.bounds.width - margin, Math.max(margin, position.x)),
y: Math.min(state.bounds.height - margin, Math.max(margin, position.y)),
}
if (clamped.x !== position.x || clamped.y !== position.y) return clamped
return addVec2(position, scaleVec2(normalizeVec2(subtractVec2(center, position)), 18))
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function withCinderbackIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createCinderbackIndicators(result.boss),
}
}
function createCinderbackIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = cinderbackPhase(boss)
if (
phase === CINDERBACK_PHASE.ricochetWindup
|| phase === CINDERBACK_PHASE.ricocheting
) {
indicators.push(createLaneIndicator({
color: phase === CINDERBACK_PHASE.ricocheting ? '#ff6a2a' : '#ffd166',
end: boss.chargeEnd,
id: `${boss.id}:cinderback-ricochet-lane`,
mechanicId: 'cinderback-ricochet',
phase: indicatorPhaseFromAttack(
phase === CINDERBACK_PHASE.ricochetWindup,
phase === CINDERBACK_PHASE.ricocheting,
),
sourceId: boss.id,
start: boss.chargeStart,
width: CINDERBACK_TUNING.ricochetWidth,
}))
}
if (
phase === CINDERBACK_PHASE.armorSlamWindup
|| phase === CINDERBACK_PHASE.armorSlamRecover
) {
indicators.push(createCircleIndicator({
color: '#ff7a2f',
id: `${boss.id}:cinderback-armor-slam`,
mechanicId: 'cinderback-armor-slam',
phase: indicatorPhaseFromAttack(
phase === CINDERBACK_PHASE.armorSlamWindup,
phase === CINDERBACK_PHASE.armorSlamRecover,
),
position: boss.position,
radius: CINDERBACK_TUNING.armorSlamRadius,
sourceId: boss.id,
}))
}
return indicators
}
function cinderbackPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: CinderbackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+682
View File
@@ -0,0 +1,682 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createDonutIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const CRYSTAL_BAT_PHASE = {
shardRecover: 'crystalBatShardRecover',
shardWindup: 'crystalBatShardWindup',
sonicRecover: 'crystalBatSonicRecover',
sonicWindup: 'crystalBatSonicWindup',
swoopRecover: 'crystalBatSwoopRecover',
swoopWindup: 'crystalBatSwoopWindup',
swooping: 'crystalBatSwooping',
} as const
type CrystalBatPhase = typeof CRYSTAL_BAT_PHASE[keyof typeof CRYSTAL_BAT_PHASE]
const CRYSTAL_BAT_TUNING = {
clusterRadius: 84,
hoverDistance: 138,
meleeCooldown: 1.18,
meleeDamage: 4,
meleeRange: 54,
moveSpeed: 138,
relocateCooldownFloor: 1.2,
relocateSeconds: 1.25,
relocateSpeed: 184,
shardCooldown: 5.8,
shardDamage: 13,
shardLaneWidth: 30,
shardRecover: 0.58,
shardStunSeconds: 0.35,
shardWindup: 0.82,
sonicCooldown: 6.4,
sonicDamage: 10,
sonicRecover: 0.42,
sonicRingRadius: 58,
sonicStunSeconds: 0.32,
sonicWindup: 0.78,
swoopCooldown: 4.9,
swoopDamage: 17,
swoopLaneWidth: 44,
swoopRecover: 0.62,
swoopSpeed: 430,
swoopStunSeconds: 0.48,
swoopWindup: 0.56,
wallContactLimit: 0.7,
wallMargin: 62,
} as const
const SONIC_COLOR = '#d9b7ff'
const SHARD_COLOR = '#9de9ff'
const SWOOP_COLOR = '#f6d36b'
export function tickCrystalBatMatriarch(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const activePhase = crystalBatPhase(boss)
boss = {
...boss,
wallContactSeconds: activePhase !== 'idle'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withCrystalBatIndicators({ boss, events, party })
const phase = crystalBatPhase(boss)
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, CRYSTAL_BAT_TUNING.relocateCooldownFloor),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.sonicWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
const result = applySonicRings(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicRecover,
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.sonicRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', mechanicCircles: [], phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.shardWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyShardLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardRecover,
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.shardRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', mechanicLanes: [], phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swoopWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swooping),
chargeHitEntityIds: [],
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swooping) {
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, boss.chargeEnd, CRYSTAL_BAT_TUNING.swoopSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applySwoopHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2) {
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopRecover,
velocity: { x: 0, y: 0 },
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swoopRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
const cluster = findBestCluster(party)
if (boss.wallContactSeconds >= CRYSTAL_BAT_TUNING.wallContactLimit) {
boss = beginRelocation(boss, state)
return withCrystalBatIndicators({ boss, events, party })
}
if (cluster.count >= 2 && boss.fireballCooldownRemaining <= 0) {
boss = beginSwoop(boss, cluster.center, state)
return withCrystalBatIndicators({ boss, events, party })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = boss.chargeCount % 2 === 0
? beginSonicRings(boss, party, state)
: beginShardLanes(boss, party, state)
return withCrystalBatIndicators({ boss, events, party })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withCrystalBatIndicators({ boss, events, party })
boss = moveBossToHoverPoint(boss, state, target, dt)
return withCrystalBatIndicators({ boss, events, party })
}
function beginSonicRings(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const circles = party
.filter((member) => member.health > 0)
.map((member) => ({
id: `crystal-sonic-${member.id}`,
position: clampVec2ToArena(member.position, CRYSTAL_BAT_TUNING.sonicRingRadius, state.bounds),
radius: CRYSTAL_BAT_TUNING.sonicRingRadius,
}))
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicWindup),
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.sonicCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: circles,
mechanicLanes: [],
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicWindup,
velocity: { x: 0, y: 0 },
}
}
function beginShardLanes(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const lanes = createReflectedShardLanes(boss, party, state)
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardWindup),
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.shardCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: [],
mechanicLanes: lanes,
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardWindup,
velocity: { x: 0, y: 0 },
}
}
function beginSwoop(
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const end = clampVec2ToArena(addVec2(targetPosition, scaleVec2(facing, 170)), boss.radius, state.bounds)
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopWindup),
chargeEnd: end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
fireballCooldownRemaining: CRYSTAL_BAT_TUNING.swoopCooldown,
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopWindup,
velocity: { x: 0, y: 0 },
}
}
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
return {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
}
function createReflectedShardLanes(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2MechanicLaneState[] {
const living = party.filter((member) => member.health > 0)
const cluster = findBestCluster(party)
const priorityTargets = [
cluster.center,
living.find((member) => member.aiRole === 'ranged')?.position ?? getBossTarget(party)?.position ?? arenaCenter(state),
]
const mirrors = mirrorPointsForPattern(boss, state)
const lanes: Iwt2MechanicLaneState[] = []
for (let index = 0; index < mirrors.length; index += 1) {
const mirror = mirrors[index]
const target = priorityTargets[index] ?? cluster.center
const reflectedDirection = withFallbackFacing(subtractVec2(target, mirror), boss.facing)
const reflectedEnd = clampVec2ToArena(
addVec2(mirror, scaleVec2(reflectedDirection, 380)),
CRYSTAL_BAT_TUNING.shardLaneWidth,
state.bounds,
)
lanes.push({
end: mirror,
id: `crystal-shard-${index}-in`,
start: boss.position,
width: CRYSTAL_BAT_TUNING.shardLaneWidth * 0.78,
})
lanes.push({
end: reflectedEnd,
id: `crystal-shard-${index}-out`,
start: mirror,
width: CRYSTAL_BAT_TUNING.shardLaneWidth,
})
}
return lanes
}
function mirrorPointsForPattern(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2[] {
const center = arenaCenter(state)
const verticalFirst = boss.chargeCount % 4 < 2
if (verticalFirst) {
return [
{ x: clamp(center.x + 210, 80, state.bounds.width - 80), y: 42 },
{ x: clamp(center.x - 180, 80, state.bounds.width - 80), y: state.bounds.height - 42 },
]
}
return [
{ x: 44, y: clamp(center.y - 120, 74, state.bounds.height - 74) },
{ x: state.bounds.width - 44, y: clamp(center.y + 110, 74, state.bounds.height - 74) },
]
}
function applySonicRings(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const ownerId = sonicRingOwnerId(circle)
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: CRYSTAL_BAT_TUNING.sonicDamage,
excludedEntityIds: ownerId ? [ownerId, ...hitEntityIds] : hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.sonicStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { events, party: nextParty }
}
function applyShardLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: CRYSTAL_BAT_TUNING.shardDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { events, party: nextParty }
}
function applySwoopHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: CRYSTAL_BAT_TUNING.swoopLaneWidth,
}, {
damage: CRYSTAL_BAT_TUNING.swoopDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > CRYSTAL_BAT_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CRYSTAL_BAT_TUNING.meleeRange,
}, {
damage: CRYSTAL_BAT_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: CRYSTAL_BAT_TUNING.meleeCooldown },
events: result.events,
party: result.party,
}
}
function moveBossToHoverPoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + CRYSTAL_BAT_TUNING.wallMargin, state)
const awayFromTarget = withFallbackFacing(subtractVec2(center, target.position), { x: -1, y: 0 })
const desired = targetNearWall
? addVec2(target.position, scaleVec2(awayFromTarget, CRYSTAL_BAT_TUNING.hoverDistance))
: addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(boss.position, target.position)), CRYSTAL_BAT_TUNING.hoverDistance))
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, desired, CRYSTAL_BAT_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, boss.relocateTarget, CRYSTAL_BAT_TUNING.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveTowardLimited(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = Math.hypot(offset.x, offset.y)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
function findBestCluster(party: Iwt2PartyEntityState[]): { center: Iwt2Vec2, count: number } {
const living = party.filter((member) => member.health > 0)
if (living.length <= 0) return { center: { x: 0, y: 0 }, count: 0 }
let bestMembers = [living[0]]
for (const member of living) {
const nearby = living.filter((other) => distanceVec2(member.position, other.position) <= CRYSTAL_BAT_TUNING.clusterRadius)
if (nearby.length > bestMembers.length) bestMembers = nearby
}
return { center: partyCenter(bestMembers), count: bestMembers.length }
}
function partyCenter(party: Iwt2PartyEntityState[]): Iwt2Vec2 {
if (party.length <= 0) return { x: 0, y: 0 }
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 88)), boss.radius, state.bounds)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + CRYSTAL_BAT_TUNING.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function sonicRingOwnerId(circle: Iwt2MechanicCircleState): Iwt2EntityId | undefined {
const prefix = 'crystal-sonic-'
return circle.id.startsWith(prefix) ? circle.id.slice(prefix.length) : undefined
}
function withCrystalBatIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createCrystalBatIndicators(result.boss),
}
}
function createCrystalBatIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = crystalBatPhase(boss)
const indicators: Iwt2ArenaIndicator[] = []
if (phase === CRYSTAL_BAT_PHASE.sonicWindup || phase === CRYSTAL_BAT_PHASE.sonicRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.sonicWindup,
phase === CRYSTAL_BAT_PHASE.sonicRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createDonutIndicator({
color: SONIC_COLOR,
id: `${boss.id}:${circle.id}:ring`,
innerRadius: Math.max(12, circle.radius * 0.34),
mechanicId: 'crystal-bat-sonic-spacing-ring',
outerRadius: circle.radius,
phase: indicatorPhase,
position: circle.position,
sourceId: boss.id,
}))
indicators.push(createCircleIndicator({
color: SONIC_COLOR,
id: `${boss.id}:${circle.id}:center`,
mechanicId: 'crystal-bat-sonic-overlap-zone',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius * 0.22,
sourceId: boss.id,
}))
}
}
if (phase === CRYSTAL_BAT_PHASE.shardWindup || phase === CRYSTAL_BAT_PHASE.shardRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.shardWindup,
phase === CRYSTAL_BAT_PHASE.shardRecover,
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: SHARD_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: lane.id.endsWith('-out') ? 'crystal-bat-reflected-shard' : 'crystal-bat-mirror-shard',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (
phase === CRYSTAL_BAT_PHASE.swoopWindup
|| phase === CRYSTAL_BAT_PHASE.swooping
|| phase === CRYSTAL_BAT_PHASE.swoopRecover
) {
indicators.push(createLaneIndicator({
color: SWOOP_COLOR,
end: boss.chargeEnd,
id: `${boss.id}:crystal-bat-swoop`,
mechanicId: 'crystal-bat-cluster-swoop',
phase: indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.swoopWindup,
phase === CRYSTAL_BAT_PHASE.swooping,
),
sourceId: boss.id,
start: boss.chargeStart,
width: CRYSTAL_BAT_TUNING.swoopLaneWidth,
}))
}
return indicators
}
function crystalBatPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: CrystalBatPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+459
View File
@@ -0,0 +1,459 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type EmberMantisAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'bladeSidestep'
| 'lineSlashWindup'
| 'lineSlashRecover'
| 'crossSlashWindup'
| 'crossSlashRecover'
const EMBER_MANTIS = {
accentColor: '#ffb13b',
crossSlashCooldown: 7.5,
crossSlashDamage: 20,
crossSlashStunSeconds: 0.45,
crossSlashWindup: 0.82,
duelistRange: 118,
lineSlashCooldown: 3.4,
lineSlashDamage: 15,
lineSlashStunSeconds: 0.25,
lineSlashWindup: 0.48,
meleeCooldown: 0.8,
meleeDamage: 7,
meleeRange: 52,
moveSpeed: 168,
recoverSeconds: 0.38,
sidestepInterval: 1.9,
sidestepSeconds: 0.32,
sidestepSpeed: 330,
slashLaneWidth: 34,
wallMargin: 86,
}
export function tickEmberMantisDuelist(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = state.boss.attackPhase as EmberMantisAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.min(EMBER_MANTIS.sidestepInterval, state.boss.mechanicEnergy + dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: incomingPhase === 'idle' && isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withEmberMantisIndicators({ boss, party, events })
const phase = boss.attackPhase as EmberMantisAttackPhase
if (phase === 'bladeSidestep') {
boss = moveBossTowardRelocateTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 6) {
boss = {
...boss,
attackPhase: asBossPhase('idle'),
mechanicEnergy: 0,
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'lineSlashWindup') {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applySlashLanes(
party,
boss,
EMBER_MANTIS.lineSlashDamage,
EMBER_MANTIS.lineSlashStunSeconds,
state.time + dt,
)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('lineSlashRecover'),
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'crossSlashWindup') {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applySlashLanes(
party,
boss,
EMBER_MANTIS.crossSlashDamage,
EMBER_MANTIS.crossSlashStunSeconds,
state.time + dt,
)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('crossSlashRecover'),
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds + 0.12,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'lineSlashRecover' || phase === 'crossSlashRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('idle'),
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.wallContactSeconds >= 0.58) {
boss = beginSidestep(boss, state, target, true)
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('crossSlashWindup'),
fireballCooldownRemaining: EMBER_MANTIS.crossSlashCooldown,
mechanicLanes: createCrossSlashLanes(state, boss, target),
phaseSecondsRemaining: EMBER_MANTIS.crossSlashWindup,
velocity: { x: 0, y: 0 },
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('lineSlashWindup'),
chargeCooldownRemaining: EMBER_MANTIS.lineSlashCooldown,
mechanicLanes: [createLineSlashLane(state, boss, target)],
phaseSecondsRemaining: EMBER_MANTIS.lineSlashWindup,
velocity: { x: 0, y: 0 },
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.mechanicEnergy >= EMBER_MANTIS.sidestepInterval && distanceVec2(boss.position, target.position) <= 220) {
boss = beginSidestep(boss, state, target, false)
return withEmberMantisIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withEmberMantisIndicators({ boss, party, events })
boss = moveDuelist(boss, state, target, dt)
return withEmberMantisIndicators({ boss, party, events })
}
function asBossPhase(phase: EmberMantisAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function beginSidestep(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
forcedFromWall: boolean,
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase('bladeSidestep'),
mechanicEnergy: 0,
phaseSecondsRemaining: forcedFromWall ? EMBER_MANTIS.sidestepSeconds * 1.6 : EMBER_MANTIS.sidestepSeconds,
relocateTarget: chooseSidestepTarget(boss, state, target, forcedFromWall),
velocity: { x: 0, y: 0 },
}
}
function moveBossTowardRelocateTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, EMBER_MANTIS.sidestepSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveDuelist(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const offset = subtractVec2(boss.position, target.position)
const distance = distanceVec2(boss.position, target.position)
const desiredDistance = distance < EMBER_MANTIS.duelistRange * 0.72
? EMBER_MANTIS.duelistRange
: EMBER_MANTIS.duelistRange * 0.82
const direction = distance < EMBER_MANTIS.duelistRange * 0.72
? withFallbackFacing(offset, boss.facing)
: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const targetPosition = distance < desiredDistance
? addVec2(target.position, scaleVec2(direction, EMBER_MANTIS.duelistRange))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, targetPosition, EMBER_MANTIS.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function chooseSidestepTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
forcedFromWall: boolean,
): Iwt2Vec2 {
if (forcedFromWall) {
return clampVec2ToArena(arenaCenter(state), boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
}
const towardTarget = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const sideSign = boss.chargeCount % 2 === 0 ? 1 : -1
const lateral = { x: -towardTarget.y * sideSign, y: towardTarget.x * sideSign }
const center = arenaCenter(state)
const centerBias = normalizeVec2(subtractVec2(center, boss.position))
const sideWeight = forcedFromWall ? 0.35 : 1
const centerWeight = forcedFromWall ? 1.15 : 0.35
const direction = withFallbackFacing(addVec2(scaleVec2(lateral, sideWeight), scaleVec2(centerBias, centerWeight)), centerBias)
const distance = forcedFromWall ? 152 : 118
const rawTarget = addVec2(boss.position, scaleVec2(direction, distance))
return clampVec2ToArena(rawTarget, boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
}
function createLineSlashLane(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState {
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
return createArenaLane('line-slash', target.position, direction, EMBER_MANTIS.slashLaneWidth, state)
}
function createCrossSlashLanes(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState[] {
const center = targetClusterCenter(state.party, target.position)
const baseAngle = Math.atan2(target.position.y - boss.position.y, target.position.x - boss.position.x)
const angles = [baseAngle + Math.PI * 0.25, baseAngle - Math.PI * 0.25]
return angles.map((angle, index) => createArenaLane(
`cross-slash-${index}`,
center,
{ x: Math.cos(angle), y: Math.sin(angle) },
EMBER_MANTIS.slashLaneWidth,
state,
))
}
function createArenaLane(
id: string,
center: Iwt2Vec2,
direction: Iwt2Vec2,
width: number,
state: Iwt2ArenaState,
): Iwt2MechanicLaneState {
const normalized = withFallbackFacing(direction, { x: 1, y: 0 })
const halfLength = Math.hypot(state.bounds.width, state.bounds.height) * 0.62 + 48
return {
end: clampLanePoint(addVec2(center, scaleVec2(normalized, halfLength)), state),
id,
start: clampLanePoint(addVec2(center, scaleVec2(normalized, -halfLength)), state),
width,
}
}
function clampLanePoint(point: Iwt2Vec2, state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: clamp(point.x, -24, state.bounds.width + 24),
y: clamp(point.y, -24, state.bounds.height + 24),
}
}
function targetClusterCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
const living = party.filter((member) => member.health > 0)
if (living.length === 0) return fallback
const total = living.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / living.length)
}
function applySlashLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
damage: number,
stunSeconds: number,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: lane.width,
}, {
damage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
const lane = boss.mechanicLanes[0]
if (!lane) return boss
return {
...boss,
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
velocity: { x: 0, y: 0 },
}
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > EMBER_MANTIS.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: EMBER_MANTIS.meleeRange,
}, {
damage: EMBER_MANTIS.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: EMBER_MANTIS.meleeCooldown },
events: result.events,
party: result.party,
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + EMBER_MANTIS.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function withEmberMantisIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createEmberMantisIndicators(result.boss),
}
}
function createEmberMantisIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = boss.attackPhase as EmberMantisAttackPhase
const isSlashPhase = phase === 'lineSlashWindup'
|| phase === 'lineSlashRecover'
|| phase === 'crossSlashWindup'
|| phase === 'crossSlashRecover'
if (!isSlashPhase) return []
const isWindup = phase === 'lineSlashWindup' || phase === 'crossSlashWindup'
const isRecover = phase === 'lineSlashRecover' || phase === 'crossSlashRecover'
return boss.mechanicLanes.map((lane) => createLaneIndicator({
color: isRecover ? '#ff6a2a' : EMBER_MANTIS.accentColor,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: phase === 'crossSlashWindup' || phase === 'crossSlashRecover'
? 'ember-mantis-cross-slash'
: 'ember-mantis-line-slash',
phase: indicatorPhaseFromAttack(isWindup, isRecover),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
+166 -26
View File
@@ -15,6 +15,10 @@ const MAX_FIRE_PUDDLES = 8
const FIRE_PUDDLE_TICK_SECONDS = 0.55
const FIRE_PUDDLE_FADE_SECONDS = 0.8
const FIRE_PUDDLE_MERGE_DISTANCE = 28
const MAX_POISON_PUDDLES = 6
const MAX_MUD_PUDDLES = 7
const MUD_PUDDLE_TICK_SECONDS = 0.22
const MUD_SLOW_SECONDS = 0.5
export function addFirePuddle({
damage,
@@ -34,9 +38,115 @@ export function addFirePuddle({
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage,
duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
hazardKind: 'firePuddle',
hazards,
maxActive: MAX_FIRE_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
export function addPoisonPuddle({
damage,
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage,
duration,
fadeSeconds: 1,
hazardKind: 'poisonPuddle',
hazards,
maxActive: MAX_POISON_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
export function addMudPuddle({
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage: 0,
duration,
fadeSeconds: 0.9,
hazardKind: 'mudPuddle',
hazards,
maxActive: MAX_MUD_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
function addGroundPuddle({
damage,
duration,
fadeSeconds,
hazardKind,
hazards,
maxActive,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
fadeSeconds: number
hazardKind: Iwt2GroundHazardState['hazardKind']
hazards: Iwt2GroundHazardState[]
maxActive: number
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
const overlappingIndex = hazards.findIndex((hazard) => (
hazard.hazardKind === 'firePuddle'
hazard.hazardKind === hazardKind
&& hazard.remainingSeconds > hazard.fadeSeconds
&& Math.hypot(hazard.position.x - position.x, hazard.position.y - position.y) <= FIRE_PUDDLE_MERGE_DISTANCE
))
@@ -59,19 +169,19 @@ export function addFirePuddle({
}
const nextHazard: Iwt2GroundHazardState = {
id: `fire-puddle-${nextHazardId}`,
id: `${hazardKind}-${nextHazardId}`,
kind: 'groundHazard',
hazardKind: 'firePuddle',
hazardKind,
sourceId,
position: { ...position },
radius,
damage,
remainingSeconds: duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
fadeSeconds,
nextDamageAt: time + 0.12,
}
return {
hazards: capFirePuddles([...hazards, nextHazard]),
hazards: capPuddles([...hazards, nextHazard], hazardKind, maxActive),
nextHazardId: nextHazardId + 1,
}
}
@@ -101,22 +211,27 @@ export function tickGroundHazards({
let nextDamageAt = hazard.nextDamageAt
if (time >= hazard.nextDamageAt) {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
if (hazard.hazardKind === 'mudPuddle') {
nextParty = applyMudSlow(nextParty, hazard)
nextDamageAt = time + MUD_PUDDLE_TICK_SECONDS
} else {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
}
}
nextHazards.push({
@@ -135,9 +250,9 @@ export function tickGroundHazards({
export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2ArenaIndicator[] {
return hazards.map((hazard) => createCircleIndicator({
color: '#ff7a2f',
color: hazardColor(hazard),
id: `${hazard.id}:indicator`,
mechanicId: 'fire-puddle',
mechanicId: hazard.hazardKind,
phase: hazard.remainingSeconds <= hazard.fadeSeconds ? 'recover' : 'active',
position: hazard.position,
radius: hazard.radius,
@@ -145,16 +260,41 @@ export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2Ar
}))
}
function capFirePuddles(hazards: Iwt2GroundHazardState[]): Iwt2GroundHazardState[] {
function applyMudSlow(party: Iwt2PartyEntityState[], hazard: Iwt2GroundHazardState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
const distance = Math.hypot(member.position.x - hazard.position.x, member.position.y - hazard.position.y)
if (distance > hazard.radius + member.radius) return member
return {
...member,
status: {
...member.status,
slowedSeconds: Math.max(member.status.slowedSeconds, MUD_SLOW_SECONDS),
},
}
})
}
function hazardColor(hazard: Iwt2GroundHazardState): string {
if (hazard.hazardKind === 'poisonPuddle') return '#7bd84f'
if (hazard.hazardKind === 'mudPuddle') return '#9a6a3a'
return '#ff7a2f'
}
function capPuddles(
hazards: Iwt2GroundHazardState[],
hazardKind: Iwt2GroundHazardState['hazardKind'],
maxActive: number,
): Iwt2GroundHazardState[] {
let activePuddles = 0
let oldestActivePuddleIndex = -1
for (let index = 0; index < hazards.length; index += 1) {
const hazard = hazards[index]
if (hazard.hazardKind !== 'firePuddle' || hazard.remainingSeconds <= hazard.fadeSeconds) continue
if (hazard.hazardKind !== hazardKind || hazard.remainingSeconds <= hazard.fadeSeconds) continue
activePuddles += 1
if (oldestActivePuddleIndex < 0) oldestActivePuddleIndex = index
}
if (activePuddles <= MAX_FIRE_PUDDLES || oldestActivePuddleIndex < 0) return hazards
if (activePuddles <= maxActive || oldestActivePuddleIndex < 0) return hazards
return hazards.map((hazard, index) => index === oldestActivePuddleIndex
? { ...hazard, remainingSeconds: Math.min(hazard.remainingSeconds, hazard.fadeSeconds) }
+712
View File
@@ -0,0 +1,712 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const HOLLOWCROWN_PHASE = {
antlerChargeRecover: 'hollowcrownAntlerChargeRecover',
antlerChargeWindup: 'hollowcrownAntlerChargeWindup',
hoofCurseRecover: 'hollowcrownHoofCurseRecover',
hoofCurseWindup: 'hollowcrownHoofCurseWindup',
trailDashing: 'hollowcrownTrailDashing',
trailDashWindup: 'hollowcrownTrailDashWindup',
trailFade: 'hollowcrownTrailFade',
} as const
type HollowcrownSpecialPhase = typeof HOLLOWCROWN_PHASE[keyof typeof HOLLOWCROWN_PHASE]
type HollowcrownAttackPhase = Iwt2BossEntityState['attackPhase'] | HollowcrownSpecialPhase
const HOLLOWCROWN_TUNING = {
antlerCooldown: 7.4,
antlerDamage: 17,
antlerLaneLength: 440,
antlerLaneOffset: 44,
antlerLineWidth: 30,
antlerRecover: 0.52,
antlerStunSeconds: 0.48,
antlerWindup: 0.82,
dashCooldown: 5.2,
dashDamage: 13,
dashLength: 330,
dashRecover: 0.42,
dashSpeed: 430,
dashStunSeconds: 0.32,
dashTrailDamage: 9,
dashTrailSeconds: 0.5,
dashTrailWidth: 40,
hoofCooldown: 6.2,
hoofDamage: 12,
hoofRadius: 46,
hoofRecover: 0.46,
hoofStunSeconds: 0.34,
hoofWindup: 0.74,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 116,
relocateSeconds: 1.25,
relocateSpeed: 168,
wallContactLimit: 0.68,
wallMargin: 76,
} as const
const CURSE_COLOR = '#9b74ff'
const TRAIL_COLOR = '#7ee7ff'
const ANTLER_COLOR = '#e8dcff'
export function tickHollowcrownRevenant(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = state.boss.attackPhase as HollowcrownAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: incomingPhase === 'idle' && isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withHollowcrownIndicators({ boss, party, events, state })
const phase = boss.attackPhase as HollowcrownAttackPhase
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
fireballCooldownRemaining: Math.max(boss.fireballCooldownRemaining, 1.0),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.hoofCurseWindup) {
boss = faceTarget(boss, target)
if (boss.phaseSecondsRemaining <= 0) {
const result = applyHoofCurse(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.hoofCurseRecover),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.hoofRecover,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.hoofCurseRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailDashWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailDashing),
chargeHitEntityIds: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailDashing) {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, HOLLOWCROWN_TUNING.dashSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyTrailDashHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds + dt : 0,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2 || boss.wallContactSeconds >= HOLLOWCROWN_TUNING.wallContactLimit) {
const trailResult = applyFadingTrail(party, boss, state.time + dt)
party = trailResult.party
events.push(...trailResult.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailFade),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.dashTrailSeconds,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailFade) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeWindup) {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applyAntlerLines(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.antlerChargeRecover),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.antlerRecover,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.wallContactSeconds >= HOLLOWCROWN_TUNING.wallContactLimit) {
boss = beginRelocation(boss, state)
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = beginRangedPattern(boss, state, party, target)
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = beginTrailDash(boss, state, target)
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withHollowcrownIndicators({ boss, party, events, state })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withHollowcrownIndicators({ boss, party, events, state })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withHollowcrownIndicators({ boss, party, events, state })
}
function asBossPhase(phase: HollowcrownAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function beginRangedPattern(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const useAntlers = boss.chargeCount % 2 === 1
if (useAntlers) {
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.antlerChargeWindup),
chargeCount: boss.chargeCount + 1,
fireballCooldownRemaining: HOLLOWCROWN_TUNING.antlerCooldown,
mechanicCircles: [],
mechanicLanes: createAntlerLines(state, boss, target),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.antlerWindup,
velocity: { x: 0, y: 0 },
}
}
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.hoofCurseWindup),
chargeCount: boss.chargeCount + 1,
fireballCooldownRemaining: HOLLOWCROWN_TUNING.hoofCooldown,
mechanicCircles: createHoofZones(state, party, boss),
mechanicLanes: [],
phaseSecondsRemaining: HOLLOWCROWN_TUNING.hoofWindup,
velocity: { x: 0, y: 0 },
}
}
function beginTrailDash(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const lane = createDashTrailLane(state, boss, target)
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailDashWindup),
chargeCooldownRemaining: HOLLOWCROWN_TUNING.dashCooldown,
chargeEnd: lane.end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: withFallbackFacing(subtractVec2(lane.end, boss.position), boss.facing),
mechanicCircles: [],
mechanicLanes: [lane],
phaseSecondsRemaining: 0.58,
velocity: { x: 0, y: 0 },
}
}
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
return {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: HOLLOWCROWN_TUNING.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
}
function createHoofZones(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const priority = [
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player'),
...living.filter((member) => member.aiRole !== 'ranged' && member.aiRole !== 'flanker' && member.aiRole !== 'player'),
]
return priority.slice(0, 4).map((member, index) => {
const drift = normalizeVec2(addVec2(
subtractVec2(member.position, boss.position),
scaleVec2(subtractVec2(arenaCenter(state), member.position), 0.35),
))
return {
id: `hollowcrown-hoof-zone-${index}`,
position: clampVec2ToArena(
addVec2(member.position, scaleVec2(drift, 18 + index * 4)),
HOLLOWCROWN_TUNING.hoofRadius,
state.bounds,
),
radius: HOLLOWCROWN_TUNING.hoofRadius,
}
})
}
function createDashTrailLane(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState {
const wallBias = isBossNearWall(boss, state)
? normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
: { x: 0, y: 0 }
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.9)), boss.facing)
const rawEnd = addVec2(boss.position, scaleVec2(direction, HOLLOWCROWN_TUNING.dashLength))
return {
end: clampVec2ToArena(rawEnd, boss.radius + 8, state.bounds),
id: 'hollowcrown-dash-trail',
start: { ...boss.position },
width: HOLLOWCROWN_TUNING.dashTrailWidth,
}
}
function createAntlerLines(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState[] {
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const perpendicular = { x: -direction.y, y: direction.x }
return [-1, 0, 1].map((index) => {
const offset = scaleVec2(perpendicular, index * HOLLOWCROWN_TUNING.antlerLaneOffset)
const start = clampVec2ToArena(addVec2(boss.position, offset), 4, state.bounds)
const end = clampVec2ToArena(
addVec2(start, scaleVec2(direction, HOLLOWCROWN_TUNING.antlerLaneLength)),
4,
state.bounds,
)
return {
end,
id: `hollowcrown-antler-line-${index + 1}`,
start,
width: index === 0 ? HOLLOWCROWN_TUNING.antlerLineWidth + 8 : HOLLOWCROWN_TUNING.antlerLineWidth,
}
})
}
function applyHoofCurse(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: HOLLOWCROWN_TUNING.hoofDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.hoofStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyTrailDashHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: HOLLOWCROWN_TUNING.dashTrailWidth,
}, {
damage: HOLLOWCROWN_TUNING.dashDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: HOLLOWCROWN_TUNING.dashStunSeconds,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.dashStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyFadingTrail(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const lane = boss.mechanicLanes[0]
if (!lane) return { party, events: [] }
const result = applyPartyDamageInShape(party, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width * 1.15,
}, {
damage: HOLLOWCROWN_TUNING.dashTrailDamage,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.dashStunSeconds * 0.65,
time,
})
return { party: result.party, events: result.events }
}
function applyAntlerLines(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: HOLLOWCROWN_TUNING.antlerDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: HOLLOWCROWN_TUNING.antlerStunSeconds,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.antlerStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > HOLLOWCROWN_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: HOLLOWCROWN_TUNING.meleeRange,
}, {
damage: HOLLOWCROWN_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: HOLLOWCROWN_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, HOLLOWCROWN_TUNING.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + HOLLOWCROWN_TUNING.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), HOLLOWCROWN_TUNING.meleeRange))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, HOLLOWCROWN_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function faceTarget(boss: Iwt2BossEntityState, target: Iwt2PartyEntityState): Iwt2BossEntityState {
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
}
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
const lane = boss.mechanicLanes[0]
if (!lane) return { ...boss, velocity: { x: 0, y: 0 } }
return {
...boss,
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
velocity: { x: 0, y: 0 },
}
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2(addVec2(
subtractVec2(center, boss.position),
{
x: boss.position.x < center.x ? 1 : -1,
y: boss.position.y < center.y ? 1 : -1,
},
))
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 72)), boss.radius, state.bounds)
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + HOLLOWCROWN_TUNING.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withHollowcrownIndicators(result: {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
state: Iwt2ArenaState
}): Iwt2BossTickResult {
return {
boss: result.boss,
events: result.events,
indicators: createHollowcrownIndicators(result.boss),
party: result.party,
}
}
function createHollowcrownIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = boss.attackPhase as HollowcrownAttackPhase
if (phase === HOLLOWCROWN_PHASE.hoofCurseWindup || phase === HOLLOWCROWN_PHASE.hoofCurseRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === HOLLOWCROWN_PHASE.hoofCurseWindup,
phase === HOLLOWCROWN_PHASE.hoofCurseRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: CURSE_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'hollowcrown-cursed-hoof-zone',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
if (
phase === HOLLOWCROWN_PHASE.trailDashWindup
|| phase === HOLLOWCROWN_PHASE.trailDashing
|| phase === HOLLOWCROWN_PHASE.trailFade
) {
const indicatorPhase: Iwt2ArenaIndicatorPhase = phase === HOLLOWCROWN_PHASE.trailDashWindup
? 'windup'
: phase === HOLLOWCROWN_PHASE.trailDashing
? 'active'
: 'recover'
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: TRAIL_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'hollowcrown-fading-dash-trail',
phase: indicatorPhase,
sourceId: boss.id,
start: phase === HOLLOWCROWN_PHASE.trailDashing ? boss.position : lane.start,
width: phase === HOLLOWCROWN_PHASE.trailFade ? lane.width * 1.15 : lane.width,
}))
}
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeWindup || phase === HOLLOWCROWN_PHASE.antlerChargeRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === HOLLOWCROWN_PHASE.antlerChargeWindup,
phase === HOLLOWCROWN_PHASE.antlerChargeRecover,
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: ANTLER_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'hollowcrown-ghost-antler-line-charge',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
return indicators
}
+82 -2
View File
@@ -1,5 +1,6 @@
import type {
Iwt2ArenaBounds,
Iwt2ArenaArcIndicator,
Iwt2ArenaCircleIndicator,
Iwt2ArenaConeIndicator,
Iwt2ArenaDonutIndicator,
@@ -51,6 +52,14 @@ export type Iwt2CircleHitShape = {
radius: number
}
export type Iwt2ConeHitShape = {
kind: 'cone'
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
export type Iwt2DonutHitShape = {
kind: 'donut'
position: Iwt2Vec2
@@ -58,7 +67,21 @@ export type Iwt2DonutHitShape = {
outerRadius: number
}
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape | Iwt2DonutHitShape
export type Iwt2ArcHitShape = {
kind: 'arc'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2HitShape =
| Iwt2LaneHitShape
| Iwt2CircleHitShape
| Iwt2ConeHitShape
| Iwt2DonutHitShape
| Iwt2ArcHitShape
export function createArenaEvent(
id: number,
@@ -162,9 +185,26 @@ export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape:
shape.width,
)
}
if (shape.kind === 'cone') {
const distance = distanceVec2(member.position, shape.origin)
if (distance > shape.range + member.radius) return false
const direction = normalizeVec2(shape.direction)
const offset = normalizeVec2(subtractVec2(member.position, shape.origin))
const dot = Math.max(-1, Math.min(1, direction.x * offset.x + direction.y * offset.y))
return Math.acos(dot) <= shape.angleRadians / 2
}
const distance = distanceVec2(member.position, shape.position)
if (shape.kind === 'circle') return distance <= shape.radius + member.radius
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
if (shape.kind === 'donut') {
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
}
if (distance > shape.outerRadius + member.radius || distance < Math.max(0, shape.innerRadius - member.radius)) {
return false
}
const direction = normalizeVec2(shape.direction)
const offset = normalizeVec2(subtractVec2(member.position, shape.position))
const dot = Math.max(-1, Math.min(1, direction.x * offset.x + direction.y * offset.y))
return Math.acos(dot) <= shape.angleRadians / 2
}
export function createLaneIndicator({
@@ -303,6 +343,46 @@ export function createDonutIndicator({
}
}
export function createArcIndicator({
angleRadians,
color,
direction,
id,
innerRadius,
mechanicId,
outerRadius,
phase,
position,
sourceId,
}: {
angleRadians: number
color: string
direction: Iwt2Vec2
id: string
innerRadius: number
mechanicId: string
outerRadius: number
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
sourceId: Iwt2EntityId
}): Iwt2ArenaArcIndicator {
return {
angleRadians,
color,
direction: normalizeVec2(direction),
fillAlpha: phase === 'active' ? 0.16 : 0.09,
id,
innerRadius,
kind: 'arc',
lineAlpha: phase === 'active' ? 0.78 : 0.52,
mechanicId,
outerRadius,
phase,
position: { ...position },
sourceId,
}
}
export function indicatorPhaseFromAttack(
windup: boolean,
active: boolean,
+718
View File
@@ -0,0 +1,718 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const OBSIDIAN_RAM_PHASE = {
armorShatterRecover: 'obsidianRamArmorShatterRecover',
armorShatterWindup: 'obsidianRamArmorShatterWindup',
fractureQuakeRecover: 'obsidianRamFractureQuakeRecover',
fractureQuakeWindup: 'obsidianRamFractureQuakeWindup',
plateChargeRecover: 'obsidianRamPlateChargeRecover',
plateChargeWindup: 'obsidianRamPlateChargeWindup',
plateCharging: 'obsidianRamPlateCharging',
} as const
type ObsidianRamPhase = typeof OBSIDIAN_RAM_PHASE[keyof typeof OBSIDIAN_RAM_PHASE]
const OBSIDIAN_RAM = {
armorCracksBeforeShatter: 3,
centerDisengageSpeed: 118,
chargeCooldown: 4.4,
chargeDamage: 8,
chargeLength: 340,
chargeRecover: 0.5,
chargeSpeed: 390,
chargeStunSeconds: 0.55,
chargeWidth: 54,
chargeWindup: 0.68,
fractureLaneCount: 5,
fractureLaneDamage: 6,
fractureLaneLength: 300,
fractureLaneWidth: 30,
meleeCooldown: 1.3,
meleeDamage: 2,
meleeRange: 62,
moveSpeed: 82,
quakeCooldown: 7.4,
quakeRadius: 126,
quakeRecover: 0.48,
quakeStunSeconds: 0.35,
quakeWindup: 0.78,
slabDamage: 10,
slabDuration: 5.8,
slabRadius: 42,
slabSlowRadius: 46,
shatterRecover: 0.8,
shatterWindup: 0.9,
wallContactLimit: 0.72,
wallMargin: 72,
} as const
export function tickObsidianRamGolem(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = obsidianRamPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: isLockedPhase(incomingPhase)
? 0
: isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = obsidianRamPhase(boss)
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateCharging),
chargeHitEntityIds: [],
phaseSecondsRemaining: 1.25,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateCharging) {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, OBSIDIAN_RAM.chargeSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyChargeHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2 || boss.phaseSecondsRemaining <= 0) {
const crackedArmor = clamp(boss.mechanicEnergy + 1, 0, OBSIDIAN_RAM.armorCracksBeforeShatter)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeRecover),
mechanicEnergy: crackedArmor,
phaseSecondsRemaining: OBSIDIAN_RAM.chargeRecover,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateChargeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter
? beginArmorShatter(boss, state, party)
: {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyFractureQuake(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeRecover),
phaseSecondsRemaining: OBSIDIAN_RAM.quakeRecover,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slabResult = dropObsidianSlabs({
boss,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
party = slabResult.party
hazards = slabResult.hazards
nextHazardId = slabResult.nextHazardId
events.push(...slabResult.events)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterRecover),
mechanicEnergy: 0,
phaseSecondsRemaining: OBSIDIAN_RAM.shatterRecover,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= OBSIDIAN_RAM.wallContactLimit) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 1.3,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter) {
boss = beginArmorShatter(boss, state, party)
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeWindup),
fireballCooldownRemaining: OBSIDIAN_RAM.quakeCooldown,
mechanicCircles: [{ id: 'obsidian-ram-quake-core', position: { ...boss.position }, radius: OBSIDIAN_RAM.quakeRadius }],
mechanicLanes: createFractureWaveLanes(state, boss, target.position),
phaseSecondsRemaining: OBSIDIAN_RAM.quakeWindup,
velocity: { x: 0, y: 0 },
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = beginPlateCharge(boss, state, target)
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginPlateCharge(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const direction = wallSafeChargeDirection(boss, state, target.position)
const chargeEnd = clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.chargeLength)),
boss.radius,
state.bounds,
)
return {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeWindup),
chargeCooldownRemaining: OBSIDIAN_RAM.chargeCooldown,
chargeEnd,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: direction,
mechanicLanes: [{
end: chargeEnd,
id: 'obsidian-ram-plate-charge',
start: { ...boss.position },
width: OBSIDIAN_RAM.chargeWidth,
}],
phaseSecondsRemaining: OBSIDIAN_RAM.chargeWindup,
velocity: { x: 0, y: 0 },
}
}
function beginArmorShatter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
const slabCircles = createObsidianSlabCircles(state, boss, party)
return {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterWindup),
mechanicCircles: slabCircles,
mechanicLanes: [],
phaseSecondsRemaining: OBSIDIAN_RAM.shatterWindup,
velocity: { x: 0, y: 0 },
}
}
function applyChargeHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: OBSIDIAN_RAM.chargeWidth,
}, {
damage: OBSIDIAN_RAM.chargeDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: OBSIDIAN_RAM.chargeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.chargeStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyFractureQuake(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, OBSIDIAN_RAM.quakeRadius),
]
const quakeResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: boss.position,
radius: OBSIDIAN_RAM.quakeRadius,
}, {
damage: OBSIDIAN_RAM.fractureLaneDamage,
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
time,
})
nextParty = quakeResult.party
events.push(...quakeResult.events)
for (const lane of boss.mechanicLanes) {
const laneResult = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: OBSIDIAN_RAM.fractureLaneDamage,
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
time,
})
nextParty = laneResult.party
events.push(...laneResult.events)
}
return { events, party: nextParty }
}
function dropObsidianSlabs({
boss,
hazards,
nextHazardId,
party,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
events: Iwt2ArenaEvent[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
} {
let nextParty = party
let nextHazards = hazards
let nextId = nextHazardId
const events: Iwt2ArenaEvent[] = []
for (const slab of boss.mechanicCircles) {
const hitResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: slab.position,
radius: slab.radius,
}, {
damage: OBSIDIAN_RAM.slabDamage,
knockdownSeconds: 0.22,
sourceId: boss.id,
stunSeconds: 0.22,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
const hazardResult = addMudPuddle({
duration: OBSIDIAN_RAM.slabDuration,
hazards: nextHazards,
nextHazardId: nextId,
position: clampVec2ToArena(slab.position, OBSIDIAN_RAM.slabSlowRadius, state.bounds),
radius: OBSIDIAN_RAM.slabSlowRadius,
sourceId: boss.id,
time,
})
nextHazards = hazardResult.hazards
nextId = hazardResult.nextHazardId
}
return { events, hazards: nextHazards, nextHazardId: nextId, party: nextParty }
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > OBSIDIAN_RAM.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: OBSIDIAN_RAM.meleeRange,
}, {
damage: OBSIDIAN_RAM.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: OBSIDIAN_RAM.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function createFractureWaveLanes(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
): Iwt2MechanicLaneState[] {
const baseDirection = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const baseAngle = Math.atan2(baseDirection.y, baseDirection.x)
const spread = Math.PI * 0.82
const lanes: Iwt2MechanicLaneState[] = []
const laneCount: number = OBSIDIAN_RAM.fractureLaneCount
for (let index = 0; index < laneCount; index += 1) {
const ratio = laneCount === 1 ? 0.5 : index / (laneCount - 1)
const angle = baseAngle - spread * 0.5 + spread * ratio
const direction = { x: Math.cos(angle), y: Math.sin(angle) }
lanes.push({
end: clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.fractureLaneLength)),
OBSIDIAN_RAM.fractureLaneWidth,
state.bounds,
),
id: `obsidian-ram-fracture-${index}`,
start: { ...boss.position },
width: OBSIDIAN_RAM.fractureLaneWidth,
})
}
return lanes
}
function createObsidianSlabCircles(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const targetPositions = living
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
.slice(0, 3)
.map((member) => member.position)
const fallbackAngles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
while (targetPositions.length < 3) {
const angle = fallbackAngles[targetPositions.length]
targetPositions.push(addVec2(boss.position, {
x: Math.cos(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
y: Math.sin(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
}))
}
return targetPositions.map((position, index) => ({
id: `obsidian-ram-slab-${index}`,
position: clampVec2ToArena(position, OBSIDIAN_RAM.slabRadius, state.bounds),
radius: OBSIDIAN_RAM.slabRadius,
}))
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const wallBias = isBossNearWall(boss, state) ? normalizeVec2(subtractVec2(arenaCenter(state), boss.position)) : { x: 0, y: 0 }
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.8)), targetBias)
const desiredPosition = addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.moveSpeed * dt))
const nextPosition = clampVec2ToArena(desiredPosition, boss.radius, state.bounds)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, OBSIDIAN_RAM.centerDisengageSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function wallSafeChargeDirection(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
targetPosition: Iwt2Vec2,
): Iwt2Vec2 {
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
if (!isBossNearWall(boss, state)) return withFallbackFacing(targetBias, boss.facing)
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.35), scaleVec2(targetBias, 0.55)), centerBias)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const inward = normalizeVec2(subtractVec2(center, boss.position))
const offset = addVec2(scaleVec2(inward, 138), {
x: boss.position.y < center.y ? 42 : -42,
y: boss.position.x < center.x ? -34 : 34,
})
return clampVec2ToArena(addVec2(boss.position, offset), boss.radius, state.bounds)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + OBSIDIAN_RAM.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function withObsidianRamIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createObsidianRamIndicators(result.boss),
}
}
function createObsidianRamIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = obsidianRamPhase(boss)
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup || phase === OBSIDIAN_RAM_PHASE.plateCharging) {
indicators.push(createLaneIndicator({
color: phase === OBSIDIAN_RAM_PHASE.plateCharging ? '#ff7a2f' : '#f0b35a',
end: boss.chargeEnd,
id: `${boss.id}:obsidian-ram-plate-charge`,
mechanicId: 'obsidian-ram-plate-charge',
phase: indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.plateChargeWindup,
phase === OBSIDIAN_RAM_PHASE.plateCharging,
),
sourceId: boss.id,
start: boss.chargeStart,
width: OBSIDIAN_RAM.chargeWidth,
}))
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup || phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup,
phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#ff9f1c',
id: `${boss.id}:${circle.id}`,
mechanicId: 'obsidian-ram-quake-core',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: '#ffbf69',
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'obsidian-ram-fracture-wave',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup || phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#b8b6c7',
id: `${boss.id}:${circle.id}`,
mechanicId: 'obsidian-ram-broken-slab',
phase: indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.armorShatterWindup,
phase === OBSIDIAN_RAM_PHASE.armorShatterRecover,
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
function isLockedPhase(phase: string): boolean {
return phase === OBSIDIAN_RAM_PHASE.plateChargeWindup
|| phase === OBSIDIAN_RAM_PHASE.plateCharging
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover
|| phase === OBSIDIAN_RAM_PHASE.armorShatterWindup
|| phase === OBSIDIAN_RAM_PHASE.armorShatterRecover
}
function obsidianRamPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: ObsidianRamPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+293 -59
View File
@@ -1,6 +1,7 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
import {
addVec2,
clampVec2ToArena,
@@ -14,10 +15,18 @@ import {
withFallbackFacing,
} from './vector'
const CENTER_LEASH_BOSS_IDS = new Set(['great-jaggi', 'khezu'])
const NO_CENTER_LEASH_BOSS_IDS = new Set<string>()
const CENTER_LEASH_START_DISTANCE = 230
const CENTER_LEASH_WALL_MARGIN = 96
const CENTER_LEASH_EXTRA_DISTANCE = 36
const PARTY_ARRIVAL_RADIUS = 3.5
const PARTY_SAFE_DESTINATION_PADDING = 34
const PARTY_STEER_ANGLES = [0, 0.42, -0.42, 0.82, -0.82, 1.22, -1.22, 1.7, -1.7, 2.25, -2.25, Math.PI]
const HAZARD_ROUTE_BUFFER = 54
const INDICATOR_ROUTE_PENALTY = 24
const BOSS_ROUTE_BUFFER = 22
const PARTY_ROUTE_BUFFER = 8
const DANGER_CENTER_WEIGHT = 0.32
export function tickPartyMember(
member: Iwt2PartyEntityState,
@@ -30,6 +39,7 @@ export function tickPartyMember(
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
slowedSeconds: Math.max(0, member.status.slowedSeconds - dt),
}
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
@@ -42,7 +52,7 @@ export function tickPartyMember(
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
@@ -68,7 +78,7 @@ export function tickPartyMember(
const canCast = member.aiRole === 'ranged'
&& attackCooldownRemaining <= 0
&& !!attackTarget
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position)
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position, attackTarget.radius)
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining
@@ -76,7 +86,7 @@ export function tickPartyMember(
return {
...member,
velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing),
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, member.position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: nextCastSecondsRemaining,
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
@@ -85,15 +95,21 @@ export function tickPartyMember(
}
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining)
const maxDistance = metadata.moveSpeed * dt
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
const maxDistance = metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
const distanceToDesired = distanceVec2(member.position, desired)
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
const position = movingToDesired
? choosePartyStepPosition(member, state, desired, maxDistance)
: member.position
const velocity = movingToDesired
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
: { x: 0, y: 0 }
return {
...member,
position,
velocity,
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing),
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
@@ -104,54 +120,180 @@ export function tickPartyMember(
}
}
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean {
function choosePartyStepPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
desired: Iwt2Vec2,
maxDistance: number,
): Iwt2Vec2 {
if (maxDistance <= 0) return member.position
const direct = withFallbackFacing(subtractVec2(desired, member.position), member.facing)
const currentDanger = routeDangerPenalty(member.position, member, state)
let best = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
let bestScore = scoreRoutePosition(best, member, state, desired, currentDanger)
for (const angle of PARTY_STEER_ANGLES) {
const direction = rotateVec2(direct, angle)
const candidate = clampVec2ToArena(addVec2(member.position, scaleVec2(direction, maxDistance)), member.radius, state.bounds)
const score = scoreRoutePosition(candidate, member, state, desired, currentDanger)
+ (angle === 0 ? 0 : Math.abs(angle) * 1.8)
if (score < bestScore) {
best = candidate
bestScore = score
}
}
return best
}
function scoreRoutePosition(
position: Iwt2Vec2,
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
desired: Iwt2Vec2,
currentDanger: number,
): number {
const progress = distanceVec2(position, desired)
const centerPull = currentDanger > 0 ? distanceVec2(position, arenaCenter(state)) * DANGER_CENTER_WEIGHT : 0
const progressWeight = currentDanger > 0 ? 0.28 : 1
const danger = routeDangerPenalty(position, member, state)
const crowd = routeCrowdPenalty(position, member, state)
const wall = routeWallPenalty(position, member, state)
const stayingInDanger = currentDanger > 0 && distanceVec2(position, member.position) <= 0.75 ? 90 : 0
return progress * progressWeight + centerPull + danger * 100 + crowd * 42 + wall + stayingInDanger
}
function routeDangerPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
let penalty = 0
for (const hazard of state.hazards) {
const distance = distanceVec2(position, hazard.position)
const damageRadius = hazard.radius + member.radius
const avoidRadius = damageRadius + HAZARD_ROUTE_BUFFER
if (distance <= damageRadius) {
penalty += 18 + (damageRadius - distance) * 0.7
} else if (distance < avoidRadius) {
const ratio = (avoidRadius - distance) / HAZARD_ROUTE_BUFFER
penalty += ratio * ratio * 8
}
}
for (const indicator of state.indicators) {
if (indicator.phase === 'recover') continue
const shape = indicatorAvoidanceShape(indicator)
if (!partyMemberIntersectsShape({ ...member, position }, shape)) continue
penalty += indicator.phase === 'active' ? INDICATOR_ROUTE_PENALTY * 1.4 : INDICATOR_ROUTE_PENALTY
}
return penalty
}
function routeCrowdPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
let penalty = 0
for (const boss of state.bosses) {
if (boss.health <= 0) continue
const distance = distanceVec2(position, boss.position)
const avoidRadius = boss.radius + member.radius + BOSS_ROUTE_BUFFER
if (distance < avoidRadius) penalty += (avoidRadius - distance) * 1.8
}
for (const other of state.party) {
if (other.id === member.id || other.health <= 0) continue
const distance = distanceVec2(position, other.position)
const avoidRadius = other.radius + member.radius + PARTY_ROUTE_BUFFER
if (distance < avoidRadius) penalty += avoidRadius - distance
}
return penalty
}
function routeWallPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
const clearance = Math.min(
position.x - member.radius,
state.bounds.width - member.radius - position.x,
position.y - member.radius,
state.bounds.height - member.radius - position.y,
)
if (clearance >= PARTY_SAFE_DESTINATION_PADDING) return 0
return (PARTY_SAFE_DESTINATION_PADDING - clearance) * (clearance <= 4 ? 12 : 4.8)
}
function rotateVec2(vector: Iwt2Vec2, angle: number): Iwt2Vec2 {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return {
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
}
}
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius + targetRadius
}
function getPartyDesiredPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
decisionSecondsRemaining: number,
): Iwt2Vec2 {
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 }
const drift = decisionDrift(member, state.time)
const attackTarget = getPriorityAttackTarget(member, state)
const anchor = attackTarget?.position ?? state.boss.position
const anchor = attackTarget?.position ?? getPrimaryBoss(state).position
const centerLeash = getTankCenterLeashPosition(member, state)
if (centerLeash) return centerLeash
return {
return clampPartyDestination({
x: anchor.x + member.preferredOffset.x + drift.x,
y: anchor.y + member.preferredOffset.y + drift.y,
}
}, member, state)
}
function clampPartyDestination(
position: Iwt2Vec2,
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
): Iwt2Vec2 {
return clampVec2ToArena(position, member.radius + PARTY_SAFE_DESTINATION_PADDING, state.bounds)
}
function getTankCenterLeashPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
): Iwt2Vec2 | null {
if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(state.boss.bossId)) return null
if (member.aiRole !== 'tank') return null
const boss = getCenterLeashBoss(member, state)
if (!boss) return null
const center = arenaCenter(state)
const bossCenterDistance = distanceVec2(state.boss.position, center)
const nearWall = isBossNearWall(state)
if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null
const bossMetadata = IWT2_BOSS_METADATA[state.boss.bossId]
const towardCenter = withFallbackFacing(subtractVec2(center, state.boss.position), {
x: state.boss.position.x < center.x ? 1 : -1,
y: state.boss.position.y < center.y ? 0.35 : -0.35,
const bossMetadata = IWT2_BOSS_METADATA[boss.bossId]
const towardCenter = withFallbackFacing(subtractVec2(center, boss.position), {
x: boss.position.x < center.x ? 1 : -1,
y: boss.position.y < center.y ? 0.35 : -0.35,
})
const leashDistance = bossMetadata.meleeRange + state.boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
return clampVec2ToArena(
addVec2(state.boss.position, scaleVec2(towardCenter, leashDistance)),
member.radius,
state.bounds,
const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
return clampPartyDestination(
addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
member,
state,
)
}
function getCenterLeashBoss(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
): Iwt2ArenaState['boss'] | null {
const center = arenaCenter(state)
const candidates = state.bosses.filter((boss) => {
if (boss.health <= 0 || NO_CENTER_LEASH_BOSS_IDS.has(boss.bossId)) return false
return isBossNearWall(boss, state) || distanceVec2(boss.position, center) >= CENTER_LEASH_START_DISTANCE
})
if (candidates.length === 0) return null
return candidates.reduce((best, boss) => (
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
), candidates[0])
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
@@ -159,45 +301,111 @@ function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
}
}
function isBossNearWall(state: Iwt2ArenaState): boolean {
const margin = state.boss.radius + CENTER_LEASH_WALL_MARGIN
function isBossNearWall(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): boolean {
const margin = boss.radius + CENTER_LEASH_WALL_MARGIN
return (
state.boss.position.x <= margin
|| state.boss.position.x >= state.bounds.width - margin
|| state.boss.position.y <= margin
|| state.boss.position.y >= state.bounds.height - margin
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
const boss = state.boss
const indicatorEscape = getIndicatorAvoidancePosition(member, state)
if (indicatorEscape) {
return indicatorEscape
}
const hazardEscape = getHazardAvoidancePosition(member, state)
if (hazardEscape) {
return hazardEscape
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
const distance = distanceVec2(member.position, boss.position)
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
for (const boss of state.bosses) {
if (boss.health <= 0) continue
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
const distance = distanceVec2(member.position, boss.position)
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampPartyDestination(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member, state)
}
}
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
const danger = chargeDanger(member.position, state)
if (danger.inside || danger.ahead) {
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampVec2ToArena(escape, member.radius, state.bounds)
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
const danger = chargeDanger(member.position, boss)
if (danger.inside || danger.ahead) {
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampPartyDestination(escape, member, state)
}
}
}
return null
}
function getIndicatorAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let pressure = 0
for (const indicator of state.indicators) {
if (indicator.phase === 'recover') continue
const shape = indicatorAvoidanceShape(indicator)
if (!partyMemberIntersectsShape(member, shape)) continue
const origin = indicator.kind === 'lane'
? closestPointOnSegment(member.position, indicator.start, indicator.end)
: indicator.kind === 'cone'
? indicator.origin
: indicator.position
const away = withFallbackFacing(subtractVec2(member.position, origin), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
})
escape = addVec2(escape, scaleVec2(away, indicator.phase === 'active' ? 96 : 72))
pressure += indicator.phase === 'active' ? 1.35 : 1
}
if (pressure <= 0) return null
return clampPartyDestination(addVec2(member.position, escape), member, state)
}
function indicatorAvoidanceShape(indicator: Iwt2ArenaState['indicators'][number]): Iwt2HitShape {
if (indicator.kind === 'lane') {
return { kind: 'lane', start: indicator.start, end: indicator.end, width: indicator.width + 22 }
}
if (indicator.kind === 'circle') {
return { kind: 'circle', position: indicator.position, radius: indicator.radius + 18 }
}
if (indicator.kind === 'donut') {
return {
kind: 'donut',
innerRadius: indicator.innerRadius,
outerRadius: indicator.outerRadius + 18,
position: indicator.position,
}
}
if (indicator.kind === 'arc') {
return {
kind: 'arc',
angleRadians: indicator.angleRadians,
direction: indicator.direction,
innerRadius: Math.max(0, indicator.innerRadius - 8),
outerRadius: indicator.outerRadius + 24,
position: indicator.position,
}
}
return {
kind: 'cone',
angleRadians: indicator.angleRadians,
direction: indicator.direction,
origin: indicator.origin,
range: indicator.range + 24,
}
}
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let maxNeededDistance = 0
@@ -208,7 +416,7 @@ function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
const dangerRadius = hazard.radius + member.radius + 46
if (distance >= dangerRadius) continue
const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), {
const fallback = withFallbackFacing(subtractVec2(member.position, getPrimaryBoss(state).position), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
})
@@ -224,10 +432,18 @@ function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: 0,
})
return clampVec2ToArena(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
member.radius,
state.bounds,
const destination = clampPartyDestination(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 96)),
member,
state,
)
if (distanceVec2(destination, member.position) > PARTY_ARRIVAL_RADIUS) return destination
const centerDirection = withFallbackFacing(subtractVec2(arenaCenter(state), member.position), direction)
return clampPartyDestination(
addVec2(member.position, scaleVec2(centerDirection, maxNeededDistance + 120)),
member,
state,
)
}
@@ -242,11 +458,14 @@ function getPriorityAttackTarget(
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0])
}
return state.boss.health > 0 ? state.boss : undefined
const livingBosses = state.bosses.filter((boss) => boss.health > 0)
if (livingBosses.length === 0) return undefined
return livingBosses.reduce((best, boss) => (
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
), livingBosses[0])
}
function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
const boss = state.boss
function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['boss']) {
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
const lengthSq = Math.max(1, lengthSqVec2(segment))
const direction = normalizeVec2(segment)
@@ -262,6 +481,21 @@ function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
}
}
function closestPointOnSegment(position: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): Iwt2Vec2 {
const segment = subtractVec2(end, start)
const lengthSq = Math.max(1, lengthSqVec2(segment))
const t = Math.min(1, Math.max(0, dotVec2(subtractVec2(position, start), segment) / lengthSq))
return addVec2(start, scaleVec2(segment, t))
}
function movementSpeedMultiplier(slowedSeconds: number): number {
return slowedSeconds > 0 ? 0.58 : 1
}
function getPrimaryBoss(state: Iwt2ArenaState) {
return state.bosses.find((boss) => boss.health > 0) ?? state.bosses[0] ?? state.boss
}
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
const seed = member.id.length * 17
return {
+317
View File
@@ -0,0 +1,317 @@
import { RATHIAN_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicArcState,
Iwt2MechanicCircleState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addPoisonPuddle } from './hazards'
import {
applyPartyDamageInShape,
createArcIndicator,
createCircleIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickRathian(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'tailSweepWindup') {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyTailSweep(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'tailSweepRecover',
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.tailSweepRecover!,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'poisonSpitWindup') {
if (boss.phaseSecondsRemaining <= 0) {
for (const circle of boss.mechanicCircles) {
const puddle = addPoisonPuddle({
damage: RATHIAN_BOSS_METADATA.poisonPuddleDamage!,
duration: RATHIAN_BOSS_METADATA.poisonPuddleSeconds!,
hazards,
nextHazardId,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
time: state.time + dt,
})
hazards = puddle.hazards
nextHazardId = puddle.nextHazardId
}
boss = {
...boss,
attackPhase: 'poisonSpitRecover',
phaseSecondsRemaining: 0.42,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'tailSweepRecover' || boss.attackPhase === 'poisonSpitRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicArcs: [],
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.65) {
boss = moveBossTowardCenter(boss, state, RATHIAN_BOSS_METADATA.moveSpeed * 1.35, dt)
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'tailSweepWindup',
chargeCooldownRemaining: RATHIAN_BOSS_METADATA.poisonTailCooldown!,
mechanicArcs: [createTailArc(boss, target)],
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.tailSweepWindup!,
velocity: { x: 0, y: 0 },
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'poisonSpitWindup',
fireballCooldownRemaining: RATHIAN_BOSS_METADATA.poisonSpitCooldown!,
mechanicCircles: createPoisonTargets(party),
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.poisonSpitWindup!,
velocity: { x: 0, y: 0 },
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, RATHIAN_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(moveToward(boss.position, center, speed * dt), boss.radius, state.bounds)
return {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + 58
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function createTailArc(boss: Iwt2BossEntityState, target: Iwt2PartyEntityState): Iwt2MechanicArcState {
return {
id: 'tail-sweep',
angleRadians: RATHIAN_BOSS_METADATA.tailSweepAngleRadians!,
direction: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
innerRadius: RATHIAN_BOSS_METADATA.tailSweepInnerRadius!,
outerRadius: RATHIAN_BOSS_METADATA.tailSweepOuterRadius!,
position: { ...boss.position },
}
}
function createPoisonTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] {
return [...party]
.filter((member) => member.health > 0)
.sort((a, b) => b.health / b.maxHealth - a.health / a.maxHealth)
.slice(0, 2)
.map((member, index) => ({
id: `poison-spit-${index}`,
position: { ...member.position },
radius: RATHIAN_BOSS_METADATA.poisonPuddleRadius!,
}))
}
function applyTailSweep(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
for (const arc of boss.mechanicArcs) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'arc',
angleRadians: arc.angleRadians,
direction: arc.direction,
innerRadius: arc.innerRadius,
outerRadius: arc.outerRadius,
position: arc.position,
}, {
damage: RATHIAN_BOSS_METADATA.tailSweepDamage!,
knockdownSeconds: RATHIAN_BOSS_METADATA.tailSweepStunSeconds!,
sourceId: boss.id,
stunSeconds: RATHIAN_BOSS_METADATA.tailSweepStunSeconds!,
time,
})
nextParty = result.party
events.push(...result.events)
}
return { party: nextParty, events }
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > RATHIAN_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: RATHIAN_BOSS_METADATA.meleeRange,
}, {
damage: RATHIAN_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: RATHIAN_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withRathianIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createRathianIndicators(result.boss),
}
}
function createRathianIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (boss.attackPhase === 'tailSweepWindup' || boss.attackPhase === 'tailSweepRecover') {
for (const arc of boss.mechanicArcs) {
indicators.push(createArcIndicator({
angleRadians: arc.angleRadians,
color: '#a7e768',
direction: arc.direction,
id: `${boss.id}:${arc.id}`,
innerRadius: arc.innerRadius,
mechanicId: 'rathian-tail-sweep',
outerRadius: arc.outerRadius,
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'tailSweepWindup',
boss.attackPhase === 'tailSweepRecover',
),
position: arc.position,
sourceId: boss.id,
}))
}
}
if (boss.attackPhase === 'poisonSpitWindup' || boss.attackPhase === 'poisonSpitRecover') {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#7bd84f',
id: `${boss.id}:${circle.id}`,
mechanicId: 'rathian-poison-spit',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'poisonSpitWindup',
boss.attackPhase === 'poisonSpitRecover',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
+578
View File
@@ -0,0 +1,578 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createConeIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type RimebastionAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'iceWallWindup'
| 'shardShatterWindup'
| 'shardShatterRecover'
type RimebastionBossState = Omit<Iwt2BossEntityState, 'attackPhase'> & {
attackPhase: RimebastionAttackPhase
}
type ShardCone = {
id: string
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
const RIMEBASTION_BALANCE = {
iceWallCooldown: 7.2,
iceWallDamage: 6,
iceWallStunSeconds: 0.25,
iceWallWindup: 1,
iceCircleCount: 2,
iceCircleRadius: 58,
iceWallLaneCount: 3,
iceWallWidth: 22,
meleeCooldown: 1.45,
meleeDamage: 4,
meleeRange: 66,
moveSpeed: 64,
relocateCooldownFloor: 1.3,
relocateSpeed: 112,
relocateSeconds: 1.6,
shatterConeAngleRadians: Math.PI * 0.34,
shatterConeDamage: 9,
shatterConeRange: 158,
shatterLaneDamage: 11,
shatterLaneWidth: 34,
shatterRecover: 0.52,
shatterStunSeconds: 0.4,
shatterWindup: 0.72,
wallDisengageSeconds: 0.7,
wallMargin: 68,
}
const WALL_COLOR = '#a7ecff'
const SHARD_COLOR = '#e8fbff'
export function tickRimebastion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss: RimebastionBossState = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withRimebastionIndicators({ boss, party, events, state })
if (boss.attackPhase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, RIMEBASTION_BALANCE.relocateCooldownFloor),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'iceWallWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyIceWalls(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'shardShatterWindup',
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterWindup,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'shardShatterWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyShardShatter(party, boss, state, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'shardShatterRecover',
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterRecover,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'shardShatterRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= RIMEBASTION_BALANCE.wallDisengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: RIMEBASTION_BALANCE.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.chargeCooldownRemaining <= 0) {
const pattern = createIceWallPattern(state, party, boss)
boss = {
...boss,
attackPhase: 'iceWallWindup',
chargeCooldownRemaining: RIMEBASTION_BALANCE.iceWallCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: pattern.circles,
mechanicLanes: pattern.lanes,
phaseSecondsRemaining: RIMEBASTION_BALANCE.iceWallWindup,
velocity: { x: 0, y: 0 },
}
return withRimebastionIndicators({ boss, party, events, state })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withRimebastionIndicators({ boss, party, events, state })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withRimebastionIndicators({ boss, party, events, state })
}
function createIceWallPattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
): { lanes: Iwt2MechanicLaneState[], circles: Iwt2MechanicCircleState[] } {
const living = party.filter((member) => member.health > 0)
const center = partyCenter(living, arenaCenter(state))
const vertical = boss.chargeCount % 2 === 0
const lanes: Iwt2MechanicLaneState[] = []
const offsets = [-96, 0, 96]
for (let index = 0; index < RIMEBASTION_BALANCE.iceWallLaneCount; index += 1) {
const offset = offsets[index] ?? 0
if (vertical) {
const x = clamp(center.x + offset, 82, state.bounds.width - 82)
lanes.push({
end: { x, y: state.bounds.height - 38 },
id: `rime-wall-lane-${index}`,
start: { x, y: 38 },
width: RIMEBASTION_BALANCE.iceWallWidth,
})
} else {
const y = clamp(center.y + offset * 0.72, 74, state.bounds.height - 74)
lanes.push({
end: { x: state.bounds.width - 38, y },
id: `rime-wall-lane-${index}`,
start: { x: 38, y },
width: RIMEBASTION_BALANCE.iceWallWidth,
})
}
}
const circles = living
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
.slice(0, RIMEBASTION_BALANCE.iceCircleCount)
.map((member, index) => ({
id: `rime-wall-circle-${index}`,
position: clampVec2ToArena(member.position, RIMEBASTION_BALANCE.iceCircleRadius, state.bounds),
radius: RIMEBASTION_BALANCE.iceCircleRadius,
}))
return { circles, lanes }
}
function applyIceWalls(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: lane.width,
}, {
damage: RIMEBASTION_BALANCE.iceWallDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: RIMEBASTION_BALANCE.iceWallDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyShardShatter(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
state: Iwt2ArenaState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: RIMEBASTION_BALANCE.shatterLaneWidth,
}, {
damage: RIMEBASTION_BALANCE.shatterLaneDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
for (const cone of createShardCones(boss, state)) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'cone',
angleRadians: cone.angleRadians,
direction: cone.direction,
origin: cone.origin,
range: cone.range,
}, {
damage: RIMEBASTION_BALANCE.shatterConeDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function createShardCones(boss: RimebastionBossState, state: Iwt2ArenaState): ShardCone[] {
const cones: ShardCone[] = []
const center = arenaCenter(state)
for (const lane of boss.mechanicLanes) {
const midpoint = {
x: (lane.start.x + lane.end.x) * 0.5,
y: (lane.start.y + lane.end.y) * 0.5,
}
cones.push({
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
direction: withFallbackFacing(subtractVec2(center, midpoint), boss.facing),
id: `${lane.id}-shard-cone`,
origin: midpoint,
range: RIMEBASTION_BALANCE.shatterConeRange,
})
}
for (const circle of boss.mechanicCircles) {
cones.push({
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
direction: withFallbackFacing(subtractVec2(circle.position, boss.position), boss.facing),
id: `${circle.id}-shard-cone`,
origin: circle.position,
range: RIMEBASTION_BALANCE.shatterConeRange * 0.86,
})
}
return cones
}
function moveBossTowardRelocationTarget(
boss: RimebastionBossState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): RimebastionBossState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, RIMEBASTION_BALANCE.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveBossTowardEngagePoint(
boss: RimebastionBossState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): RimebastionBossState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + RIMEBASTION_BALANCE.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), RIMEBASTION_BALANCE.meleeRange * 0.9))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, RIMEBASTION_BALANCE.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function relocationTarget(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 58)), boss.radius, state.bounds)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
target: Iwt2PartyEntityState,
time: number,
): { boss: RimebastionBossState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > RIMEBASTION_BALANCE.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: RIMEBASTION_BALANCE.meleeRange,
}, {
damage: RIMEBASTION_BALANCE.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: RIMEBASTION_BALANCE.meleeCooldown },
events: result.events,
party: result.party,
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + RIMEBASTION_BALANCE.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withRimebastionIndicators(result: {
boss: RimebastionBossState
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
state: Iwt2ArenaState
}): Iwt2BossTickResult {
const { state, ...rest } = result
return {
...rest,
boss: result.boss as Iwt2BossEntityState,
indicators: createRimebastionIndicators(result.boss, state),
}
}
function createRimebastionIndicators(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const showWalls = (
boss.attackPhase === 'iceWallWindup'
|| boss.attackPhase === 'shardShatterWindup'
|| boss.attackPhase === 'shardShatterRecover'
)
if (showWalls) {
const wallPhase: Iwt2ArenaIndicatorPhase = boss.attackPhase === 'iceWallWindup'
? 'windup'
: boss.attackPhase === 'shardShatterWindup'
? 'active'
: 'recover'
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: WALL_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'rimebastion-ice-wall-lane',
phase: wallPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: WALL_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'rimebastion-ice-wall-circle',
phase: wallPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
if (boss.attackPhase === 'shardShatterWindup' || boss.attackPhase === 'shardShatterRecover') {
const shardPhase = indicatorPhaseFromAttack(
boss.attackPhase === 'shardShatterWindup',
boss.attackPhase === 'shardShatterRecover',
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: SHARD_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}:shatter`,
mechanicId: 'rimebastion-shard-lane',
phase: shardPhase,
sourceId: boss.id,
start: lane.start,
width: RIMEBASTION_BALANCE.shatterLaneWidth,
}))
}
for (const cone of createShardCones(boss, state)) {
indicators.push(createConeIndicator({
angleRadians: cone.angleRadians,
color: SHARD_COLOR,
direction: cone.direction,
id: `${boss.id}:${cone.id}`,
mechanicId: 'rimebastion-shard-cone',
origin: cone.origin,
phase: shardPhase,
range: cone.range,
sourceId: boss.id,
}))
}
}
return indicators
}
+749
View File
@@ -0,0 +1,749 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type SandglassScorpionAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'sandglassBurrowWindup'
| 'sandglassBurrowing'
| 'sandglassEruptionWindup'
| 'sandglassEruptionRecover'
| 'sandglassHourglassSafe'
| 'sandglassHourglassHazard'
| 'sandglassHourglassRecover'
const SANDGLASS = {
burrowCooldown: 6.6,
burrowSeconds: 1.05,
burrowSpeed: 285,
burrowTrailInterval: 0.18,
burrowTrailRadius: 28,
burrowTrailSeconds: 4.2,
burrowTrailWidth: 44,
burrowWindup: 0.48,
disengageSeconds: 0.72,
disengageSpeed: 148,
eruptionDamage: 15,
eruptionRadius: 50,
eruptionRecover: 0.52,
eruptionStunSeconds: 0.42,
eruptionWindup: 0.76,
hourglassCooldown: 8.1,
hourglassDamage: 6,
hourglassHazardSeconds: 2.2,
hourglassRadius: 66,
hourglassRecover: 0.48,
hourglassSafeSeconds: 1.05,
hourglassTickSeconds: 0.5,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 94,
wallMargin: 70,
zoneCount: 3,
}
const BURROW_COLOR = '#c8944e'
const ERUPTION_COLOR = '#f2c15b'
const HOURGLASS_SAFE_COLOR = '#68d8ff'
const HOURGLASS_HAZARD_COLOR = '#d49a42'
export function tickSandglassScorpion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = state.boss.attackPhase as SandglassScorpionAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase === 'sandglassBurrowing'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = boss.attackPhase as SandglassScorpionAttackPhase
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassBurrowWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.relocateTarget, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassBurrowing'),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.burrowSeconds,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassBurrowing') {
const burrow = moveBurrowingBoss(boss, state, dt)
const trailResult = maybeAddBurrowTrail({
boss,
hazards,
nextHazardId,
position: burrow.position,
time: state.time + dt,
})
hazards = trailResult.hazards
nextHazardId = trailResult.nextHazardId
boss = {
...boss,
mechanicEnergy: trailResult.nextTrailInSeconds,
position: burrow.position,
velocity: burrow.velocity,
wallContactSeconds: 0,
}
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 7) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassEruptionWindup'),
mechanicCircles: createStingerEruptions(state, party, boss),
mechanicEnergy: 0,
mechanicLanes: [],
phaseSecondsRemaining: SANDGLASS.eruptionWindup,
velocity: { x: 0, y: 0 },
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassEruptionWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStingerEruptions(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('sandglassEruptionRecover'),
phaseSecondsRemaining: SANDGLASS.eruptionRecover,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassEruptionRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassSafe') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassHazard'),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.hourglassHazardSeconds,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassHazard') {
const tickResult = maybeApplyHourglassZones({
boss,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
hazards = tickResult.hazards
nextHazardId = tickResult.nextHazardId
party = tickResult.party
events.push(...tickResult.events)
boss = {
...boss,
mechanicEnergy: tickResult.nextTickInSeconds,
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassRecover'),
phaseSecondsRemaining: SANDGLASS.hourglassRecover,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= SANDGLASS.disengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 1.25,
relocateTarget: centerRelocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassSafe'),
fireballCooldownRemaining: SANDGLASS.hourglassCooldown,
mechanicCircles: createHourglassZones(state, party, boss),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.hourglassSafeSeconds,
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
const exit = chooseBurrowExit(state, party, boss, target)
boss = {
...boss,
attackPhase: asBossPhase('sandglassBurrowWindup'),
chargeCooldownRemaining: SANDGLASS.burrowCooldown,
chargeCount: boss.chargeCount + 1,
chargeEnd: { ...exit },
chargeStart: { ...boss.position },
mechanicCircles: [],
mechanicLanes: [createBurrowLane(boss.position, exit)],
phaseSecondsRemaining: SANDGLASS.burrowWindup,
relocateTarget: exit,
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
function asBossPhase(phase: SandglassScorpionAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function moveBurrowingBoss(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
dt: number,
): { position: Iwt2Vec2, velocity: Iwt2Vec2 } {
const position = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, SANDGLASS.burrowSpeed * dt),
boss.radius,
state.bounds,
)
return {
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function maybeAddBurrowTrail({
boss,
hazards,
nextHazardId,
position,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
if (boss.mechanicEnergy > 0) {
return { hazards, nextHazardId, nextTrailInSeconds: boss.mechanicEnergy }
}
const result = addMudPuddle({
duration: SANDGLASS.burrowTrailSeconds,
hazards,
nextHazardId,
position,
radius: SANDGLASS.burrowTrailRadius,
sourceId: boss.id,
time,
})
return {
hazards: result.hazards,
nextHazardId: result.nextHazardId,
nextTrailInSeconds: SANDGLASS.burrowTrailInterval,
}
}
function maybeApplyHourglassZones({
boss,
hazards,
nextHazardId,
party,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
events: Iwt2ArenaEvent[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextTickInSeconds: number
party: Iwt2PartyEntityState[]
} {
if (boss.mechanicEnergy > 0) {
return { events: [], hazards, nextHazardId, nextTickInSeconds: boss.mechanicEnergy, party }
}
let nextParty = party
let nextHazards = hazards
let nextId = nextHazardId
const events: Iwt2ArenaEvent[] = []
for (const circle of boss.mechanicCircles) {
const damage = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: SANDGLASS.hourglassDamage,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: 0.18,
time,
})
nextParty = damage.party
events.push(...damage.events)
const puddle = addMudPuddle({
duration: SANDGLASS.hourglassTickSeconds + 0.45,
hazards: nextHazards,
nextHazardId: nextId,
position: clampVec2ToArena(circle.position, circle.radius, state.bounds),
radius: circle.radius,
sourceId: boss.id,
time,
})
nextHazards = puddle.hazards
nextId = puddle.nextHazardId
}
return {
events,
hazards: nextHazards,
nextHazardId: nextId,
nextTickInSeconds: SANDGLASS.hourglassTickSeconds,
party: nextParty,
}
}
function createStingerEruptions(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const marked = [
getBossTarget(living),
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker'),
...living,
].filter((member): member is Iwt2PartyEntityState => Boolean(member))
const circles: Iwt2MechanicCircleState[] = []
const seen = new Set<string>()
for (const member of marked) {
if (seen.has(member.id)) continue
seen.add(member.id)
const offset = offsetFromBoss(member.position, boss.position, 28 + circles.length * 10)
circles.push({
id: `sandglass-stinger-${circles.length}`,
position: clampVec2ToArena(addVec2(member.position, offset), SANDGLASS.eruptionRadius, state.bounds),
radius: SANDGLASS.eruptionRadius,
})
if (circles.length >= SANDGLASS.zoneCount) break
}
return circles
}
function createHourglassZones(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const center = arenaCenter(state)
const partyCenterPoint = partyCenter(party.filter((member) => member.health > 0), center)
const angleBase = Math.atan2(partyCenterPoint.y - center.y, partyCenterPoint.x - center.x)
const circles: Iwt2MechanicCircleState[] = []
for (let index = 0; index < SANDGLASS.zoneCount; index += 1) {
const angle = angleBase + (Math.PI * 2 * index) / SANDGLASS.zoneCount + boss.chargeCount * 0.28
const radius = index === 0 ? 54 : 128
circles.push({
id: `sandglass-hourglass-${index}`,
position: clampVec2ToArena({
x: center.x + Math.cos(angle) * radius,
y: center.y + Math.sin(angle) * radius * 0.68,
}, SANDGLASS.hourglassRadius, state.bounds),
radius: SANDGLASS.hourglassRadius,
})
}
return circles
}
function applyStingerEruptions(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: SANDGLASS.eruptionDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0.2,
sourceId: boss.id,
stunSeconds: SANDGLASS.eruptionStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > SANDGLASS.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: SANDGLASS.meleeRange,
}, {
damage: SANDGLASS.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: SANDGLASS.meleeCooldown },
party: result.party,
events: result.events,
}
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const desiredDistance = 70
const offset = subtractVec2(boss.position, target.position)
const distance = distanceVec2(boss.position, target.position)
const toward = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const away = withFallbackFacing(offset, scaleVec2(toward, -1))
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
const destination = distance < desiredDistance
? addVec2(target.position, scaleVec2(away, desiredDistance))
: addVec2(target.position, scaleVec2(centerBias, 18))
const position = clampVec2ToArena(
moveToward(boss.position, destination, SANDGLASS.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const position = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, SANDGLASS.disengageSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: isBossNearWall({ ...boss, position }, state) ? boss.wallContactSeconds : 0,
}
}
function chooseBurrowExit(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2Vec2 {
const center = arenaCenter(state)
const living = party.filter((member) => member.health > 0)
const partyCenterPoint = partyCenter(living, target.position)
const baseDirection = withFallbackFacing(subtractVec2(partyCenterPoint, boss.position), boss.facing)
const flank = { x: -baseDirection.y, y: baseDirection.x }
const side = boss.chargeCount % 2 === 0 ? 1 : -1
const candidates = [
addVec2(target.position, scaleVec2(flank, 118 * side)),
addVec2(partyCenterPoint, scaleVec2(flank, -124 * side)),
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), -76)),
center,
]
let best = clampVec2ToArena(candidates[0], boss.radius + SANDGLASS.wallMargin, state.bounds)
let bestScore = -Infinity
for (const candidate of candidates) {
const clamped = clampVec2ToArena(candidate, boss.radius + SANDGLASS.wallMargin, state.bounds)
const wall = wallClearance(clamped, state, boss.radius)
const targetDistance = distanceVec2(clamped, target.position)
const centerDistance = distanceVec2(clamped, center)
const bossDistance = distanceVec2(clamped, boss.position)
const score = wall * 1.6
+ clamp(targetDistance, 72, 210) * 0.55
- centerDistance * 0.18
+ clamp(bossDistance, 90, 280) * 0.25
if (score > bestScore) {
best = clamped
bestScore = score
}
}
return best
}
function centerRelocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(
addVec2(center, scaleVec2(awayFromWall, 54)),
boss.radius + SANDGLASS.wallMargin,
state.bounds,
)
}
function createBurrowLane(start: Iwt2Vec2, end: Iwt2Vec2): Iwt2MechanicLaneState {
return {
end: { ...end },
id: 'sandglass-burrow-trail',
start: { ...start },
width: SANDGLASS.burrowTrailWidth,
}
}
function offsetFromBoss(position: Iwt2Vec2, bossPosition: Iwt2Vec2, distance: number): Iwt2Vec2 {
const direction = withFallbackFacing(subtractVec2(position, bossPosition), { x: 1, y: 0 })
return scaleVec2(direction, distance)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function wallClearance(position: Iwt2Vec2, state: Iwt2ArenaState, radius: number): number {
return Math.min(
position.x - radius,
state.bounds.width - radius - position.x,
position.y - radius,
state.bounds.height - radius - position.y,
)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return wallClearance(boss.position, state, boss.radius) <= SANDGLASS.wallMargin
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function withSandglassIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createSandglassIndicators(result.boss),
}
}
function createSandglassIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = boss.attackPhase as SandglassScorpionAttackPhase
const indicators: Iwt2ArenaIndicator[] = []
if (phase === 'sandglassBurrowWindup' || phase === 'sandglassBurrowing') {
indicators.push(...boss.mechanicLanes.map((lane) => createLaneIndicator({
color: BURROW_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'sandglass-burrow-trail',
phase: indicatorPhaseFromAttack(
phase === 'sandglassBurrowWindup',
phase === 'sandglassBurrowing',
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
})))
}
if (phase === 'sandglassEruptionWindup' || phase === 'sandglassEruptionRecover') {
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
color: ERUPTION_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'sandglass-stinger-eruption',
phase: indicatorPhaseFromAttack(
phase === 'sandglassEruptionWindup',
phase === 'sandglassEruptionRecover',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
})))
}
if (
phase === 'sandglassHourglassSafe'
|| phase === 'sandglassHourglassHazard'
|| phase === 'sandglassHourglassRecover'
) {
const color = phase === 'sandglassHourglassSafe' ? HOURGLASS_SAFE_COLOR : HOURGLASS_HAZARD_COLOR
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
color,
id: `${boss.id}:${circle.id}`,
mechanicId: 'sandglass-hourglass-zone',
phase: indicatorPhaseFromAttack(
phase === 'sandglassHourglassSafe',
phase === 'sandglassHourglassHazard',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
})))
}
return indicators
}
+543
View File
@@ -0,0 +1,543 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicArcState,
Iwt2MechanicLinkState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createArcIndicator,
createDonutIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const STORMCOIL_PHASE = {
arcRecover: 'stormcoilArcRecover',
arcWindup: 'stormcoilArcWindup',
chainRecover: 'stormcoilChainRecover',
chainWindup: 'stormcoilChainWindup',
donutRecover: 'stormcoilDonutRecover',
donutWindup: 'stormcoilDonutWindup',
} as const
type StormcoilPhase = typeof STORMCOIL_PHASE[keyof typeof STORMCOIL_PHASE]
const STORMCOIL_TUNING = {
arcAngleRadians: Math.PI * 0.34,
arcCooldown: 5.6,
arcDamage: 6,
arcInnerRadius: 78,
arcOuterRadius: 196,
arcRecover: 0.46,
arcStunSeconds: 0.24,
arcWindup: 0.74,
chainCooldown: 7.4,
chainDamage: 5,
chainLinkRadius: 150,
chainMaxLinks: 7,
chainRecover: 0.5,
chainStunSeconds: 0.38,
chainWidth: 16,
chainWindup: 0.68,
disengageSpeed: 168,
donutCooldown: 6.2,
donutDamage: 7,
donutInnerRadius: 64,
donutOuterRadius: 156,
donutRecover: 0.48,
donutStunSeconds: 0.44,
donutWindup: 0.82,
meleeCooldown: 1.12,
meleeDamage: 2,
meleeRange: 58,
moveSpeed: 124,
wallContactLimit: 0.72,
wallMargin: 68,
}
export function tickStormcoilWyrm(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = stormcoilPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase !== 'idle'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withStormcoilIndicators({ boss, party, events })
const phase = stormcoilPhase(boss)
if (phase === 'relocating') {
boss = moveBossToward(boss, boss.relocateTarget, state, STORMCOIL_TUNING.disengageSpeed, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.donutWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStormDonut(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.donutRecover),
mechanicCircles: [],
phaseSecondsRemaining: STORMCOIL_TUNING.donutRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.arcWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStormArcs(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.arcRecover),
phaseSecondsRemaining: STORMCOIL_TUNING.arcRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.chainWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyChainLightning(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.chainRecover),
phaseSecondsRemaining: STORMCOIL_TUNING.chainRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (
phase === STORMCOIL_PHASE.donutRecover
|| phase === STORMCOIL_PHASE.arcRecover
|| phase === STORMCOIL_PHASE.chainRecover
) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicArcs: [],
mechanicCircles: [],
mechanicLinks: [],
phaseSecondsRemaining: 0,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.wallContactSeconds >= STORMCOIL_TUNING.wallContactLimit) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicArcs: [],
mechanicCircles: [],
mechanicLinks: [],
phaseSecondsRemaining: 1.25,
relocateTarget: wallDisengageTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
const links = createChainLinks(boss, party)
if (links.length > 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.chainWindup),
fireballCooldownRemaining: STORMCOIL_TUNING.chainCooldown,
mechanicLinks: links,
phaseSecondsRemaining: STORMCOIL_TUNING.chainWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
boss = { ...boss, fireballCooldownRemaining: 1.0 }
}
if (boss.mechanicEnergy <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.arcWindup),
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
mechanicArcs: createStormArcs(boss, target.position),
mechanicEnergy: STORMCOIL_TUNING.arcCooldown,
phaseSecondsRemaining: STORMCOIL_TUNING.arcWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.donutWindup),
chargeCooldownRemaining: STORMCOIL_TUNING.donutCooldown,
mechanicCircles: [{
id: 'stormcoil-donut-center',
position: { ...boss.position },
radius: STORMCOIL_TUNING.donutOuterRadius,
}],
phaseSecondsRemaining: STORMCOIL_TUNING.donutWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withStormcoilIndicators({ boss, party, events })
const desired = isBossNearWall(boss, state)
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(arenaCenter(state), boss.position)), 84))
: target.position
boss = moveBossToward(boss, desired, state, STORMCOIL_TUNING.moveSpeed, dt)
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
}
return withStormcoilIndicators({ boss, party, events })
}
function applyStormDonut(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
innerRadius: STORMCOIL_TUNING.donutInnerRadius,
kind: 'donut',
outerRadius: STORMCOIL_TUNING.donutOuterRadius,
position: boss.position,
}, {
damage: STORMCOIL_TUNING.donutDamage,
knockdownSeconds: STORMCOIL_TUNING.donutStunSeconds,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.donutStunSeconds,
time,
})
return { party: result.party, events: result.events }
}
function applyStormArcs(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const arc of boss.mechanicArcs) {
const result = applyPartyDamageInShape(nextParty, {
angleRadians: arc.angleRadians,
direction: arc.direction,
innerRadius: arc.innerRadius,
kind: 'arc',
outerRadius: arc.outerRadius,
position: arc.position,
}, {
damage: STORMCOIL_TUNING.arcDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.arcStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyChainLightning(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const link of boss.mechanicLinks) {
const result = applyPartyDamageInShape(nextParty, {
end: link.end,
kind: 'lane',
start: link.start,
width: link.width,
}, {
damage: STORMCOIL_TUNING.chainDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.chainStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function createStormArcs(boss: Iwt2BossEntityState, targetPosition: Iwt2Vec2): Iwt2MechanicArcState[] {
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const directions = [
rotateVec2(facing, -0.66),
facing,
rotateVec2(facing, 0.66),
]
return directions.map((direction, index) => ({
angleRadians: STORMCOIL_TUNING.arcAngleRadians,
direction,
id: `stormcoil-arc-${index}`,
innerRadius: STORMCOIL_TUNING.arcInnerRadius,
outerRadius: STORMCOIL_TUNING.arcOuterRadius,
position: { ...boss.position },
}))
}
function createChainLinks(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicLinkState[] {
const living = party.filter((member) => member.health > 0)
const links: Iwt2MechanicLinkState[] = []
const nearest = living
.map((member) => ({ member, distance: distanceVec2(member.position, boss.position) }))
.sort((a, b) => a.distance - b.distance)[0]?.member
if (nearest) {
links.push({
end: { ...nearest.position },
id: `stormcoil-chain-${boss.id}-${nearest.id}`,
start: { ...boss.position },
width: STORMCOIL_TUNING.chainWidth,
})
}
for (let a = 0; a < living.length; a += 1) {
for (let b = a + 1; b < living.length; b += 1) {
if (distanceVec2(living[a].position, living[b].position) > STORMCOIL_TUNING.chainLinkRadius) continue
links.push({
end: { ...living[b].position },
id: `stormcoil-chain-${living[a].id}-${living[b].id}`,
start: { ...living[a].position },
width: STORMCOIL_TUNING.chainWidth,
})
if (links.length >= STORMCOIL_TUNING.chainMaxLinks) return links
}
}
return links.length > 1 ? links : []
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > STORMCOIL_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: STORMCOIL_TUNING.meleeRange,
}, {
damage: STORMCOIL_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: STORMCOIL_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossToward(
boss: Iwt2BossEntityState,
target: Iwt2Vec2,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const offset = subtractVec2(target, boss.position)
const distance = Math.hypot(offset.x, offset.y)
const nextPosition = clampVec2ToArena(
distance <= speed * dt || distance <= 0.0001
? target
: addVec2(boss.position, scaleVec2(offset, (speed * dt) / distance)),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function wallDisengageTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const toCenter = normalizeVec2(subtractVec2(center, boss.position))
return clampVec2ToArena(addVec2(boss.position, scaleVec2(toCenter, 180)), boss.radius, state.bounds)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + STORMCOIL_TUNING.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
const cos = Math.cos(radians)
const sin = Math.sin(radians)
return normalizeVec2({
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
})
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function withStormcoilIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createStormcoilIndicators(result.boss),
}
}
function createStormcoilIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = stormcoilPhase(boss)
if (phase === STORMCOIL_PHASE.donutWindup || phase === STORMCOIL_PHASE.donutRecover) {
indicators.push(createDonutIndicator({
color: '#8ff2ff',
id: `${boss.id}:stormcoil-donut`,
innerRadius: STORMCOIL_TUNING.donutInnerRadius,
mechanicId: 'stormcoil-charged-ring',
outerRadius: STORMCOIL_TUNING.donutOuterRadius,
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.donutWindup,
phase === STORMCOIL_PHASE.donutRecover,
),
position: boss.position,
sourceId: boss.id,
}))
}
if (phase === STORMCOIL_PHASE.arcWindup || phase === STORMCOIL_PHASE.arcRecover) {
for (const arc of boss.mechanicArcs) {
indicators.push(createArcIndicator({
angleRadians: arc.angleRadians,
color: phase === STORMCOIL_PHASE.arcRecover ? '#d8fbff' : '#60cfff',
direction: arc.direction,
id: `${boss.id}:${arc.id}`,
innerRadius: arc.innerRadius,
mechanicId: 'stormcoil-arc-trail',
outerRadius: arc.outerRadius,
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.arcWindup,
phase === STORMCOIL_PHASE.arcRecover,
),
position: arc.position,
sourceId: boss.id,
}))
}
}
if (phase === STORMCOIL_PHASE.chainWindup || phase === STORMCOIL_PHASE.chainRecover) {
for (const link of boss.mechanicLinks) {
indicators.push(createLaneIndicator({
color: '#b8f7ff',
end: link.end,
id: `${boss.id}:${link.id}`,
mechanicId: 'stormcoil-chain-lightning',
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.chainWindup,
phase === STORMCOIL_PHASE.chainRecover,
),
sourceId: boss.id,
start: link.start,
width: link.width,
}))
}
}
return indicators
}
function stormcoilPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: StormcoilPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+321
View File
@@ -0,0 +1,321 @@
import { TOBI_KADACHI_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicLinkState,
Iwt2PartyEntityState,
} from './types'
import {
applyPartyDamageInShape,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickTobiKadachi(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.min(
state.boss.mechanicEnergyMax,
state.boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerSecond! * dt,
),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withTobiIndicators({ boss, party, events })
if (boss.attackPhase === 'staticPounceWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'staticPouncing' }
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'staticPouncing') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, TOBI_KADACHI_BOSS_METADATA.staticPounceSpeed! * dt),
boss.radius,
state.bounds,
)
const hitResult = applyPounceHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) {
boss = {
...boss,
attackPhase: 'staticPounceRecover',
phaseSecondsRemaining: 0.42,
velocity: { x: 0, y: 0 },
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chainShockWindup') {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyChainShock(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'chainShockRecover',
phaseSecondsRemaining: 0.42,
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'staticPounceRecover' || boss.attackPhase === 'chainShockRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLinks: [],
phaseSecondsRemaining: 0,
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.mechanicEnergy >= boss.mechanicEnergyMax && boss.chargeCooldownRemaining <= 0) {
const pounce = createLinearMovementPlan({
bounds: state.bounds,
from: boss.position,
length: TOBI_KADACHI_BOSS_METADATA.staticPounceLength!,
radius: boss.radius,
target: target.position,
})
boss = {
...boss,
attackPhase: 'staticPounceWindup',
chargeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chargeCooldown,
chargeEnd: pounce.end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: pounce.direction,
mechanicEnergy: 0,
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.staticPounceWindup!,
velocity: { x: 0, y: 0 },
}
return withTobiIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
const links = createChainLinks(party)
boss = {
...boss,
attackPhase: 'chainShockWindup',
fireballCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockCooldown!,
mechanicLinks: links,
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockWindup!,
velocity: { x: 0, y: 0 },
}
return withTobiIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withTobiIndicators({ boss, party, events })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, TOBI_KADACHI_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withTobiIndicators({ boss, party, events })
}
function applyPounceHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2BossEntityState['position'],
end: Iwt2BossEntityState['position'],
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'lane',
start,
end,
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.staticPounceDamage!,
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
sourceId: boss.id,
stunSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function createChainLinks(party: Iwt2PartyEntityState[]): Iwt2MechanicLinkState[] {
const living = party.filter((member) => member.health > 0)
const links: Iwt2MechanicLinkState[] = []
for (let a = 0; a < living.length; a += 1) {
for (let b = a + 1; b < living.length; b += 1) {
if (distanceVec2(living[a].position, living[b].position) > TOBI_KADACHI_BOSS_METADATA.chainShockRadius!) continue
links.push({
id: `chain-${living[a].id}-${living[b].id}`,
start: { ...living[a].position },
end: { ...living[b].position },
width: 5,
})
}
}
return links.slice(0, 6)
}
function applyChainShock(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const link of boss.mechanicLinks) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
start: link.start,
end: link.end,
width: TOBI_KADACHI_BOSS_METADATA.chainShockRadius! * 0.12,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.chainShockDamage!,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: TOBI_KADACHI_BOSS_METADATA.chainShockStunSeconds!,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > TOBI_KADACHI_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: TOBI_KADACHI_BOSS_METADATA.meleeRange,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: {
...boss,
mechanicEnergy: Math.min(
boss.mechanicEnergyMax,
boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerMelee!,
),
meleeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.meleeCooldown,
},
party: result.party,
events: result.events,
}
}
function withTobiIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createTobiIndicators(result.boss),
}
}
function createTobiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (
boss.attackPhase === 'staticPounceWindup'
|| boss.attackPhase === 'staticPouncing'
|| boss.attackPhase === 'staticPounceRecover'
) {
indicators.push(createLaneIndicator({
color: boss.attackPhase === 'staticPouncing' ? '#a8f3ff' : '#6bdcff',
end: boss.chargeEnd,
id: `${boss.id}:static-pounce`,
mechanicId: 'tobi-static-pounce',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'staticPounceWindup',
boss.attackPhase === 'staticPouncing',
),
sourceId: boss.id,
start: boss.chargeStart,
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
}))
}
if (boss.attackPhase === 'chainShockWindup' || boss.attackPhase === 'chainShockRecover') {
for (const link of boss.mechanicLinks) {
indicators.push(createLaneIndicator({
color: '#a8f3ff',
end: link.end,
id: `${boss.id}:${link.id}`,
mechanicId: 'tobi-chain-shock',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'chainShockWindup',
boss.attackPhase === 'chainShockRecover',
),
sourceId: boss.id,
start: link.start,
width: link.width,
}))
}
}
return indicators
}
+98 -1
View File
@@ -31,6 +31,7 @@ export type Iwt2StatusState = {
stunnedSeconds: number
knockedDownSeconds: number
invulnerableSeconds: number
slowedSeconds: number
}
export type Iwt2HotEffectState = {
@@ -100,6 +101,69 @@ export type Iwt2BossAttackPhase =
| 'thunderRingRecover'
| 'lightningStrikeWindup'
| 'lightningStrikeRecover'
| 'tailSweepWindup'
| 'tailSweepRecover'
| 'poisonSpitWindup'
| 'poisonSpitRecover'
| 'mudSprayWindup'
| 'mudSprayRecover'
| 'staticPounceWindup'
| 'staticPouncing'
| 'staticPounceRecover'
| 'chainShockWindup'
| 'chainShockRecover'
| 'iceWallWindup'
| 'shardShatterWindup'
| 'shardShatterRecover'
| 'bladeSidestep'
| 'lineSlashWindup'
| 'lineSlashRecover'
| 'crossSlashWindup'
| 'crossSlashRecover'
| 'cinderbackRicochetWindup'
| 'cinderbackRicocheting'
| 'cinderbackArmorSlamWindup'
| 'cinderbackArmorSlamRecover'
| 'obsidianRamPlateChargeWindup'
| 'obsidianRamPlateCharging'
| 'obsidianRamPlateChargeRecover'
| 'obsidianRamFractureQuakeWindup'
| 'obsidianRamFractureQuakeRecover'
| 'obsidianRamArmorShatterWindup'
| 'obsidianRamArmorShatterRecover'
| 'stormcoilDonutWindup'
| 'stormcoilDonutRecover'
| 'stormcoilArcWindup'
| 'stormcoilArcRecover'
| 'stormcoilChainWindup'
| 'stormcoilChainRecover'
| 'crystalBatSonicWindup'
| 'crystalBatSonicRecover'
| 'crystalBatShardWindup'
| 'crystalBatShardRecover'
| 'crystalBatSwoopWindup'
| 'crystalBatSwooping'
| 'crystalBatSwoopRecover'
| 'venomOrchidHydraConeWindup'
| 'venomOrchidHydraConeRecover'
| 'venomOrchidHydraRootSnareWindup'
| 'venomOrchidHydraRootSnareRecover'
| 'venomOrchidHydraBloomPodWindup'
| 'venomOrchidHydraBloomPodRecover'
| 'sandglassBurrowWindup'
| 'sandglassBurrowing'
| 'sandglassEruptionWindup'
| 'sandglassEruptionRecover'
| 'sandglassHourglassSafe'
| 'sandglassHourglassHazard'
| 'sandglassHourglassRecover'
| 'hollowcrownHoofCurseWindup'
| 'hollowcrownHoofCurseRecover'
| 'hollowcrownTrailDashWindup'
| 'hollowcrownTrailDashing'
| 'hollowcrownTrailFade'
| 'hollowcrownAntlerChargeWindup'
| 'hollowcrownAntlerChargeRecover'
export type Iwt2HostileAddAttackPhase =
| 'idle'
@@ -117,6 +181,10 @@ export type Iwt2BossEntityState = {
radius: number
health: number
maxHealth: number
armor: number
maxArmor: number
mechanicEnergy: number
mechanicEnergyMax: number
meleeCooldownRemaining: number
chargeCooldownRemaining: number
chargeCount: number
@@ -133,6 +201,8 @@ export type Iwt2BossEntityState = {
birdWaveThresholdsTriggered: number[]
mechanicLanes: Iwt2MechanicLaneState[]
mechanicCircles: Iwt2MechanicCircleState[]
mechanicArcs: Iwt2MechanicArcState[]
mechanicLinks: Iwt2MechanicLinkState[]
}
export type Iwt2MechanicLaneState = {
@@ -148,6 +218,22 @@ export type Iwt2MechanicCircleState = {
radius: number
}
export type Iwt2MechanicArcState = {
id: string
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2MechanicLinkState = {
id: string
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2HostileAddState = {
id: string
kind: 'hostileAdd'
@@ -172,7 +258,7 @@ export type Iwt2HostileAddState = {
export type Iwt2GroundHazardState = {
id: string
kind: 'groundHazard'
hazardKind: 'firePuddle'
hazardKind: 'firePuddle' | 'poisonPuddle' | 'mudPuddle'
sourceId: Iwt2EntityId
position: Iwt2Vec2
radius: number
@@ -241,11 +327,21 @@ export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & {
outerRadius: number
}
export type Iwt2ArenaArcIndicator = Iwt2ArenaIndicatorBase & {
kind: 'arc'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2ArenaIndicator =
| Iwt2ArenaLaneIndicator
| Iwt2ArenaCircleIndicator
| Iwt2ArenaConeIndicator
| Iwt2ArenaDonutIndicator
| Iwt2ArenaArcIndicator
export type Iwt2ArenaState = {
schemaVersion: 1
@@ -256,6 +352,7 @@ export type Iwt2ArenaState = {
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
boss: Iwt2BossEntityState
bosses: Iwt2BossEntityState[]
indicators: Iwt2ArenaIndicator[]
nextEventId: number
nextProjectileId: number
+736
View File
@@ -0,0 +1,736 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addPoisonPuddle } from './hazards'
import {
applyPartyDamageInShape,
createCircleIndicator,
createConeIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const VENOM_ORCHID_HYDRA_PHASE = {
bloomPodRecover: 'venomOrchidHydraBloomPodRecover',
bloomPodWindup: 'venomOrchidHydraBloomPodWindup',
coneRecover: 'venomOrchidHydraConeRecover',
coneWindup: 'venomOrchidHydraConeWindup',
rootSnareRecover: 'venomOrchidHydraRootSnareRecover',
rootSnareWindup: 'venomOrchidHydraRootSnareWindup',
} as const
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
const VENOM_ORCHID_HYDRA = {
bloomBurstDamage: 7,
bloomCooldown: 8.6,
bloomPodCount: 3,
bloomPodDamage: 3,
bloomPodRadius: 30,
bloomPodSeconds: 1.4,
bloomRecover: 0.5,
bloomWindup: 0.9,
coneAngleRadians: Math.PI * 0.34,
coneCooldown: 5.7,
coneDamage: 10,
coneRange: 178,
coneRecover: 0.62,
coneStaggerWindup: 0.48,
coneStunSeconds: 0.18,
headOffset: 22,
meleeCooldown: 1.35,
meleeDamage: 4,
meleeRange: 58,
moveSpeed: 58,
relocateSpeed: 114,
relocateSeconds: 1.45,
rootLaneDamage: 8,
rootLaneLengthInset: 36,
rootLaneWidth: 28,
rootRecover: 0.58,
rootSnareSeconds: 0.85,
rootWindup: 0.82,
wallDisengageSeconds: 0.72,
wallMargin: 70,
}
const VENOM_COLOR = '#b7ff4f'
const ROOT_COLOR = '#d68b49'
const BLOOM_COLOR = '#ff70d7'
type PoisonConePlan = {
direction: Iwt2Vec2
end: Iwt2Vec2
origin: Iwt2Vec2
}
export function tickVenomOrchidHydra(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const phase = venomOrchidHydraPhase(boss)
boss = {
...boss,
wallContactSeconds: phase === 'relocating'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyPoisonCone(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
const nextHeadIndex = Math.floor(boss.mechanicEnergy) + 1
if (nextHeadIndex < 3) {
const cone = createPoisonConePlan(boss, party, state, nextHeadIndex)
boss = {
...boss,
chargeEnd: cone.end,
chargeHitEntityIds: result.hitEntityIds,
chargeStart: cone.origin,
facing: cone.direction,
mechanicEnergy: nextHeadIndex,
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
}
} else {
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneRecover),
chargeHitEntityIds: [],
mechanicEnergy: 0,
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneRecover,
}
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyRootSnareLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootRecover,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const burst = applyBloomBursts(party, boss, state.time + dt)
const pods = addBloomPoisonPods({
boss,
hazards,
nextHazardId,
time: state.time + dt,
})
party = burst.party
hazards = pods.hazards
nextHazardId = pods.nextHazardId
events.push(...burst.events)
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomRecover,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= VENOM_ORCHID_HYDRA.wallDisengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = beginBloomPods(boss, state, party)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = boss.chargeCount % 2 === 0
? beginStaggeredPoisonCones(boss, state, party)
: beginRootSnareLanes(boss, state, party)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveHydraTowardEngagePoint(boss, state, target, dt)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginStaggeredPoisonCones(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
const cone = createPoisonConePlan(boss, party, state, 0)
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneWindup),
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
chargeCount: boss.chargeCount + 1,
chargeEnd: cone.end,
chargeHitEntityIds: [],
chargeStart: cone.origin,
facing: cone.direction,
mechanicCircles: [],
mechanicEnergy: 0,
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
velocity: { x: 0, y: 0 },
}
}
function beginRootSnareLanes(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup),
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: [],
mechanicLanes: createRootSnarePattern(state, party),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootWindup,
velocity: { x: 0, y: 0 },
}
}
function beginBloomPods(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup),
fireballCooldownRemaining: VENOM_ORCHID_HYDRA.bloomCooldown,
mechanicCircles: createBloomPodPattern(state, party, boss),
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomWindup,
velocity: { x: 0, y: 0 },
}
}
function createPoisonConePlan(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
headIndex: number,
): PoisonConePlan {
const living = livingParty(party)
const fallbackTarget = getBossTarget(party)?.position ?? arenaCenter(state)
const target = living[(headIndex + boss.chargeCount) % Math.max(1, living.length)]?.position ?? fallbackTarget
const headOffset = (headIndex - 1) * VENOM_ORCHID_HYDRA.headOffset
const baseDirection = withFallbackFacing(subtractVec2(target, boss.position), boss.facing)
const direction = rotateVec2(baseDirection, (headIndex - 1) * 0.28)
const perpendicular = { x: -baseDirection.y, y: baseDirection.x }
const origin = clampVec2ToArena(
addVec2(boss.position, scaleVec2(perpendicular, headOffset)),
8,
state.bounds,
)
return {
direction,
end: addVec2(origin, scaleVec2(direction, VENOM_ORCHID_HYDRA.coneRange)),
origin,
}
}
function createRootSnarePattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicLaneState[] {
const living = livingParty(party)
const center = partyCenter(living, arenaCenter(state))
const selected = [
getPriorityMember(living, 'player')?.position ?? center,
getPriorityMember(living, 'ranged')?.position ?? center,
getPriorityMember(living, 'flanker')?.position ?? center,
]
const directions = [
{ x: 1, y: 0 },
{ x: 0, y: 1 },
normalizeVec2({ x: state.bounds.width >= state.bounds.height ? 1 : 0.8, y: 0.72 }),
]
return selected.map((position, index) => {
const lane = createArenaLaneThroughPoint(position, directions[index], state)
return {
...lane,
id: `venom-orchid-root-snare-${index}`,
width: VENOM_ORCHID_HYDRA.rootLaneWidth,
}
})
}
function createBloomPodPattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = livingParty(party)
const center = partyCenter(living, arenaCenter(state))
const chosen = [
getPriorityMember(living, 'ranged')?.position,
getPriorityMember(living, 'player')?.position,
getPriorityMember(living, 'flanker')?.position,
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), 54)),
].filter((position): position is Iwt2Vec2 => Boolean(position))
return chosen.slice(0, VENOM_ORCHID_HYDRA.bloomPodCount).map((position, index) => ({
id: `venom-orchid-bloom-pod-${index}`,
position: clampVec2ToArena(position, VENOM_ORCHID_HYDRA.bloomPodRadius, state.bounds),
radius: VENOM_ORCHID_HYDRA.bloomPodRadius,
}))
}
function applyPoisonCone(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
direction: boss.facing,
kind: 'cone',
origin: boss.chargeStart,
range: VENOM_ORCHID_HYDRA.coneRange,
}, {
damage: VENOM_ORCHID_HYDRA.coneDamage,
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: VENOM_ORCHID_HYDRA.coneStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyRootSnareLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: VENOM_ORCHID_HYDRA.rootLaneDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: VENOM_ORCHID_HYDRA.rootSnareSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyBloomBursts(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: VENOM_ORCHID_HYDRA.bloomBurstDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: 0,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function addBloomPoisonPods({
boss,
hazards,
nextHazardId,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
for (const circle of boss.mechanicCircles) {
const result = addPoisonPuddle({
damage: VENOM_ORCHID_HYDRA.bloomPodDamage,
duration: VENOM_ORCHID_HYDRA.bloomPodSeconds,
hazards: nextHazards,
nextHazardId: nextId,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
time,
})
nextHazards = result.hazards
nextId = result.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > VENOM_ORCHID_HYDRA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: VENOM_ORCHID_HYDRA.meleeRange,
}, {
damage: VENOM_ORCHID_HYDRA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: VENOM_ORCHID_HYDRA.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, VENOM_ORCHID_HYDRA.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveHydraTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + VENOM_ORCHID_HYDRA.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), 106))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, VENOM_ORCHID_HYDRA.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 66)), boss.radius, state.bounds)
}
function createArenaLaneThroughPoint(
point: Iwt2Vec2,
direction: Iwt2Vec2,
state: Iwt2ArenaState,
): { start: Iwt2Vec2, end: Iwt2Vec2 } {
const dir = withFallbackFacing(direction, { x: 1, y: 0 })
const inset = VENOM_ORCHID_HYDRA.rootLaneLengthInset
const minX = inset
const maxX = state.bounds.width - inset
const minY = inset
const maxY = state.bounds.height - inset
const candidates: number[] = []
if (Math.abs(dir.x) > 0.0001) {
candidates.push((minX - point.x) / dir.x, (maxX - point.x) / dir.x)
}
if (Math.abs(dir.y) > 0.0001) {
candidates.push((minY - point.y) / dir.y, (maxY - point.y) / dir.y)
}
const valid = candidates.filter((t) => {
const position = addVec2(point, scaleVec2(dir, t))
return position.x >= minX - 0.1 && position.x <= maxX + 0.1 && position.y >= minY - 0.1 && position.y <= maxY + 0.1
})
const startT = Math.min(...valid)
const endT = Math.max(...valid)
return {
end: addVec2(point, scaleVec2(dir, endT)),
start: addVec2(point, scaleVec2(dir, startT)),
}
}
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
const sin = Math.sin(radians)
const cos = Math.cos(radians)
return normalizeVec2({
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
})
}
function livingParty(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState[] {
return party.filter((member) => member.health > 0)
}
function getPriorityMember(
party: Iwt2PartyEntityState[],
role: Iwt2PartyEntityState['aiRole'],
): Iwt2PartyEntityState | undefined {
return party.find((member) => member.aiRole === role)
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + VENOM_ORCHID_HYDRA.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withVenomOrchidHydraIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createVenomOrchidHydraIndicators(result.boss),
}
}
function createVenomOrchidHydraIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = venomOrchidHydraPhase(boss)
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup || phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
indicators.push(createConeIndicator({
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
color: VENOM_COLOR,
direction: boss.facing,
id: `${boss.id}:venom-orchid-hydra-cone-${Math.floor(boss.mechanicEnergy)}`,
mechanicId: 'venom-orchid-hydra-poison-cone',
origin: boss.chargeStart,
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover,
),
range: VENOM_ORCHID_HYDRA.coneRange,
sourceId: boss.id,
}))
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup || phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: ROOT_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'venom-orchid-hydra-root-snare',
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover,
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup || phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: BLOOM_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'venom-orchid-hydra-bloom-pod',
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover,
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
function venomOrchidHydraPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: VenomOrchidHydraPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}