Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
956c9e32f9 | ||
|
|
547044892d | ||
|
|
81d65bc381 | ||
|
|
bdae0007a1 | ||
|
|
c052b086f8 | ||
|
|
c2a14bac7d | ||
|
|
0d269a6041 |
@@ -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.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 94
|
||||
versionName "1.1.15"
|
||||
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.
|
||||
|
||||
@@ -164,6 +164,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
}
|
||||
|
||||
Set<String> currentTokens = new HashSet<>();
|
||||
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||
dispatchNativeControllerMotion(leftStickX, leftStickY);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||
@@ -178,13 +181,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_X),
|
||||
leftStickX,
|
||||
"Axis0-",
|
||||
"Axis0+"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_Y),
|
||||
leftStickY,
|
||||
"Axis1-",
|
||||
"Axis1+"
|
||||
);
|
||||
@@ -240,6 +243,19 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
);
|
||||
}
|
||||
|
||||
private void dispatchNativeControllerMotion(float x, float y) {
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller-motion',"
|
||||
+ "{detail:{x:" + x + ",y:" + y + "}}));";
|
||||
bridge.getWebView().post(
|
||||
() -> {
|
||||
bridge.getWebView().requestFocus();
|
||||
bridge.getWebView().evaluateJavascript(script, null);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private boolean shouldThrottleDpad(String token) {
|
||||
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
||||
long now = SystemClock.uptimeMillis();
|
||||
|
||||
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 358 KiB |
@@ -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
|
||||
```
|
||||
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 411 KiB |
|
After Width: | Height: | Size: 173 KiB |
@@ -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 |
|
After Width: | Height: | Size: 224 KiB |
@@ -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 |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -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 |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -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 |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 199 KiB |
@@ -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 |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 181 KiB |
@@ -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 |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 200 KiB |
@@ -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 |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -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;
|
||||
@@ -630,23 +632,44 @@
|
||||
}
|
||||
|
||||
.iwt2-profile-grid,
|
||||
.iwt2-settings-panel {
|
||||
.iwt2-settings-panel,
|
||||
.iwt2-customize-layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.iwt2-action-list {
|
||||
.iwt2-loadout-preview {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.iwt2-loadout-preview > div {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.iwt2-loadout-preview h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.iwt2-loadout-preview span {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.iwt2-action-list,
|
||||
.iwt2-info-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.iwt2-action-row {
|
||||
.iwt2-action-row,
|
||||
.iwt2-info-row {
|
||||
align-items: center;
|
||||
background: #10141b;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -656,12 +679,22 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.iwt2-action-row strong {
|
||||
.iwt2-action-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iwt2-info-row {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.iwt2-action-row strong,
|
||||
.iwt2-info-row strong {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.iwt2-action-row small {
|
||||
.iwt2-action-row small,
|
||||
.iwt2-info-row small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
@@ -669,18 +702,44 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.iwt2-action-row em {
|
||||
.iwt2-action-row em,
|
||||
.iwt2-info-row em {
|
||||
color: #fff4a8;
|
||||
font-style: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iwt2-action-row:disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.iwt2-action-row:disabled em {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.iwt2-action-row.selected,
|
||||
.iwt2-menu-card.game-selected {
|
||||
background: #1c2633;
|
||||
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;
|
||||
@@ -708,6 +767,13 @@
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.iwt2-bottom-display .dual-control-chip {
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #090a0d;
|
||||
outline: 2px solid #494756;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.game-version-grid,
|
||||
.iwt2-menu-grid {
|
||||
@@ -725,7 +791,8 @@
|
||||
}
|
||||
|
||||
.iwt2-profile-grid,
|
||||
.iwt2-settings-panel {
|
||||
.iwt2-settings-panel,
|
||||
.iwt2-customize-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -787,6 +854,295 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) and (max-height: 620px) {
|
||||
.game-shell.iwt2-shell {
|
||||
padding: 6px 0;
|
||||
width: min(100%, calc(100% - 20px));
|
||||
}
|
||||
|
||||
.iwt2-shell .app-header {
|
||||
min-height: 44px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.iwt2-shell .brand-button strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.iwt2-shell .character-summary {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.iwt2-shell .character-summary strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.iwt2-shell .character-summary small {
|
||||
font-size: 6px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen {
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.iwt2-menu-heading {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.iwt2-menu-heading .eyebrow {
|
||||
font-size: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.iwt2-menu-heading h1 {
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.iwt2-menu-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.iwt2-menu-card {
|
||||
gap: 8px;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
min-height: 92px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.iwt2-menu-card > span {
|
||||
font-size: 0.95rem;
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.iwt2-menu-card strong {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.iwt2-menu-card small {
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell {
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
max-width: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell .screen-heading {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell .screen-heading .eyebrow {
|
||||
font-size: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell .screen-heading h1 {
|
||||
font-size: 17px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell .back-button {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
|
||||
.iwt2-action-list,
|
||||
.iwt2-info-list {
|
||||
gap: 7px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.iwt2-action-row,
|
||||
.iwt2-info-row {
|
||||
gap: 8px;
|
||||
min-height: 54px;
|
||||
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;
|
||||
}
|
||||
|
||||
.iwt2-action-row small,
|
||||
.iwt2-info-row small {
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.iwt2-profile-grid,
|
||||
.iwt2-settings-panel,
|
||||
.iwt2-customize-layout {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iwt2-settings-panel,
|
||||
.iwt2-customize-layout {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.iwt2-settings-panel > *,
|
||||
.iwt2-customize-layout > *,
|
||||
.iwt2-loadout-preview {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iwt2-loadout-preview {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.iwt2-loadout-preview h2 {
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.iwt2-loadout-preview span {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.iwt2-controller-preview {
|
||||
min-height: 112px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.iwt2-boss-hud {
|
||||
min-width: 280px;
|
||||
padding: 6px 10px 8px;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.iwt2-boss-hud strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.iwt2-boss-phase {
|
||||
font-size: 0.5rem;
|
||||
height: 10px;
|
||||
line-height: 10px;
|
||||
}
|
||||
|
||||
.iwt2-party-list {
|
||||
gap: 5px;
|
||||
left: 10px;
|
||||
max-height: calc(100% - 84px);
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
top: 10px;
|
||||
width: 154px;
|
||||
}
|
||||
|
||||
.iwt2-party-row {
|
||||
gap: 5px;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
height: 62px;
|
||||
min-height: 62px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.iwt2-party-row.has-target-binding {
|
||||
grid-template-columns: 24px 20px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.iwt2-party-row > span,
|
||||
.iwt2-party-row .iwt2-party-target-key {
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.iwt2-party-row-title {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.iwt2-party-row strong {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.iwt2-party-row small {
|
||||
font-size: 0.52rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.iwt2-party-row .iwt2-bar {
|
||||
height: 7px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.iwt2-party-effects {
|
||||
height: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.iwt2-effect-badge {
|
||||
font-size: 0.44rem;
|
||||
line-height: 9px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.iwt2-party-damage {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.iwt2-ability-bar {
|
||||
bottom: 8px;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(6, 36px);
|
||||
max-width: 256px;
|
||||
width: 256px;
|
||||
}
|
||||
|
||||
.iwt2-ability-button {
|
||||
min-height: 44px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.iwt2-ability-button > span {
|
||||
font-size: 0.9rem;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.iwt2-ability-button strong {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.iwt2-ability-button i {
|
||||
font-size: 0.46rem;
|
||||
}
|
||||
}
|
||||
|
||||
.combat-touch-lock-status {
|
||||
background: #101216;
|
||||
border: 2px solid #d9b55a;
|
||||
@@ -1808,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,
|
||||
@@ -1843,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;
|
||||
@@ -1866,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;
|
||||
@@ -2038,7 +2442,8 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.dual-controls-targets button {
|
||||
.dual-controls-targets button,
|
||||
.dual-controls-targets .dual-control-chip {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
@@ -2050,7 +2455,8 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dual-controls-targets.direct button {
|
||||
.dual-controls-targets.direct button,
|
||||
.dual-controls-targets.direct .dual-control-chip {
|
||||
font-size: 15px;
|
||||
min-height: 34px;
|
||||
}
|
||||
@@ -2196,7 +2602,9 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
}
|
||||
|
||||
.dual-controls-targets button,
|
||||
.dual-controls-targets.direct button {
|
||||
.dual-controls-targets.direct button,
|
||||
.dual-controls-targets .dual-control-chip,
|
||||
.dual-controls-targets.direct .dual-control-chip {
|
||||
font-size: 12px;
|
||||
min-height: 30px;
|
||||
padding: 4px 5px;
|
||||
@@ -2234,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 {
|
||||
@@ -2257,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;
|
||||
@@ -6998,6 +7416,15 @@ h2 {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.iwt2-bottom-display .iwt2-bottom-spell {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.iwt2-bottom-display .iwt2-bottom-spell:hover {
|
||||
outline-color: #494756;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.spell kbd {
|
||||
align-items: center;
|
||||
background: #090a0d;
|
||||
@@ -11513,3 +11940,57 @@ h2 {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) and (max-height: 620px) {
|
||||
.iwt2-screen-shell.content-screen {
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
max-width: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell.content-screen > .screen-heading {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell.content-screen > .screen-heading .eyebrow {
|
||||
font-size: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell.content-screen > .screen-heading h1 {
|
||||
font-size: 17px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell .iwt2-action-list,
|
||||
.iwt2-screen-shell .iwt2-info-list,
|
||||
.iwt2-settings-panel .iwt2-action-list,
|
||||
.iwt2-customize-layout .iwt2-action-list,
|
||||
.iwt2-loadout-preview .iwt2-info-list {
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell.content-screen > .iwt2-action-list,
|
||||
.iwt2-screen-shell.content-screen > .iwt2-info-list {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.iwt2-action-row:not(:has(small)),
|
||||
.iwt2-info-row:not(:has(small)) {
|
||||
min-height: 32px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.iwt2-settings-panel,
|
||||
.iwt2-customize-layout,
|
||||
.iwt2-settings-panel > *,
|
||||
.iwt2-customize-layout > *,
|
||||
.iwt2-loadout-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -1396,16 +1396,13 @@ function applyIwt2AdminOverrides(
|
||||
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
|
||||
overrides: Iwt2BalanceOverrides,
|
||||
): Record<Iwt2BossId, Iwt2BossMetadata> {
|
||||
return {
|
||||
bulldrome: {
|
||||
...metadata.bulldrome,
|
||||
...overrides.bosses?.bulldrome,
|
||||
return (Object.keys(metadata) as Iwt2BossId[]).reduce((nextMetadata, bossId) => ({
|
||||
...nextMetadata,
|
||||
[bossId]: {
|
||||
...metadata[bossId],
|
||||
...overrides.bosses?.[bossId],
|
||||
},
|
||||
'yian-kut-ku': {
|
||||
...metadata['yian-kut-ku'],
|
||||
...overrides.bosses?.['yian-kut-ku'],
|
||||
},
|
||||
}
|
||||
}), {} as Record<Iwt2BossId, Iwt2BossMetadata>)
|
||||
}
|
||||
|
||||
function createIwt2AdminRows(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -134,6 +134,7 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||
const NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
|
||||
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
|
||||
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
|
||||
const HEADER_CONTENT_SELECTOR = '.app-header'
|
||||
@@ -142,6 +143,7 @@ const GAMEPAD_MENU_POLL_MS = 1000 / 30
|
||||
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
|
||||
const CONTROLLER_REPEAT_INITIAL_MS = 260
|
||||
const CONTROLLER_REPEAT_MS = 85
|
||||
const NATIVE_STICK_TIMEOUT_MS = 140
|
||||
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
@@ -1087,6 +1089,8 @@ export function useMovementVectorRef(enabled = true) {
|
||||
|
||||
let frame = 0
|
||||
const pressedKeys = new Set<string>()
|
||||
let nativeStick: MovementVector = { x: 0, y: 0 }
|
||||
let nativeStickUpdatedAt = 0
|
||||
const keyboardVector = () => normalizedVector(
|
||||
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
|
||||
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
|
||||
@@ -1094,12 +1098,26 @@ export function useMovementVectorRef(enabled = true) {
|
||||
const updateMovement = () => {
|
||||
const gamepad = firstConnectedGamepad()
|
||||
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
|
||||
const native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
|
||||
? applyStickDeadzone(nativeStick.x, nativeStick.y)
|
||||
: { x: 0, y: 0 }
|
||||
const keyboard = keyboardVector()
|
||||
movementRef.current = Math.hypot(stick.x, stick.y) > 0
|
||||
? stick
|
||||
: keyboard
|
||||
movementRef.current = Math.hypot(native.x, native.y) > 0
|
||||
? native
|
||||
: Math.hypot(stick.x, stick.y) > 0
|
||||
? stick
|
||||
: keyboard
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
}
|
||||
const onNativeMotion = (event: Event) => {
|
||||
const detail = (event as CustomEvent<Partial<MovementVector>>).detail
|
||||
nativeStick = {
|
||||
x: Number(detail.x) || 0,
|
||||
y: Number(detail.y) || 0,
|
||||
}
|
||||
nativeStickUpdatedAt = performance.now()
|
||||
document.documentElement.dataset.inputDevice = 'controller'
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isTextInput(document.activeElement)) return
|
||||
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
||||
@@ -1113,12 +1131,14 @@ export function useMovementVectorRef(enabled = true) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
movementRef.current = { x: 0, y: 0 }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { useEffect, 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,
|
||||
useDualScreenSetupPublisher,
|
||||
useDualScreenWorkshopPublisher,
|
||||
type DualScreenSetupState,
|
||||
type DualScreenWorkshopState,
|
||||
} from '../../dualScreen'
|
||||
import { BossArenaScreen } from './screens/BossArenaScreen'
|
||||
import {
|
||||
Iwt2CloudSaveScreen,
|
||||
Iwt2CustomizeCharacterScreen,
|
||||
Iwt2DungeonsScreen,
|
||||
Iwt2HunterProfileScreen,
|
||||
Iwt2ModePlaceholderScreen,
|
||||
Iwt2ModeScreen,
|
||||
Iwt2RoguelikeScreen,
|
||||
Iwt2RoguelikeUpgradeScreen,
|
||||
Iwt2SettingsScreen,
|
||||
} from './screens/Iwt2ShellScreens'
|
||||
import {
|
||||
@@ -14,7 +26,18 @@ import {
|
||||
writeIwt2Save,
|
||||
type Iwt2Save,
|
||||
} from './save/iwt2Repository'
|
||||
import type { Iwt2BossId } from './content/bosses'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||
import {
|
||||
buildIwt2OpponentDebuffChoices,
|
||||
buildIwt2SelfBuffChoices,
|
||||
IWT2_REVIVE_PARTY_CHOICE,
|
||||
type Iwt2RoguelikeChoice,
|
||||
type Iwt2RoguelikeContentType,
|
||||
type Iwt2RoguelikeOpponentDebuffId,
|
||||
type Iwt2RoguelikeSelfBuffId,
|
||||
type Iwt2RoguelikeVariant,
|
||||
} from './content/roguelike'
|
||||
|
||||
type Iwt2Screen =
|
||||
| 'menu'
|
||||
@@ -23,12 +46,25 @@ type Iwt2Screen =
|
||||
| 'dungeons'
|
||||
| 'raids'
|
||||
| 'roguelike'
|
||||
| 'pvp'
|
||||
| 'roguelike-arena'
|
||||
| 'roguelike-upgrade'
|
||||
| 'hunter-profile'
|
||||
| 'customize-character'
|
||||
| 'settings'
|
||||
|
||||
const IWT2_MENU_COLUMNS = 4
|
||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||
|
||||
type Iwt2RoguelikeRunState = {
|
||||
bossIds: Iwt2BossId[]
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
debuffChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
|
||||
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
|
||||
stage: number
|
||||
variant: Iwt2RoguelikeVariant
|
||||
}
|
||||
|
||||
const MENU_ITEMS: Array<{
|
||||
screen: Iwt2Screen
|
||||
@@ -38,32 +74,32 @@ const MENU_ITEMS: Array<{
|
||||
}> = [
|
||||
{
|
||||
screen: 'cloud-save',
|
||||
title: 'Cloud Save',
|
||||
description: 'Account sync shell for IWT2 progression and local save status.',
|
||||
title: 'Backup Slot',
|
||||
description: 'Save or restore the isolated IWT2 progress slot.',
|
||||
glyph: 'C',
|
||||
},
|
||||
{
|
||||
screen: 'dungeons',
|
||||
title: 'Dungeons',
|
||||
description: 'Queue into IWT2 boss arenas. Bulldrome and Yian Kut Ku are playable slices.',
|
||||
description: 'Queue into seven modular IWT2 boss arenas.',
|
||||
glyph: 'D',
|
||||
},
|
||||
{
|
||||
screen: 'raids',
|
||||
title: 'Raids',
|
||||
description: 'Large-party encounter shell for future multi-group boss fights.',
|
||||
description: 'Open raid assignments built from active IWT2 boss mechanics.',
|
||||
glyph: 'R',
|
||||
},
|
||||
{
|
||||
screen: 'roguelike',
|
||||
title: 'Roguelike',
|
||||
description: 'Run-based IWT2 progression shell for room chains and reward drafts.',
|
||||
glyph: 'G',
|
||||
description: 'Draft upgrades through escalating random encounters.',
|
||||
glyph: 'L',
|
||||
},
|
||||
{
|
||||
screen: 'pvp',
|
||||
screen: 'roguelike',
|
||||
title: 'PvP',
|
||||
description: 'Competitive healing shell with controller targeting preserved.',
|
||||
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
||||
glyph: 'P',
|
||||
},
|
||||
{
|
||||
@@ -75,7 +111,7 @@ const MENU_ITEMS: Array<{
|
||||
{
|
||||
screen: 'customize-character',
|
||||
title: 'Customize Character',
|
||||
description: 'IWT2-only character identity and cosmetic setup shell.',
|
||||
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
||||
glyph: 'K',
|
||||
},
|
||||
{
|
||||
@@ -87,15 +123,38 @@ const MENU_ITEMS: Array<{
|
||||
]
|
||||
|
||||
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||
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],
|
||||
)
|
||||
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
||||
() => buildIwt2WorkshopDualScreenState(screen, save),
|
||||
[save, screen],
|
||||
)
|
||||
|
||||
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
|
||||
useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled)
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (screen !== 'menu' || device !== 'controller') return
|
||||
if (action === 'back') {
|
||||
@@ -103,7 +162,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
setScreen(MENU_ITEMS[selectedIndex].screen)
|
||||
openMenuItem(MENU_ITEMS[selectedIndex])
|
||||
return
|
||||
}
|
||||
if (action === 'navigateUp' || action === 'navigateLeft') {
|
||||
@@ -115,10 +174,25 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
}
|
||||
})
|
||||
|
||||
function openMenuItem(item: (typeof MENU_ITEMS)[number]) {
|
||||
if (item.title === 'PvP') {
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
return
|
||||
}
|
||||
if (item.title === 'Roguelike') {
|
||||
setRoguelikeVariant('pve')
|
||||
setRoguelikeContentType((current) => current === 'stadium' ? 'dungeon' : current)
|
||||
}
|
||||
setScreen(item.screen)
|
||||
}
|
||||
|
||||
if (screen === 'arena') {
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossId={selectedBossId}
|
||||
key={`arena-${selectedBossId}-${arenaModeLabel}`}
|
||||
modeLabel={arenaModeLabel}
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
@@ -126,6 +200,59 @@ 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,
|
||||
debuffs: roguelikeRun.debuffs,
|
||||
onVictory: () => {
|
||||
setRoguelikeRun((current) => current
|
||||
? {
|
||||
...current,
|
||||
...buildRoguelikeChoices(save, current.variant),
|
||||
}
|
||||
: current)
|
||||
setScreen('roguelike-upgrade')
|
||||
},
|
||||
stage: roguelikeRun.stage,
|
||||
variant: roguelikeRun.variant,
|
||||
}}
|
||||
save={save}
|
||||
onBack={() => setScreen('roguelike')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2RoguelikeUpgradeScreen
|
||||
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
||||
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
||||
contentType={roguelikeRun.contentType}
|
||||
debuffChoices={roguelikeRun.debuffChoices}
|
||||
selfChoices={roguelikeRun.selfChoices}
|
||||
stage={roguelikeRun.stage}
|
||||
variant={roguelikeRun.variant}
|
||||
onBack={() => setScreen('roguelike')}
|
||||
onChoose={(buffId, debuffId) => {
|
||||
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||
setScreen('roguelike-arena')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'dungeons') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
@@ -133,6 +260,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
<Iwt2DungeonsScreen
|
||||
onBack={() => setScreen('menu')}
|
||||
onOpenBoss={(bossId) => {
|
||||
setArenaModeLabel('Dungeon')
|
||||
setSelectedBossId(bossId)
|
||||
setScreen('arena')
|
||||
}}
|
||||
@@ -141,12 +269,46 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'raids' || screen === 'roguelike' || screen === 'pvp') {
|
||||
const modeName = screen === 'raids' ? 'Raids' : screen === 'roguelike' ? 'Roguelike' : 'PvP'
|
||||
if (screen === 'roguelike') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2ModePlaceholderScreen mode={modeName} onBack={() => setScreen('menu')} />
|
||||
<Iwt2RoguelikeScreen
|
||||
contentType={roguelikeContentType}
|
||||
variant={roguelikeVariant}
|
||||
onBack={() => setScreen('menu')}
|
||||
onContentTypeChange={setRoguelikeContentType}
|
||||
onStart={() => {
|
||||
startIwt2RoguelikeRun()
|
||||
}}
|
||||
onVariantChange={(nextVariant) => {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
setRoguelikeVariant(nextVariant)
|
||||
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||
setRoguelikeContentType('dungeon')
|
||||
}
|
||||
}}
|
||||
queueMessage={pvpQueueMessage}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'raids') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2ModeScreen
|
||||
mode="Raids"
|
||||
onBack={() => setScreen('menu')}
|
||||
onOpenBoss={(bossId) => {
|
||||
setArenaModeLabel('Raid')
|
||||
setSelectedBossId(bossId)
|
||||
setScreen('arena')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -154,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>
|
||||
)
|
||||
@@ -164,7 +325,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2CustomizeCharacterScreen save={save} onBack={() => setScreen('menu')} />
|
||||
<Iwt2CustomizeCharacterScreen
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -173,7 +338,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2CloudSaveScreen save={save} onBack={() => setScreen('menu')} />
|
||||
<Iwt2CloudSaveScreen
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -202,8 +371,9 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
<button
|
||||
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
key={item.screen}
|
||||
onClick={() => setScreen(item.screen)}
|
||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||
key={`${item.screen}-${item.title}`}
|
||||
onClick={() => openMenuItem(item)}
|
||||
onPointerDown={() => setSelectedIndex(index)}
|
||||
type="button"
|
||||
>
|
||||
@@ -219,6 +389,147 @@ 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 formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||
if (contentType === 'raid') return 'Raid'
|
||||
if (contentType === 'stadium') return 'Stadium'
|
||||
return 'Dungeon'
|
||||
}
|
||||
|
||||
function createRoguelikeRun(
|
||||
save: Iwt2Save,
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
): Iwt2RoguelikeRunState {
|
||||
return {
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
buffs: [],
|
||||
contentType,
|
||||
debuffs: [],
|
||||
...buildRoguelikeChoices(save, variant),
|
||||
stage: 1,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
||||
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
||||
return {
|
||||
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
||||
debuffChoices: variant === 'pvp'
|
||||
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeIwt2Buffs(save: Iwt2Save, buffs: Iwt2RoguelikeSelfBuffId[]) {
|
||||
if (buffs.length === 0) return ''
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
return summarizeChoiceStacks(
|
||||
buffs,
|
||||
[IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)],
|
||||
'None',
|
||||
)
|
||||
}
|
||||
|
||||
function summarizeIwt2Debuffs(save: Iwt2Save, debuffs: Iwt2RoguelikeOpponentDebuffId[]) {
|
||||
if (debuffs.length === 0) return ''
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
return summarizeChoiceStacks(
|
||||
debuffs,
|
||||
buildIwt2OpponentDebuffChoices(abilities),
|
||||
'None',
|
||||
)
|
||||
}
|
||||
|
||||
function applyRoguelikeChoice(
|
||||
run: Iwt2RoguelikeRunState,
|
||||
buffId: Iwt2RoguelikeSelfBuffId,
|
||||
debuffId: Iwt2RoguelikeOpponentDebuffId | undefined,
|
||||
save: Iwt2Save,
|
||||
): Iwt2RoguelikeRunState {
|
||||
const nextDebuffs = debuffId ? [...run.debuffs, debuffId] : run.debuffs
|
||||
const nextBase = buffId === IWT2_REVIVE_PARTY_CHOICE.id
|
||||
? {
|
||||
buffs: run.buffs,
|
||||
debuffs: nextDebuffs.slice(1),
|
||||
}
|
||||
: {
|
||||
buffs: [...run.buffs, buffId],
|
||||
debuffs: nextDebuffs,
|
||||
}
|
||||
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[] = []
|
||||
while (pool.length > 0 && choices.length < count) {
|
||||
const index = Math.floor(Math.random() * pool.length)
|
||||
const [choice] = pool.splice(index, 1)
|
||||
if (choice) choices.push(choice)
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
function Iwt2Header({
|
||||
@@ -241,3 +552,54 @@ function Iwt2Header({
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function buildIwt2SetupDualScreenState(
|
||||
screen: Iwt2Screen,
|
||||
selectedBossId: Iwt2BossId,
|
||||
save: Iwt2Save,
|
||||
): DualScreenSetupState | null {
|
||||
if (screen !== 'dungeons' && screen !== 'raids') return null
|
||||
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
||||
const raid = screen === 'raids'
|
||||
return {
|
||||
contentType: raid ? 'raid' : 'dungeon',
|
||||
description: raid
|
||||
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
|
||||
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
|
||||
difficultyName: `IWT2 Level ${save.character.level}`,
|
||||
experience: 125,
|
||||
initials: boss.icon,
|
||||
itemLevel: save.character.level,
|
||||
stats: {
|
||||
damage: `${boss.meleeDamage}`,
|
||||
health: `${boss.maxHealth}`,
|
||||
loot: 'IWT2',
|
||||
xp: '125',
|
||||
},
|
||||
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
||||
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
|
||||
}
|
||||
}
|
||||
|
||||
function buildIwt2WorkshopDualScreenState(
|
||||
screen: Iwt2Screen,
|
||||
save: Iwt2Save,
|
||||
): DualScreenWorkshopState | null {
|
||||
if (screen === 'customize-character') {
|
||||
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||
return {
|
||||
items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({
|
||||
glyph: ability.icon,
|
||||
meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`,
|
||||
status: `Slot ${ability.slot}`,
|
||||
title: ability.name,
|
||||
})),
|
||||
mode: 'class',
|
||||
subtitle: healer.description,
|
||||
summary: healer.name,
|
||||
title: 'Customize Character',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ControllerBindingLabel } from '../../components/ControllerIcons'
|
||||
import { DEFAULT_BINDINGS, useInput } from '../../input'
|
||||
import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER } from './content/classes'
|
||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from './content/controls'
|
||||
import { abilitiesForHealer } from './content/healerAbilities'
|
||||
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||
import { loadIwt2Save } from './save/iwt2Repository'
|
||||
|
||||
export function Iwt2BottomDisplay() {
|
||||
const {
|
||||
@@ -13,7 +14,9 @@ export function Iwt2BottomDisplay() {
|
||||
const activeBindings = lastDevice === 'controller'
|
||||
? bindings.controller
|
||||
: DEFAULT_BINDINGS.controller
|
||||
const abilities = abilitiesForHealer('field_medic')
|
||||
const save = loadIwt2Save()
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||
const partyTargets = IWT2_PARTY_ORDER.map((classId) => IWT2_CLASS_METADATA[classId])
|
||||
|
||||
return (
|
||||
@@ -21,7 +24,7 @@ export function Iwt2BottomDisplay() {
|
||||
<section className="dual-controls-resource iwt2-bottom-resource">
|
||||
<div>
|
||||
<p className="eyebrow">I Want To Heal 2</p>
|
||||
<strong>Field Medic</strong>
|
||||
<strong>{healer.name}</strong>
|
||||
</div>
|
||||
<div className="dual-controls-mana">
|
||||
<span>Mana 100 / 100</span>
|
||||
@@ -34,23 +37,23 @@ export function Iwt2BottomDisplay() {
|
||||
const action = IWT2_TARGET_ACTIONS[index] ?? 'targetParty1'
|
||||
const label = target.id === 'healer' ? 'Player' : target.name.replace(' Tank', '')
|
||||
return (
|
||||
<button type="button" key={target.id}>
|
||||
<div className="dual-control-chip" key={target.id}>
|
||||
<ControllerBindingLabel
|
||||
binding={activeBindings[action]}
|
||||
iconStyle="playstation"
|
||||
/>{' '}
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<button type="button">
|
||||
<div className="dual-control-chip">
|
||||
<ControllerBindingLabel binding="Button12" iconStyle="playstation" /> Previous Target
|
||||
</button>
|
||||
<button type="button">
|
||||
</div>
|
||||
<div className="dual-control-chip">
|
||||
Next Target <ControllerBindingLabel binding="Button13" iconStyle="playstation" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
@@ -58,7 +61,7 @@ export function Iwt2BottomDisplay() {
|
||||
{abilities.map((ability, index) => {
|
||||
const action = IWT2_ABILITY_ACTIONS[index] ?? 'ability1'
|
||||
return (
|
||||
<button className="spell iwt2-bottom-spell" key={ability.id} type="button">
|
||||
<div className="spell iwt2-bottom-spell" key={ability.id}>
|
||||
<kbd>
|
||||
<ControllerBindingLabel
|
||||
binding={activeBindings[action]}
|
||||
@@ -69,7 +72,7 @@ export function Iwt2BottomDisplay() {
|
||||
<span className={`spell-icon spell-${ability.kind}`}>{ability.icon}</span>
|
||||
<strong>{ability.name}</strong>
|
||||
<small>{ability.manaCost} Mana</small>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
|
||||
|
||||
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku'
|
||||
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
|
||||
@@ -46,12 +67,100 @@ export type Iwt2BossMetadata = {
|
||||
birdRadius?: number
|
||||
birdContactDamage?: number
|
||||
birdStunSeconds?: number
|
||||
packHowlCooldown?: number
|
||||
packHowlWindup?: number
|
||||
packLaneDamage?: number
|
||||
packLaneStunSeconds?: number
|
||||
packLaneWidth?: number
|
||||
packLaneCount?: number
|
||||
thunderRingCooldown?: number
|
||||
thunderRingWindup?: number
|
||||
thunderRingInnerRadius?: number
|
||||
thunderRingOuterRadius?: number
|
||||
thunderRingDamage?: number
|
||||
thunderRingStunSeconds?: number
|
||||
lightningStrikeCooldown?: number
|
||||
lightningStrikeWindup?: number
|
||||
lightningStrikeRadius?: number
|
||||
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,
|
||||
@@ -78,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,
|
||||
@@ -116,6 +230,536 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
birdStunSeconds: 0.75,
|
||||
}
|
||||
|
||||
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,
|
||||
radius: 27,
|
||||
moveSpeed: 146,
|
||||
meleeRange: 52,
|
||||
meleeDamage: 7,
|
||||
meleeCooldown: 0.82,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
packHowlCooldown: 5.8,
|
||||
packHowlWindup: 0.95,
|
||||
packLaneDamage: 15,
|
||||
packLaneStunSeconds: 0.45,
|
||||
packLaneWidth: 24,
|
||||
packLaneCount: 3,
|
||||
}
|
||||
|
||||
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,
|
||||
radius: 30,
|
||||
moveSpeed: 92,
|
||||
meleeRange: 58,
|
||||
meleeDamage: 10,
|
||||
meleeCooldown: 1.15,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
thunderRingCooldown: 7.2,
|
||||
thunderRingWindup: 0.9,
|
||||
thunderRingInnerRadius: 70,
|
||||
thunderRingOuterRadius: 172,
|
||||
thunderRingDamage: 22,
|
||||
thunderRingStunSeconds: 0.7,
|
||||
lightningStrikeCooldown: 4.2,
|
||||
lightningStrikeWindup: 0.78,
|
||||
lightningStrikeRadius: 46,
|
||||
lightningStrikeDamage: 17,
|
||||
lightningStrikeStunSeconds: 0.55,
|
||||
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
|
||||
@@ -127,8 +771,36 @@ function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
|
||||
|
||||
export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BULLDROME_BOSS_METADATA)
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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,12 +1,18 @@
|
||||
import type { Spell } from '../../../game'
|
||||
import type { Ability } from '../../../profile'
|
||||
import { toCombatSpell } from '../../../combat/rules'
|
||||
|
||||
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
|
||||
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
|
||||
|
||||
export type Iwt2HealerMetadata = {
|
||||
id: Iwt2HealerId
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type Iwt2HealerAbility = {
|
||||
id: string
|
||||
healerId: string
|
||||
healerId: Iwt2HealerId
|
||||
slot: number
|
||||
name: string
|
||||
icon: string
|
||||
@@ -16,110 +22,95 @@ export type Iwt2HealerAbility = {
|
||||
manaCost: number
|
||||
cooldownSeconds: number
|
||||
target: Iwt2AbilityTarget
|
||||
extraTargets?: number
|
||||
}
|
||||
|
||||
const IWT1_DAWNWEAVER_ABILITIES: Ability[] = [
|
||||
{
|
||||
id: 1,
|
||||
classId: 1,
|
||||
slug: 'mend',
|
||||
name: 'Mend',
|
||||
spellType: 'direct_heal',
|
||||
cost: 5,
|
||||
cooldown: 0.5,
|
||||
power: 30,
|
||||
unlockLevel: 1,
|
||||
glyph: '+',
|
||||
description: 'A fast, efficient single-target heal.',
|
||||
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
|
||||
dawnweaver: {
|
||||
id: 'dawnweaver',
|
||||
name: 'Dawnweaver',
|
||||
icon: '+',
|
||||
description: 'Direct heals, radiant group recovery, shields, and cleanse.',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
classId: 1,
|
||||
slug: 'renew',
|
||||
name: 'Renew',
|
||||
spellType: 'heal_over_time',
|
||||
cost: 7,
|
||||
cooldown: 0.5,
|
||||
power: 12,
|
||||
unlockLevel: 1,
|
||||
glyph: '~',
|
||||
description: 'Heals now and continues healing over time.',
|
||||
lifebinder: {
|
||||
id: 'lifebinder',
|
||||
name: 'Lifebinder',
|
||||
icon: '~',
|
||||
description: 'Verdant healing over time, barkskin shielding, and steady group recovery.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
classId: 1,
|
||||
slug: 'radiance',
|
||||
name: 'Radiance',
|
||||
spellType: 'party_heal',
|
||||
cost: 12,
|
||||
cooldown: 8,
|
||||
power: 18,
|
||||
unlockLevel: 1,
|
||||
glyph: '*',
|
||||
description: 'Restores health to up to 4 injured party members.',
|
||||
runesage: {
|
||||
id: 'runesage',
|
||||
name: 'Runesage',
|
||||
icon: 'R',
|
||||
description: 'Rune-scripted mends, concordance healing, aegis shielding, and unraveling cleanse.',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
classId: 1,
|
||||
slug: 'sun-ward',
|
||||
name: 'Sun Ward',
|
||||
spellType: 'absorb',
|
||||
cost: 8,
|
||||
cooldown: 7,
|
||||
power: 36,
|
||||
unlockLevel: 1,
|
||||
glyph: 'O',
|
||||
description: 'Places a damage-absorbing shield on your target.',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
classId: 1,
|
||||
slug: 'purify',
|
||||
name: 'Purify',
|
||||
spellType: 'cleanse',
|
||||
cost: 5,
|
||||
cooldown: 5,
|
||||
power: 10,
|
||||
unlockLevel: 1,
|
||||
glyph: 'x',
|
||||
description: 'Removes a harmful effect and restores health.',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
classId: 1,
|
||||
slug: 'dawn-burst',
|
||||
name: 'Dawn Burst',
|
||||
spellType: 'party_heal',
|
||||
cost: 16,
|
||||
cooldown: 12,
|
||||
power: 28,
|
||||
unlockLevel: 5,
|
||||
glyph: 'D',
|
||||
description: 'A brilliant wave of healing for up to 4 injured allies.',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function toIwt2Ability(ability: Ability, index: number): Iwt2HealerAbility {
|
||||
const spell = toCombatSpell(ability, String(index + 1))
|
||||
export const IWT2_HEALER_ORDER: Iwt2HealerId[] = ['dawnweaver', 'lifebinder', 'runesage']
|
||||
|
||||
export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> = {
|
||||
dawnweaver: [
|
||||
createAbility('dawnweaver', 1, 'mend', 'Mend', '+', 'direct', 30, 5, 0.5),
|
||||
createAbility('dawnweaver', 2, 'renew', 'Renew', '~', 'hot', 12, 7, 0.5, 'heal_over_time'),
|
||||
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
|
||||
createAbility('dawnweaver', 4, 'sun-ward', 'Sun Ward', 'O', 'shield', 36, 8, 7, 'shield'),
|
||||
createAbility('dawnweaver', 5, 'purify', 'Purify', 'x', 'cleanse', 10, 5, 5, 'cleanse'),
|
||||
createAbility('dawnweaver', 6, 'dawn-burst', 'Dawn Burst', 'D', 'group', 28, 16, 12),
|
||||
],
|
||||
lifebinder: [
|
||||
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
|
||||
createAbility('lifebinder', 2, 'seed-of-life', 'Seed of Life', '~', 'hot', 16, 9, 1, 'heal_over_time'),
|
||||
createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5),
|
||||
createAbility('lifebinder', 4, 'barkskin', 'Barkskin', 'O', 'shield', 46, 10, 5.5, 'shield'),
|
||||
createAbility('lifebinder', 5, 'purging-sap', 'Purging Sap', 'x', 'cleanse', 12, 6, 4.5, 'cleanse'),
|
||||
createAbility('lifebinder', 6, 'ancient-grove', 'Ancient Grove', 'G', 'shield', 72, 18, 12, 'shield'),
|
||||
],
|
||||
runesage: [
|
||||
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
|
||||
createAbility('runesage', 2, 'mending-rune', 'Mending Rune', '~', 'hot', 10, 5, 0.45, 'heal_over_time'),
|
||||
createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5),
|
||||
createAbility('runesage', 4, 'aegis-script', 'Aegis Script', 'O', 'shield', 28, 6, 4.5, 'shield'),
|
||||
createAbility('runesage', 5, 'unravel', 'Unravel', 'x', 'cleanse', 8, 4, 3.5, 'cleanse'),
|
||||
createAbility('runesage', 6, 'grand-design', 'Grand Design', 'R', 'group', 22, 13, 8.5),
|
||||
],
|
||||
}
|
||||
|
||||
export function abilitiesForHealer(healerId: Iwt2HealerId | string) {
|
||||
return IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
|
||||
}
|
||||
|
||||
export function asIwt2HealerId(value: unknown): Iwt2HealerId {
|
||||
if (value === 'field_medic') return 'dawnweaver'
|
||||
if (value === 'ward_sage') return 'lifebinder'
|
||||
if (value === 'storm_chanter') return 'runesage'
|
||||
return IWT2_HEALER_ORDER.includes(value as Iwt2HealerId)
|
||||
? value as Iwt2HealerId
|
||||
: 'dawnweaver'
|
||||
}
|
||||
|
||||
function createAbility(
|
||||
healerId: Iwt2HealerId,
|
||||
slot: number,
|
||||
slug: string,
|
||||
name: string,
|
||||
icon: string,
|
||||
kind: Spell['kind'],
|
||||
power: number,
|
||||
manaCost: number,
|
||||
cooldownSeconds: number,
|
||||
effectType?: string,
|
||||
): Iwt2HealerAbility {
|
||||
return {
|
||||
id: spell.id,
|
||||
healerId: 'field_medic',
|
||||
slot: index + 1,
|
||||
name: spell.name,
|
||||
icon: spell.glyph,
|
||||
kind: spell.kind,
|
||||
power: spell.power,
|
||||
effectType: spell.effectType,
|
||||
manaCost: spell.cost,
|
||||
cooldownSeconds: spell.cooldown,
|
||||
id: `${healerId}-${slug}`,
|
||||
healerId,
|
||||
slot,
|
||||
name,
|
||||
icon,
|
||||
kind,
|
||||
power,
|
||||
effectType,
|
||||
manaCost,
|
||||
cooldownSeconds,
|
||||
target: 'party-member',
|
||||
}
|
||||
}
|
||||
|
||||
export const IWT2_HEALER_ABILITIES: Record<string, Iwt2HealerAbility[]> = {
|
||||
field_medic: IWT1_DAWNWEAVER_ABILITIES.map(toIwt2Ability),
|
||||
}
|
||||
|
||||
export function abilitiesForHealer(healerId: string) {
|
||||
return IWT2_HEALER_ABILITIES[healerId] ?? IWT2_HEALER_ABILITIES.field_medic
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
buildOpponentSlotDebuffChoices,
|
||||
buildSelfSlotUpgradeChoices,
|
||||
} from '../../../combat/roguelikeUpgrades'
|
||||
import type { Spell } from '../../../game'
|
||||
import { type Iwt2HealerAbility } from './healerAbilities'
|
||||
|
||||
export type Iwt2RoguelikeVariant = 'pve' | 'pvp'
|
||||
export type Iwt2RoguelikeContentType = 'dungeon' | 'raid' | 'stadium'
|
||||
export type Iwt2RoguelikeSlot = '1' | '2' | '3' | '4' | '5'
|
||||
|
||||
export type Iwt2RoguelikeSelfBuffId =
|
||||
| 'revive-party-members'
|
||||
| `slot${Iwt2RoguelikeSlot}-extra-target`
|
||||
| `slot${Iwt2RoguelikeSlot}-cost-down`
|
||||
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
|
||||
|
||||
export type Iwt2RoguelikeOpponentDebuffId =
|
||||
| `opp-slot${Iwt2RoguelikeSlot}-cost-up`
|
||||
| `opp-slot${Iwt2RoguelikeSlot}-cooldown-up`
|
||||
|
||||
export type Iwt2RoguelikeChoice<T extends string> = {
|
||||
id: T
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
|
||||
|
||||
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
|
||||
id: 'revive-party-members',
|
||||
name: 'Revive Party Members',
|
||||
description: 'Revive fallen party members before the next IWT2 arena.',
|
||||
}
|
||||
|
||||
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
||||
return {
|
||||
id: ability.id,
|
||||
key: String(ability.slot),
|
||||
name: ability.name,
|
||||
description: ability.name,
|
||||
cost: ability.manaCost,
|
||||
cooldown: ability.cooldownSeconds,
|
||||
power: ability.power,
|
||||
glyph: ability.icon,
|
||||
kind: ability.kind,
|
||||
effectType: ability.effectType,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||
return buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
|
||||
slots: IWT2_ROGUELIKE_SLOTS,
|
||||
spells: abilities.map(iwt2AbilityToSpell),
|
||||
labelMode: 'ability',
|
||||
})
|
||||
}
|
||||
|
||||
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||
return buildOpponentSlotDebuffChoices<Iwt2RoguelikeOpponentDebuffId>({
|
||||
slots: IWT2_ROGUELIKE_SLOTS,
|
||||
spells: abilities.map(iwt2AbilityToSpell),
|
||||
labelMode: 'ability',
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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
|
||||
name: string
|
||||
@@ -5,21 +9,50 @@ export type Iwt2InventoryItem = {
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
}
|
||||
|
||||
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
|
||||
|
||||
export type Iwt2CollectionLog = {
|
||||
bossKills: Record<string, number>
|
||||
bossPets: Record<string, number>
|
||||
dropsFound: Record<string, number>
|
||||
}
|
||||
|
||||
export type Iwt2Character = {
|
||||
name: string
|
||||
level: number
|
||||
experience: number
|
||||
healerStyle: Iwt2HealerId
|
||||
armorPalette: Iwt2ArmorPaletteId
|
||||
}
|
||||
|
||||
export type Iwt2CloudSlot = {
|
||||
savedAt: number
|
||||
character: Iwt2Character
|
||||
inventory: Iwt2InventoryItem[]
|
||||
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
|
||||
character: {
|
||||
name: string
|
||||
level: number
|
||||
experience: number
|
||||
}
|
||||
character: Iwt2Character
|
||||
inventory: Iwt2InventoryItem[]
|
||||
collectionLog: Iwt2CollectionLog
|
||||
cloudSlot?: Iwt2CloudSlot
|
||||
}
|
||||
|
||||
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
||||
@@ -32,10 +65,13 @@ export function createDefaultIwt2Save(): Iwt2Save {
|
||||
name: 'Healer',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
healerStyle: 'dawnweaver',
|
||||
armorPalette: 'guild_green',
|
||||
},
|
||||
inventory: [],
|
||||
collectionLog: {
|
||||
bossKills: {},
|
||||
bossPets: {},
|
||||
dropsFound: {},
|
||||
},
|
||||
}
|
||||
@@ -52,12 +88,16 @@ function normalizeSave(value: unknown): Iwt2Save {
|
||||
name: candidate.character?.name || 'Healer',
|
||||
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
|
||||
experience: Math.max(0, Math.floor(candidate.character?.experience ?? 0)),
|
||||
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
|
||||
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
|
||||
},
|
||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
||||
collectionLog: {
|
||||
bossKills: candidate.collectionLog?.bossKills ?? {},
|
||||
bossPets: candidate.collectionLog?.bossPets ?? {},
|
||||
dropsFound: candidate.collectionLog?.dropsFound ?? {},
|
||||
},
|
||||
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,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: {
|
||||
@@ -101,15 +148,114 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
function bossDropFor(bossId: string): { id: string, name: string } {
|
||||
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
|
||||
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
|
||||
export function updateIwt2CharacterSettings(
|
||||
save: Iwt2Save,
|
||||
settings: Partial<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
|
||||
): Iwt2Save {
|
||||
return {
|
||||
...save,
|
||||
updatedAt: Date.now(),
|
||||
character: {
|
||||
...save.character,
|
||||
...settings,
|
||||
name: normalizeCharacterName(settings.name ?? save.character.name),
|
||||
healerStyle: asIwt2HealerId(settings.healerStyle ?? save.character.healerStyle),
|
||||
armorPalette: asIwt2ArmorPaletteId(settings.armorPalette ?? save.character.armorPalette),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
||||
return {
|
||||
...save,
|
||||
updatedAt: Date.now(),
|
||||
cloudSlot: {
|
||||
savedAt: Date.now(),
|
||||
character: { ...save.character },
|
||||
inventory: save.inventory.map((item) => ({ ...item })),
|
||||
collectionLog: {
|
||||
bossKills: { ...save.collectionLog.bossKills },
|
||||
bossPets: { ...save.collectionLog.bossPets },
|
||||
dropsFound: { ...save.collectionLog.dropsFound },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
||||
if (!save.cloudSlot) return save
|
||||
return {
|
||||
...save,
|
||||
updatedAt: Date.now(),
|
||||
character: { ...save.cloudSlot.character },
|
||||
inventory: save.cloudSlot.inventory.map((item) => ({ ...item })),
|
||||
collectionLog: {
|
||||
bossKills: { ...save.cloudSlot.collectionLog.bossKills },
|
||||
bossPets: { ...save.cloudSlot.collectionLog.bossPets },
|
||||
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId {
|
||||
return value === 'sun_gold'
|
||||
|| value === 'ember_red'
|
||||
|| value === 'moon_blue'
|
||||
|| value === 'guild_green'
|
||||
? value
|
||||
: 'guild_green'
|
||||
}
|
||||
|
||||
function normalizeCharacterName(name: string): string {
|
||||
const trimmed = name.trim().slice(0, 18)
|
||||
return trimmed || 'Healer'
|
||||
}
|
||||
|
||||
function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const candidate = value as Partial<Iwt2CloudSlot>
|
||||
if (!candidate.character || !candidate.collectionLog) return undefined
|
||||
return {
|
||||
savedAt: typeof candidate.savedAt === 'number' ? candidate.savedAt : Date.now(),
|
||||
character: {
|
||||
name: normalizeCharacterName(candidate.character.name ?? 'Healer'),
|
||||
level: Math.max(1, Math.floor(candidate.character.level ?? 1)),
|
||||
experience: Math.max(0, Math.floor(candidate.character.experience ?? 0)),
|
||||
healerStyle: asIwt2HealerId(candidate.character.healerStyle),
|
||||
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
|
||||
},
|
||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
||||
collectionLog: {
|
||||
bossKills: candidate.collectionLog.bossKills ?? {},
|
||||
bossPets: candidate.collectionLog.bossPets ?? {},
|
||||
dropsFound: candidate.collectionLog.dropsFound ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import type { MovementVector } from '../../../input'
|
||||
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
|
||||
import {
|
||||
useDualScreen,
|
||||
useDualScreenPublisher,
|
||||
type DualScreenCombatState,
|
||||
} from '../../../dualScreen'
|
||||
import type { PartyMember, Role, Spell } from '../../../game'
|
||||
import {
|
||||
createInitialIwt2ArenaState,
|
||||
tickIwt2Arena,
|
||||
@@ -10,38 +16,73 @@ 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'
|
||||
import { BossHud } from '../components/BossHud'
|
||||
import { PartyFrames } from '../components/PartyFrames'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
||||
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
import type {
|
||||
Iwt2RoguelikeContentType,
|
||||
Iwt2RoguelikeOpponentDebuffId,
|
||||
Iwt2RoguelikeSelfBuffId,
|
||||
Iwt2RoguelikeVariant,
|
||||
} from '../content/roguelike'
|
||||
|
||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||
type OverlayAction = 'primary' | 'menu'
|
||||
type OverlayNavEntry = {
|
||||
action: OverlayAction
|
||||
row: number
|
||||
}
|
||||
|
||||
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||
{ action: 'primary', row: 0 },
|
||||
{ action: 'menu', row: 1 },
|
||||
]
|
||||
|
||||
type BossArenaScreenProps = {
|
||||
bossId: Iwt2BossId
|
||||
bossIds?: Iwt2BossId[]
|
||||
modeLabel?: string
|
||||
save: Iwt2Save
|
||||
onBack: () => void
|
||||
onSaveUpdated: (save: Iwt2Save) => void
|
||||
roguelikeRun?: {
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
onVictory: () => void
|
||||
stage: number
|
||||
variant: Iwt2RoguelikeVariant
|
||||
}
|
||||
}
|
||||
|
||||
export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossArenaScreenProps) {
|
||||
export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
|
||||
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
||||
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, 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>>({})
|
||||
@@ -52,16 +93,25 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
directPartyTargeting,
|
||||
lastDevice,
|
||||
} = useInput()
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const activeBindings = bindings[lastDevice]
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = arenaState
|
||||
}, [arenaState])
|
||||
|
||||
useEffect(() => {
|
||||
opponentStateRef.current = opponentArenaState
|
||||
}, [opponentArenaState])
|
||||
|
||||
useEffect(() => {
|
||||
statusRef.current = status
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
selectedOverlayActionRef.current = selectedOverlayAction
|
||||
}, [selectedOverlayAction])
|
||||
|
||||
useEffect(() => {
|
||||
selectedPartyIdRef.current = selectedPartyId
|
||||
}, [selectedPartyId])
|
||||
@@ -71,16 +121,19 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
}, [save])
|
||||
|
||||
const resetArena = useCallback(() => {
|
||||
const next = createInitialIwt2ArenaState(bossId)
|
||||
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
|
||||
opponentStateRef.current = nextOpponentState
|
||||
setArenaState(next)
|
||||
setOpponentArenaState(nextOpponentState)
|
||||
setAbilityCooldowns({})
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus('playing')
|
||||
}, [bossId])
|
||||
}, [bossHealthScale, bossId, bossIds, pvpRoguelike])
|
||||
|
||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||
setSelectedOverlayAction('primary')
|
||||
@@ -88,8 +141,12 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
}, [])
|
||||
|
||||
const abilities = useMemo(
|
||||
() => abilitiesForHealer('field_medic'),
|
||||
[],
|
||||
() => applyRoguelikeModifiers(
|
||||
abilitiesForHealer(save.character.healerStyle),
|
||||
roguelikeRun?.buffs ?? [],
|
||||
roguelikeRun?.debuffs ?? [],
|
||||
),
|
||||
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
|
||||
)
|
||||
|
||||
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
||||
@@ -109,21 +166,49 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
setArenaState(result.state)
|
||||
}, [])
|
||||
|
||||
const moveOverlaySelection = useCallback((action: string) => {
|
||||
setSelectedOverlayAction((current) => {
|
||||
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
|
||||
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
|
||||
if (entry.action === current) return false
|
||||
if (action === 'navigateUp') return entry.row < active.row
|
||||
if (action === 'navigateDown') return entry.row > active.row
|
||||
return false
|
||||
})
|
||||
if (candidates.length === 0) return current
|
||||
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
|
||||
return candidates[0]?.action ?? current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
|
||||
if (overlayAction === 'menu') {
|
||||
onBack()
|
||||
return
|
||||
}
|
||||
if (statusRef.current === 'paused') {
|
||||
setStatus('playing')
|
||||
return
|
||||
}
|
||||
if (statusRef.current === 'victory' && roguelikeRun) {
|
||||
roguelikeRun.onVictory()
|
||||
return
|
||||
}
|
||||
resetArena()
|
||||
}, [onBack, resetArena, roguelikeRun])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (device === 'controller' && statusRef.current !== 'playing') {
|
||||
if (action.startsWith('navigate')) {
|
||||
setSelectedOverlayAction((current) => current === 'primary' ? 'menu' : 'primary')
|
||||
moveOverlaySelection(action)
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
if (selectedOverlayAction === 'menu') onBack()
|
||||
else if (statusRef.current === 'paused') setStatus('playing')
|
||||
else resetArena()
|
||||
activateOverlayAction()
|
||||
return
|
||||
}
|
||||
if (action === 'back') {
|
||||
if (statusRef.current === 'paused') setStatus('playing')
|
||||
else onBack()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -170,12 +255,38 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
moveY: movement.y,
|
||||
}, dtSeconds)
|
||||
stateRef.current = next
|
||||
let nextOpponentState = opponentStateRef.current
|
||||
if (pvpRoguelike && nextOpponentState) {
|
||||
const opponentMovement = iwt2OpponentMovement(nextOpponentState)
|
||||
nextOpponentState = tickIwt2Arena(
|
||||
nextOpponentState,
|
||||
{
|
||||
moveX: opponentMovement.x,
|
||||
moveY: opponentMovement.y,
|
||||
},
|
||||
dtSeconds,
|
||||
)
|
||||
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')
|
||||
@@ -185,15 +296,16 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
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
|
||||
setArenaState(next)
|
||||
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
|
||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||
}
|
||||
return next
|
||||
}, [bossId, onSaveUpdated, showOverlay])
|
||||
}, [onSaveUpdated, pvpRoguelike, showOverlay])
|
||||
|
||||
const targetBindings = directPartyTargeting
|
||||
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
||||
@@ -201,9 +313,15 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
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 overlayPrimaryLabel = status === 'paused' ? 'Resume' : 'Restart'
|
||||
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'
|
||||
@@ -217,6 +335,47 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
: status === 'defeat'
|
||||
? 'is-defeat'
|
||||
: 'is-paused'
|
||||
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
|
||||
const pauseCopy = roguelikeRun?.variant === 'pvp'
|
||||
? undefined
|
||||
: 'Combat is stopped. Resume the fight or leave the current run.'
|
||||
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
|
||||
? 'Leave'
|
||||
: roguelikeRun
|
||||
? 'Leave Roguelike'
|
||||
: `Leave ${modeLabel ?? 'Arena'}`
|
||||
const dualScreenState = useMemo(
|
||||
() => buildIwt2DualScreenCombatState({
|
||||
abilities,
|
||||
arenaState,
|
||||
bindings: activeBindings,
|
||||
cooldowns: abilityCooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel: modeLabel ?? 'Arena',
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
}),
|
||||
[
|
||||
abilities,
|
||||
activeBindings,
|
||||
arenaState,
|
||||
abilityCooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel,
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
],
|
||||
)
|
||||
|
||||
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -226,7 +385,7 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
>
|
||||
<section className="iwt2-arena-layout">
|
||||
<div className="iwt2-arena-stage">
|
||||
<BossHud boss={arenaState.boss} />
|
||||
<BossHud bosses={arenaState.bosses} />
|
||||
<PartyFrames
|
||||
controllerIconStyle={controllerIconStyle}
|
||||
onTarget={setSelectedPartyId}
|
||||
@@ -241,42 +400,84 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
selectedPartyIdRef={selectedPartyIdRef}
|
||||
stateRef={stateRef}
|
||||
/>
|
||||
{status !== 'playing' && (
|
||||
{status === 'paused' && (
|
||||
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
|
||||
<div>
|
||||
<p className="eyebrow">Paused</p>
|
||||
<h2>{pauseTitle}</h2>
|
||||
{pauseCopy && <p>{pauseCopy}</p>}
|
||||
<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"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{pauseLeaveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== 'playing' && status !== 'paused' && (
|
||||
<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>
|
||||
{status !== 'paused' && (
|
||||
<div className="iwt2-result-summary" aria-label="Arena result summary">
|
||||
<span>
|
||||
<strong>{formatArenaTime(arenaState.time)}</strong>
|
||||
Clear
|
||||
</span>
|
||||
<span>
|
||||
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
||||
Standing
|
||||
</span>
|
||||
<span>
|
||||
<strong>{Math.round(totalPartyDamage)}</strong>
|
||||
Damage
|
||||
</span>
|
||||
</div>
|
||||
{roguelikeRun && (
|
||||
<p className="iwt2-result-hint">
|
||||
{roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage}
|
||||
</p>
|
||||
)}
|
||||
<div className="iwt2-result-summary" aria-label="Arena result summary">
|
||||
<span>
|
||||
<strong>{formatArenaTime(arenaState.time)}</strong>
|
||||
Clear
|
||||
</span>
|
||||
<span>
|
||||
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
||||
Standing
|
||||
</span>
|
||||
<span>
|
||||
<strong>{defeatedBossCount}/{arenaState.bosses.length}</strong>
|
||||
Bosses
|
||||
</span>
|
||||
<span>
|
||||
<strong>{Math.round(totalPartyDamage)}</strong>
|
||||
Damage
|
||||
</span>
|
||||
</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">
|
||||
<button
|
||||
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||
onClick={status === 'paused' ? () => setStatus('playing') : resetArena}
|
||||
onClick={() => activateOverlayAction('primary')}
|
||||
type="button"
|
||||
>
|
||||
{overlayPrimaryLabel}
|
||||
@@ -284,14 +485,14 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
<button
|
||||
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||
onClick={onBack}
|
||||
onClick={() => activateOverlayAction('menu')}
|
||||
type="button"
|
||||
>
|
||||
Menu
|
||||
</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>
|
||||
@@ -316,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) => [
|
||||
@@ -334,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
|
||||
@@ -352,3 +565,250 @@ function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
|
||||
}
|
||||
return changed ? next : cooldowns
|
||||
}
|
||||
|
||||
function applyRoguelikeModifiers(
|
||||
abilities: Iwt2HealerAbility[],
|
||||
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[],
|
||||
): Iwt2HealerAbility[] {
|
||||
if (buffs.length === 0 && debuffs.length === 0) return abilities
|
||||
return abilities.map((ability) => {
|
||||
const slot = String(ability.slot)
|
||||
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
|
||||
const costUp = countStacks(debuffs, `opp-slot${slot}-cost-up`)
|
||||
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
|
||||
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
|
||||
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
|
||||
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
|
||||
return {
|
||||
...ability,
|
||||
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
|
||||
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
|
||||
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function countStacks(items: readonly string[], id: string) {
|
||||
return items.filter((item) => item === id).length
|
||||
}
|
||||
|
||||
function roundModifier(value: number) {
|
||||
return Math.max(0.1, Math.round(value * 100) / 100)
|
||||
}
|
||||
|
||||
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||
if (contentType === 'raid') return 'Raid'
|
||||
if (contentType === 'stadium') return 'Stadium'
|
||||
return 'Dungeon'
|
||||
}
|
||||
|
||||
function arenaPauseTitle(
|
||||
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
|
||||
fallbackTitle: string,
|
||||
) {
|
||||
if (!roguelikeRun) return fallbackTitle
|
||||
if (roguelikeRun.contentType === 'stadium') return 'Stadium'
|
||||
if (roguelikeRun.variant === 'pvp') {
|
||||
return roguelikeRun.contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'
|
||||
}
|
||||
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
|
||||
}
|
||||
|
||||
function buildIwt2DualScreenCombatState({
|
||||
abilities,
|
||||
arenaState,
|
||||
bindings,
|
||||
cooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel,
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
}: {
|
||||
abilities: Iwt2HealerAbility[]
|
||||
arenaState: Iwt2ArenaState
|
||||
bindings: DualScreenCombatState['bindings']
|
||||
cooldowns: Record<string, number>
|
||||
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
|
||||
directPartyTargeting: boolean
|
||||
modeLabel: string
|
||||
opponentArenaState: Iwt2ArenaState | null
|
||||
playerMana: number
|
||||
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined
|
||||
selectedPartyId: Iwt2EntityId
|
||||
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),
|
||||
opponentArena: toDualScreenOpponentArena(opponentArenaState),
|
||||
opponentEnemyHealth: totalBossHealth(opponentArenaState),
|
||||
opponentMaxResource: opponentHealer?.maxMana ?? 100,
|
||||
opponentName: 'CPU Rival',
|
||||
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
|
||||
opponentResource: opponentHealer?.mana ?? 100,
|
||||
opponentResourceName: 'Mana',
|
||||
}
|
||||
: {}
|
||||
return {
|
||||
...pvpOpponentState,
|
||||
bindings,
|
||||
contentName: modeLabel,
|
||||
controllerIconStyle,
|
||||
difficultyName: 'IWT2',
|
||||
directPartyTargeting,
|
||||
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
|
||||
encounterCount: 1,
|
||||
encounterDescription: arenaState.boss.attackPhase,
|
||||
encounterHealth: arenaBossHealth,
|
||||
encounterIndex: 0,
|
||||
encounterIsBoss: true,
|
||||
encounterMaxHealth: arenaBossMaxHealth,
|
||||
encounterName: formatBossEncounterTitle(arenaState.bosses),
|
||||
floatingTexts: [],
|
||||
maxResource: 100,
|
||||
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
|
||||
partySize: arenaState.party.length,
|
||||
paused: status === 'paused',
|
||||
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
|
||||
resource: playerMana,
|
||||
resourceName: 'Mana',
|
||||
selectedId: selectedPartyId,
|
||||
speedMultiplier: 1,
|
||||
spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)),
|
||||
status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing',
|
||||
targetGroup: 0,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
bounceHeals: [],
|
||||
health: member.health,
|
||||
hotEffects: member.hotEffects.map((effect) => ({
|
||||
id: effect.id,
|
||||
label: effect.label,
|
||||
power: effect.power,
|
||||
spellId: effect.id,
|
||||
ticks: Math.ceil(effect.remainingSeconds),
|
||||
})),
|
||||
hotTicks: member.hotEffects.length,
|
||||
id: opponent ? `opponent-${member.id}` : member.id,
|
||||
maxHealth: member.maxHealth,
|
||||
name: opponent ? opponentPartyName(metadata.name) : metadata.name,
|
||||
role: toDualScreenRole(metadata.role),
|
||||
shield: member.shield,
|
||||
}
|
||||
}
|
||||
|
||||
function opponentPartyName(name: string): string {
|
||||
if (name === 'Player Healer') return 'CPU Healer'
|
||||
return `CPU ${name.replace(' Tank', '')}`
|
||||
}
|
||||
|
||||
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
|
||||
if (role === 'tank') return 'Tank'
|
||||
if (role === 'healer') return 'Healer'
|
||||
return 'Damage'
|
||||
}
|
||||
|
||||
function toDualScreenSpell(
|
||||
ability: Iwt2HealerAbility,
|
||||
slotIndex: number,
|
||||
remaining: number,
|
||||
): Spell & { slotIndex: number; remaining: number } {
|
||||
return {
|
||||
cooldown: ability.cooldownSeconds,
|
||||
cost: ability.manaCost,
|
||||
description: 'IWT2 arena ability',
|
||||
effectType: ability.effectType,
|
||||
glyph: ability.icon,
|
||||
id: ability.id,
|
||||
key: String(ability.slot),
|
||||
kind: ability.kind,
|
||||
name: ability.name,
|
||||
power: ability.power,
|
||||
remaining,
|
||||
slotIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function iwt2OpponentMovement(state: Iwt2ArenaState): MovementVector {
|
||||
const healer = state.party.find((member) => member.id === 'player-healer')
|
||||
if (!healer || healer.health <= 0) return { x: 0, y: 0 }
|
||||
const orbitSeconds = Math.max(1, state.time)
|
||||
return normalizedArenaMovement({
|
||||
x: Math.cos(orbitSeconds * 0.75),
|
||||
y: Math.sin(orbitSeconds * 0.75) * 0.55,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizedArenaMovement(vector: MovementVector): MovementVector {
|
||||
const magnitude = Math.hypot(vector.x, vector.y)
|
||||
if (magnitude <= 1) return vector
|
||||
return {
|
||||
x: vector.x / magnitude,
|
||||
y: vector.y / magnitude,
|
||||
}
|
||||
}
|
||||
|
||||
function formatIwt2DebuffSummary(debuffs: readonly string[]): string {
|
||||
if (debuffs.length === 0) return 'none'
|
||||
return debuffs
|
||||
.map((debuff) => debuff
|
||||
.replace('opp-', '')
|
||||
.replaceAll('-', ' '))
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
@@ -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,31 +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: bossId === 'bulldrome' ? 2 : 0,
|
||||
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: bossId === 'yian-kut-ku' ? 1.2 : 0,
|
||||
fireballTarget: { x: 320, y: 250 },
|
||||
birdWaveThresholdsTriggered: [],
|
||||
},
|
||||
boss: bosses[0],
|
||||
bosses,
|
||||
nextEventId: 1,
|
||||
nextProjectileId: 1,
|
||||
nextAddId: 1,
|
||||
@@ -97,6 +84,90 @@ 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
|
||||
}
|
||||
|
||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||
const step = Math.max(0, Math.min(dt, MAX_DT))
|
||||
if (step <= 0) return { ...state, events: [...state.events] }
|
||||
@@ -113,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,
|
||||
@@ -136,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,
|
||||
@@ -251,6 +342,7 @@ function createEmptyStatus(): Iwt2StatusState {
|
||||
stunnedSeconds: 0,
|
||||
knockedDownSeconds: 0,
|
||||
invulnerableSeconds: 0,
|
||||
slowedSeconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,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,
|
||||
@@ -283,7 +538,7 @@ function advanceProjectiles(
|
||||
time: number,
|
||||
dt: number,
|
||||
): {
|
||||
boss: Iwt2ArenaState['boss']
|
||||
bosses: Iwt2BossEntityState[]
|
||||
party: Iwt2PartyEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
@@ -293,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
|
||||
@@ -319,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,
|
||||
@@ -356,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
|
||||
@@ -390,7 +649,7 @@ function advanceProjectiles(
|
||||
}
|
||||
|
||||
return {
|
||||
boss: nextBoss,
|
||||
bosses: nextBosses,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds,
|
||||
hazards: nextHazards,
|
||||
@@ -521,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[]
|
||||
@@ -535,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
|
||||
@@ -570,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) }
|
||||
@@ -586,7 +851,7 @@ function applyPartyAttacks(
|
||||
targetId: target.id,
|
||||
value: damage,
|
||||
})
|
||||
if (target.health - damage <= 0) {
|
||||
if (defeated) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
@@ -607,7 +872,7 @@ function applyPartyAttacks(
|
||||
}
|
||||
})
|
||||
return {
|
||||
boss: nextBoss,
|
||||
bosses: nextBosses,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
|
||||
projectiles,
|
||||
@@ -618,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) {
|
||||
@@ -628,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(
|
||||
@@ -641,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 }))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})]
|
||||
}
|
||||
@@ -11,6 +11,20 @@ import type {
|
||||
Iwt2ProjectileEntityState,
|
||||
Iwt2Vec2,
|
||||
} 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,
|
||||
@@ -44,6 +58,20 @@ export type Iwt2BossTickResult = {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { GREAT_JAGGI_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2MechanicLaneState,
|
||||
Iwt2PartyEntityState,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
clamp,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
export function tickGreatJaggi(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),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) return withGreatJaggiIndicators({ boss, party, events })
|
||||
|
||||
if (boss.attackPhase === 'packHowlWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyPackLanes(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'packHowlRecover',
|
||||
phaseSecondsRemaining: 0.45,
|
||||
}
|
||||
}
|
||||
return withGreatJaggiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'packHowlRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withGreatJaggiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'packHowlWindup',
|
||||
chargeCooldownRemaining: GREAT_JAGGI_BOSS_METADATA.packHowlCooldown!,
|
||||
mechanicLanes: createPackLanes(state, party),
|
||||
phaseSecondsRemaining: GREAT_JAGGI_BOSS_METADATA.packHowlWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withGreatJaggiIndicators({ 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 withGreatJaggiIndicators({ boss, party, events })
|
||||
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, target.position, GREAT_JAGGI_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 withGreatJaggiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
function createPackLanes(state: Iwt2ArenaState, party: Iwt2PartyEntityState[]): Iwt2MechanicLaneState[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const count = GREAT_JAGGI_BOSS_METADATA.packLaneCount!
|
||||
const lanes: Iwt2MechanicLaneState[] = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const target = living[(index * 2) % Math.max(1, living.length)]
|
||||
const baseY = target
|
||||
? target.position.y
|
||||
: state.bounds.height * ((index + 1) / (count + 1))
|
||||
const slope = index % 2 === 0 ? 54 : -54
|
||||
const y = clamp(baseY + (index - 1) * 28, 54, state.bounds.height - 54)
|
||||
lanes.push({
|
||||
id: `pack-lane-${index}`,
|
||||
start: { x: -28, y: clamp(y - slope, 36, state.bounds.height - 36) },
|
||||
end: { x: state.bounds.width + 28, y: clamp(y + slope, 36, state.bounds.height - 36) },
|
||||
width: GREAT_JAGGI_BOSS_METADATA.packLaneWidth!,
|
||||
})
|
||||
}
|
||||
return lanes
|
||||
}
|
||||
|
||||
function applyPackLanes(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
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',
|
||||
start: lane.start,
|
||||
end: lane.end,
|
||||
width: lane.width,
|
||||
}, {
|
||||
damage: GREAT_JAGGI_BOSS_METADATA.packLaneDamage!,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: GREAT_JAGGI_BOSS_METADATA.packLaneStunSeconds!,
|
||||
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) > GREAT_JAGGI_BOSS_METADATA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: GREAT_JAGGI_BOSS_METADATA.meleeRange,
|
||||
}, {
|
||||
damage: GREAT_JAGGI_BOSS_METADATA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: GREAT_JAGGI_BOSS_METADATA.meleeCooldown },
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function withGreatJaggiIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createGreatJaggiIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createGreatJaggiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
if (
|
||||
boss.attackPhase !== 'packHowlWindup'
|
||||
&& boss.attackPhase !== 'packHowlRecover'
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return boss.mechanicLanes.map((lane) => createLaneIndicator({
|
||||
color: boss.attackPhase === 'packHowlRecover' ? '#f05b4f' : '#b8f0aa',
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: 'great-jaggi-pack-lane',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'packHowlWindup',
|
||||
boss.attackPhase === 'packHowlRecover',
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}))
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -63,14 +63,22 @@ function targetIdsForAbility(
|
||||
): Iwt2EntityId[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
if (living.length === 0) return []
|
||||
const extraTargets = Math.max(0, Math.floor(ability.extraTargets ?? 0))
|
||||
if (ability.kind === 'group') {
|
||||
return [...living]
|
||||
.sort((a, b) => healthRatio(a) - healthRatio(b))
|
||||
.slice(0, 4)
|
||||
.slice(0, 4 + extraTargets)
|
||||
.map((member) => member.id)
|
||||
}
|
||||
const selected = living.find((member) => member.id === selectedTargetId)
|
||||
return [selected?.id ?? living[0].id]
|
||||
const primaryId = selected?.id ?? living[0].id
|
||||
if (extraTargets === 0) return [primaryId]
|
||||
const additionalTargets = living
|
||||
.filter((member) => member.id !== primaryId)
|
||||
.sort((a, b) => healthRatio(a) - healthRatio(b))
|
||||
.slice(0, extraTargets)
|
||||
.map((member) => member.id)
|
||||
return [primaryId, ...additionalTargets]
|
||||
}
|
||||
|
||||
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { KHEZU_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2PartyEntityState,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createCircleIndicator,
|
||||
createDonutIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
export function tickKhezu(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),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) return withKhezuIndicators({ boss, party, events })
|
||||
|
||||
if (boss.attackPhase === 'thunderRingWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyThunderRing(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'thunderRingRecover',
|
||||
phaseSecondsRemaining: 0.45,
|
||||
}
|
||||
}
|
||||
return withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'lightningStrikeWindup') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyLightningStrikes(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'lightningStrikeRecover',
|
||||
phaseSecondsRemaining: 0.38,
|
||||
}
|
||||
}
|
||||
return withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'thunderRingRecover' || boss.attackPhase === 'lightningStrikeRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'thunderRingWindup',
|
||||
chargeCooldownRemaining: KHEZU_BOSS_METADATA.thunderRingCooldown!,
|
||||
phaseSecondsRemaining: KHEZU_BOSS_METADATA.thunderRingWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'lightningStrikeWindup',
|
||||
fireballCooldownRemaining: KHEZU_BOSS_METADATA.lightningStrikeCooldown!,
|
||||
mechanicCircles: createLightningTargets(party),
|
||||
phaseSecondsRemaining: KHEZU_BOSS_METADATA.lightningStrikeWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withKhezuIndicators({ 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 withKhezuIndicators({ boss, party, events })
|
||||
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, target.position, KHEZU_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 withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
function createLightningTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] {
|
||||
return [...party]
|
||||
.filter((member) => member.health > 0)
|
||||
.sort((a, b) => (a.health / a.maxHealth) - (b.health / b.maxHealth))
|
||||
.slice(0, KHEZU_BOSS_METADATA.lightningStrikeCount!)
|
||||
.map((member, index) => ({
|
||||
id: `lightning-${index}`,
|
||||
position: { ...member.position },
|
||||
radius: KHEZU_BOSS_METADATA.lightningStrikeRadius!,
|
||||
}))
|
||||
}
|
||||
|
||||
function applyThunderRing(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'donut',
|
||||
position: boss.position,
|
||||
innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!,
|
||||
outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!,
|
||||
}, {
|
||||
damage: KHEZU_BOSS_METADATA.thunderRingDamage!,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: KHEZU_BOSS_METADATA.thunderRingStunSeconds!,
|
||||
time,
|
||||
})
|
||||
return { party: result.party, events: result.events }
|
||||
}
|
||||
|
||||
function applyLightningStrikes(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
}, {
|
||||
damage: KHEZU_BOSS_METADATA.lightningStrikeDamage!,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: KHEZU_BOSS_METADATA.lightningStrikeStunSeconds!,
|
||||
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) > KHEZU_BOSS_METADATA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: KHEZU_BOSS_METADATA.meleeRange,
|
||||
}, {
|
||||
damage: KHEZU_BOSS_METADATA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: KHEZU_BOSS_METADATA.meleeCooldown },
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function withKhezuIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createKhezuIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createKhezuIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
if (boss.attackPhase === 'thunderRingWindup' || boss.attackPhase === 'thunderRingRecover') {
|
||||
indicators.push(createDonutIndicator({
|
||||
color: '#77d9ff',
|
||||
id: `${boss.id}:thunder-ring`,
|
||||
innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!,
|
||||
mechanicId: 'khezu-thunder-ring',
|
||||
outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!,
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'thunderRingWindup',
|
||||
boss.attackPhase === 'thunderRingRecover',
|
||||
),
|
||||
position: boss.position,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
if (boss.attackPhase === 'lightningStrikeWindup' || boss.attackPhase === 'lightningStrikeRecover') {
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: '#9be8ff',
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'khezu-lightning-strike',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'lightningStrikeWindup',
|
||||
boss.attackPhase === 'lightningStrikeRecover',
|
||||
),
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Iwt2ArenaBounds,
|
||||
Iwt2ArenaArcIndicator,
|
||||
Iwt2ArenaCircleIndicator,
|
||||
Iwt2ArenaConeIndicator,
|
||||
Iwt2ArenaDonutIndicator,
|
||||
@@ -51,7 +52,36 @@ export type Iwt2CircleHitShape = {
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape
|
||||
export type Iwt2ConeHitShape = {
|
||||
kind: 'cone'
|
||||
origin: Iwt2Vec2
|
||||
direction: Iwt2Vec2
|
||||
range: number
|
||||
angleRadians: number
|
||||
}
|
||||
|
||||
export type Iwt2DonutHitShape = {
|
||||
kind: 'donut'
|
||||
position: Iwt2Vec2
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -155,7 +185,26 @@ export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape:
|
||||
shape.width,
|
||||
)
|
||||
}
|
||||
return distanceVec2(member.position, shape.position) <= shape.radius + member.radius
|
||||
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
|
||||
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({
|
||||
@@ -294,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,
|
||||
|
||||