Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9708ba9e40 | ||
|
|
956c9e32f9 | ||
|
|
547044892d | ||
|
|
81d65bc381 | ||
|
|
bdae0007a1 | ||
|
|
c052b086f8 |
@@ -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 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 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.
|
- 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.
|
- 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 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
|
## Performance Requirements
|
||||||
|
|
||||||
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 96
|
versionCode 102
|
||||||
versionName "1.1.17"
|
versionName "1.1.23"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// 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<>();
|
Set<String> currentTokens = new HashSet<>();
|
||||||
|
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||||
|
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||||
|
dispatchNativeControllerMotion(leftStickX, leftStickY);
|
||||||
addAxisTokens(
|
addAxisTokens(
|
||||||
currentTokens,
|
currentTokens,
|
||||||
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||||
@@ -178,13 +181,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
);
|
);
|
||||||
addAxisTokens(
|
addAxisTokens(
|
||||||
currentTokens,
|
currentTokens,
|
||||||
event.getAxisValue(MotionEvent.AXIS_X),
|
leftStickX,
|
||||||
"Axis0-",
|
"Axis0-",
|
||||||
"Axis0+"
|
"Axis0+"
|
||||||
);
|
);
|
||||||
addAxisTokens(
|
addAxisTokens(
|
||||||
currentTokens,
|
currentTokens,
|
||||||
event.getAxisValue(MotionEvent.AXIS_Y),
|
leftStickY,
|
||||||
"Axis1-",
|
"Axis1-",
|
||||||
"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) {
|
private boolean shouldThrottleDpad(String token) {
|
||||||
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
||||||
long now = SystemClock.uptimeMillis();
|
long now = SystemClock.uptimeMillis();
|
||||||
|
|||||||
@@ -173,6 +173,13 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS account_iwt2_saves (
|
||||||
|
account_id INTEGER PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
save_json TEXT NOT NULL,
|
||||||
|
character_level INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
||||||
ip_address TEXT PRIMARY KEY,
|
ip_address TEXT PRIMARY KEY,
|
||||||
max_accounts INTEGER NOT NULL CHECK (max_accounts >= 1),
|
max_accounts INTEGER NOT NULL CHECK (max_accounts >= 1),
|
||||||
|
|||||||
|
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="Mirehorn 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: 766 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="Bristlemaw 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: 982 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="Packfang Alpha 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: 828 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="Palevolt Maw 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: 846 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="Verdant Wyrm 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: 849 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="Stormtail 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: 874 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="Emberquill 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: 895 B |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -51,6 +51,14 @@ function ensureRuntimeMigrations(database) {
|
|||||||
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||||
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||||
`).run()
|
`).run()
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS account_iwt2_saves (
|
||||||
|
account_id INTEGER PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
save_json TEXT NOT NULL,
|
||||||
|
character_level INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sqliteDateFromMs(value) {
|
function sqliteDateFromMs(value) {
|
||||||
@@ -1384,6 +1392,46 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readIwt2SyncSave(database, accountId) {
|
||||||
|
const row = database.prepare(`
|
||||||
|
SELECT save_json AS saveJson
|
||||||
|
FROM account_iwt2_saves
|
||||||
|
WHERE account_id = ?
|
||||||
|
`).get(accountId)
|
||||||
|
if (!row) return { save: null }
|
||||||
|
try {
|
||||||
|
return { save: JSON.parse(row.saveJson) }
|
||||||
|
} catch {
|
||||||
|
return { save: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function importIwt2SyncSave(database, accountId, payload) {
|
||||||
|
const save = payload?.save
|
||||||
|
if (
|
||||||
|
!save
|
||||||
|
|| typeof save !== 'object'
|
||||||
|
|| Number(save.version) !== 2
|
||||||
|
|| typeof save.updatedAt !== 'number'
|
||||||
|
|| !save.character
|
||||||
|
|| typeof save.character !== 'object'
|
||||||
|
|| typeof save.character.level !== 'number'
|
||||||
|
) {
|
||||||
|
throw new Error('The IWT2 save snapshot is invalid.')
|
||||||
|
}
|
||||||
|
const savedAt = sqliteDateFromMs(save.updatedAt)
|
||||||
|
const characterLevel = clampInteger(save.character.level, 1, 1, 1000000)
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO account_iwt2_saves (account_id, save_json, character_level, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
save_json = excluded.save_json,
|
||||||
|
character_level = excluded.character_level,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(accountId, JSON.stringify(save), characterLevel, savedAt)
|
||||||
|
return readIwt2SyncSave(database, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
function itemById(database, itemId) {
|
function itemById(database, itemId) {
|
||||||
return database.prepare(`
|
return database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1544,11 +1592,6 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
|
|||||||
`).get(encounterId, difficultyId)
|
`).get(encounterId, difficultyId)
|
||||||
if (!context) throw new Error('That loot table is not available.')
|
if (!context) throw new Error('That loot table is not available.')
|
||||||
|
|
||||||
const character = database.prepare('SELECT level FROM characters WHERE id = ?').get(characterId)
|
|
||||||
if (character.level < context.unlockLevel) {
|
|
||||||
throw new Error(`${context.difficultyName} unlocks at level ${context.unlockLevel}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = database.prepare(`
|
const entries = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
encounter_loot.drop_weight AS dropWeight,
|
encounter_loot.drop_weight AS dropWeight,
|
||||||
@@ -2249,9 +2292,6 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
|||||||
JOIN classes ON classes.id = characters.class_id
|
JOIN classes ON classes.id = characters.class_id
|
||||||
WHERE characters.id = ?
|
WHERE characters.id = ?
|
||||||
`).get(characterId)
|
`).get(characterId)
|
||||||
if (character.level < dungeon.unlockLevel) {
|
|
||||||
throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
|
|
||||||
}
|
|
||||||
const maxLevel = Number(
|
const maxLevel = Number(
|
||||||
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
||||||
)
|
)
|
||||||
@@ -2483,9 +2523,6 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
FROM characters
|
FROM characters
|
||||||
WHERE characters.id = ?
|
WHERE characters.id = ?
|
||||||
`).get(characterId)
|
`).get(characterId)
|
||||||
if (character.level < dungeon.unlockLevel) {
|
|
||||||
throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
|
|
||||||
}
|
|
||||||
const maxLevel = Number(
|
const maxLevel = Number(
|
||||||
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
||||||
)
|
)
|
||||||
@@ -3137,6 +3174,17 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/iwt2/sync-save' && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, readIwt2SyncSave(database, session.accountId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/iwt2/sync-save' && request.method === 'PUT') {
|
||||||
|
const payload = await readJson(request, 512 * 1024)
|
||||||
|
sendJson(response, 200, importIwt2SyncSave(database, session.accountId, payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (request.url === '/api/profile' && request.method === 'GET') {
|
if (request.url === '/api/profile' && request.method === 'GET') {
|
||||||
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
|
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -111,6 +111,106 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
box-shadow: 7px 7px 0 #08090c;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
margin-top: 12px;
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||||
|
height: min(100%, 388px);
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-height: 0;
|
||||||
|
outline: 2px solid #42414c;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card > span {
|
||||||
|
align-items: center;
|
||||||
|
background: #13141a;
|
||||||
|
border: 2px solid var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 48px;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 16px;
|
||||||
|
height: 48px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card strong {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card.game-selected > span {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #13141a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 620px) {
|
||||||
|
.iwt2-menu-screen {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card {
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card > span {
|
||||||
|
flex-basis: 44px;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card strong {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen .iwt2-menu-card small {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-arena-layout {
|
.iwt2-arena-layout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -145,6 +245,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-boss-hud {
|
.iwt2-boss-hud {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
min-width: 310px;
|
min-width: 310px;
|
||||||
padding: 8px 12px 10px;
|
padding: 8px 12px 10px;
|
||||||
@@ -155,7 +257,7 @@
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-boss-hud > div:first-child {
|
.iwt2-boss-hud-row > div:first-child {
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -257,7 +359,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: 24px minmax(0, 1fr);
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
height: 86px;
|
height: 86px;
|
||||||
min-height: 86px;
|
min-height: 86px;
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
@@ -267,27 +369,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row.has-target-binding {
|
.iwt2-party-row.has-target-binding {
|
||||||
grid-template-columns: 28px 24px minmax(0, 1fr);
|
grid-template-columns: 28px 28px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row > span {
|
.iwt2-party-row > .iwt2-party-portrait {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 2px solid #050608;
|
border: 2px solid #050608;
|
||||||
color: #fff7df;
|
color: #fff7df;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
overflow: hidden;
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-portrait img {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row .iwt2-party-target-key {
|
.iwt2-party-row .iwt2-party-target-key {
|
||||||
|
align-items: center;
|
||||||
background: #050608;
|
background: #050608;
|
||||||
border-color: #0b0d12;
|
border-color: #0b0d12;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
font-family: var(--pixel-font);
|
font-family: var(--pixel-font);
|
||||||
font-size: 0.52rem;
|
font-size: 0.52rem;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
justify-content: center;
|
||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -589,6 +702,55 @@
|
|||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-pvp-queue-overlay {
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(5, 5, 8, 0.86);
|
||||||
|
display: flex;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-pvp-queue-panel {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0b0c0f;
|
||||||
|
box-shadow: 8px 8px 0 #050507;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 440px;
|
||||||
|
outline: 2px solid var(--gold);
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
width: min(100%, 440px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-pvp-queue-panel .placeholder-runes {
|
||||||
|
font-size: 34px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-pvp-queue-panel h2 {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-pvp-queue-panel small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-pvp-queue-panel .iwt2-result-button {
|
||||||
|
margin-top: 6px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-result-button.is-primary {
|
.iwt2-result-button.is-primary {
|
||||||
background: #263448;
|
background: #263448;
|
||||||
color: #fff4a8;
|
color: #fff4a8;
|
||||||
@@ -656,6 +818,68 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-name-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-name-editor label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-name-editor label span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-name-editor input {
|
||||||
|
background: #10141b;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 1rem;
|
||||||
|
min-height: 48px;
|
||||||
|
outline: 2px solid #34343d;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-name-editor input:focus {
|
||||||
|
outline-color: #e5b95f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-name-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-name-grid button {
|
||||||
|
background: #10141b;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
min-height: 42px;
|
||||||
|
outline: 2px solid #34343d;
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-name-grid button.selected {
|
||||||
|
background: #1c2633;
|
||||||
|
outline-color: #e5b95f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-action-list,
|
.iwt2-action-list,
|
||||||
.iwt2-info-list {
|
.iwt2-info-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -723,6 +947,313 @@
|
|||||||
outline-color: #e5b95f;
|
outline-color: #e5b95f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-header-title {
|
||||||
|
color: #f2c96d;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-header-back {
|
||||||
|
margin-left: 10px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-heading-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-pager-button {
|
||||||
|
background: #1a1b22;
|
||||||
|
border: 2px solid #0a0b0f;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 8px;
|
||||||
|
min-height: 30px;
|
||||||
|
outline: 2px solid #4d4b59;
|
||||||
|
padding: 5px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-pager-button:disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-page-counter {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip > button {
|
||||||
|
background: #151922;
|
||||||
|
border: 2px solid #08090d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-height: 44px;
|
||||||
|
outline: 2px solid #34343d;
|
||||||
|
padding: 7px 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip > button.active,
|
||||||
|
.iwt2-difficulty-strip > button.game-selected {
|
||||||
|
background: #1c2633;
|
||||||
|
outline-color: #e5b95f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip > button:disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip strong,
|
||||||
|
.iwt2-difficulty-strip small {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip strong {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-gear-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: 0.8fr 1fr 1.35fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-column {
|
||||||
|
background: #10141b;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
outline: 2px solid #34343d;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-screen {
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-column h2 {
|
||||||
|
color: #f2c96d;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class-list,
|
||||||
|
.iwt2-gear-slot-list,
|
||||||
|
.iwt2-infusion-list,
|
||||||
|
.iwt2-gear-cost-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class-list,
|
||||||
|
.iwt2-infusion-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class,
|
||||||
|
.iwt2-gear-slot,
|
||||||
|
.iwt2-infusion-row,
|
||||||
|
.iwt2-gear-upgrade-button {
|
||||||
|
background: #151922;
|
||||||
|
border: 2px solid #08090d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--body-font);
|
||||||
|
outline: 2px solid #34343d;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
min-height: 54px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class > span {
|
||||||
|
align-items: center;
|
||||||
|
background: color-mix(in srgb, var(--class-color) 25%, #10141b);
|
||||||
|
border: 2px solid color-mix(in srgb, var(--class-color) 60%, #090a0d);
|
||||||
|
display: inline-flex;
|
||||||
|
height: 32px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class > div,
|
||||||
|
.iwt2-infusion-row > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class strong,
|
||||||
|
.iwt2-gear-slot strong,
|
||||||
|
.iwt2-infusion-row strong,
|
||||||
|
.iwt2-gear-detail strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class small,
|
||||||
|
.iwt2-gear-slot small,
|
||||||
|
.iwt2-infusion-row small,
|
||||||
|
.iwt2-gear-detail small {
|
||||||
|
color: var(--muted);
|
||||||
|
display: block;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-slot {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-slot em {
|
||||||
|
color: #fff4a8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class.active,
|
||||||
|
.iwt2-gear-slot.active,
|
||||||
|
.iwt2-infusion-row.active {
|
||||||
|
background: #1a2330;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class.game-selected,
|
||||||
|
.iwt2-gear-slot.game-selected,
|
||||||
|
.iwt2-infusion-row.game-selected,
|
||||||
|
.iwt2-gear-upgrade-button.game-selected {
|
||||||
|
outline-color: #e5b95f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-upgrade-button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-upgrade-button:disabled,
|
||||||
|
.iwt2-infusion-row:disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-detail-panel {
|
||||||
|
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-detail {
|
||||||
|
background: #151922;
|
||||||
|
border: 2px solid #08090d;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-detail p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-cost-list span {
|
||||||
|
background: #0d1118;
|
||||||
|
border: 1px solid #29313d;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-cost-list span.met {
|
||||||
|
color: #9ef0bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-cost-list span.missing {
|
||||||
|
color: #ff9c9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-infusion-row {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-infusion-row > span {
|
||||||
|
align-items: center;
|
||||||
|
background: #202938;
|
||||||
|
border: 2px solid #08090d;
|
||||||
|
color: #f2c96d;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 32px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-message {
|
||||||
|
color: #fff4a8;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-controller-preview {
|
.iwt2-controller-preview {
|
||||||
align-content: center;
|
align-content: center;
|
||||||
background: #141922;
|
background: #141922;
|
||||||
@@ -751,11 +1282,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-bottom-display .dual-control-chip {
|
.iwt2-bottom-display .dual-control-chip {
|
||||||
|
align-items: center;
|
||||||
background: var(--panel-light);
|
background: var(--panel-light);
|
||||||
border: 2px solid #090a0d;
|
border: 2px solid #090a0d;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
outline: 2px solid #494756;
|
outline: 2px solid #494756;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-bottom-target-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 22px;
|
||||||
|
object-fit: contain;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.game-version-grid,
|
.game-version-grid,
|
||||||
@@ -854,6 +1395,7 @@
|
|||||||
|
|
||||||
.iwt2-shell .character-summary {
|
.iwt2-shell .character-summary {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-shell .character-summary strong {
|
.iwt2-shell .character-summary strong {
|
||||||
@@ -864,6 +1406,16 @@
|
|||||||
font-size: 6px;
|
font-size: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-header-title {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-header-back {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-menu-screen {
|
.iwt2-menu-screen {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -922,6 +1474,11 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-screen {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-screen-shell .screen-heading {
|
.iwt2-screen-shell .screen-heading {
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -941,6 +1498,39 @@
|
|||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-heading-actions {
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-pager-button {
|
||||||
|
font-size: 8px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-page-counter {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip {
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip > button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip strong {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip small {
|
||||||
|
font-size: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-action-list,
|
.iwt2-action-list,
|
||||||
.iwt2-info-list {
|
.iwt2-info-list {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@@ -956,6 +1546,94 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-dungeon-list .iwt2-action-list {
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-dungeon-list .iwt2-action-row {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-layout {
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 0.78fr 0.92fr 1.3fr;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-column {
|
||||||
|
gap: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-column h2 {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class-list,
|
||||||
|
.iwt2-gear-slot-list,
|
||||||
|
.iwt2-infusion-list,
|
||||||
|
.iwt2-gear-cost-list {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class {
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class > span,
|
||||||
|
.iwt2-infusion-row > span {
|
||||||
|
height: 26px;
|
||||||
|
width: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class strong,
|
||||||
|
.iwt2-gear-slot strong,
|
||||||
|
.iwt2-infusion-row strong,
|
||||||
|
.iwt2-gear-detail strong {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-class small,
|
||||||
|
.iwt2-gear-slot small,
|
||||||
|
.iwt2-infusion-row small,
|
||||||
|
.iwt2-gear-detail small,
|
||||||
|
.iwt2-gear-cost-list span {
|
||||||
|
font-size: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-slot {
|
||||||
|
min-height: 45px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-upgrade-button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-detail {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-infusion-row {
|
||||||
|
gap: 7px;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-message {
|
||||||
|
font-size: 0.54rem;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-action-row strong,
|
.iwt2-action-row strong,
|
||||||
.iwt2-info-row strong {
|
.iwt2-info-row strong {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -1033,21 +1711,21 @@
|
|||||||
|
|
||||||
.iwt2-party-row {
|
.iwt2-party-row {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
grid-template-columns: 20px minmax(0, 1fr);
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
height: 62px;
|
height: 62px;
|
||||||
min-height: 62px;
|
min-height: 62px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row.has-target-binding {
|
.iwt2-party-row.has-target-binding {
|
||||||
grid-template-columns: 24px 20px minmax(0, 1fr);
|
grid-template-columns: 24px 22px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row > span,
|
.iwt2-party-row > .iwt2-party-portrait,
|
||||||
.iwt2-party-row .iwt2-party-target-key {
|
.iwt2-party-row .iwt2-party-target-key {
|
||||||
height: 20px;
|
height: 22px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
width: 20px;
|
width: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
|
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
|
||||||
@@ -2137,7 +2815,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pvp-opponent-bottom-display {
|
.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,
|
.dual-controls-header,
|
||||||
@@ -2172,6 +2850,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dual-opponent-progress,
|
.dual-opponent-progress,
|
||||||
|
.dual-opponent-arena,
|
||||||
.dual-opponent-effects {
|
.dual-opponent-effects {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 3px solid #0c0d11;
|
border: 3px solid #0c0d11;
|
||||||
@@ -2195,6 +2874,53 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
height: 22px;
|
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 {
|
.dual-opponent-party-grid {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 3px solid #0c0d11;
|
border: 3px solid #0c0d11;
|
||||||
@@ -2567,7 +3293,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pvp-opponent-bottom-display {
|
.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 {
|
.dual-opponent-progress {
|
||||||
@@ -2590,6 +3316,16 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena {
|
||||||
|
border-width: 2px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena-field {
|
||||||
|
max-height: 112px;
|
||||||
|
min-height: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-opponent-party-grid {
|
.dual-opponent-party-grid {
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -11895,6 +12631,17 @@ h2 {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip {
|
||||||
|
gap: 4px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-difficulty-strip > button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-action-row:not(:has(small)),
|
.iwt2-action-row:not(:has(small)),
|
||||||
.iwt2-info-row:not(:has(small)) {
|
.iwt2-info-row:not(:has(small)) {
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
|
|||||||
@@ -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 IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
|
||||||
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
||||||
import { useGameAction } from './input'
|
import { useGameAction } from './input'
|
||||||
|
import {
|
||||||
|
loadAuthSession,
|
||||||
|
type AuthSession,
|
||||||
|
} from './profile'
|
||||||
|
|
||||||
type GameVersion = 'iwt1' | 'iwt2'
|
type GameVersion = 'iwt1' | 'iwt2'
|
||||||
|
|
||||||
@@ -31,9 +36,46 @@ const GAME_OPTIONS: Array<{
|
|||||||
function App() {
|
function App() {
|
||||||
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
|
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
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) => {
|
useGameAction((action, device) => {
|
||||||
if (selectedVersion || device !== 'controller') return
|
if (!authSession || selectedVersion || device !== 'controller') return
|
||||||
if (action === 'navigateLeft' || action === 'navigateUp') {
|
if (action === 'navigateLeft' || action === 'navigateUp') {
|
||||||
setSelectedIndex((current) => Math.max(0, current - 1))
|
setSelectedIndex((current) => Math.max(0, current - 1))
|
||||||
} else if (action === 'navigateRight' || action === 'navigateDown') {
|
} else if (action === 'navigateRight' || action === 'navigateDown') {
|
||||||
@@ -43,12 +85,43 @@ 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') {
|
if (selectedVersion === 'iwt1') {
|
||||||
return <IWantToHeal1App onBackToGameSelect={() => setSelectedVersion(null)} />
|
return (
|
||||||
|
<IWantToHeal1App
|
||||||
|
initialSession={authSession}
|
||||||
|
onAuthenticationCleared={clearAuthSession}
|
||||||
|
onBackToGameSelect={() => setSelectedVersion(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedVersion === 'iwt2') {
|
if (selectedVersion === 'iwt2') {
|
||||||
return <IWantToHeal2App onBackToGameSelect={() => setSelectedVersion(null)} />
|
return (
|
||||||
|
<IWantToHeal2App
|
||||||
|
onlineBackupsAvailable={authSession.account?.id !== -1}
|
||||||
|
onBackToGameSelect={() => setSelectedVersion(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ export type RewardSummaryBase = {
|
|||||||
unlockedAbilities: DungeonReward['unlockedAbilities']
|
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RunBossCoinAward = NonNullable<DungeonReward['bonusItem']> & {
|
||||||
|
sourceLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
export type PvpRunRewardSummary = RewardSummaryBase & {
|
export type PvpRunRewardSummary = RewardSummaryBase & {
|
||||||
bossesKilled: number
|
bossesKilled: number
|
||||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
loot: RunBossCoinAward[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyRewardSummary(): RewardSummaryBase {
|
export function createEmptyRewardSummary(): RewardSummaryBase {
|
||||||
@@ -60,11 +64,13 @@ export function mergeDungeonRewardSummary<TSummary extends RewardSummaryBase>(
|
|||||||
export function mergePvpRunRewardSummary(
|
export function mergePvpRunRewardSummary(
|
||||||
current: PvpRunRewardSummary,
|
current: PvpRunRewardSummary,
|
||||||
reward: DungeonReward,
|
reward: DungeonReward,
|
||||||
{ bossKilled }: { bossKilled: boolean },
|
{ bossKilled, sourceLabel }: { bossKilled: boolean; sourceLabel?: string },
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
...mergeDungeonRewardSummary(current, reward),
|
...mergeDungeonRewardSummary(current, reward),
|
||||||
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
|
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
|
||||||
loot: reward.bonusItem ? [...current.loot, reward.bonusItem] : current.loot,
|
loot: reward.bonusItem
|
||||||
|
? [...current.loot, { ...reward.bonusItem, sourceLabel: sourceLabel ?? `Boss ${current.bossesKilled + 1}` }]
|
||||||
|
: current.loot,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
completeDungeon,
|
completeDungeon,
|
||||||
completeRoguelike,
|
completeRoguelike,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
recordBossKill,
|
|
||||||
type DungeonReward,
|
type DungeonReward,
|
||||||
rollEncounterLoot,
|
rollEncounterLoot,
|
||||||
type LootRoll,
|
type LootRoll,
|
||||||
@@ -55,6 +54,7 @@ import {
|
|||||||
createPveCombatState,
|
createPveCombatState,
|
||||||
createPveRoguelikeRunStart,
|
createPveRoguelikeRunStart,
|
||||||
} from '../combat/pveRoguelikeRunSetup'
|
} from '../combat/pveRoguelikeRunSetup'
|
||||||
|
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
||||||
import {
|
import {
|
||||||
appendCombatLog,
|
appendCombatLog,
|
||||||
type BasicFloatingCombatText,
|
type BasicFloatingCombatText,
|
||||||
@@ -79,7 +79,7 @@ import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
|||||||
import { PartyMemberFrame } from './PartyFrames'
|
import { PartyMemberFrame } from './PartyFrames'
|
||||||
import { ResourceBar, SpellBar } from './SpellBars'
|
import { ResourceBar, SpellBar } from './SpellBars'
|
||||||
import { barFillStyle } from './barStyles'
|
import { barFillStyle } from './barStyles'
|
||||||
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
|
import { BonusItemReward, LootRollList, PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
||||||
import { ResultScreen } from './ResultScreen'
|
import { ResultScreen } from './ResultScreen'
|
||||||
import {
|
import {
|
||||||
DualScreenTopCombat,
|
DualScreenTopCombat,
|
||||||
@@ -236,6 +236,7 @@ function makeRoguelikeSegment(
|
|||||||
mode: RoguelikeMode,
|
mode: RoguelikeMode,
|
||||||
): RoguelikeEncounter[] {
|
): RoguelikeEncounter[] {
|
||||||
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
||||||
|
const stageOneDamageScale = 0.58 + (mode === 'raid' ? 0.13 : 0.1)
|
||||||
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
|
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
|
||||||
pool,
|
pool,
|
||||||
stage,
|
stage,
|
||||||
@@ -243,8 +244,8 @@ function makeRoguelikeSegment(
|
|||||||
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
|
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
|
||||||
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
||||||
healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
|
healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
|
||||||
damageScale: difficulty.damageMultiplier * (0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)),
|
damageScale: difficulty.damageMultiplier * stageOneDamageScale,
|
||||||
partyDamageScale: 0.9 + stage * 0.05,
|
partyDamageScale: 0.95,
|
||||||
idBase: 900000,
|
idBase: 900000,
|
||||||
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
|
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
|
||||||
extraFields: (_encounter, isBoss, selectedMechanics) => ({
|
extraFields: (_encounter, isBoss, selectedMechanics) => ({
|
||||||
@@ -359,6 +360,7 @@ export function CombatScreen({
|
|||||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||||
const [rewardError, setRewardError] = useState('')
|
const [rewardError, setRewardError] = useState('')
|
||||||
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
|
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
|
||||||
|
const [roguelikeBossCoins, setRoguelikeBossCoins] = useState<RunBossCoinAward[]>([])
|
||||||
const [showEndLog, setShowEndLog] = useState(false)
|
const [showEndLog, setShowEndLog] = useState(false)
|
||||||
const {
|
const {
|
||||||
floatingTexts,
|
floatingTexts,
|
||||||
@@ -540,9 +542,31 @@ export function CombatScreen({
|
|||||||
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
|
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
|
||||||
if (recordedBossKillIdsRef.current.has(key)) return
|
if (recordedBossKillIdsRef.current.has(key)) return
|
||||||
recordedBossKillIdsRef.current.add(key)
|
recordedBossKillIdsRef.current.add(key)
|
||||||
recordBossKill(encounter.id, { petVariant: 'purple' })
|
completeRoguelike(
|
||||||
|
dungeon.id,
|
||||||
|
difficulty.id,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
|
||||||
|
{
|
||||||
|
bossesCleared: 0,
|
||||||
|
fightsCleared: 0,
|
||||||
|
lootSourceEncounterId: encounter.id,
|
||||||
|
roguelikeStage,
|
||||||
|
},
|
||||||
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
|
if (result.bonusItem) {
|
||||||
|
setRoguelikeBossCoins((current) => [...current, {
|
||||||
|
...result.bonusItem!,
|
||||||
|
sourceLabel: encounter.enemyName,
|
||||||
|
}])
|
||||||
|
addLog(
|
||||||
|
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
||||||
|
'loot',
|
||||||
|
)
|
||||||
|
}
|
||||||
if (result.petAwarded) {
|
if (result.petAwarded) {
|
||||||
addLog(
|
addLog(
|
||||||
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
||||||
@@ -552,11 +576,11 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
addLog(
|
addLog(
|
||||||
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
|
reason instanceof Error ? reason.message : 'Unable to award boss coins.',
|
||||||
'danger',
|
'danger',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
|
}, [addLog, difficulty.id, dungeon.id, encounterIndex, isRoguelike, onProfileUpdated, roguelikeStage])
|
||||||
|
|
||||||
const resetRun = useCallback(() => {
|
const resetRun = useCallback(() => {
|
||||||
const nextRoguelikeEncounters = roguelikeMode
|
const nextRoguelikeEncounters = roguelikeMode
|
||||||
@@ -581,6 +605,7 @@ export function CombatScreen({
|
|||||||
setReward(setup.defaults.reward)
|
setReward(setup.defaults.reward)
|
||||||
setRewardError(setup.defaults.rewardError)
|
setRewardError(setup.defaults.rewardError)
|
||||||
setLootRolls(setup.defaults.lootRolls)
|
setLootRolls(setup.defaults.lootRolls)
|
||||||
|
setRoguelikeBossCoins([])
|
||||||
setShowEndLog(setup.defaults.showEndLog)
|
setShowEndLog(setup.defaults.showEndLog)
|
||||||
clearFloatingTexts()
|
clearFloatingTexts()
|
||||||
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
|
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
|
||||||
@@ -1434,7 +1459,11 @@ export function CombatScreen({
|
|||||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||||
{reward && (
|
{reward && (
|
||||||
<>
|
<>
|
||||||
|
{isRoguelike && <p>Run totals</p>}
|
||||||
<RewardXpSummary reward={reward} />
|
<RewardXpSummary reward={reward} />
|
||||||
|
{isRoguelike && (
|
||||||
|
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
||||||
|
)}
|
||||||
{!isRoguelike && (
|
{!isRoguelike && (
|
||||||
<>
|
<>
|
||||||
<p>Component tier: item level {reward.droppedItemLevel}.</p>
|
<p>Component tier: item level {reward.droppedItemLevel}.</p>
|
||||||
@@ -1459,6 +1488,7 @@ export function CombatScreen({
|
|||||||
<>
|
<>
|
||||||
<RewardXpSummary reward={reward} />
|
<RewardXpSummary reward={reward} />
|
||||||
<p>{encounterIndex} encounters cleared.</p>
|
<p>{encounterIndex} encounters cleared.</p>
|
||||||
|
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
||||||
<p className="efficiency-result">
|
<p className="efficiency-result">
|
||||||
{reward.resourceSpent} {gameClass.resourceName} spent
|
{reward.resourceSpent} {gameClass.resourceName} spent
|
||||||
<small>{reward.durationSeconds}s survived</small>
|
<small>{reward.durationSeconds}s survived</small>
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ function spellResourceCost(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs
|
|||||||
|
|
||||||
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
||||||
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
||||||
|
const stageOneDamageScale = 0.8 + (kind === 'raid' ? 0.18 : 0.14)
|
||||||
return buildRoguelikeSegment<BossMechanic, {
|
return buildRoguelikeSegment<BossMechanic, {
|
||||||
bossMechanics: BossMechanic[]
|
bossMechanics: BossMechanic[]
|
||||||
sourceEncounterId?: number
|
sourceEncounterId?: number
|
||||||
@@ -240,8 +241,8 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
|||||||
trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
|
trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
|
||||||
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
||||||
healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
|
healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
|
||||||
damageScale: 0.8 + stage * (kind === 'raid' ? 0.18 : 0.14),
|
damageScale: stageOneDamageScale,
|
||||||
partyDamageScale: 0.85 + stage * 0.04,
|
partyDamageScale: 0.89,
|
||||||
idBase: 910000,
|
idBase: 910000,
|
||||||
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
|
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
|
||||||
extraFields: (encounter, isBoss, selectedMechanics) => ({
|
extraFields: (encounter, isBoss, selectedMechanics) => ({
|
||||||
@@ -537,7 +538,10 @@ export function PvPRoguelikeScreen({
|
|||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(result)
|
setReward(result)
|
||||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: isBossReward }))
|
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
|
||||||
|
bossKilled: isBossReward,
|
||||||
|
sourceLabel: rewardEncounter?.enemyName,
|
||||||
|
}))
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
if (result.experienceGained > 0) {
|
if (result.experienceGained > 0) {
|
||||||
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
||||||
@@ -579,7 +583,10 @@ export function PvPRoguelikeScreen({
|
|||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(result)
|
setReward(result)
|
||||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: false }))
|
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
|
||||||
|
bossKilled: false,
|
||||||
|
sourceLabel: 'Match Win',
|
||||||
|
}))
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
if (result.experienceGained > 0) {
|
if (result.experienceGained > 0) {
|
||||||
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
|
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
|
||||||
@@ -1808,11 +1815,12 @@ export function PvPRoguelikeScreen({
|
|||||||
>
|
>
|
||||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||||
<div className="reward-summary">
|
<div className="reward-summary">
|
||||||
|
<p>Run totals</p>
|
||||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||||
<RewardXpSummary reward={runSummary} />
|
<RewardXpSummary reward={runSummary} />
|
||||||
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
||||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||||
<PvpRunLootList loot={runSummary.loot} />
|
<PvpRunLootList loot={runSummary.loot} emptyLabel="No boss coins collected this run." />
|
||||||
{reward && runSummary.bossesKilled === 0 && (
|
{reward && runSummary.bossesKilled === 0 && (
|
||||||
<>
|
<>
|
||||||
<RewardXpSummary reward={reward} />
|
<RewardXpSummary reward={reward} />
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
type Spell,
|
type Spell,
|
||||||
} from '../game'
|
} from '../game'
|
||||||
import { completeRoguelike, recordPvpMatch } from '../profile'
|
import { completeRoguelike, recordPvpMatch } from '../profile'
|
||||||
import type { CharacterProfile } from '../profile'
|
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||||
import type { GameMode } from '../gameRepository'
|
import type { GameMode } from '../gameRepository'
|
||||||
|
import { roguelikeCoinItemLevel } from '../shared/rewardRules.mjs'
|
||||||
import { PartyMemberFrame } from './PartyFrames'
|
import { PartyMemberFrame } from './PartyFrames'
|
||||||
import { SpellBar } from './SpellBars'
|
import { SpellBar } from './SpellBars'
|
||||||
import { barFillStyle } from './barStyles'
|
import { barFillStyle } from './barStyles'
|
||||||
@@ -64,9 +65,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
createEmptyRewardSummary,
|
createEmptyRewardSummary,
|
||||||
mergeDungeonRewardSummary,
|
mergeDungeonRewardSummary,
|
||||||
|
type RunBossCoinAward,
|
||||||
type RewardSummaryBase,
|
type RewardSummaryBase,
|
||||||
} from '../combat/rewardSummaries'
|
} from '../combat/rewardSummaries'
|
||||||
import { RewardXpSummary } from './RewardPanels'
|
import { PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
||||||
import { ResultScreen } from './ResultScreen'
|
import { ResultScreen } from './ResultScreen'
|
||||||
import {
|
import {
|
||||||
publishPvpMatchState,
|
publishPvpMatchState,
|
||||||
@@ -158,6 +160,25 @@ function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
|
|||||||
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
|
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function starterSpellsForClass(gameClass: CharacterProfile['classes'][number]) {
|
||||||
|
return gameClass.spells
|
||||||
|
.filter((spell) => spell.unlockLevel === 1)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((spell, index) => toCombatSpell(spell, String(index + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomCpuClass(classes: CharacterProfile['classes'], fallbackId: CharacterProfile['classes'][number]['id']) {
|
||||||
|
const pool = classes.length > 0 ? classes : []
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)] ?? classes.find((candidate) => candidate.id === fallbackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stadiumBossCoinSource(bosses: DungeonEncounter[], stage: number) {
|
||||||
|
const targetItemLevel = roguelikeCoinItemLevel(stage)
|
||||||
|
const eligible = bosses.filter((boss) => boss.lootTables.some((item) => item.itemLevel === targetItemLevel))
|
||||||
|
const pool = eligible.length > 0 ? eligible : bosses
|
||||||
|
return pool.length > 0 ? pool[(stage - 1) % pool.length] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
|
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
|
||||||
const directName = slotSpellName('1', spells, 'Mend')
|
const directName = slotSpellName('1', spells, 'Mend')
|
||||||
const sustainName = slotSpellName('2', spells, 'Renew')
|
const sustainName = slotSpellName('2', spells, 'Renew')
|
||||||
@@ -330,11 +351,11 @@ export function PvpStadiumScreen({
|
|||||||
onProfileUpdated: (profile: CharacterProfile) => void
|
onProfileUpdated: (profile: CharacterProfile) => void
|
||||||
}) {
|
}) {
|
||||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||||
const starterSpells = useMemo(() => gameClass.spells
|
const [cpuGameClass, setCpuGameClass] = useState(() => randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
|
||||||
.filter((spell) => spell.unlockLevel === 1)
|
const starterSpells = useMemo(() => starterSpellsForClass(gameClass), [gameClass])
|
||||||
.slice(0, 5)
|
const cpuStarterSpells = useMemo(() => starterSpellsForClass(cpuGameClass), [cpuGameClass])
|
||||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
|
||||||
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
|
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
|
||||||
|
const cpuBuffCatalog = useMemo(() => buildStadiumBuffs(cpuStarterSpells), [cpuStarterSpells])
|
||||||
const partyTemplate = useMemo(
|
const partyTemplate = useMemo(
|
||||||
() => INITIAL_PARTY.map((member) => ({
|
() => INITIAL_PARTY.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
@@ -351,6 +372,12 @@ export function PvpStadiumScreen({
|
|||||||
)
|
)
|
||||||
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
|
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
|
||||||
const rewardDifficulty = rewardDungeon.difficulties[0]
|
const rewardDifficulty = rewardDungeon.difficulties[0]
|
||||||
|
const stadiumCoinBosses = useMemo(
|
||||||
|
() => profile.dungeons
|
||||||
|
.flatMap((candidate) => candidate.encounters)
|
||||||
|
.filter((candidate) => candidate.isBoss && candidate.lootTables.length > 0),
|
||||||
|
[profile.dungeons],
|
||||||
|
)
|
||||||
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
|
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
|
||||||
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
|
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
|
||||||
partyTemplate,
|
partyTemplate,
|
||||||
@@ -382,6 +409,7 @@ export function PvpStadiumScreen({
|
|||||||
clearFloatingTexts,
|
clearFloatingTexts,
|
||||||
} = useSidedFloatingCombatText()
|
} = useSidedFloatingCombatText()
|
||||||
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
|
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
|
||||||
|
const [stadiumBossCoins, setStadiumBossCoins] = useState<RunBossCoinAward[]>([])
|
||||||
const [rewardError, setRewardError] = useState('')
|
const [rewardError, setRewardError] = useState('')
|
||||||
const [showEndLog, setShowEndLog] = useState(false)
|
const [showEndLog, setShowEndLog] = useState(false)
|
||||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
@@ -417,8 +445,8 @@ export function PvpStadiumScreen({
|
|||||||
cost: playerSpellSlotCost,
|
cost: playerSpellSlotCost,
|
||||||
})
|
})
|
||||||
const opponentBuffSummary = useMemo(
|
const opponentBuffSummary = useMemo(
|
||||||
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
() => summarizeStacks(cpuSide.buffs, liveMatch ? buffCatalog : cpuBuffCatalog),
|
||||||
[buffCatalog, cpuSide.buffs],
|
[buffCatalog, cpuBuffCatalog, cpuSide.buffs, liveMatch],
|
||||||
)
|
)
|
||||||
const playerBuffSummary = useMemo(
|
const playerBuffSummary = useMemo(
|
||||||
() => summarizeStacks(playerSide.buffs, buffCatalog),
|
() => summarizeStacks(playerSide.buffs, buffCatalog),
|
||||||
@@ -473,18 +501,31 @@ export function PvpStadiumScreen({
|
|||||||
setStatus('round-countdown')
|
setStatus('round-countdown')
|
||||||
}, [clearRoundCountdown, startRoundCountdown])
|
}, [clearRoundCountdown, startRoundCountdown])
|
||||||
|
|
||||||
const awardXp = useCallback((key: string, mode: StadiumExperienceMode) => {
|
const awardXp = useCallback((
|
||||||
|
key: string,
|
||||||
|
mode: StadiumExperienceMode,
|
||||||
|
coinSource?: { encounter: DungeonEncounter; stage: number; label: string },
|
||||||
|
) => {
|
||||||
if (awardedXpRef.current.has(key)) return
|
if (awardedXpRef.current.has(key)) return
|
||||||
awardedXpRef.current.add(key)
|
awardedXpRef.current.add(key)
|
||||||
completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
|
completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
|
||||||
bossesCleared: 0,
|
bossesCleared: 0,
|
||||||
fightsCleared: 1,
|
fightsCleared: 1,
|
||||||
experienceMode: mode,
|
experienceMode: mode,
|
||||||
|
lootSourceEncounterId: coinSource?.encounter.id,
|
||||||
|
roguelikeStage: coinSource?.stage,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
|
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
|
||||||
|
if (result.bonusItem && coinSource) {
|
||||||
|
setStadiumBossCoins((current) => [...current, {
|
||||||
|
...result.bonusItem!,
|
||||||
|
sourceLabel: coinSource.label,
|
||||||
|
}])
|
||||||
|
}
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
||||||
|
if (result.bonusItem) addLog(`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`, 'loot')
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
|
setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
|
||||||
@@ -521,6 +562,7 @@ export function PvpStadiumScreen({
|
|||||||
setLiveMatch(setup.liveMatch)
|
setLiveMatch(setup.liveMatch)
|
||||||
setPaused(setup.defaults.paused)
|
setPaused(setup.defaults.paused)
|
||||||
setRewardSummary(createEmptyRewardSummary())
|
setRewardSummary(createEmptyRewardSummary())
|
||||||
|
setStadiumBossCoins([])
|
||||||
setRewardError(setup.defaults.rewardError)
|
setRewardError(setup.defaults.rewardError)
|
||||||
setShowEndLog(setup.defaults.showEndLog)
|
setShowEndLog(setup.defaults.showEndLog)
|
||||||
clearFloatingTexts()
|
clearFloatingTexts()
|
||||||
@@ -559,11 +601,13 @@ export function PvpStadiumScreen({
|
|||||||
setLiveMatch(null)
|
setLiveMatch(null)
|
||||||
setPaused(setup.defaults.paused)
|
setPaused(setup.defaults.paused)
|
||||||
setRewardSummary(createEmptyRewardSummary())
|
setRewardSummary(createEmptyRewardSummary())
|
||||||
|
setStadiumBossCoins([])
|
||||||
setRewardError(setup.defaults.rewardError)
|
setRewardError(setup.defaults.rewardError)
|
||||||
setShowEndLog(setup.defaults.showEndLog)
|
setShowEndLog(setup.defaults.showEndLog)
|
||||||
clearFloatingTexts()
|
clearFloatingTexts()
|
||||||
loggedOpponentRoundRef.current = ''
|
loggedOpponentRoundRef.current = ''
|
||||||
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
||||||
|
setCpuGameClass(randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
|
||||||
setCpuDifficulty(randomCpu)
|
setCpuDifficulty(randomCpu)
|
||||||
setQueueMessage(message)
|
setQueueMessage(message)
|
||||||
setLog([{ id: 1, text: message, tone: 'system' }])
|
setLog([{ id: 1, text: message, tone: 'system' }])
|
||||||
@@ -591,7 +635,7 @@ export function PvpStadiumScreen({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, resetShopTimer, setSelectedTargetId, startLiveMatch])
|
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameClass, gameMode, partyTemplate, profile.classes, resetShopTimer, setSelectedTargetId, startLiveMatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = window.requestAnimationFrame(() => startMatch())
|
const frame = window.requestAnimationFrame(() => startMatch())
|
||||||
@@ -602,6 +646,7 @@ export function PvpStadiumScreen({
|
|||||||
current: StadiumSideState,
|
current: StadiumSideState,
|
||||||
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
|
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
|
||||||
sideName: 'player' | 'cpu',
|
sideName: 'player' | 'cpu',
|
||||||
|
spells: Spell[],
|
||||||
spell: Spell,
|
spell: Spell,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
) => {
|
) => {
|
||||||
@@ -614,7 +659,7 @@ export function PvpStadiumScreen({
|
|||||||
id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId,
|
id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId,
|
||||||
})
|
})
|
||||||
const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5
|
const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5
|
||||||
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
|
const shieldEffect = spells.find((candidate) => candidate.kind === 'shield')
|
||||||
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
|
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
|
||||||
sourcePower
|
sourcePower
|
||||||
* strength
|
* strength
|
||||||
@@ -717,7 +762,7 @@ export function PvpStadiumScreen({
|
|||||||
wasReady: effectiveCost === 0 && current.freeCastReady,
|
wasReady: effectiveCost === 0 && current.freeCastReady,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [addFloatingHeal, starterSpells])
|
}, [addFloatingHeal])
|
||||||
|
|
||||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||||
if (status !== 'playing' || !playerAlive) return
|
if (status !== 'playing' || !playerAlive) return
|
||||||
@@ -726,9 +771,9 @@ export function PvpStadiumScreen({
|
|||||||
const next = typeof value === 'function' ? value(playerRef.current) : value
|
const next = typeof value === 'function' ? value(playerRef.current) : value
|
||||||
playerRef.current = next
|
playerRef.current = next
|
||||||
setPlayerSide(next)
|
setPlayerSide(next)
|
||||||
}, 'player', spell, targetId)
|
}, 'player', starterSpells, spell, targetId)
|
||||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
||||||
}, [addLog, applySpell, playerAlive, status])
|
}, [addLog, applySpell, playerAlive, starterSpells, status])
|
||||||
|
|
||||||
const getTargetParty = useCallback(() => playerRef.current.party, [])
|
const getTargetParty = useCallback(() => playerRef.current.party, [])
|
||||||
const {
|
const {
|
||||||
@@ -749,7 +794,7 @@ export function PvpStadiumScreen({
|
|||||||
runCpuHealTurn({
|
runCpuHealTurn({
|
||||||
side: cpuRef.current,
|
side: cpuRef.current,
|
||||||
elapsedTicks,
|
elapsedTicks,
|
||||||
spells: starterSpells,
|
spells: cpuStarterSpells,
|
||||||
behavior,
|
behavior,
|
||||||
preferSlots: true,
|
preferSlots: true,
|
||||||
applySpell: (side, spell, targetId) => {
|
applySpell: (side, spell, targetId) => {
|
||||||
@@ -757,10 +802,10 @@ export function PvpStadiumScreen({
|
|||||||
const next = typeof value === 'function' ? value(cpuRef.current) : value
|
const next = typeof value === 'function' ? value(cpuRef.current) : value
|
||||||
cpuRef.current = next
|
cpuRef.current = next
|
||||||
setCpuSide(next)
|
setCpuSide(next)
|
||||||
}, 'cpu', spell, targetId)
|
}, 'cpu', cpuStarterSpells, spell, targetId)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status])
|
}, [applySpell, cpuDifficulty, cpuStarterSpells, elapsedTicks, status])
|
||||||
|
|
||||||
const advanceBoss = useCallback((side: StadiumSideState) => {
|
const advanceBoss = useCallback((side: StadiumSideState) => {
|
||||||
if (side.roundStatus !== 'playing') return side
|
if (side.roundStatus !== 'playing') return side
|
||||||
@@ -823,7 +868,7 @@ export function PvpStadiumScreen({
|
|||||||
})
|
})
|
||||||
if (!liveMatchRef.current) {
|
if (!liveMatchRef.current) {
|
||||||
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
|
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
|
||||||
buffCatalog,
|
cpuBuffCatalog,
|
||||||
stadiumShopPointsForOutcome(outcome, 'opponent'),
|
stadiumShopPointsForOutcome(outcome, 'opponent'),
|
||||||
)
|
)
|
||||||
setCpuSide((current) => {
|
setCpuSide((current) => {
|
||||||
@@ -832,14 +877,17 @@ export function PvpStadiumScreen({
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [buffCatalog, startShopTimer])
|
}, [cpuBuffCatalog, startShopTimer])
|
||||||
|
|
||||||
const finishRound = useCallback((outcome: StadiumRoundOutcome) => {
|
const finishRound = useCallback((outcome: StadiumRoundOutcome) => {
|
||||||
if (status !== 'playing') return
|
if (status !== 'playing') return
|
||||||
if (roundResolvedRef.current) return
|
if (roundResolvedRef.current) return
|
||||||
roundResolvedRef.current = true
|
roundResolvedRef.current = true
|
||||||
const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins })
|
const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins })
|
||||||
awardXp(result.roundExperience.key, result.roundExperience.mode)
|
const roundCoinSource = stadiumBossCoinSource(stadiumCoinBosses, roundIndex)
|
||||||
|
awardXp(result.roundExperience.key, result.roundExperience.mode, roundCoinSource
|
||||||
|
? { encounter: roundCoinSource, stage: roundIndex, label: `Round ${roundIndex}: ${roundCoinSource.enemyName}` }
|
||||||
|
: undefined)
|
||||||
addLog(result.log.text, result.log.tone)
|
addLog(result.log.text, result.log.tone)
|
||||||
if (result.status === 'won') {
|
if (result.status === 'won') {
|
||||||
setRoundWins(result.nextWins)
|
setRoundWins(result.nextWins)
|
||||||
@@ -849,7 +897,13 @@ export function PvpStadiumScreen({
|
|||||||
playerRef.current = next
|
playerRef.current = next
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
if (result.matchExperience) awardXp(result.matchExperience.key, result.matchExperience.mode)
|
if (result.matchExperience) {
|
||||||
|
const matchCoinStage = roundIndex + 1
|
||||||
|
const matchCoinSource = stadiumBossCoinSource(stadiumCoinBosses, matchCoinStage)
|
||||||
|
awardXp(result.matchExperience.key, result.matchExperience.mode, matchCoinSource
|
||||||
|
? { encounter: matchCoinSource, stage: matchCoinStage, label: `Match Win: ${matchCoinSource.enemyName}` }
|
||||||
|
: undefined)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.status === 'lost') {
|
if (result.status === 'lost') {
|
||||||
@@ -863,7 +917,7 @@ export function PvpStadiumScreen({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
beginShop(outcome, result.nextWins)
|
beginShop(outcome, result.nextWins)
|
||||||
}, [addLog, awardXp, beginShop, roundIndex, roundWins, status])
|
}, [addLog, awardXp, beginShop, roundIndex, roundWins, stadiumCoinBosses, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'playing' || paused) return
|
if (status !== 'playing' || paused) return
|
||||||
@@ -1077,12 +1131,12 @@ export function PvpStadiumScreen({
|
|||||||
encounterCount: 5,
|
encounterCount: 5,
|
||||||
party: playerSide.party,
|
party: playerSide.party,
|
||||||
opponentName: opponentLabel,
|
opponentName: opponentLabel,
|
||||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `${cpuGameClass.name} | CPU ${cpuDifficulty}` : cpuGameClass.name),
|
||||||
opponentParty: cpuSide.party,
|
opponentParty: cpuSide.party,
|
||||||
opponentEnemyHealth: 0,
|
opponentEnemyHealth: 0,
|
||||||
opponentResource: cpuSide.resource,
|
opponentResource: cpuSide.resource,
|
||||||
opponentMaxResource: MAX_RESOURCE,
|
opponentMaxResource: MAX_RESOURCE,
|
||||||
opponentResourceName: gameClass.resourceName,
|
opponentResourceName: liveMatch ? gameClass.resourceName : cpuGameClass.resourceName,
|
||||||
opponentBuffSummary,
|
opponentBuffSummary,
|
||||||
opponentDebuffSummary,
|
opponentDebuffSummary,
|
||||||
floatingTexts: dualScreenFloatingTexts,
|
floatingTexts: dualScreenFloatingTexts,
|
||||||
@@ -1110,13 +1164,15 @@ export function PvpStadiumScreen({
|
|||||||
activeBindings,
|
activeBindings,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
cpuDifficulty,
|
cpuDifficulty,
|
||||||
|
cpuGameClass.name,
|
||||||
|
cpuGameClass.resourceName,
|
||||||
cpuSide.party,
|
cpuSide.party,
|
||||||
cpuSide.resource,
|
cpuSide.resource,
|
||||||
cpuSide.survivalSeconds,
|
cpuSide.survivalSeconds,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
dualScreenFloatingTexts,
|
dualScreenFloatingTexts,
|
||||||
gameClass.resourceName,
|
gameClass.resourceName,
|
||||||
liveMatch?.opponentClassName,
|
liveMatch,
|
||||||
opponentBuffSummary,
|
opponentBuffSummary,
|
||||||
opponentDebuffSummary,
|
opponentDebuffSummary,
|
||||||
opponentLabel,
|
opponentLabel,
|
||||||
@@ -1277,10 +1333,10 @@ export function PvpStadiumScreen({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Opponent</p>
|
<p className="eyebrow">Opponent</p>
|
||||||
<h2>{opponentLabel}</h2>
|
<h2>{opponentLabel}</h2>
|
||||||
<small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
<small>{liveMatch?.opponentClassName ?? cpuGameClass.name} | Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
<span>{liveMatch ? gameClass.resourceName : cpuGameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||||
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1401,8 +1457,10 @@ export function PvpStadiumScreen({
|
|||||||
>
|
>
|
||||||
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
|
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
|
||||||
<div className="reward-summary">
|
<div className="reward-summary">
|
||||||
|
<p>Run totals</p>
|
||||||
<RewardXpSummary reward={rewardSummary} />
|
<RewardXpSummary reward={rewardSummary} />
|
||||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||||
|
<PvpRunLootList loot={stadiumBossCoins} emptyLabel="No boss coins collected this run." />
|
||||||
</div>
|
</div>
|
||||||
</ResultScreen>
|
</ResultScreen>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CombatLogEntry } from '../game'
|
import type { CombatLogEntry } from '../game'
|
||||||
|
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
||||||
import type { DungeonReward, LootRoll } from '../profile'
|
import type { DungeonReward, LootRoll } from '../profile'
|
||||||
|
|
||||||
type BonusItem = NonNullable<DungeonReward['bonusItem']>
|
type BonusItem = NonNullable<DungeonReward['bonusItem']>
|
||||||
@@ -119,14 +120,16 @@ export function LootRollList({
|
|||||||
|
|
||||||
export function PvpRunLootList({
|
export function PvpRunLootList({
|
||||||
loot,
|
loot,
|
||||||
|
emptyLabel = 'No boss coins awarded',
|
||||||
}: {
|
}: {
|
||||||
loot: BonusItem[]
|
loot: RunBossCoinAward[]
|
||||||
|
emptyLabel?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="run-loot-rolls">
|
<div className="run-loot-rolls">
|
||||||
{loot.length > 0 ? loot.map((item, index) => (
|
{loot.length > 0 ? loot.map((item, index) => (
|
||||||
<div className="dropped" key={`${item.id}-${index}`}>
|
<div className="dropped" key={`${item.id}-${index}`}>
|
||||||
<strong>Boss {index + 1}</strong>
|
<strong>{item.sourceLabel}</strong>
|
||||||
<span>
|
<span>
|
||||||
{item.glyph} {item.name} x{item.quantity}
|
{item.glyph} {item.name} x{item.quantity}
|
||||||
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||||
@@ -134,8 +137,8 @@ export function PvpRunLootList({
|
|||||||
</div>
|
</div>
|
||||||
)) : (
|
)) : (
|
||||||
<div>
|
<div>
|
||||||
<strong>Loot</strong>
|
<strong>Boss Coins</strong>
|
||||||
<span>No boss loot awarded</span>
|
<span>{emptyLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export type DualScreenCombatState = {
|
|||||||
opponentClassName?: string
|
opponentClassName?: string
|
||||||
opponentParty?: PartyMember[]
|
opponentParty?: PartyMember[]
|
||||||
opponentEnemyHealth?: number
|
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
|
opponentResource?: number
|
||||||
opponentMaxResource?: number
|
opponentMaxResource?: number
|
||||||
opponentResourceName?: string
|
opponentResourceName?: string
|
||||||
@@ -638,6 +643,48 @@ export function DualScreenBottomDisplay() {
|
|||||||
</section>
|
</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' : ''}`}>
|
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
||||||
{state.opponentParty.map((member) => (
|
{state.opponentParty.map((member) => (
|
||||||
<PartyMemberFrame
|
<PartyMemberFrame
|
||||||
|
|||||||
@@ -1127,9 +1127,6 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
throw new Error('That difficulty is not available for this dungeon.')
|
throw new Error('That difficulty is not available for this dungeon.')
|
||||||
}
|
}
|
||||||
const cd = save.characters[save.activeClassId]
|
const cd = save.characters[save.activeClassId]
|
||||||
if (cd.level < difficulty.unlockLevel) {
|
|
||||||
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousLevel = cd.level
|
const previousLevel = cd.level
|
||||||
const previousExperience = cd.experience
|
const previousExperience = cd.experience
|
||||||
@@ -1251,9 +1248,6 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
throw new Error('That difficulty is not available for this roguelike.')
|
throw new Error('That difficulty is not available for this roguelike.')
|
||||||
}
|
}
|
||||||
const cd = save.characters[save.activeClassId]
|
const cd = save.characters[save.activeClassId]
|
||||||
if (cd.level < difficulty.unlockLevel) {
|
|
||||||
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousLevel = cd.level
|
const previousLevel = cd.level
|
||||||
const previousExperience = cd.experience
|
const previousExperience = cd.experience
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
|||||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
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 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 MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
|
||||||
const HEADER_CONTENT_SELECTOR = '.app-header'
|
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 GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
|
||||||
const CONTROLLER_REPEAT_INITIAL_MS = 260
|
const CONTROLLER_REPEAT_INITIAL_MS = 260
|
||||||
const CONTROLLER_REPEAT_MS = 85
|
const CONTROLLER_REPEAT_MS = 85
|
||||||
|
const NATIVE_STICK_TIMEOUT_MS = 140
|
||||||
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
||||||
Button12: 'navigateUp',
|
Button12: 'navigateUp',
|
||||||
Button13: 'navigateDown',
|
Button13: 'navigateDown',
|
||||||
@@ -1087,6 +1089,8 @@ export function useMovementVectorRef(enabled = true) {
|
|||||||
|
|
||||||
let frame = 0
|
let frame = 0
|
||||||
const pressedKeys = new Set<string>()
|
const pressedKeys = new Set<string>()
|
||||||
|
let nativeStick: MovementVector = { x: 0, y: 0 }
|
||||||
|
let nativeStickUpdatedAt = 0
|
||||||
const keyboardVector = () => normalizedVector(
|
const keyboardVector = () => normalizedVector(
|
||||||
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
|
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
|
||||||
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
|
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
|
||||||
@@ -1094,12 +1098,26 @@ export function useMovementVectorRef(enabled = true) {
|
|||||||
const updateMovement = () => {
|
const updateMovement = () => {
|
||||||
const gamepad = firstConnectedGamepad()
|
const gamepad = firstConnectedGamepad()
|
||||||
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
|
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()
|
const keyboard = keyboardVector()
|
||||||
movementRef.current = Math.hypot(stick.x, stick.y) > 0
|
movementRef.current = Math.hypot(native.x, native.y) > 0
|
||||||
? stick
|
? native
|
||||||
: keyboard
|
: Math.hypot(stick.x, stick.y) > 0
|
||||||
|
? stick
|
||||||
|
: keyboard
|
||||||
frame = window.requestAnimationFrame(updateMovement)
|
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) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (isTextInput(document.activeElement)) return
|
if (isTextInput(document.activeElement)) return
|
||||||
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
||||||
@@ -1113,12 +1131,14 @@ export function useMovementVectorRef(enabled = true) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
window.addEventListener('keyup', onKeyUp)
|
window.addEventListener('keyup', onKeyUp)
|
||||||
frame = window.requestAnimationFrame(updateMovement)
|
frame = window.requestAnimationFrame(updateMovement)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.cancelAnimationFrame(frame)
|
window.cancelAnimationFrame(frame)
|
||||||
|
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
window.removeEventListener('keyup', onKeyUp)
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
movementRef.current = { x: 0, y: 0 }
|
movementRef.current = { x: 0, y: 0 }
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||||
import { AuthScreen } from '../../components/AuthScreen'
|
|
||||||
import {
|
import {
|
||||||
loadCpuPvpLeaderboard,
|
loadCpuPvpLeaderboard,
|
||||||
type CpuPvpLeaderboardEntry,
|
type CpuPvpLeaderboardEntry,
|
||||||
@@ -138,11 +137,21 @@ function ScreenLoading() {
|
|||||||
|
|
||||||
type RoguelikeVariant = 'pve' | 'pvp'
|
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 { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [screen, setScreen] = useState<Screen>('menu')
|
const [screen, setScreen] = useState<Screen>('menu')
|
||||||
const [account, setAccount] = useState<Account | null>(null)
|
const [account, setAccount] = useState<Account | null>(initialSession.account)
|
||||||
const [profile, setProfile] = useState<CharacterProfile | null>(null)
|
const [profile, setProfile] = useState<CharacterProfile | null>(initialSession.profile)
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
|
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
|
||||||
const [serverMessage, setServerMessage] = useState('')
|
const [serverMessage, setServerMessage] = useState('')
|
||||||
@@ -186,6 +195,10 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
.finally(() => setAuthChecked(true))
|
.finally(() => setAuthChecked(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authChecked && (!account || !profile)) onAuthenticationCleared()
|
||||||
|
}, [account, authChecked, onAuthenticationCleared, profile])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleModeChange = (event: Event) => {
|
const handleModeChange = (event: Event) => {
|
||||||
const nextMode = (event as CustomEvent<GameMode>).detail
|
const nextMode = (event as CustomEvent<GameMode>).detail
|
||||||
@@ -292,7 +305,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
const characterLevel = profile?.character.level ?? 0
|
const characterLevel = profile?.character.level ?? 0
|
||||||
const savedTier = tierOptions.find((candidate) => (
|
const savedTier = tierOptions.find((candidate) => (
|
||||||
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||||
&& characterLevel >= candidate.unlockLevel
|
|
||||||
))
|
))
|
||||||
if (savedTier) return savedTier
|
if (savedTier) return savedTier
|
||||||
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
|
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
|
||||||
@@ -350,18 +362,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
|
[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() {
|
async function signOut() {
|
||||||
try {
|
try {
|
||||||
await logoutAccount()
|
await logoutAccount()
|
||||||
@@ -371,6 +371,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
setSyncMessage('')
|
setSyncMessage('')
|
||||||
setSyncComparison(null)
|
setSyncComparison(null)
|
||||||
|
onAuthenticationCleared()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
||||||
}
|
}
|
||||||
@@ -498,7 +499,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
|
|
||||||
function dungeonEntries() {
|
function dungeonEntries() {
|
||||||
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
||||||
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
|
const locked = !profile || !difficulty
|
||||||
const entries: DungeonNavEntry[] = [
|
const entries: DungeonNavEntry[] = [
|
||||||
{ kind: 'back' },
|
{ kind: 'back' },
|
||||||
]
|
]
|
||||||
@@ -520,7 +521,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
entries.push({
|
entries.push({
|
||||||
kind: 'activity',
|
kind: 'activity',
|
||||||
index,
|
index,
|
||||||
disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel,
|
disabled: !profile || !candidateDifficulty,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
entries.push(
|
entries.push(
|
||||||
@@ -531,7 +532,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
entries.push({
|
entries.push({
|
||||||
kind: 'tier',
|
kind: 'tier',
|
||||||
index,
|
index,
|
||||||
disabled: !profile || profile.character.level < difficultyOption.unlockLevel,
|
disabled: !profile || !difficultyOption,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
entries.push({ kind: 'loot' })
|
entries.push({ kind: 'loot' })
|
||||||
@@ -590,7 +591,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
|
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
|
||||||
) ?? candidate.difficulties[0]
|
) ?? candidate.difficulties[0]
|
||||||
: candidate.difficulties[0]
|
: candidate.difficulties[0]
|
||||||
if (profile && profile.character.level < difficulty.unlockLevel) return
|
|
||||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||||
else setSelectedDungeonId(candidate.id)
|
else setSelectedDungeonId(candidate.id)
|
||||||
setSelectedDifficultyId(difficulty.id)
|
setSelectedDifficultyId(difficulty.id)
|
||||||
@@ -599,7 +599,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
function selectTierByIndex(index: number) {
|
function selectTierByIndex(index: number) {
|
||||||
const difficulty = tierOptions[index]
|
const difficulty = tierOptions[index]
|
||||||
const activity = selectedActivityOption ?? activityOptions[0]
|
const activity = selectedActivityOption ?? activityOptions[0]
|
||||||
if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return
|
if (!difficulty || !activity) return
|
||||||
setActivityPage(0)
|
setActivityPage(0)
|
||||||
const nextActivity = activity.difficulties.some(
|
const nextActivity = activity.difficulties.some(
|
||||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
@@ -620,7 +620,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
function startSelectedRun(marathon: boolean) {
|
function startSelectedRun(marathon: boolean) {
|
||||||
const activity = selectedActivityOption ?? activityOptions[0]
|
const activity = selectedActivityOption ?? activityOptions[0]
|
||||||
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
|
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
|
||||||
if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return
|
if (!activity || !difficulty) return
|
||||||
setSelectedMarathonMode(marathon)
|
setSelectedMarathonMode(marathon)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
setSelectedDifficultyId(difficulty.id)
|
setSelectedDifficultyId(difficulty.id)
|
||||||
@@ -785,7 +785,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
|| !selectedActivityOption
|
|| !selectedActivityOption
|
||||||
|| !selectedDifficultyOption
|
|| !selectedDifficultyOption
|
||||||
) return null
|
) return null
|
||||||
const locked = profile.character.level < selectedDifficultyOption.unlockLevel
|
|
||||||
return {
|
return {
|
||||||
contentType: selectedActivityOption.contentType,
|
contentType: selectedActivityOption.contentType,
|
||||||
title: selectedActivityOption.name,
|
title: selectedActivityOption.name,
|
||||||
@@ -795,7 +794,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
difficultyName: selectedDifficultyOption.name,
|
difficultyName: selectedDifficultyOption.name,
|
||||||
itemLevel: selectedDifficultyOption.droppedItemLevel,
|
itemLevel: selectedDifficultyOption.droppedItemLevel,
|
||||||
experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier),
|
experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier),
|
||||||
lockedReason: locked ? `Unlocks at level ${selectedDifficultyOption.unlockLevel}` : undefined,
|
lockedReason: undefined,
|
||||||
stats: {
|
stats: {
|
||||||
health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`,
|
health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`,
|
||||||
damage: `${selectedDifficultyOption.damageMultiplier.toFixed(2)}x`,
|
damage: `${selectedDifficultyOption.damageMultiplier.toFixed(2)}x`,
|
||||||
@@ -832,10 +831,13 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
|
|
||||||
if (!account || !profile) {
|
if (!account || !profile) {
|
||||||
return (
|
return (
|
||||||
<AuthScreen
|
<main className="game-shell">
|
||||||
onAuthenticated={acceptSession}
|
<section className="message-panel">
|
||||||
serverMessage={serverMessage}
|
<p className="eyebrow">Opening Chronicle</p>
|
||||||
/>
|
<h1>Returning to Sign In...</h1>
|
||||||
|
{serverMessage && <p>{serverMessage}</p>}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,7 +914,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
|
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
|
||||||
const activity = selectedActivityOption ?? dungeon
|
const activity = selectedActivityOption ?? dungeon
|
||||||
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
|
||||||
return (
|
return (
|
||||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
||||||
{screen !== 'hunter-profile' && (
|
{screen !== 'hunter-profile' && (
|
||||||
@@ -1266,13 +1267,11 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
const difficulty = candidate.difficulties.find(
|
const difficulty = candidate.difficulties.find(
|
||||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||||
) ?? candidate.difficulties[0]
|
) ?? candidate.difficulties[0]
|
||||||
const locked = profile.character.level < difficulty.unlockLevel
|
|
||||||
const selected = candidate.id === activity.id
|
const selected = candidate.id === activity.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
className={`activity-card ${selected ? 'selected' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={locked}
|
|
||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||||
@@ -1305,16 +1304,13 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
<h2>Run</h2>
|
<h2>Run</h2>
|
||||||
</div>
|
</div>
|
||||||
<small>
|
<small>
|
||||||
{difficultyLocked
|
Pick a hunt or marathon.
|
||||||
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
|
|
||||||
: 'Pick a hunt or marathon.'}
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="part-picker">
|
<div className="part-picker">
|
||||||
<button
|
<button
|
||||||
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
|
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={difficultyLocked}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMarathonMode(false)
|
setSelectedMarathonMode(false)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
@@ -1329,7 +1325,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
<button
|
<button
|
||||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
|
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={difficultyLocked}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMarathonMode(true)
|
setSelectedMarathonMode(true)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
@@ -1350,17 +1345,15 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
<p className="eyebrow">Item Level</p>
|
<p className="eyebrow">Item Level</p>
|
||||||
<h2>Tier</h2>
|
<h2>Tier</h2>
|
||||||
</div>
|
</div>
|
||||||
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers are available at every level.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="tier-grid">
|
<div className="tier-grid">
|
||||||
{tierOptions.map((difficulty, index) => {
|
{tierOptions.map((difficulty, index) => {
|
||||||
const locked = profile.character.level < difficulty.unlockLevel
|
|
||||||
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
className={`${selected ? 'selected' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={locked}
|
|
||||||
key={difficulty.id}
|
key={difficulty.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActivityPage(0)
|
setActivityPage(0)
|
||||||
@@ -1384,7 +1377,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||||
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
|
<span>{difficulty.name}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useGameAction } from '../../input'
|
import { useGameAction } from '../../input'
|
||||||
|
import { getGameMode } from '../../gameRepository'
|
||||||
|
import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle'
|
||||||
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
|
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
|
||||||
import {
|
import {
|
||||||
useDualScreen,
|
useDualScreen,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
Iwt2CloudSaveScreen,
|
Iwt2CloudSaveScreen,
|
||||||
Iwt2CustomizeCharacterScreen,
|
Iwt2CustomizeCharacterScreen,
|
||||||
Iwt2DungeonsScreen,
|
Iwt2DungeonsScreen,
|
||||||
|
Iwt2GearUpgradeScreen,
|
||||||
Iwt2HunterProfileScreen,
|
Iwt2HunterProfileScreen,
|
||||||
Iwt2ModeScreen,
|
Iwt2ModeScreen,
|
||||||
Iwt2RoguelikeScreen,
|
Iwt2RoguelikeScreen,
|
||||||
@@ -25,6 +28,13 @@ import {
|
|||||||
type Iwt2Save,
|
type Iwt2Save,
|
||||||
} from './save/iwt2Repository'
|
} from './save/iwt2Repository'
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||||
|
import { iwt2BossCoinRewardFor } from './content/bossRewards'
|
||||||
|
import {
|
||||||
|
findIwt2Difficulty,
|
||||||
|
IWT2_DUNGEON_DIFFICULTIES,
|
||||||
|
IWT2_RAID_DIFFICULTIES,
|
||||||
|
type Iwt2Difficulty,
|
||||||
|
} from './content/difficulties'
|
||||||
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||||
import {
|
import {
|
||||||
buildIwt2OpponentDebuffChoices,
|
buildIwt2OpponentDebuffChoices,
|
||||||
@@ -46,14 +56,17 @@ type Iwt2Screen =
|
|||||||
| 'roguelike'
|
| 'roguelike'
|
||||||
| 'roguelike-arena'
|
| 'roguelike-arena'
|
||||||
| 'roguelike-upgrade'
|
| 'roguelike-upgrade'
|
||||||
|
| 'gear-upgrade'
|
||||||
| 'hunter-profile'
|
| 'hunter-profile'
|
||||||
| 'customize-character'
|
| 'customize-character'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
|
||||||
const IWT2_MENU_COLUMNS = 4
|
const IWT2_MENU_COLUMNS = 2
|
||||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||||
|
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
||||||
|
|
||||||
type Iwt2RoguelikeRunState = {
|
type Iwt2RoguelikeRunState = {
|
||||||
|
bossIds: Iwt2BossId[]
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
@@ -69,16 +82,10 @@ const MENU_ITEMS: Array<{
|
|||||||
description: string
|
description: string
|
||||||
glyph: string
|
glyph: string
|
||||||
}> = [
|
}> = [
|
||||||
{
|
|
||||||
screen: 'cloud-save',
|
|
||||||
title: 'Backup Slot',
|
|
||||||
description: 'Save or restore the isolated IWT2 progress slot.',
|
|
||||||
glyph: 'C',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
screen: 'dungeons',
|
screen: 'dungeons',
|
||||||
title: 'Dungeons',
|
title: 'Dungeons',
|
||||||
description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.',
|
description: 'Queue into seven modular IWT2 boss arenas.',
|
||||||
glyph: 'D',
|
glyph: 'D',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,6 +106,12 @@ const MENU_ITEMS: Array<{
|
|||||||
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
||||||
glyph: 'P',
|
glyph: 'P',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
screen: 'gear-upgrade',
|
||||||
|
title: 'Gear Upgrade',
|
||||||
|
description: 'Spend boss coins on class gear slots and infusion abilities.',
|
||||||
|
glyph: 'G',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
screen: 'hunter-profile',
|
screen: 'hunter-profile',
|
||||||
title: 'Hunter Profile',
|
title: 'Hunter Profile',
|
||||||
@@ -111,6 +124,12 @@ const MENU_ITEMS: Array<{
|
|||||||
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
||||||
glyph: 'K',
|
glyph: 'K',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
screen: 'cloud-save',
|
||||||
|
title: 'Backup Slot',
|
||||||
|
description: 'Choose local, online, or fresh IWT2 progress.',
|
||||||
|
glyph: 'C',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
screen: 'settings',
|
screen: 'settings',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -119,24 +138,47 @@ const MENU_ITEMS: Array<{
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
export function IWantToHeal2App({
|
||||||
|
onlineBackupsAvailable,
|
||||||
|
onBackToGameSelect,
|
||||||
|
}: {
|
||||||
|
onlineBackupsAvailable: boolean
|
||||||
|
onBackToGameSelect: () => void
|
||||||
|
}) {
|
||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||||
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||||
|
const [arenaDifficulty, setArenaDifficulty] = useState<Iwt2Difficulty>(IWT2_DUNGEON_DIFFICULTIES[0])
|
||||||
|
const [selectedDungeonDifficultySlug, setSelectedDungeonDifficultySlug] = useState(IWT2_DUNGEON_DIFFICULTIES[0].slug)
|
||||||
|
const [selectedRaidDifficultySlug, setSelectedRaidDifficultySlug] = useState(IWT2_RAID_DIFFICULTIES[0].slug)
|
||||||
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
||||||
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
||||||
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
||||||
|
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
|
||||||
|
const [pvpQueueing, setPvpQueueing] = useState(false)
|
||||||
|
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeIwt2Save(save)
|
writeIwt2Save(save)
|
||||||
}, [save])
|
}, [save])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
cancelPvpQueueRef.current?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function cancelIwt2PvpQueue() {
|
||||||
|
cancelPvpQueueRef.current?.()
|
||||||
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueing(false)
|
||||||
|
setPvpQueueMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
|
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
|
||||||
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
|
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save, currentSetupDifficulty(screen, selectedDungeonDifficultySlug, selectedRaidDifficultySlug)),
|
||||||
[save, screen, selectedBossId],
|
[save, screen, selectedBossId, selectedDungeonDifficultySlug, selectedRaidDifficultySlug],
|
||||||
)
|
)
|
||||||
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
||||||
() => buildIwt2WorkshopDualScreenState(screen, save),
|
() => buildIwt2WorkshopDualScreenState(screen, save),
|
||||||
@@ -182,6 +224,8 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
return (
|
return (
|
||||||
<BossArenaScreen
|
<BossArenaScreen
|
||||||
bossId={selectedBossId}
|
bossId={selectedBossId}
|
||||||
|
difficulty={arenaDifficulty}
|
||||||
|
key={`arena-${selectedBossId}-${arenaModeLabel}-${arenaDifficulty.slug}`}
|
||||||
modeLabel={arenaModeLabel}
|
modeLabel={arenaModeLabel}
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
@@ -193,7 +237,9 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
if (screen === 'roguelike-arena' && roguelikeRun) {
|
if (screen === 'roguelike-arena' && roguelikeRun) {
|
||||||
return (
|
return (
|
||||||
<BossArenaScreen
|
<BossArenaScreen
|
||||||
|
bossIds={roguelikeRun.bossIds}
|
||||||
bossId={selectedBossId}
|
bossId={selectedBossId}
|
||||||
|
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
|
||||||
roguelikeRun={{
|
roguelikeRun={{
|
||||||
buffs: roguelikeRun.buffs,
|
buffs: roguelikeRun.buffs,
|
||||||
contentType: roguelikeRun.contentType,
|
contentType: roguelikeRun.contentType,
|
||||||
@@ -212,6 +258,10 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
}}
|
}}
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('roguelike')}
|
onBack={() => setScreen('roguelike')}
|
||||||
|
onPvpRequeue={() => {
|
||||||
|
setScreen('roguelike')
|
||||||
|
startIwt2RoguelikeRun()
|
||||||
|
}}
|
||||||
onSaveUpdated={setSave}
|
onSaveUpdated={setSave}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -233,7 +283,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
onChoose={(buffId, debuffId) => {
|
onChoose={(buffId, debuffId) => {
|
||||||
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
|
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
|
||||||
setRoguelikeRun(nextRun)
|
setRoguelikeRun(nextRun)
|
||||||
setSelectedBossId(bossForRoguelike(nextRun.variant, nextRun.contentType, nextRun.stage))
|
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||||
setScreen('roguelike-arena')
|
setScreen('roguelike-arena')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -246,12 +296,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2DungeonsScreen
|
<Iwt2DungeonsScreen
|
||||||
|
difficultySlug={selectedDungeonDifficultySlug}
|
||||||
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onOpenBoss={(bossId) => {
|
onDifficultyChange={setSelectedDungeonDifficultySlug}
|
||||||
setArenaModeLabel('Dungeon')
|
onOpenBoss={(bossId, difficulty) => {
|
||||||
|
setArenaModeLabel(`${difficulty.name} Dungeon`)
|
||||||
|
setArenaDifficulty(difficulty)
|
||||||
setSelectedBossId(bossId)
|
setSelectedBossId(bossId)
|
||||||
setScreen('arena')
|
setScreen('arena')
|
||||||
}}
|
}}
|
||||||
|
onPreviewBoss={setSelectedBossId}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -264,20 +319,24 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<Iwt2RoguelikeScreen
|
<Iwt2RoguelikeScreen
|
||||||
contentType={roguelikeContentType}
|
contentType={roguelikeContentType}
|
||||||
variant={roguelikeVariant}
|
variant={roguelikeVariant}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => {
|
||||||
|
cancelIwt2PvpQueue()
|
||||||
|
setScreen('menu')
|
||||||
|
}}
|
||||||
|
onCancelQueue={cancelIwt2PvpQueue}
|
||||||
onContentTypeChange={setRoguelikeContentType}
|
onContentTypeChange={setRoguelikeContentType}
|
||||||
onStart={() => {
|
onStart={() => {
|
||||||
const nextRun = createRoguelikeRun(save, roguelikeVariant, roguelikeContentType)
|
startIwt2RoguelikeRun()
|
||||||
setRoguelikeRun(nextRun)
|
|
||||||
setSelectedBossId(bossForRoguelike(roguelikeVariant, roguelikeContentType, nextRun.stage))
|
|
||||||
setScreen('roguelike-arena')
|
|
||||||
}}
|
}}
|
||||||
onVariantChange={(nextVariant) => {
|
onVariantChange={(nextVariant) => {
|
||||||
|
cancelIwt2PvpQueue()
|
||||||
setRoguelikeVariant(nextVariant)
|
setRoguelikeVariant(nextVariant)
|
||||||
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||||
setRoguelikeContentType('dungeon')
|
setRoguelikeContentType('dungeon')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
queueing={pvpQueueing}
|
||||||
|
queueMessage={pvpQueueMessage}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -288,13 +347,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2ModeScreen
|
<Iwt2ModeScreen
|
||||||
|
difficultySlug={selectedRaidDifficultySlug}
|
||||||
mode="Raids"
|
mode="Raids"
|
||||||
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onOpenBoss={(bossId) => {
|
onDifficultyChange={setSelectedRaidDifficultySlug}
|
||||||
setArenaModeLabel('Raid')
|
onOpenBoss={(bossId, difficulty) => {
|
||||||
|
setArenaModeLabel(`${difficulty.name} Raid`)
|
||||||
|
setArenaDifficulty(difficulty)
|
||||||
setSelectedBossId(bossId)
|
setSelectedBossId(bossId)
|
||||||
setScreen('arena')
|
setScreen('arena')
|
||||||
}}
|
}}
|
||||||
|
onPreviewBoss={setSelectedBossId}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -303,12 +367,29 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
if (screen === 'hunter-profile') {
|
if (screen === 'hunter-profile') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
|
||||||
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
|
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (screen === 'gear-upgrade') {
|
||||||
|
return (
|
||||||
|
<main className="game-shell iwt2-shell">
|
||||||
|
<Iwt2Header
|
||||||
|
save={save}
|
||||||
|
title="Gear Upgrade"
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
onBackToGameSelect={onBackToGameSelect}
|
||||||
|
/>
|
||||||
|
<Iwt2GearUpgradeScreen
|
||||||
|
save={save}
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
onSaveUpdated={setSave}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (screen === 'customize-character') {
|
if (screen === 'customize-character') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
@@ -328,6 +409,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2CloudSaveScreen
|
<Iwt2CloudSaveScreen
|
||||||
save={save}
|
save={save}
|
||||||
|
onlineBackupsAvailable={onlineBackupsAvailable}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onSaveUpdated={setSave}
|
onSaveUpdated={setSave}
|
||||||
/>
|
/>
|
||||||
@@ -350,15 +432,13 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
||||||
<div className="iwt2-menu-heading">
|
|
||||||
<p className="eyebrow">I Want To Heal 2</p>
|
|
||||||
<h1>Mode Select</h1>
|
|
||||||
</div>
|
|
||||||
<div className="iwt2-menu-grid">
|
<div className="iwt2-menu-grid">
|
||||||
{MENU_ITEMS.map((item, index) => (
|
{MENU_ITEMS.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
||||||
|
aria-label={`${item.title}. ${item.description}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||||
key={`${item.screen}-${item.title}`}
|
key={`${item.screen}-${item.title}`}
|
||||||
onClick={() => openMenuItem(item)}
|
onClick={() => openMenuItem(item)}
|
||||||
onPointerDown={() => setSelectedIndex(index)}
|
onPointerDown={() => setSelectedIndex(index)}
|
||||||
@@ -376,24 +456,63 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function startIwt2RoguelikeRun() {
|
||||||
|
cancelIwt2PvpQueue()
|
||||||
|
if (roguelikeVariant !== 'pvp') {
|
||||||
|
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const startStage = 1
|
||||||
|
setPvpQueueing(true)
|
||||||
|
setPvpQueueMessage('Queuing for PvP...')
|
||||||
|
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
|
||||||
|
contentType: roguelikeContentType,
|
||||||
|
startStage,
|
||||||
|
gameMode: getGameMode(),
|
||||||
|
liveMatchActive: () => false,
|
||||||
|
onSearching: (message) => {
|
||||||
|
setPvpQueueing(true)
|
||||||
|
setPvpQueueMessage(message)
|
||||||
|
},
|
||||||
|
onCpuMatch: (_difficulty, message) => {
|
||||||
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueing(false)
|
||||||
|
setPvpQueueMessage(message)
|
||||||
|
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||||
|
},
|
||||||
|
onLiveMatch: (...liveMatchArgs) => {
|
||||||
|
const message = liveMatchArgs[2]
|
||||||
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueing(false)
|
||||||
|
setPvpQueueMessage(message)
|
||||||
|
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`,
|
||||||
|
searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`,
|
||||||
|
notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`,
|
||||||
|
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
|
||||||
|
liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginIwt2RoguelikeArena(
|
||||||
|
variant: Iwt2RoguelikeVariant,
|
||||||
|
contentType: Iwt2RoguelikeContentType,
|
||||||
|
) {
|
||||||
|
const nextRun = createRoguelikeRun(save, variant, contentType)
|
||||||
|
setRoguelikeRun(nextRun)
|
||||||
|
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||||
|
setScreen('roguelike-arena')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bossForRoguelike(
|
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||||
variant: Iwt2RoguelikeVariant,
|
if (contentType === 'raid') return 'Raid'
|
||||||
contentType: Iwt2RoguelikeContentType,
|
if (contentType === 'stadium') return 'Stadium'
|
||||||
stage = 1,
|
return 'Dungeon'
|
||||||
): Iwt2BossId {
|
|
||||||
const dungeonBosses: Iwt2BossId[] = variant === 'pvp'
|
|
||||||
? ['yian-kut-ku', 'great-jaggi', 'bulldrome', 'khezu']
|
|
||||||
: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
|
|
||||||
const raidBosses: Iwt2BossId[] = ['khezu', 'great-jaggi', 'yian-kut-ku', 'bulldrome']
|
|
||||||
const stadiumBosses: Iwt2BossId[] = ['great-jaggi', 'yian-kut-ku', 'khezu', 'bulldrome']
|
|
||||||
const pool = contentType === 'raid'
|
|
||||||
? raidBosses
|
|
||||||
: contentType === 'stadium'
|
|
||||||
? stadiumBosses
|
|
||||||
: dungeonBosses
|
|
||||||
return pool[(stage - 1) % pool.length]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRoguelikeRun(
|
function createRoguelikeRun(
|
||||||
@@ -402,6 +521,7 @@ function createRoguelikeRun(
|
|||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
): Iwt2RoguelikeRunState {
|
): Iwt2RoguelikeRunState {
|
||||||
return {
|
return {
|
||||||
|
bossIds: createRandomRoguelikeBossPair(),
|
||||||
buffs: [],
|
buffs: [],
|
||||||
contentType,
|
contentType,
|
||||||
debuffs: [],
|
debuffs: [],
|
||||||
@@ -416,7 +536,14 @@ function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
|
|||||||
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
||||||
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
||||||
return {
|
return {
|
||||||
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
selfChoices: variant === 'pvp'
|
||||||
|
? chooseRunChoicesWithPreferredFirst(
|
||||||
|
selfCatalog,
|
||||||
|
IWT2_ROGUELIKE_CHOICE_COUNT,
|
||||||
|
isExtraTargetBuff,
|
||||||
|
IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE,
|
||||||
|
)
|
||||||
|
: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
||||||
debuffChoices: variant === 'pvp'
|
debuffChoices: variant === 'pvp'
|
||||||
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
||||||
: [],
|
: [],
|
||||||
@@ -462,11 +589,17 @@ function applyRoguelikeChoice(
|
|||||||
return {
|
return {
|
||||||
...run,
|
...run,
|
||||||
...nextBase,
|
...nextBase,
|
||||||
|
bossIds: createRandomRoguelikeBossPair(),
|
||||||
...buildRoguelikeChoices(save, run.variant),
|
...buildRoguelikeChoices(save, run.variant),
|
||||||
stage: run.stage + 1,
|
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[] {
|
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
||||||
const pool = [...items]
|
const pool = [...items]
|
||||||
const choices: T[] = []
|
const choices: T[] = []
|
||||||
@@ -478,23 +611,63 @@ function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
|||||||
return choices
|
return choices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseRunChoicesWithPreferredFirst<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
count: number,
|
||||||
|
preferred: (item: T) => boolean,
|
||||||
|
preferredChance: number,
|
||||||
|
): T[] {
|
||||||
|
if (count <= 0) return []
|
||||||
|
const pool = [...items]
|
||||||
|
const choices: T[] = []
|
||||||
|
const preferredPool = pool.filter(preferred)
|
||||||
|
if (preferredPool.length > 0 && Math.random() < preferredChance) {
|
||||||
|
const preferredChoice = preferredPool[Math.floor(Math.random() * preferredPool.length)]
|
||||||
|
const preferredIndex = pool.indexOf(preferredChoice)
|
||||||
|
if (preferredIndex >= 0) {
|
||||||
|
const [choice] = pool.splice(preferredIndex, 1)
|
||||||
|
if (choice) choices.push(choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>) {
|
||||||
|
return choice.id.endsWith('-extra-target')
|
||||||
|
}
|
||||||
|
|
||||||
function Iwt2Header({
|
function Iwt2Header({
|
||||||
|
onBack,
|
||||||
onBackToGameSelect,
|
onBackToGameSelect,
|
||||||
save,
|
save,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
|
onBack?: () => void
|
||||||
onBackToGameSelect: () => void
|
onBackToGameSelect: () => void
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
||||||
<strong>Games</strong>
|
<strong>Games</strong>
|
||||||
</button>
|
</button>
|
||||||
|
{title && <strong className="iwt2-header-title">{title}</strong>}
|
||||||
<div className="character-summary">
|
<div className="character-summary">
|
||||||
<strong>{save.character.name}</strong>
|
<strong>{save.character.name}</strong>
|
||||||
<small>IWT2 Level {save.character.level}</small>
|
<small>IWT2 Level {save.character.level}</small>
|
||||||
<small>{save.character.experience} XP</small>
|
<small>{save.character.experience} XP</small>
|
||||||
</div>
|
</div>
|
||||||
|
{onBack && (
|
||||||
|
<button className="back-button iwt2-header-back" data-controller-nav="skip" onClick={onBack} type="button">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -503,63 +676,48 @@ function buildIwt2SetupDualScreenState(
|
|||||||
screen: Iwt2Screen,
|
screen: Iwt2Screen,
|
||||||
selectedBossId: Iwt2BossId,
|
selectedBossId: Iwt2BossId,
|
||||||
save: Iwt2Save,
|
save: Iwt2Save,
|
||||||
|
difficulty: Iwt2Difficulty,
|
||||||
): DualScreenSetupState | null {
|
): DualScreenSetupState | null {
|
||||||
if (screen !== 'dungeons' && screen !== 'raids') return null
|
if (screen !== 'dungeons' && screen !== 'raids') return null
|
||||||
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
||||||
const raid = screen === 'raids'
|
const raid = screen === 'raids'
|
||||||
|
const coinReward = iwt2BossCoinRewardFor(selectedBossId, difficulty.slug)
|
||||||
return {
|
return {
|
||||||
contentType: raid ? 'raid' : 'dungeon',
|
contentType: raid ? 'raid' : 'dungeon',
|
||||||
description: raid
|
description: raid
|
||||||
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
|
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics. Reward: ${coinReward.name}.`
|
||||||
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
|
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery. Reward: ${coinReward.name}.`,
|
||||||
difficultyName: `IWT2 Level ${save.character.level}`,
|
difficultyName: difficulty.name,
|
||||||
experience: 125,
|
experience: Math.round(125 * difficulty.experienceMultiplier),
|
||||||
initials: boss.icon,
|
initials: boss.icon,
|
||||||
itemLevel: save.character.level,
|
itemLevel: difficulty.droppedItemLevel,
|
||||||
|
lockedReason: undefined,
|
||||||
stats: {
|
stats: {
|
||||||
damage: `${boss.meleeDamage}`,
|
damage: `${difficulty.damageMultiplier.toFixed(2)}x`,
|
||||||
health: `${boss.maxHealth}`,
|
health: `${difficulty.healthMultiplier.toFixed(2)}x`,
|
||||||
loot: 'IWT2',
|
loot: coinReward.name,
|
||||||
xp: '125',
|
xp: `${difficulty.experienceMultiplier.toFixed(1)}x`,
|
||||||
},
|
},
|
||||||
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
||||||
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
|
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentSetupDifficulty(
|
||||||
|
screen: Iwt2Screen,
|
||||||
|
selectedDungeonDifficultySlug: string,
|
||||||
|
selectedRaidDifficultySlug: string,
|
||||||
|
): Iwt2Difficulty {
|
||||||
|
if (screen === 'raids') {
|
||||||
|
return findIwt2Difficulty(IWT2_RAID_DIFFICULTIES, selectedRaidDifficultySlug)
|
||||||
|
}
|
||||||
|
return findIwt2Difficulty(IWT2_DUNGEON_DIFFICULTIES, selectedDungeonDifficultySlug)
|
||||||
|
}
|
||||||
|
|
||||||
function buildIwt2WorkshopDualScreenState(
|
function buildIwt2WorkshopDualScreenState(
|
||||||
screen: Iwt2Screen,
|
screen: Iwt2Screen,
|
||||||
save: Iwt2Save,
|
save: Iwt2Save,
|
||||||
): DualScreenWorkshopState | null {
|
): DualScreenWorkshopState | null {
|
||||||
if (screen === 'hunter-profile') {
|
|
||||||
return {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
glyph: 'H',
|
|
||||||
meta: `${save.character.experience} XP`,
|
|
||||||
status: `Level ${save.character.level}`,
|
|
||||||
title: save.character.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
|
|
||||||
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
|
|
||||||
status: 'Class',
|
|
||||||
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
|
|
||||||
},
|
|
||||||
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
|
|
||||||
glyph: IWT2_BOSS_METADATA[bossId].icon,
|
|
||||||
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
|
|
||||||
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
|
|
||||||
title: IWT2_BOSS_METADATA[bossId].name,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
mode: 'collection',
|
|
||||||
subtitle: 'IWT2 inventory and collection log',
|
|
||||||
summary: `${save.inventory.length} inventory slots`,
|
|
||||||
title: 'Hunter Profile',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (screen === 'customize-character') {
|
if (screen === 'customize-character') {
|
||||||
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||||
return {
|
return {
|
||||||
@@ -578,10 +736,3 @@ function buildIwt2WorkshopDualScreenState(
|
|||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
|
|
||||||
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
|
|
||||||
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
|
|
||||||
if (bossId === 'khezu') return 'khezu-pearl'
|
|
||||||
return 'raw-bulldrome-coin'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function Iwt2BottomDisplay() {
|
|||||||
binding={activeBindings[action]}
|
binding={activeBindings[action]}
|
||||||
iconStyle="playstation"
|
iconStyle="playstation"
|
||||||
/>{' '}
|
/>{' '}
|
||||||
|
<img alt="" aria-hidden="true" className="iwt2-bottom-target-icon" draggable={false} src={target.uiIconUrl} />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,16 +2,25 @@ import type { Iwt2BossEntityState } from '../sim'
|
|||||||
import { ArenaBar } from './ArenaBars'
|
import { ArenaBar } from './ArenaBars'
|
||||||
import { IWT2_BOSS_METADATA } from '../content/bosses'
|
import { IWT2_BOSS_METADATA } from '../content/bosses'
|
||||||
|
|
||||||
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) {
|
export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
|
||||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
const entries = bosses ?? (boss ? [boss] : [])
|
||||||
return (
|
return (
|
||||||
<div className="iwt2-boss-hud">
|
<div className="iwt2-boss-hud">
|
||||||
<div>
|
{entries.map((entry) => {
|
||||||
<strong>{metadata.name}</strong>
|
const metadata = IWT2_BOSS_METADATA[entry.bossId]
|
||||||
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
|
return (
|
||||||
</div>
|
<div className="iwt2-boss-hud-row" key={entry.id}>
|
||||||
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
|
<div>
|
||||||
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function PartyFrames({
|
|||||||
<button
|
<button
|
||||||
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
|
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected ? 'true' : undefined}
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => onTarget(member.id)}
|
onClick={() => onTarget(member.id)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -39,7 +40,9 @@ export function PartyFrames({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span style={{ background: meta.color }}>{meta.icon}</span>
|
<span className="iwt2-party-portrait" style={{ backgroundColor: meta.color }}>
|
||||||
|
<img alt="" aria-hidden="true" draggable={false} src={meta.uiIconUrl} />
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="iwt2-party-row-title">
|
<div className="iwt2-party-row-title">
|
||||||
<strong>{meta.name}</strong>
|
<strong>{meta.name}</strong>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Iwt2BossId } from './bosses'
|
||||||
|
|
||||||
|
export const IWT2_BOSS_PET_DROP_RATE = 1 / 500
|
||||||
|
|
||||||
|
export type Iwt2BossCoinReward = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
|
itemLevel: number
|
||||||
|
glyph: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2BossPetReward = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Iwt2CoinTier = {
|
||||||
|
idPrefix: string
|
||||||
|
namePrefix: string
|
||||||
|
rarity: Iwt2BossCoinReward['rarity']
|
||||||
|
itemLevel: number
|
||||||
|
glyph: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_COIN_TIERS: Record<string, Iwt2CoinTier> = {
|
||||||
|
initiate: { idPrefix: 'raw', namePrefix: 'Raw', rarity: 'common', itemLevel: 1, glyph: 'R' },
|
||||||
|
veteran: { idPrefix: 'green', namePrefix: 'Green', rarity: 'uncommon', itemLevel: 10, glyph: 'G' },
|
||||||
|
champion: { idPrefix: 'blue', namePrefix: 'Blue', rarity: 'rare', itemLevel: 15, glyph: 'B' },
|
||||||
|
mythic: { idPrefix: 'purple', namePrefix: 'Purple', rarity: 'epic', itemLevel: 20, glyph: 'P' },
|
||||||
|
ascendant: { idPrefix: 'orange', namePrefix: 'Orange', rarity: 'legendary', itemLevel: 25, glyph: 'O' },
|
||||||
|
'raid-normal': { idPrefix: 'green', namePrefix: 'Green', rarity: 'uncommon', itemLevel: 10, glyph: 'G' },
|
||||||
|
'raid-champion': { idPrefix: 'blue', namePrefix: 'Blue', rarity: 'rare', itemLevel: 15, glyph: 'B' },
|
||||||
|
'raid-mythic': { idPrefix: 'purple', namePrefix: 'Purple', rarity: 'epic', itemLevel: 20, glyph: 'P' },
|
||||||
|
'raid-ascendant': { idPrefix: 'orange', namePrefix: 'Orange', rarity: 'legendary', itemLevel: 25, glyph: 'O' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_BOSS_REWARD_DISPLAY_NAMES: Record<Iwt2BossId, string> = {
|
||||||
|
'barroth': 'Mirehorn',
|
||||||
|
'bulldrome': 'Bristlemaw',
|
||||||
|
'cinderback-ricochet': 'Cinderback Ricochet',
|
||||||
|
'crystal-bat-matriarch': 'Crystal Bat Matriarch',
|
||||||
|
'ember-mantis-duelist': 'Ember Mantis Duelist',
|
||||||
|
'great-jaggi': 'Packfang Alpha',
|
||||||
|
'hollowcrown-revenant': 'Hollowcrown Revenant',
|
||||||
|
'khezu': 'Palevolt Maw',
|
||||||
|
'obsidian-ram-golem': 'Obsidian Ram Golem',
|
||||||
|
'rathian': 'Verdant Wyrm',
|
||||||
|
'rimebastion': 'Rimebastion',
|
||||||
|
'sandglass-scorpion': 'Sandglass Scorpion',
|
||||||
|
'stormcoil-wyrm': 'Stormcoil Wyrm',
|
||||||
|
'tobi-kadachi': 'Stormtail',
|
||||||
|
'venom-orchid-hydra': 'Venom Orchid Hydra',
|
||||||
|
'yian-kut-ku': 'Emberquill',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_BOSS_COIN_DIFFICULTY_SLUGS = [
|
||||||
|
'initiate',
|
||||||
|
'veteran',
|
||||||
|
'champion',
|
||||||
|
'mythic',
|
||||||
|
'ascendant',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const IWT2_LEGACY_BOSS_MATERIAL_REWARDS: Record<Iwt2BossId, { id: string, name: string }> = {
|
||||||
|
'barroth': { id: 'barroth-shell', name: 'Mirehorn Shell' },
|
||||||
|
'bulldrome': { id: 'raw-bulldrome-coin', name: 'Raw Bristlemaw 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: 'Packfang Alpha Hide' },
|
||||||
|
'hollowcrown-revenant': { id: 'hollowcrown-antler', name: 'Hollowcrown Antler' },
|
||||||
|
'khezu': { id: 'khezu-pearl', name: 'Palevolt Maw Pearl' },
|
||||||
|
'obsidian-ram-golem': { id: 'obsidian-ram-core', name: 'Obsidian Ram Core' },
|
||||||
|
'rathian': { id: 'rathian-scale', name: 'Verdant Wyrm 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: 'Stormtail Pelt' },
|
||||||
|
'venom-orchid-hydra': { id: 'venom-orchid-pod', name: 'Venom Orchid Pod' },
|
||||||
|
'yian-kut-ku': { id: 'yian-kut-ku-scale', name: 'Emberquill Scale' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_BOSS_PET_REWARDS: Record<Iwt2BossId, Iwt2BossPetReward> = {
|
||||||
|
'barroth': { id: 'barroth-pet', name: 'Mirehorn Pet' },
|
||||||
|
'bulldrome': { id: 'bulldrome-pet', name: 'Bristlemaw 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: 'Packfang Alpha Pet' },
|
||||||
|
'hollowcrown-revenant': { id: 'hollowcrown-revenant-pet', name: 'Hollowcrown Revenant Pet' },
|
||||||
|
'khezu': { id: 'khezu-pet', name: 'Palevolt Maw Pet' },
|
||||||
|
'obsidian-ram-golem': { id: 'obsidian-ram-golem-pet', name: 'Obsidian Ram Golem Pet' },
|
||||||
|
'rathian': { id: 'rathian-pet', name: 'Verdant Wyrm 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: 'Stormtail Pet' },
|
||||||
|
'venom-orchid-hydra': { id: 'venom-orchid-hydra-pet', name: 'Venom Orchid Hydra Pet' },
|
||||||
|
'yian-kut-ku': { id: 'yian-kut-ku-pet', name: 'Emberquill Pet' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2BossCoinRewardFor(
|
||||||
|
bossId: Iwt2BossId,
|
||||||
|
difficultySlug = 'initiate',
|
||||||
|
): Iwt2BossCoinReward {
|
||||||
|
const tier = IWT2_COIN_TIERS[difficultySlug] ?? IWT2_COIN_TIERS.initiate
|
||||||
|
return {
|
||||||
|
id: `${tier.idPrefix}-${bossId}-coin`,
|
||||||
|
name: `${tier.namePrefix} ${formatBossNameForCoin(bossId)} Coin`,
|
||||||
|
rarity: tier.rarity,
|
||||||
|
itemLevel: tier.itemLevel,
|
||||||
|
glyph: tier.glyph,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2BossCoinRewardsFor(bossId: Iwt2BossId): Iwt2BossCoinReward[] {
|
||||||
|
return IWT2_BOSS_COIN_DIFFICULTY_SLUGS.map((difficultySlug) => (
|
||||||
|
iwt2BossCoinRewardFor(bossId, difficultySlug)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2BossPetRewardFor(bossId: Iwt2BossId): Iwt2BossPetReward {
|
||||||
|
return IWT2_BOSS_PET_REWARDS[bossId]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBossNameForCoin(bossId: Iwt2BossId): string {
|
||||||
|
return IWT2_BOSS_REWARD_DISPLAY_NAMES[bossId]
|
||||||
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
|
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
|
||||||
|
|
||||||
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' | 'great-jaggi' | 'khezu'
|
export type Iwt2BossId =
|
||||||
|
| 'bulldrome'
|
||||||
|
| 'yian-kut-ku'
|
||||||
|
| 'great-jaggi'
|
||||||
|
| 'khezu'
|
||||||
|
| 'rathian'
|
||||||
|
| 'barroth'
|
||||||
|
| 'tobi-kadachi'
|
||||||
|
| 'rimebastion'
|
||||||
|
| 'ember-mantis-duelist'
|
||||||
|
| 'cinderback-ricochet'
|
||||||
|
| 'obsidian-ram-golem'
|
||||||
|
| 'stormcoil-wyrm'
|
||||||
|
| 'venom-orchid-hydra'
|
||||||
|
| 'sandglass-scorpion'
|
||||||
|
| 'crystal-bat-matriarch'
|
||||||
|
| 'hollowcrown-revenant'
|
||||||
|
|
||||||
export type Iwt2BalanceOverrides = {
|
export type Iwt2BalanceOverrides = {
|
||||||
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
|
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
|
||||||
@@ -10,6 +26,11 @@ export type Iwt2BossMetadata = {
|
|||||||
id: Iwt2BossId
|
id: Iwt2BossId
|
||||||
name: string
|
name: string
|
||||||
icon: string
|
icon: string
|
||||||
|
spriteUrl: string
|
||||||
|
spriteView?: 'topDown' | 'side'
|
||||||
|
spriteWidthScale?: number
|
||||||
|
spriteHeightScale?: number
|
||||||
|
spriteYOffsetScale?: number
|
||||||
color: string
|
color: string
|
||||||
accentColor: string
|
accentColor: string
|
||||||
maxHealth: number
|
maxHealth: number
|
||||||
@@ -64,45 +85,120 @@ export type Iwt2BossMetadata = {
|
|||||||
lightningStrikeDamage?: number
|
lightningStrikeDamage?: number
|
||||||
lightningStrikeStunSeconds?: number
|
lightningStrikeStunSeconds?: number
|
||||||
lightningStrikeCount?: 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 = {
|
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'bulldrome',
|
id: 'bulldrome',
|
||||||
name: 'Bulldrome',
|
name: 'Bristlemaw',
|
||||||
icon: 'B',
|
icon: 'B',
|
||||||
|
spriteUrl: '/iwt2/bosses/bulldrome-side-cel.png',
|
||||||
|
spriteView: 'side',
|
||||||
|
spriteWidthScale: 6.2,
|
||||||
|
spriteHeightScale: 4.5,
|
||||||
|
spriteYOffsetScale: -0.14,
|
||||||
color: '#8f5a3c',
|
color: '#8f5a3c',
|
||||||
accentColor: '#e6b17f',
|
accentColor: '#e6b17f',
|
||||||
maxHealth: 950,
|
maxHealth: 950,
|
||||||
radius: 31,
|
radius: 31,
|
||||||
moveSpeed: 128,
|
moveSpeed: 128,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
meleeDamage: 9,
|
meleeDamage: 20,
|
||||||
meleeCooldown: 1.05,
|
meleeCooldown: 1.05,
|
||||||
chargeCooldown: 4.75,
|
chargeCooldown: 4.75,
|
||||||
chargeWindup: 0.55,
|
chargeWindup: 0.55,
|
||||||
chargeSpeed: 510,
|
chargeSpeed: 510,
|
||||||
chargeDamage: 32,
|
chargeDamage: 60,
|
||||||
chargeStunSeconds: 0.75,
|
chargeStunSeconds: 0.75,
|
||||||
chargeLength: 560,
|
chargeLength: 560,
|
||||||
chargeOvershoot: 120,
|
chargeOvershoot: 120,
|
||||||
chargeWidth: 62,
|
chargeWidth: 62,
|
||||||
slamWindup: 0.45,
|
slamWindup: 0.45,
|
||||||
slamRadius: 104,
|
slamRadius: 104,
|
||||||
slamDamage: 24,
|
slamDamage: 55,
|
||||||
slamStunSeconds: 0.75,
|
slamStunSeconds: 0.75,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'yian-kut-ku',
|
id: 'yian-kut-ku',
|
||||||
name: 'Yian Kut Ku',
|
name: 'Emberquill',
|
||||||
icon: 'Y',
|
icon: 'E',
|
||||||
|
spriteUrl: '/iwt2/bosses/yian-kut-ku-side-cel.png',
|
||||||
|
spriteView: 'side',
|
||||||
|
spriteWidthScale: 5.9,
|
||||||
|
spriteHeightScale: 4.7,
|
||||||
|
spriteYOffsetScale: -0.1,
|
||||||
color: '#d66a35',
|
color: '#d66a35',
|
||||||
accentColor: '#ffd166',
|
accentColor: '#ffd166',
|
||||||
maxHealth: 400,
|
maxHealth: 400,
|
||||||
radius: 29,
|
radius: 29,
|
||||||
moveSpeed: 118,
|
moveSpeed: 118,
|
||||||
meleeRange: 56,
|
meleeRange: 56,
|
||||||
meleeDamage: 8,
|
meleeDamage: 12,
|
||||||
meleeCooldown: 1,
|
meleeCooldown: 1,
|
||||||
chargeCooldown: 0,
|
chargeCooldown: 0,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -119,10 +215,10 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
fireballCooldown: 3.6,
|
fireballCooldown: 3.6,
|
||||||
fireballWindup: 0.55,
|
fireballWindup: 0.55,
|
||||||
fireballSpeed: 285,
|
fireballSpeed: 285,
|
||||||
fireballDamage: 12,
|
fireballDamage: 32,
|
||||||
fireballRadius: 9,
|
fireballRadius: 9,
|
||||||
firePuddleRadius: 42,
|
firePuddleRadius: 42,
|
||||||
firePuddleDamage: 7,
|
firePuddleDamage: 15,
|
||||||
firePuddleSeconds: 8,
|
firePuddleSeconds: 8,
|
||||||
birdWaveThresholds: [0.9, 0.4],
|
birdWaveThresholds: [0.9, 0.4],
|
||||||
birdFlightCooldown: 10,
|
birdFlightCooldown: 10,
|
||||||
@@ -130,21 +226,26 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
birdFlightSpeed: 360,
|
birdFlightSpeed: 360,
|
||||||
birdHealth: 58,
|
birdHealth: 58,
|
||||||
birdRadius: 16,
|
birdRadius: 16,
|
||||||
birdContactDamage: 10,
|
birdContactDamage: 26,
|
||||||
birdStunSeconds: 0.75,
|
birdStunSeconds: 0.75,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'great-jaggi',
|
id: 'great-jaggi',
|
||||||
name: 'Great Jaggi',
|
name: 'Packfang Alpha',
|
||||||
icon: 'J',
|
icon: 'P',
|
||||||
|
spriteUrl: '/iwt2/bosses/great-jaggi-side-cel.png',
|
||||||
|
spriteView: 'side',
|
||||||
|
spriteWidthScale: 5.9,
|
||||||
|
spriteHeightScale: 4.1,
|
||||||
|
spriteYOffsetScale: -0.09,
|
||||||
color: '#3f8f73',
|
color: '#3f8f73',
|
||||||
accentColor: '#b8f0aa',
|
accentColor: '#b8f0aa',
|
||||||
maxHealth: 620,
|
maxHealth: 1000,
|
||||||
radius: 27,
|
radius: 27,
|
||||||
moveSpeed: 146,
|
moveSpeed: 146,
|
||||||
meleeRange: 52,
|
meleeRange: 52,
|
||||||
meleeDamage: 7,
|
meleeDamage: 18,
|
||||||
meleeCooldown: 0.82,
|
meleeCooldown: 0.82,
|
||||||
chargeCooldown: 0,
|
chargeCooldown: 0,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -160,7 +261,7 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
slamStunSeconds: 0,
|
slamStunSeconds: 0,
|
||||||
packHowlCooldown: 5.8,
|
packHowlCooldown: 5.8,
|
||||||
packHowlWindup: 0.95,
|
packHowlWindup: 0.95,
|
||||||
packLaneDamage: 15,
|
packLaneDamage: 45,
|
||||||
packLaneStunSeconds: 0.45,
|
packLaneStunSeconds: 0.45,
|
||||||
packLaneWidth: 24,
|
packLaneWidth: 24,
|
||||||
packLaneCount: 3,
|
packLaneCount: 3,
|
||||||
@@ -168,15 +269,20 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
|
|
||||||
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'khezu',
|
id: 'khezu',
|
||||||
name: 'Khezu',
|
name: 'Palevolt Maw',
|
||||||
icon: 'K',
|
icon: 'P',
|
||||||
|
spriteUrl: '/iwt2/bosses/khezu-side-cel.png',
|
||||||
|
spriteView: 'side',
|
||||||
|
spriteWidthScale: 5.9,
|
||||||
|
spriteHeightScale: 4.4,
|
||||||
|
spriteYOffsetScale: -0.12,
|
||||||
color: '#d8d7c9',
|
color: '#d8d7c9',
|
||||||
accentColor: '#77d9ff',
|
accentColor: '#77d9ff',
|
||||||
maxHealth: 760,
|
maxHealth: 1100,
|
||||||
radius: 30,
|
radius: 30,
|
||||||
moveSpeed: 92,
|
moveSpeed: 92,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
meleeDamage: 10,
|
meleeDamage: 20,
|
||||||
meleeCooldown: 1.15,
|
meleeCooldown: 1.15,
|
||||||
chargeCooldown: 0,
|
chargeCooldown: 0,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -194,16 +300,466 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
thunderRingWindup: 0.9,
|
thunderRingWindup: 0.9,
|
||||||
thunderRingInnerRadius: 70,
|
thunderRingInnerRadius: 70,
|
||||||
thunderRingOuterRadius: 172,
|
thunderRingOuterRadius: 172,
|
||||||
thunderRingDamage: 22,
|
thunderRingDamage: 60,
|
||||||
thunderRingStunSeconds: 0.7,
|
thunderRingStunSeconds: 0.7,
|
||||||
lightningStrikeCooldown: 4.2,
|
lightningStrikeCooldown: 4.2,
|
||||||
lightningStrikeWindup: 0.78,
|
lightningStrikeWindup: 0.78,
|
||||||
lightningStrikeRadius: 46,
|
lightningStrikeRadius: 46,
|
||||||
lightningStrikeDamage: 17,
|
lightningStrikeDamage: 48,
|
||||||
lightningStrikeStunSeconds: 0.55,
|
lightningStrikeStunSeconds: 0.55,
|
||||||
lightningStrikeCount: 2,
|
lightningStrikeCount: 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
|
id: 'rathian',
|
||||||
|
name: 'Verdant Wyrm',
|
||||||
|
icon: 'V',
|
||||||
|
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: 16,
|
||||||
|
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: 45,
|
||||||
|
tailSweepStunSeconds: 0.55,
|
||||||
|
poisonSpitCooldown: 6.2,
|
||||||
|
poisonSpitWindup: 0.7,
|
||||||
|
poisonPuddleRadius: 46,
|
||||||
|
poisonPuddleDamage: 15,
|
||||||
|
poisonPuddleSeconds: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
|
id: 'barroth',
|
||||||
|
name: 'Mirehorn',
|
||||||
|
icon: 'M',
|
||||||
|
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: 16,
|
||||||
|
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: 28,
|
||||||
|
mudSprayStunSeconds: 0.25,
|
||||||
|
mudPuddleRadius: 40,
|
||||||
|
mudPuddleSeconds: 7.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
|
id: 'tobi-kadachi',
|
||||||
|
name: 'Stormtail',
|
||||||
|
icon: 'S',
|
||||||
|
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: 13,
|
||||||
|
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: 46,
|
||||||
|
staticPounceStunSeconds: 0.6,
|
||||||
|
staticPounceLength: 520,
|
||||||
|
staticPounceWidth: 46,
|
||||||
|
chainShockCooldown: 5.2,
|
||||||
|
chainShockWindup: 0.85,
|
||||||
|
chainShockRadius: 86,
|
||||||
|
chainShockDamage: 34,
|
||||||
|
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: 16,
|
||||||
|
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: 18,
|
||||||
|
iceShardDamage: 34,
|
||||||
|
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: 13,
|
||||||
|
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: 36,
|
||||||
|
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: 14,
|
||||||
|
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: 40,
|
||||||
|
ricochetStunSeconds: 0.45,
|
||||||
|
ricochetSeconds: 1.45,
|
||||||
|
lavaTrailRadius: 34,
|
||||||
|
lavaTrailDamage: 15,
|
||||||
|
lavaTrailSeconds: 5.5,
|
||||||
|
armorSlamWindup: 0.42,
|
||||||
|
armorSlamRadius: 86,
|
||||||
|
armorSlamDamage: 44,
|
||||||
|
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: 14,
|
||||||
|
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: 12,
|
||||||
|
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: 18,
|
||||||
|
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: 12,
|
||||||
|
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: 15,
|
||||||
|
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: 1245,
|
||||||
|
radius: 33,
|
||||||
|
moveSpeed: 126,
|
||||||
|
meleeRange: 58,
|
||||||
|
meleeDamage: 13,
|
||||||
|
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: 11,
|
||||||
|
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: 12,
|
||||||
|
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 {
|
function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
|
||||||
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
|
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
|
||||||
if (!override) return metadata
|
if (!override) return metadata
|
||||||
@@ -217,10 +773,34 @@ export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFA
|
|||||||
export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA)
|
export const 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 GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_GREAT_JAGGI_BOSS_METADATA)
|
||||||
export const KHEZU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_KHEZU_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> = {
|
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
|
||||||
bulldrome: BULLDROME_BOSS_METADATA,
|
bulldrome: BULLDROME_BOSS_METADATA,
|
||||||
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
|
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
|
||||||
'great-jaggi': GREAT_JAGGI_BOSS_METADATA,
|
'great-jaggi': GREAT_JAGGI_BOSS_METADATA,
|
||||||
khezu: KHEZU_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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type Iwt2ClassMetadata = {
|
|||||||
name: string
|
name: string
|
||||||
role: Iwt2PlayerClassRole
|
role: Iwt2PlayerClassRole
|
||||||
icon: string
|
icon: string
|
||||||
|
arenaSpriteUrl: string
|
||||||
|
uiIconUrl: string
|
||||||
color: string
|
color: string
|
||||||
accentColor: string
|
accentColor: string
|
||||||
maxHealth: number
|
maxHealth: number
|
||||||
@@ -25,6 +27,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Player Healer',
|
name: 'Player Healer',
|
||||||
role: 'healer',
|
role: 'healer',
|
||||||
icon: '+',
|
icon: '+',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/healer-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/healer-medallion.png',
|
||||||
color: '#2fbf71',
|
color: '#2fbf71',
|
||||||
accentColor: '#b7f7cf',
|
accentColor: '#b7f7cf',
|
||||||
maxHealth: 125,
|
maxHealth: 125,
|
||||||
@@ -41,6 +45,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Paladin Tank',
|
name: 'Paladin Tank',
|
||||||
role: 'tank',
|
role: 'tank',
|
||||||
icon: '🛡',
|
icon: '🛡',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/paladin-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/paladin-medallion.png',
|
||||||
color: '#f2c94c',
|
color: '#f2c94c',
|
||||||
accentColor: '#fff0a8',
|
accentColor: '#fff0a8',
|
||||||
maxHealth: 190,
|
maxHealth: 190,
|
||||||
@@ -57,6 +63,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Ranger',
|
name: 'Ranger',
|
||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '🏹',
|
icon: '🏹',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/ranger-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/ranger-medallion.png',
|
||||||
color: '#4aa3df',
|
color: '#4aa3df',
|
||||||
accentColor: '#b9e3ff',
|
accentColor: '#b9e3ff',
|
||||||
maxHealth: 115,
|
maxHealth: 115,
|
||||||
@@ -64,8 +72,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
radius: 14,
|
radius: 14,
|
||||||
attackRange: 245,
|
attackRange: 245,
|
||||||
attackDamage: 8,
|
attackDamage: 8,
|
||||||
attackCooldown: 0.62,
|
attackCooldown: 0.31,
|
||||||
castTime: 0.32,
|
castTime: 0.16,
|
||||||
projectileSpeed: 410,
|
projectileSpeed: 410,
|
||||||
},
|
},
|
||||||
mage: {
|
mage: {
|
||||||
@@ -73,6 +81,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Mage',
|
name: 'Mage',
|
||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '⚚',
|
icon: '⚚',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/mage-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/mage-medallion.png',
|
||||||
color: '#c56cf0',
|
color: '#c56cf0',
|
||||||
accentColor: '#efc4ff',
|
accentColor: '#efc4ff',
|
||||||
maxHealth: 100,
|
maxHealth: 100,
|
||||||
@@ -80,8 +90,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
radius: 14,
|
radius: 14,
|
||||||
attackRange: 220,
|
attackRange: 220,
|
||||||
attackDamage: 11,
|
attackDamage: 11,
|
||||||
attackCooldown: 0.78,
|
attackCooldown: 0.39,
|
||||||
castTime: 0.42,
|
castTime: 0.21,
|
||||||
projectileSpeed: 330,
|
projectileSpeed: 330,
|
||||||
},
|
},
|
||||||
rogue: {
|
rogue: {
|
||||||
@@ -89,6 +99,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Rogue',
|
name: 'Rogue',
|
||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '††',
|
icon: '††',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/rogue-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/rogue-medallion.png',
|
||||||
color: '#55efc4',
|
color: '#55efc4',
|
||||||
accentColor: '#c8fff2',
|
accentColor: '#c8fff2',
|
||||||
maxHealth: 110,
|
maxHealth: 110,
|
||||||
@@ -105,6 +117,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Warrior',
|
name: 'Warrior',
|
||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '⚔',
|
icon: '⚔',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
|
||||||
color: '#ff7675',
|
color: '#ff7675',
|
||||||
accentColor: '#ffd0d0',
|
accentColor: '#ffd0d0',
|
||||||
maxHealth: 150,
|
maxHealth: 150,
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
export type Iwt2Difficulty = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
droppedItemLevel: number
|
||||||
|
unlockLevel: number
|
||||||
|
healthMultiplier: number
|
||||||
|
damageMultiplier: number
|
||||||
|
experienceMultiplier: number
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_DUNGEON_DIFFICULTIES: Iwt2Difficulty[] = [
|
||||||
|
{
|
||||||
|
slug: 'initiate',
|
||||||
|
name: 'Initiate',
|
||||||
|
droppedItemLevel: 1,
|
||||||
|
unlockLevel: 1,
|
||||||
|
healthMultiplier: 0.8,
|
||||||
|
damageMultiplier: 0.8,
|
||||||
|
experienceMultiplier: 1,
|
||||||
|
description: 'IWT1 starter dungeon tuning.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'veteran',
|
||||||
|
name: 'Veteran',
|
||||||
|
droppedItemLevel: 10,
|
||||||
|
unlockLevel: 10,
|
||||||
|
healthMultiplier: 1.45,
|
||||||
|
damageMultiplier: 1.25,
|
||||||
|
experienceMultiplier: 2,
|
||||||
|
description: 'IWT1 veteran dungeon tuning.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'champion',
|
||||||
|
name: 'Champion',
|
||||||
|
droppedItemLevel: 15,
|
||||||
|
unlockLevel: 15,
|
||||||
|
healthMultiplier: 1.7,
|
||||||
|
damageMultiplier: 1.45,
|
||||||
|
experienceMultiplier: 2.2,
|
||||||
|
description: 'IWT1 champion dungeon tuning.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'mythic',
|
||||||
|
name: 'Mythic',
|
||||||
|
droppedItemLevel: 20,
|
||||||
|
unlockLevel: 20,
|
||||||
|
healthMultiplier: 2.25,
|
||||||
|
damageMultiplier: 1.85,
|
||||||
|
experienceMultiplier: 3.5,
|
||||||
|
description: 'IWT1 mythic dungeon tuning.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'ascendant',
|
||||||
|
name: 'Ascendant',
|
||||||
|
droppedItemLevel: 25,
|
||||||
|
unlockLevel: 25,
|
||||||
|
healthMultiplier: 2.8,
|
||||||
|
damageMultiplier: 2.25,
|
||||||
|
experienceMultiplier: 4.5,
|
||||||
|
description: 'IWT1 ascendant dungeon tuning.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const IWT2_RAID_DIFFICULTIES: Iwt2Difficulty[] = [
|
||||||
|
{
|
||||||
|
slug: 'raid-normal',
|
||||||
|
name: 'Normal',
|
||||||
|
droppedItemLevel: 10,
|
||||||
|
unlockLevel: 10,
|
||||||
|
healthMultiplier: 1.45,
|
||||||
|
damageMultiplier: 1.25,
|
||||||
|
experienceMultiplier: 2,
|
||||||
|
description: 'IWT1 normal raid tuning.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'raid-champion',
|
||||||
|
name: 'Champion Raid',
|
||||||
|
droppedItemLevel: 15,
|
||||||
|
unlockLevel: 15,
|
||||||
|
healthMultiplier: 1.7,
|
||||||
|
damageMultiplier: 1.45,
|
||||||
|
experienceMultiplier: 2.4,
|
||||||
|
description: 'IWT1 champion raid tuning.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'raid-mythic',
|
||||||
|
name: 'Mythic Raid',
|
||||||
|
droppedItemLevel: 20,
|
||||||
|
unlockLevel: 20,
|
||||||
|
healthMultiplier: 2.25,
|
||||||
|
damageMultiplier: 1.85,
|
||||||
|
experienceMultiplier: 3.5,
|
||||||
|
description: 'IWT1 mythic raid tuning.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'raid-ascendant',
|
||||||
|
name: 'Ascendant Raid',
|
||||||
|
droppedItemLevel: 25,
|
||||||
|
unlockLevel: 25,
|
||||||
|
healthMultiplier: 2.8,
|
||||||
|
damageMultiplier: 2.25,
|
||||||
|
experienceMultiplier: 4.5,
|
||||||
|
description: 'IWT1 ascendant raid tuning.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function findIwt2Difficulty(
|
||||||
|
difficulties: readonly Iwt2Difficulty[],
|
||||||
|
slug: string,
|
||||||
|
): Iwt2Difficulty {
|
||||||
|
return difficulties.find((difficulty) => difficulty.slug === slug) ?? difficulties[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIwt2DifficultyUnlocked(difficulty: Iwt2Difficulty, level: number): boolean {
|
||||||
|
void difficulty
|
||||||
|
void level
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type { Iwt2BossId } from './bosses'
|
||||||
|
import type { Iwt2PlayerClassId } from './classes'
|
||||||
|
import { iwt2BossCoinRewardFor } from './bossRewards'
|
||||||
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
||||||
|
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
|
||||||
|
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
||||||
|
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5
|
||||||
|
export type Iwt2GearStatId =
|
||||||
|
| 'maxHealth'
|
||||||
|
| 'moveSpeed'
|
||||||
|
| 'healingPower'
|
||||||
|
| 'damage'
|
||||||
|
| 'attackCooldown'
|
||||||
|
| 'projectileSpeed'
|
||||||
|
| 'stunResist'
|
||||||
|
| 'hazardDamageTaken'
|
||||||
|
|
||||||
|
export type Iwt2GearSlotProgress = {
|
||||||
|
level: Iwt2GearLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2ClassGearProgress = {
|
||||||
|
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
||||||
|
infusionAbilityId: Iwt2InfusionAbilityId | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
||||||
|
|
||||||
|
export type Iwt2GearUpgradeCost = {
|
||||||
|
itemId: string
|
||||||
|
itemName: string
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2GearSlotRecipe = {
|
||||||
|
classId: Iwt2PlayerClassId
|
||||||
|
slotId: Iwt2GearSlotId
|
||||||
|
primaryBossId: Iwt2BossId
|
||||||
|
secondaryBossId: Iwt2BossId
|
||||||
|
statId: Iwt2GearStatId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
||||||
|
|
||||||
|
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
||||||
|
weapon: 'Weapon',
|
||||||
|
helmet: 'Helmet',
|
||||||
|
chest: 'Chest',
|
||||||
|
legs: 'Legs',
|
||||||
|
feet: 'Feet',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_GEAR_STAT_LABELS: Record<Iwt2GearStatId, string> = {
|
||||||
|
maxHealth: 'Max Health',
|
||||||
|
moveSpeed: 'Move Speed',
|
||||||
|
healingPower: 'Healing Power',
|
||||||
|
damage: 'Damage',
|
||||||
|
attackCooldown: 'Cooldown',
|
||||||
|
projectileSpeed: 'Projectile Speed',
|
||||||
|
stunResist: 'Stun Resist',
|
||||||
|
hazardDamageTaken: 'Hazard Damage Taken',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_GEAR_SLOT_RECIPES: Record<Iwt2PlayerClassId, Record<Iwt2GearSlotId, Iwt2GearSlotRecipe>> = {
|
||||||
|
healer: {
|
||||||
|
weapon: slotRecipe('healer', 'weapon', 'rathian', 'yian-kut-ku', 'healingPower'),
|
||||||
|
helmet: slotRecipe('healer', 'helmet', 'khezu', 'rimebastion', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('healer', 'chest', 'barroth', 'obsidian-ram-golem', 'maxHealth'),
|
||||||
|
legs: slotRecipe('healer', 'legs', 'tobi-kadachi', 'great-jaggi', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('healer', 'feet', 'venom-orchid-hydra', 'sandglass-scorpion', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
paladin: {
|
||||||
|
weapon: slotRecipe('paladin', 'weapon', 'obsidian-ram-golem', 'bulldrome', 'damage'),
|
||||||
|
helmet: slotRecipe('paladin', 'helmet', 'khezu', 'hollowcrown-revenant', 'stunResist'),
|
||||||
|
chest: slotRecipe('paladin', 'chest', 'barroth', 'rimebastion', 'maxHealth'),
|
||||||
|
legs: slotRecipe('paladin', 'legs', 'bulldrome', 'tobi-kadachi', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('paladin', 'feet', 'sandglass-scorpion', 'great-jaggi', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
ranger: {
|
||||||
|
weapon: slotRecipe('ranger', 'weapon', 'cinderback-ricochet', 'yian-kut-ku', 'damage'),
|
||||||
|
helmet: slotRecipe('ranger', 'helmet', 'crystal-bat-matriarch', 'stormcoil-wyrm', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('ranger', 'chest', 'great-jaggi', 'barroth', 'maxHealth'),
|
||||||
|
legs: slotRecipe('ranger', 'legs', 'tobi-kadachi', 'crystal-bat-matriarch', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('ranger', 'feet', 'sandglass-scorpion', 'venom-orchid-hydra', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
mage: {
|
||||||
|
weapon: slotRecipe('mage', 'weapon', 'stormcoil-wyrm', 'yian-kut-ku', 'damage'),
|
||||||
|
helmet: slotRecipe('mage', 'helmet', 'rimebastion', 'khezu', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('mage', 'chest', 'venom-orchid-hydra', 'barroth', 'maxHealth'),
|
||||||
|
legs: slotRecipe('mage', 'legs', 'tobi-kadachi', 'crystal-bat-matriarch', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('mage', 'feet', 'hollowcrown-revenant', 'sandglass-scorpion', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
rogue: {
|
||||||
|
weapon: slotRecipe('rogue', 'weapon', 'ember-mantis-duelist', 'rathian', 'damage'),
|
||||||
|
helmet: slotRecipe('rogue', 'helmet', 'crystal-bat-matriarch', 'khezu', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('rogue', 'chest', 'great-jaggi', 'barroth', 'maxHealth'),
|
||||||
|
legs: slotRecipe('rogue', 'legs', 'tobi-kadachi', 'ember-mantis-duelist', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('rogue', 'feet', 'hollowcrown-revenant', 'sandglass-scorpion', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
warrior: {
|
||||||
|
weapon: slotRecipe('warrior', 'weapon', 'bulldrome', 'ember-mantis-duelist', 'damage'),
|
||||||
|
helmet: slotRecipe('warrior', 'helmet', 'obsidian-ram-golem', 'khezu', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('warrior', 'chest', 'barroth', 'rimebastion', 'maxHealth'),
|
||||||
|
legs: slotRecipe('warrior', 'legs', 'great-jaggi', 'tobi-kadachi', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('warrior', 'feet', 'rathian', 'venom-orchid-hydra', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
|
||||||
|
return {
|
||||||
|
healer: createDefaultClassGearProgress(),
|
||||||
|
paladin: createDefaultClassGearProgress(),
|
||||||
|
ranger: createDefaultClassGearProgress(),
|
||||||
|
mage: createDefaultClassGearProgress(),
|
||||||
|
rogue: createDefaultClassGearProgress(),
|
||||||
|
warrior: createDefaultClassGearProgress(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
||||||
|
return Object.values(progress.slots).some((slot) => slot.level >= 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2GearUpgradeCosts(
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
slotId: Iwt2GearSlotId,
|
||||||
|
currentLevel: Iwt2GearLevel,
|
||||||
|
): Iwt2GearUpgradeCost[] {
|
||||||
|
if (currentLevel >= 5) return []
|
||||||
|
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||||
|
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
||||||
|
const slug = upgradeDifficultySlug(nextLevel)
|
||||||
|
const primary = iwt2BossCoinRewardFor(recipe.primaryBossId, slug)
|
||||||
|
const secondary = iwt2BossCoinRewardFor(recipe.secondaryBossId, slug)
|
||||||
|
if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }]
|
||||||
|
if (nextLevel === 2) return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 3 },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 1 },
|
||||||
|
]
|
||||||
|
if (nextLevel === 3) return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 3 },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 2 },
|
||||||
|
]
|
||||||
|
if (nextLevel === 4) return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2InfusionCosts(
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
slotId: Iwt2GearSlotId,
|
||||||
|
abilityId: Iwt2InfusionAbilityId,
|
||||||
|
): Iwt2GearUpgradeCost[] {
|
||||||
|
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||||
|
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||||
|
const linked = iwt2BossCoinRewardFor(ability.linkedBossId, 'ascendant')
|
||||||
|
const slotPrimary = iwt2BossCoinRewardFor(recipe.primaryBossId, 'mythic')
|
||||||
|
return [
|
||||||
|
{ itemId: linked.id, itemName: linked.name, quantity: 5 },
|
||||||
|
{ itemId: slotPrimary.id, itemName: slotPrimary.name, quantity: 5 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
|
||||||
|
return {
|
||||||
|
slots: {
|
||||||
|
weapon: { level: 0 },
|
||||||
|
helmet: { level: 0 },
|
||||||
|
chest: { level: 0 },
|
||||||
|
legs: { level: 0 },
|
||||||
|
feet: { level: 0 },
|
||||||
|
},
|
||||||
|
infusionAbilityId: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotRecipe(
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
slotId: Iwt2GearSlotId,
|
||||||
|
primaryBossId: Iwt2BossId,
|
||||||
|
secondaryBossId: Iwt2BossId,
|
||||||
|
statId: Iwt2GearStatId,
|
||||||
|
): Iwt2GearSlotRecipe {
|
||||||
|
return { classId, primaryBossId, secondaryBossId, slotId, statId }
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
||||||
|
if (level <= 2) return 'initiate'
|
||||||
|
if (level === 3) return 'veteran'
|
||||||
|
if (level === 4) return 'champion'
|
||||||
|
return 'mythic'
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Spell } from '../../../game'
|
import type { Spell } from '../../../game'
|
||||||
|
import type { Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
import { iwt2HealerInfusionAbility } from './healerInfusionAbilities'
|
||||||
|
|
||||||
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
|
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
|
||||||
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
|
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
|
||||||
@@ -23,6 +25,15 @@ export type Iwt2HealerAbility = {
|
|||||||
cooldownSeconds: number
|
cooldownSeconds: number
|
||||||
target: Iwt2AbilityTarget
|
target: Iwt2AbilityTarget
|
||||||
extraTargets?: number
|
extraTargets?: number
|
||||||
|
triggeredEffects?: Iwt2TriggeredAbilityEffect[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2TriggeredAbilityEffect = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: Spell['kind']
|
||||||
|
power: number
|
||||||
|
effectType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
|
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
|
||||||
@@ -55,7 +66,6 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
|
|||||||
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
|
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
|
||||||
createAbility('dawnweaver', 4, 'sun-ward', 'Sun Ward', 'O', 'shield', 36, 8, 7, 'shield'),
|
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', 5, 'purify', 'Purify', 'x', 'cleanse', 10, 5, 5, 'cleanse'),
|
||||||
createAbility('dawnweaver', 6, 'dawn-burst', 'Dawn Burst', 'D', 'group', 28, 16, 12),
|
|
||||||
],
|
],
|
||||||
lifebinder: [
|
lifebinder: [
|
||||||
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
|
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
|
||||||
@@ -63,7 +73,6 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
|
|||||||
createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5),
|
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', 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', 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: [
|
runesage: [
|
||||||
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
|
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
|
||||||
@@ -71,12 +80,13 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
|
|||||||
createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5),
|
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', 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', 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) {
|
export function abilitiesForHealer(healerId: Iwt2HealerId | string, infusionAbilityId?: Iwt2InfusionAbilityId | null) {
|
||||||
return IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
|
const base = IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
|
||||||
|
const infusion = infusionAbilityId ? iwt2HealerInfusionAbility(infusionAbilityId, asIwt2HealerId(healerId)) : null
|
||||||
|
return infusion ? [...base, infusion] : base
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asIwt2HealerId(value: unknown): Iwt2HealerId {
|
export function asIwt2HealerId(value: unknown): Iwt2HealerId {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
import type { Iwt2HealerAbility, Iwt2HealerId } from './healerAbilities'
|
||||||
|
|
||||||
|
export function iwt2HealerInfusionAbility(
|
||||||
|
infusionAbilityId: Iwt2InfusionAbilityId,
|
||||||
|
healerId: Iwt2HealerId,
|
||||||
|
): Iwt2HealerAbility | null {
|
||||||
|
const infusion = IWT2_INFUSION_ABILITIES[infusionAbilityId]
|
||||||
|
if (!infusion || infusion.classId !== 'healer') return null
|
||||||
|
if (infusion.effectKey === 'sanctuary') {
|
||||||
|
return createInfusedHealerAbility(healerId, 'sanctuary', infusion.name, infusion.icon, 'shield', 50, 16, infusion.cooldownSeconds ?? 45, 'shield')
|
||||||
|
}
|
||||||
|
if (infusion.effectKey === 'guardianPulse') {
|
||||||
|
return createInfusedHealerAbility(healerId, 'guardian-pulse', infusion.name, infusion.icon, 'group', 26, 14, infusion.cooldownSeconds ?? 40)
|
||||||
|
}
|
||||||
|
if (infusion.effectKey === 'emergencyMiracle') {
|
||||||
|
return createInfusedHealerAbility(healerId, 'emergency-miracle', infusion.name, infusion.icon, 'direct', 90, 20, infusion.cooldownSeconds ?? 75)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInfusedHealerAbility(
|
||||||
|
healerId: Iwt2HealerId,
|
||||||
|
slug: string,
|
||||||
|
name: string,
|
||||||
|
icon: string,
|
||||||
|
kind: Iwt2HealerAbility['kind'],
|
||||||
|
power: number,
|
||||||
|
manaCost: number,
|
||||||
|
cooldownSeconds: number,
|
||||||
|
effectType?: string,
|
||||||
|
): Iwt2HealerAbility {
|
||||||
|
return {
|
||||||
|
cooldownSeconds,
|
||||||
|
effectType,
|
||||||
|
healerId,
|
||||||
|
icon,
|
||||||
|
id: `${healerId}-infusion-${slug}`,
|
||||||
|
kind,
|
||||||
|
manaCost,
|
||||||
|
name,
|
||||||
|
power,
|
||||||
|
slot: 6,
|
||||||
|
target: 'party-member',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { Iwt2BossId } from './bosses'
|
||||||
|
import type { Iwt2PlayerClassId } from './classes'
|
||||||
|
|
||||||
|
export type Iwt2InfusionAbilityId =
|
||||||
|
| 'healer-sanctuary'
|
||||||
|
| 'healer-guardian-pulse'
|
||||||
|
| 'healer-emergency-miracle'
|
||||||
|
| 'paladin-unbreakable'
|
||||||
|
| 'paladin-bulwark-field'
|
||||||
|
| 'paladin-intercept'
|
||||||
|
| 'ranger-decoy-companion'
|
||||||
|
| 'ranger-pinning-volley'
|
||||||
|
| 'ranger-hunters-mark'
|
||||||
|
| 'mage-panic-blink'
|
||||||
|
| 'mage-arcane-barrier'
|
||||||
|
| 'mage-overcharge'
|
||||||
|
| 'rogue-shadow-step'
|
||||||
|
| 'rogue-vanish'
|
||||||
|
| 'rogue-expose-weakness'
|
||||||
|
| 'warrior-bladestorm'
|
||||||
|
| 'warrior-iron-rush'
|
||||||
|
| 'warrior-blood-rally'
|
||||||
|
|
||||||
|
export type Iwt2InfusionAbilityTrigger =
|
||||||
|
| 'active'
|
||||||
|
| 'danger'
|
||||||
|
| 'passive'
|
||||||
|
| 'postMechanic'
|
||||||
|
| 'survival'
|
||||||
|
|
||||||
|
export type Iwt2InfusionAbilityEffectKey =
|
||||||
|
| 'arcaneBarrier'
|
||||||
|
| 'bladestorm'
|
||||||
|
| 'bloodRally'
|
||||||
|
| 'bulwarkField'
|
||||||
|
| 'decoyCompanion'
|
||||||
|
| 'emergencyMiracle'
|
||||||
|
| 'exposeWeakness'
|
||||||
|
| 'guardianPulse'
|
||||||
|
| 'huntersMark'
|
||||||
|
| 'intercept'
|
||||||
|
| 'ironRush'
|
||||||
|
| 'overcharge'
|
||||||
|
| 'panicBlink'
|
||||||
|
| 'pinningVolley'
|
||||||
|
| 'sanctuary'
|
||||||
|
| 'shadowStep'
|
||||||
|
| 'unbreakable'
|
||||||
|
| 'vanish'
|
||||||
|
|
||||||
|
export type Iwt2InfusionAbility = {
|
||||||
|
id: Iwt2InfusionAbilityId
|
||||||
|
classId: Iwt2PlayerClassId
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
linkedBossId: Iwt2BossId
|
||||||
|
cooldownSeconds: number | null
|
||||||
|
description: string
|
||||||
|
trigger: Iwt2InfusionAbilityTrigger
|
||||||
|
effectKey: Iwt2InfusionAbilityEffectKey
|
||||||
|
slot: 6
|
||||||
|
reuseTags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_INFUSION_ABILITIES: Record<Iwt2InfusionAbilityId, Iwt2InfusionAbility> = {
|
||||||
|
'healer-sanctuary': infusion('healer-sanctuary', 'healer', 'Sanctuary', 'S', 'rimebastion', 45, 'Place a safe zone that reduces hazard damage for party members inside it.', 'active', 'sanctuary', ['healer', 'hazard-counter', 'zone']),
|
||||||
|
'healer-guardian-pulse': infusion('healer-guardian-pulse', 'healer', 'Guardian Pulse', 'G', 'khezu', 40, 'Shield the party after a major boss telegraph resolves.', 'postMechanic', 'guardianPulse', ['healer', 'shield', 'telegraph-counter']),
|
||||||
|
'healer-emergency-miracle': infusion('healer-emergency-miracle', 'healer', 'Emergency Miracle', 'M', 'hollowcrown-revenant', 75, 'Auto-cast a large heal on the lowest-health ally at danger threshold.', 'survival', 'emergencyMiracle', ['healer', 'survival', 'auto-save']),
|
||||||
|
'paladin-unbreakable': infusion('paladin-unbreakable', 'paladin', 'Unbreakable', 'U', 'bulldrome', null, 'Become immune to stuns and knockdowns.', 'passive', 'unbreakable', ['tank', 'control-immunity']),
|
||||||
|
'paladin-bulwark-field': infusion('paladin-bulwark-field', 'paladin', 'Bulwark Field', 'B', 'barroth', 35, 'Nearby party members take reduced damage.', 'active', 'bulwarkField', ['tank', 'aura', 'damage-reduction']),
|
||||||
|
'paladin-intercept': infusion('paladin-intercept', 'paladin', 'Intercept', 'I', 'obsidian-ram-golem', 30, 'Dash to an endangered party member and absorb the next hit.', 'danger', 'intercept', ['tank', 'dash', 'protection']),
|
||||||
|
'ranger-decoy-companion': infusion('ranger-decoy-companion', 'ranger', 'Decoy Companion', 'D', 'great-jaggi', 60, 'Summon a pet that holds boss attention for 8 seconds.', 'active', 'decoyCompanion', ['ranged', 'summon', 'aggro-decoy']),
|
||||||
|
'ranger-pinning-volley': infusion('ranger-pinning-volley', 'ranger', 'Pinning Volley', 'P', 'crystal-bat-matriarch', 35, 'Fire a periodic shot that briefly slows the boss.', 'active', 'pinningVolley', ['ranged', 'slow', 'projectile']),
|
||||||
|
'ranger-hunters-mark': infusion('ranger-hunters-mark', 'ranger', "Hunter's Mark", 'H', 'cinderback-ricochet', 45, 'Mark a boss so ranged hits deal increased damage briefly.', 'active', 'huntersMark', ['ranged', 'debuff', 'damage-window']),
|
||||||
|
'mage-panic-blink': infusion('mage-panic-blink', 'mage', 'Panic Blink', 'B', 'tobi-kadachi', 45, 'Teleport across the arena when danger scoring says the mage is trapped.', 'danger', 'panicBlink', ['caster', 'teleport', 'escape']),
|
||||||
|
'mage-arcane-barrier': infusion('mage-arcane-barrier', 'mage', 'Arcane Barrier', 'A', 'rimebastion', 60, 'Gain a shield before a lethal hit.', 'survival', 'arcaneBarrier', ['caster', 'shield', 'survival']),
|
||||||
|
'mage-overcharge': infusion('mage-overcharge', 'mage', 'Overcharge', 'O', 'stormcoil-wyrm', 40, 'Gain faster casts after avoiding a major mechanic.', 'postMechanic', 'overcharge', ['caster', 'haste', 'telegraph-reward']),
|
||||||
|
'rogue-shadow-step': infusion('rogue-shadow-step', 'rogue', 'Shadow Step', 'S', 'ember-mantis-duelist', 30, 'Teleport to any party member, then gain 30% damage for 8 seconds.', 'active', 'shadowStep', ['melee', 'teleport', 'burst']),
|
||||||
|
'rogue-vanish': infusion('rogue-vanish', 'rogue', 'Vanish', 'V', 'hollowcrown-revenant', 45, 'Drop boss focus and evade the next hit.', 'survival', 'vanish', ['melee', 'evade', 'survival']),
|
||||||
|
'rogue-expose-weakness': infusion('rogue-expose-weakness', 'rogue', 'Expose Weakness', 'E', 'rathian', 35, 'Flanking attacks increase party damage briefly.', 'active', 'exposeWeakness', ['melee', 'debuff', 'flank']),
|
||||||
|
'warrior-bladestorm': infusion('warrior-bladestorm', 'warrior', 'Bladestorm', 'B', 'bulldrome', 30, 'Spin for 5 seconds, damaging nearby enemies and taking 50% less damage.', 'active', 'bladestorm', ['melee', 'aoe', 'damage-reduction']),
|
||||||
|
'warrior-iron-rush': infusion('warrior-iron-rush', 'warrior', 'Iron Rush', 'I', 'obsidian-ram-golem', 35, 'Charge through the boss with brief damage reduction.', 'active', 'ironRush', ['melee', 'charge', 'damage-reduction']),
|
||||||
|
'warrior-blood-rally': infusion('warrior-blood-rally', 'warrior', 'Blood Rally', 'R', 'venom-orchid-hydra', 50, 'Gain damage when party health is low.', 'survival', 'bloodRally', ['melee', 'survival', 'damage-window']),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2InfusionAbilitiesForClass(classId: Iwt2PlayerClassId): Iwt2InfusionAbility[] {
|
||||||
|
return Object.values(IWT2_INFUSION_ABILITIES).filter((ability) => ability.classId === classId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asIwt2InfusionAbilityId(value: unknown): Iwt2InfusionAbilityId | null {
|
||||||
|
return typeof value === 'string' && value in IWT2_INFUSION_ABILITIES
|
||||||
|
? value as Iwt2InfusionAbilityId
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function infusion(
|
||||||
|
id: Iwt2InfusionAbilityId,
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
name: string,
|
||||||
|
icon: string,
|
||||||
|
linkedBossId: Iwt2BossId,
|
||||||
|
cooldownSeconds: number | null,
|
||||||
|
description: string,
|
||||||
|
trigger: Iwt2InfusionAbilityTrigger,
|
||||||
|
effectKey: Iwt2InfusionAbilityEffectKey,
|
||||||
|
reuseTags: string[],
|
||||||
|
): Iwt2InfusionAbility {
|
||||||
|
return {
|
||||||
|
classId,
|
||||||
|
cooldownSeconds,
|
||||||
|
description,
|
||||||
|
effectKey,
|
||||||
|
icon,
|
||||||
|
id,
|
||||||
|
linkedBossId,
|
||||||
|
name,
|
||||||
|
reuseTags,
|
||||||
|
slot: 6,
|
||||||
|
trigger,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,33 @@ export type Iwt2RoguelikeSlot = '1' | '2' | '3' | '4' | '5'
|
|||||||
|
|
||||||
export type Iwt2RoguelikeSelfBuffId =
|
export type Iwt2RoguelikeSelfBuffId =
|
||||||
| 'revive-party-members'
|
| 'revive-party-members'
|
||||||
|
| 'dawnweaver-mend-applies-renew'
|
||||||
|
| 'dawnweaver-mend-applies-sun-ward'
|
||||||
|
| 'dawnweaver-renew-applies-sun-ward'
|
||||||
|
| 'dawnweaver-radiance-applies-renew'
|
||||||
|
| 'dawnweaver-radiance-applies-sun-ward'
|
||||||
|
| 'dawnweaver-sun-ward-applies-renew'
|
||||||
|
| 'dawnweaver-sun-ward-damage-reduction'
|
||||||
|
| 'dawnweaver-purify-applies-renew'
|
||||||
|
| 'dawnweaver-purify-applies-sun-ward'
|
||||||
|
| 'lifebinder-verdant-touch-applies-seed-of-life'
|
||||||
|
| 'lifebinder-verdant-touch-applies-barkskin'
|
||||||
|
| 'lifebinder-seed-of-life-applies-barkskin'
|
||||||
|
| 'lifebinder-wild-growth-applies-seed-of-life'
|
||||||
|
| 'lifebinder-wild-growth-applies-barkskin'
|
||||||
|
| 'lifebinder-barkskin-applies-seed-of-life'
|
||||||
|
| 'lifebinder-barkskin-hot-bonus'
|
||||||
|
| 'lifebinder-purging-sap-applies-seed-of-life'
|
||||||
|
| 'lifebinder-purging-sap-applies-barkskin'
|
||||||
|
| 'runesage-etched-mend-applies-mending-rune'
|
||||||
|
| 'runesage-etched-mend-applies-aegis-script'
|
||||||
|
| 'runesage-mending-rune-applies-aegis-script'
|
||||||
|
| 'runesage-concordance-applies-mending-rune'
|
||||||
|
| 'runesage-concordance-applies-aegis-script'
|
||||||
|
| 'runesage-aegis-script-applies-mending-rune'
|
||||||
|
| 'runesage-aegis-script-damage-reduction'
|
||||||
|
| 'runesage-unravel-applies-mending-rune'
|
||||||
|
| 'runesage-unravel-applies-aegis-script'
|
||||||
| `slot${Iwt2RoguelikeSlot}-extra-target`
|
| `slot${Iwt2RoguelikeSlot}-extra-target`
|
||||||
| `slot${Iwt2RoguelikeSlot}-cost-down`
|
| `slot${Iwt2RoguelikeSlot}-cost-down`
|
||||||
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
|
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
|
||||||
@@ -26,6 +53,9 @@ export type Iwt2RoguelikeChoice<T extends string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
|
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
|
||||||
|
export const IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID = 'dawnweaver-sun-ward-damage-reduction'
|
||||||
|
export const IWT2_BARKSKIN_HOT_BONUS_BUFF_ID = 'lifebinder-barkskin-hot-bonus'
|
||||||
|
export const IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID = 'runesage-aegis-script-damage-reduction'
|
||||||
|
|
||||||
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
|
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
|
||||||
id: 'revive-party-members',
|
id: 'revive-party-members',
|
||||||
@@ -33,6 +63,150 @@ export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuff
|
|||||||
description: 'Revive fallen party members before the next IWT2 arena.',
|
description: 'Revive fallen party members before the next IWT2 arena.',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const IWT2_DAWNWEAVER_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-mend-applies-renew',
|
||||||
|
name: 'Mend Applies Renew',
|
||||||
|
description: 'Mend also applies Renew to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-mend-applies-sun-ward',
|
||||||
|
name: 'Mend Applies Sun Ward',
|
||||||
|
description: 'Mend also applies Sun Ward to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-renew-applies-sun-ward',
|
||||||
|
name: 'Renew Applies Sun Ward',
|
||||||
|
description: 'Renew also applies Sun Ward to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-radiance-applies-renew',
|
||||||
|
name: 'Radiance Applies Renew',
|
||||||
|
description: 'Radiance also applies Renew to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-radiance-applies-sun-ward',
|
||||||
|
name: 'Radiance Applies 50% Sun Ward',
|
||||||
|
description: 'Radiance also applies Sun Ward at 50% strength to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-sun-ward-applies-renew',
|
||||||
|
name: 'Sun Ward Applies Renew',
|
||||||
|
description: 'Sun Ward also applies Renew to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
|
||||||
|
name: 'Sun Ward Reduces Damage',
|
||||||
|
description: 'While shielded by Sun Ward, the target takes 50% less damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-purify-applies-renew',
|
||||||
|
name: 'Purify Applies Renew',
|
||||||
|
description: 'Purify also applies Renew to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-purify-applies-sun-ward',
|
||||||
|
name: 'Purify Applies Sun Ward',
|
||||||
|
description: 'Purify also applies Sun Ward to the target.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const IWT2_LIFEBINDER_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
|
||||||
|
{
|
||||||
|
id: 'lifebinder-verdant-touch-applies-seed-of-life',
|
||||||
|
name: 'Verdant Touch Applies Seed of Life',
|
||||||
|
description: 'Verdant Touch also applies Seed of Life to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-verdant-touch-applies-barkskin',
|
||||||
|
name: 'Verdant Touch Applies 50% Barkskin',
|
||||||
|
description: 'Verdant Touch also applies Barkskin at 50% strength to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-seed-of-life-applies-barkskin',
|
||||||
|
name: 'Seed of Life Applies Barkskin',
|
||||||
|
description: 'Seed of Life also applies Barkskin to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-wild-growth-applies-seed-of-life',
|
||||||
|
name: 'Wild Growth Applies Seed of Life',
|
||||||
|
description: 'Wild Growth also applies Seed of Life to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-wild-growth-applies-barkskin',
|
||||||
|
name: 'Wild Growth Applies 50% Barkskin',
|
||||||
|
description: 'Wild Growth also applies Barkskin at 50% strength to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-barkskin-applies-seed-of-life',
|
||||||
|
name: 'Barkskin Applies Seed of Life',
|
||||||
|
description: 'Barkskin also applies Seed of Life to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
|
||||||
|
name: 'Barkskin Feeds HoTs',
|
||||||
|
description: 'While shielded by Barkskin, the target receives 25% more healing over time.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-purging-sap-applies-seed-of-life',
|
||||||
|
name: 'Purging Sap Applies Seed of Life',
|
||||||
|
description: 'Purging Sap also applies Seed of Life to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-purging-sap-applies-barkskin',
|
||||||
|
name: 'Purging Sap Applies Barkskin',
|
||||||
|
description: 'Purging Sap also applies Barkskin to the target.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const IWT2_RUNESAGE_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
|
||||||
|
{
|
||||||
|
id: 'runesage-etched-mend-applies-mending-rune',
|
||||||
|
name: 'Etched Mend Applies Mending Rune',
|
||||||
|
description: 'Etched Mend also applies Mending Rune to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-etched-mend-applies-aegis-script',
|
||||||
|
name: 'Etched Mend Applies 50% Aegis Script',
|
||||||
|
description: 'Etched Mend also applies Aegis Script at 50% strength to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-mending-rune-applies-aegis-script',
|
||||||
|
name: 'Mending Rune Applies Aegis Script',
|
||||||
|
description: 'Mending Rune also applies Aegis Script to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-concordance-applies-mending-rune',
|
||||||
|
name: 'Concordance Applies Mending Rune',
|
||||||
|
description: 'Concordance also applies Mending Rune to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-concordance-applies-aegis-script',
|
||||||
|
name: 'Concordance Applies 50% Aegis Script',
|
||||||
|
description: 'Concordance also applies Aegis Script at 50% strength to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-aegis-script-applies-mending-rune',
|
||||||
|
name: 'Aegis Script Applies Mending Rune',
|
||||||
|
description: 'Aegis Script also applies Mending Rune to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID,
|
||||||
|
name: 'Aegis Script Reduces Damage',
|
||||||
|
description: 'While shielded by Aegis Script, the target takes 30% less damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-unravel-applies-mending-rune',
|
||||||
|
name: 'Unravel Applies Mending Rune',
|
||||||
|
description: 'Unravel also applies Mending Rune to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-unravel-applies-aegis-script',
|
||||||
|
name: 'Unravel Applies Aegis Script',
|
||||||
|
description: 'Unravel also applies Aegis Script to the target.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
||||||
return {
|
return {
|
||||||
id: ability.id,
|
id: ability.id,
|
||||||
@@ -49,11 +223,15 @@ export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
|
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||||
return buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
|
const baseChoices = buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
|
||||||
slots: IWT2_ROGUELIKE_SLOTS,
|
slots: IWT2_ROGUELIKE_SLOTS,
|
||||||
spells: abilities.map(iwt2AbilityToSpell),
|
spells: abilities.map(iwt2AbilityToSpell),
|
||||||
labelMode: 'ability',
|
labelMode: 'ability',
|
||||||
})
|
})
|
||||||
|
if (abilities.some((ability) => ability.id === 'dawnweaver-mend')) return [...baseChoices, ...IWT2_DAWNWEAVER_SYNERGY_CHOICES]
|
||||||
|
if (abilities.some((ability) => ability.id === 'lifebinder-verdant-touch')) return [...baseChoices, ...IWT2_LIFEBINDER_SYNERGY_CHOICES]
|
||||||
|
if (abilities.some((ability) => ability.id === 'runesage-etched-mend')) return [...baseChoices, ...IWT2_RUNESAGE_SYNERGY_CHOICES]
|
||||||
|
return baseChoices
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
|
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { Iwt2BossEntityState } from '../sim'
|
||||||
|
|
||||||
|
export type BossRenderMotion = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
scaleX: number
|
||||||
|
scaleY: number
|
||||||
|
rotation: number
|
||||||
|
tint?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type BossMotionProfile = {
|
||||||
|
idleFrequency: number
|
||||||
|
idleBob: number
|
||||||
|
moveFrequency: number
|
||||||
|
moveBob: number
|
||||||
|
impactScale: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROFILE: BossMotionProfile = {
|
||||||
|
idleFrequency: 1.6,
|
||||||
|
idleBob: 2.1,
|
||||||
|
moveFrequency: 5.4,
|
||||||
|
moveBob: 3,
|
||||||
|
impactScale: 0.035,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOSS_PROFILES: Partial<Record<Iwt2BossEntityState['bossId'], BossMotionProfile>> = {
|
||||||
|
bulldrome: {
|
||||||
|
idleFrequency: 1.35,
|
||||||
|
idleBob: 2.3,
|
||||||
|
moveFrequency: 5.9,
|
||||||
|
moveBob: 3.2,
|
||||||
|
impactScale: 0.045,
|
||||||
|
},
|
||||||
|
'yian-kut-ku': {
|
||||||
|
idleFrequency: 2.2,
|
||||||
|
idleBob: 2.8,
|
||||||
|
moveFrequency: 6.4,
|
||||||
|
moveBob: 3.4,
|
||||||
|
impactScale: 0.032,
|
||||||
|
},
|
||||||
|
'obsidian-ram-golem': {
|
||||||
|
idleFrequency: 1.05,
|
||||||
|
idleBob: 1.6,
|
||||||
|
moveFrequency: 3.7,
|
||||||
|
moveBob: 2.5,
|
||||||
|
impactScale: 0.052,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const WARM_TINT = 0xffc88a
|
||||||
|
const IMPACT_TINT = 0xff9b5f
|
||||||
|
|
||||||
|
export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: number): BossRenderMotion {
|
||||||
|
const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE
|
||||||
|
const phase = String(entity.attackPhase)
|
||||||
|
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
|
||||||
|
const moving = speed > 8 || phase === 'relocating'
|
||||||
|
const facingX = entity.facing.x < -0.05 ? -1 : 1
|
||||||
|
|
||||||
|
const motion: BossRenderMotion = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
rotation: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX)
|
||||||
|
if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX)
|
||||||
|
if (isActionPhase(phase, 'recover')) return recoverMotion(motion, profile, entity, timeSeconds)
|
||||||
|
if (moving) return movingMotion(motion, profile, entity, timeSeconds, facingX)
|
||||||
|
return idleMotion(motion, profile, timeSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion {
|
||||||
|
const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2)
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
y: -Math.max(0, wave) * profile.idleBob,
|
||||||
|
scaleY: 1 + Math.max(0, wave) * 0.025,
|
||||||
|
scaleX: 1 - Math.max(0, wave) * 0.01,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function movingMotion(
|
||||||
|
motion: BossRenderMotion,
|
||||||
|
profile: BossMotionProfile,
|
||||||
|
entity: Iwt2BossEntityState,
|
||||||
|
timeSeconds: number,
|
||||||
|
facingX: number,
|
||||||
|
): BossRenderMotion {
|
||||||
|
const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180)
|
||||||
|
const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2)
|
||||||
|
const lift = Math.abs(stride) * profile.moveBob
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
y: -lift,
|
||||||
|
scaleX: 1 + 0.012 * speedFactor,
|
||||||
|
scaleY: 1 - 0.009 * Math.abs(stride),
|
||||||
|
rotation: facingX * 0.025 * speedFactor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function windupMotion(
|
||||||
|
motion: BossRenderMotion,
|
||||||
|
entity: Iwt2BossEntityState,
|
||||||
|
phase: string,
|
||||||
|
timeSeconds: number,
|
||||||
|
facingX: number,
|
||||||
|
): BossRenderMotion {
|
||||||
|
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5
|
||||||
|
const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter')
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
x: -facingX * (isSlam ? 1.5 : 3),
|
||||||
|
y: isSlam ? -2 : 2,
|
||||||
|
scaleX: 1.035,
|
||||||
|
scaleY: 0.965,
|
||||||
|
rotation: -facingX * 0.02,
|
||||||
|
tint: pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion {
|
||||||
|
const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
x: facingX * 3 + shake,
|
||||||
|
y: Math.cos(timeSeconds * Math.PI * 42) * 1.2,
|
||||||
|
scaleX: 1.045,
|
||||||
|
scaleY: 0.96,
|
||||||
|
rotation: facingX * 0.018,
|
||||||
|
tint: WARM_TINT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverMotion(
|
||||||
|
motion: BossRenderMotion,
|
||||||
|
profile: BossMotionProfile,
|
||||||
|
entity: Iwt2BossEntityState,
|
||||||
|
timeSeconds: number,
|
||||||
|
): BossRenderMotion {
|
||||||
|
const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45))
|
||||||
|
const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle
|
||||||
|
const pop = profile.impactScale * impactPulse
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
y: -2 * impactPulse,
|
||||||
|
scaleX: 1 + pop,
|
||||||
|
scaleY: 1 + pop * 0.45,
|
||||||
|
tint: impactPulse > 0.45 ? IMPACT_TINT : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionPhase(phase: string, suffix: string): boolean {
|
||||||
|
return phase.toLowerCase().includes(suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBurstPhase(phase: string): boolean {
|
||||||
|
const normalized = phase.toLowerCase()
|
||||||
|
return (
|
||||||
|
normalized.includes('charging')
|
||||||
|
|| normalized.includes('swooping')
|
||||||
|
|| normalized.includes('burrowing')
|
||||||
|
|| normalized.includes('ricocheting')
|
||||||
|
|| normalized.includes('flying')
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import type { MovementVector } from '../../../../input'
|
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 { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
|
||||||
|
import { bossRenderMotion } from '../bossAnimation'
|
||||||
import type {
|
import type {
|
||||||
|
Iwt2ArenaEvent,
|
||||||
Iwt2ArenaIndicator,
|
Iwt2ArenaIndicator,
|
||||||
Iwt2ArenaState,
|
Iwt2ArenaState,
|
||||||
Iwt2BossEntityState,
|
Iwt2BossEntityState,
|
||||||
@@ -20,13 +22,27 @@ type SceneDeps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState
|
type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState
|
||||||
|
type FloatingCombatTextAnchor = {
|
||||||
|
kind: DrawableEntity['kind']
|
||||||
|
position: Iwt2Vec2
|
||||||
|
radius: number
|
||||||
|
}
|
||||||
|
|
||||||
export class BulldromeArenaScene extends Phaser.Scene {
|
export class BulldromeArenaScene extends Phaser.Scene {
|
||||||
private deps: SceneDeps
|
private deps: SceneDeps
|
||||||
private arenaGraphics?: Phaser.GameObjects.Graphics
|
private arenaGraphics?: Phaser.GameObjects.Graphics
|
||||||
|
private bossUnderlayGraphics?: Phaser.GameObjects.Graphics
|
||||||
private entityGraphics?: Phaser.GameObjects.Graphics
|
private entityGraphics?: Phaser.GameObjects.Graphics
|
||||||
private telegraphGraphics?: Phaser.GameObjects.Graphics
|
private telegraphGraphics?: Phaser.GameObjects.Graphics
|
||||||
private labels = new Map<string, Phaser.GameObjects.Text>()
|
private labels = new Map<string, Phaser.GameObjects.Text>()
|
||||||
|
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
private partySprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
private projectileSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
|
||||||
|
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
|
||||||
|
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
||||||
|
private lastFloatingEventId = 0
|
||||||
|
private lastStateTime = 0
|
||||||
private lastHudPublish = 0
|
private lastHudPublish = 0
|
||||||
|
|
||||||
constructor(deps: SceneDeps) {
|
constructor(deps: SceneDeps) {
|
||||||
@@ -34,11 +50,27 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
this.deps = deps
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const metadata of Object.values(IWT2_CLASS_METADATA)) {
|
||||||
|
this.load.image(classArenaSpriteKey(metadata.id), metadata.arenaSpriteUrl)
|
||||||
|
}
|
||||||
|
this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png')
|
||||||
|
this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png')
|
||||||
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.cameras.main.setBackgroundColor('#10141b')
|
this.cameras.main.setBackgroundColor('#10141b')
|
||||||
this.arenaGraphics = this.add.graphics()
|
this.arenaGraphics = this.add.graphics().setDepth(0)
|
||||||
this.telegraphGraphics = this.add.graphics()
|
this.telegraphGraphics = this.add.graphics().setDepth(10)
|
||||||
this.entityGraphics = this.add.graphics()
|
this.bossUnderlayGraphics = this.add.graphics().setDepth(20)
|
||||||
|
this.entityGraphics = this.add.graphics().setDepth(35)
|
||||||
this.drawState(this.deps.getState())
|
this.drawState(this.deps.getState())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +85,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
this.drawTelegraphs(state)
|
this.drawTelegraphs(state)
|
||||||
this.drawEntities(state)
|
this.drawEntities(state)
|
||||||
this.drawProjectiles(state)
|
this.drawProjectiles(state)
|
||||||
|
this.drawFloatingCombatTexts(state)
|
||||||
this.lastHudPublish += 1
|
this.lastHudPublish += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,22 +113,30 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private drawEntities(state: Iwt2ArenaState) {
|
private drawEntities(state: Iwt2ArenaState) {
|
||||||
|
const bossUnderlay = this.bossUnderlayGraphics!
|
||||||
const graphics = this.entityGraphics!
|
const graphics = this.entityGraphics!
|
||||||
|
bossUnderlay.clear()
|
||||||
graphics.clear()
|
graphics.clear()
|
||||||
for (const hazard of state.hazards) drawHazard(graphics, hazard)
|
for (const hazard of state.hazards) drawHazard(graphics, hazard)
|
||||||
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss]
|
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
|
||||||
const liveIds = new Set<string>(entities.map((entity) => entity.id))
|
const liveLabelIds = new Set<string>(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id))
|
||||||
|
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
|
||||||
|
const livePartyIds = new Set<string>(state.party.map((member) => member.id))
|
||||||
|
|
||||||
for (const entity of entities.sort(entitySort)) {
|
for (const entity of entities.sort(entitySort)) {
|
||||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
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 alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
|
||||||
const color = entityColor(entity)
|
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') {
|
if (entity.kind === 'boss') {
|
||||||
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
|
if (this.textures.exists(bossSpriteKey(entity.bossId))) {
|
||||||
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
|
drawBossUnderlay(bossUnderlay, entity, color, alpha)
|
||||||
|
this.drawBossSprite(entity, alpha, state.time)
|
||||||
|
} else {
|
||||||
|
drawBossFallback(graphics, entity, color, alpha)
|
||||||
|
}
|
||||||
} else if (entity.kind === 'hostileAdd') {
|
} 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
|
const wing = entity.radius * 1.55
|
||||||
graphics.fillTriangle(
|
graphics.fillTriangle(
|
||||||
entity.position.x + entity.facing.x * wing,
|
entity.position.x + entity.facing.x * wing,
|
||||||
@@ -114,19 +155,40 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
entity.position.y + entity.radius,
|
entity.position.y + entity.radius,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
graphics.lineStyle(entity.aiRole === 'player' ? 4 : 2, 0x0a0c10, 1)
|
||||||
if (entity.id === this.deps.getSelectedPartyId()) {
|
if (entity.id === this.deps.getSelectedPartyId()) {
|
||||||
graphics.lineStyle(4, 0xfff4a8, 0.95)
|
graphics.lineStyle(4, 0xfff4a8, 0.95)
|
||||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
|
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
|
||||||
}
|
}
|
||||||
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
|
if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
|
||||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
|
drawPartyUnderlay(graphics, entity, color, alpha)
|
||||||
|
this.drawPartySprite(entity, alpha)
|
||||||
|
} else {
|
||||||
|
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
|
||||||
|
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
|
||||||
|
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.drawLabel(entity)
|
if (shouldDrawLabel(entity)) this.drawLabel(entity)
|
||||||
this.drawHealthBar(graphics, 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, sprite] of this.partySprites) {
|
||||||
|
if (!livePartyIds.has(id)) {
|
||||||
|
sprite.destroy()
|
||||||
|
this.partySprites.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const [id, label] of this.labels) {
|
for (const [id, label] of this.labels) {
|
||||||
if (!liveIds.has(id)) {
|
if (!liveLabelIds.has(id)) {
|
||||||
label.destroy()
|
label.destroy()
|
||||||
this.labels.delete(id)
|
this.labels.delete(id)
|
||||||
}
|
}
|
||||||
@@ -135,18 +197,89 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private drawProjectiles(state: Iwt2ArenaState) {
|
private drawProjectiles(state: Iwt2ArenaState) {
|
||||||
const graphics = this.entityGraphics!
|
const graphics = this.entityGraphics!
|
||||||
|
const liveProjectileIds = new Set(state.projectiles.map((projectile) => projectile.id))
|
||||||
for (const projectile of state.projectiles) {
|
for (const projectile of state.projectiles) {
|
||||||
|
const spriteKey = projectileSpriteKey(projectile.projectileKind)
|
||||||
|
if (this.textures.exists(spriteKey)) {
|
||||||
|
let sprite = this.projectileSprites.get(projectile.id)
|
||||||
|
if (!sprite) {
|
||||||
|
sprite = this.add.image(projectile.position.x, projectile.position.y, spriteKey).setOrigin(0.5).setDepth(45)
|
||||||
|
this.projectileSprites.set(projectile.id, sprite)
|
||||||
|
}
|
||||||
|
const velocityAngle = Math.atan2(projectile.velocity.y, projectile.velocity.x)
|
||||||
|
const size = projectileDisplaySize(projectile.projectileKind, projectile.radius)
|
||||||
|
sprite
|
||||||
|
.setTexture(spriteKey)
|
||||||
|
.setPosition(projectile.position.x, projectile.position.y)
|
||||||
|
.setRotation(velocityAngle)
|
||||||
|
.setDisplaySize(size.width, size.height)
|
||||||
|
.setAlpha(0.95)
|
||||||
|
continue
|
||||||
|
}
|
||||||
const color = Number.parseInt(projectile.color.slice(1), 16)
|
const color = Number.parseInt(projectile.color.slice(1), 16)
|
||||||
graphics.fillStyle(color, 0.95)
|
graphics.fillStyle(color, 0.95)
|
||||||
graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75)
|
graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75)
|
||||||
graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3)
|
graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3)
|
||||||
graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius)
|
graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius)
|
||||||
}
|
}
|
||||||
|
for (const [id, sprite] of this.projectileSprites) {
|
||||||
|
if (!liveProjectileIds.has(id)) {
|
||||||
|
sprite.destroy()
|
||||||
|
this.projectileSprites.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawBossSprite(entity: Iwt2BossEntityState, alpha: number, timeSeconds: 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)
|
||||||
|
const motion = bossRenderMotion(entity, timeSeconds)
|
||||||
|
if (motion.tint) {
|
||||||
|
sprite.setTint(motion.tint)
|
||||||
|
} else {
|
||||||
|
sprite.clearTint()
|
||||||
|
}
|
||||||
|
sprite
|
||||||
|
.setTexture(key)
|
||||||
|
.setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y)
|
||||||
|
.setDisplaySize(width * motion.scaleX, height * motion.scaleY)
|
||||||
|
.setRotation(rotation + motion.rotation)
|
||||||
|
.setFlipX(isSideView && entity.facing.x < -0.05)
|
||||||
|
.setAlpha(alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number) {
|
||||||
|
const key = classArenaSpriteKey(entity.classId)
|
||||||
|
let sprite = this.partySprites.get(entity.id)
|
||||||
|
if (!sprite) {
|
||||||
|
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5, 0.66).setDepth(40)
|
||||||
|
this.partySprites.set(entity.id, sprite)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayHeight = entity.radius * 4.55
|
||||||
|
sprite
|
||||||
|
.setTexture(key)
|
||||||
|
.setPosition(entity.position.x, entity.position.y + entity.radius * 0.18)
|
||||||
|
.setDisplaySize(displayHeight * (sprite.width / Math.max(1, sprite.height)), displayHeight)
|
||||||
|
.setFlipX(entity.facing.x < -0.05)
|
||||||
|
.setAlpha(alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawLabel(entity: DrawableEntity) {
|
private drawLabel(entity: DrawableEntity) {
|
||||||
let label = this.labels.get(entity.id)
|
let label = this.labels.get(entity.id)
|
||||||
const icon = entityIcon(entity)
|
const icon = entity.kind === 'party' ? '!' : entityIcon(entity)
|
||||||
if (!label) {
|
if (!label) {
|
||||||
label = this.add.text(entity.position.x, entity.position.y, icon, {
|
label = this.add.text(entity.position.x, entity.position.y, icon, {
|
||||||
align: 'center',
|
align: 'center',
|
||||||
@@ -157,16 +290,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}).setOrigin(0.5)
|
}).setOrigin(0.5)
|
||||||
this.labels.set(entity.id, label)
|
this.labels.set(entity.id, label)
|
||||||
}
|
}
|
||||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
label.setText(icon)
|
||||||
label.setText(stunned ? '!' : icon)
|
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'party' ? entity.radius * 2.1 : 0))
|
||||||
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0))
|
|
||||||
label.setAlpha(entity.health <= 0 ? 0.35 : 1)
|
label.setAlpha(entity.health <= 0 ? 0.35 : 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) {
|
private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) {
|
||||||
const width = entity.kind === 'boss' ? 92 : 34
|
const width = entity.kind === 'boss' ? 92 : 34
|
||||||
const height = entity.kind === 'boss' ? 8 : 7
|
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))
|
const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth))
|
||||||
graphics.fillStyle(0x050608, 0.9)
|
graphics.fillStyle(0x050608, 0.9)
|
||||||
graphics.fillRect(entity.position.x - width / 2, y, width, height)
|
graphics.fillRect(entity.position.x - width / 2, y, width, height)
|
||||||
@@ -177,6 +309,109 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
graphics.fillStyle(0x58a8ff, 0.92)
|
graphics.fillStyle(0x58a8ff, 0.92)
|
||||||
graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawFloatingCombatTexts(state: Iwt2ArenaState) {
|
||||||
|
if (state.time < this.lastStateTime) {
|
||||||
|
this.lastFloatingEventId = 0
|
||||||
|
for (const text of this.floatingCombatTexts.values()) text.destroy()
|
||||||
|
this.floatingCombatTexts.clear()
|
||||||
|
for (const glow of this.castGlowEffects) glow.destroy()
|
||||||
|
this.castGlowEffects.clear()
|
||||||
|
this.lastEntityAnchors.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of state.events) {
|
||||||
|
if (event.id <= this.lastFloatingEventId) continue
|
||||||
|
if (event.type === 'healerSpellCast') this.spawnHealerCastGlow(state)
|
||||||
|
if (!shouldSpawnFloatingCombatText(event)) {
|
||||||
|
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.spawnFloatingCombatText(event, state)
|
||||||
|
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastStateTime = state.time
|
||||||
|
this.lastEntityAnchors = entityAnchors(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnFloatingCombatText(event: Iwt2ArenaEvent, state: Iwt2ArenaState) {
|
||||||
|
const anchor = event.targetId
|
||||||
|
? entityAnchors(state).get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId)
|
||||||
|
: undefined
|
||||||
|
if (!anchor || !event.value || event.value <= 0) return
|
||||||
|
|
||||||
|
const value = Math.round(event.value)
|
||||||
|
const isHeal = event.type === 'partyHealed'
|
||||||
|
const jitter = ((event.id % 5) - 2) * 9
|
||||||
|
const x = anchor.position.x + jitter
|
||||||
|
const y = anchor.position.y - floatingTextYOffset(anchor)
|
||||||
|
const text = this.add.text(x, y, `${isHeal ? '+' : ''}${value}`, {
|
||||||
|
align: 'center',
|
||||||
|
color: isHeal ? '#73f2a6' : '#ffd25f',
|
||||||
|
fontFamily: 'ui-monospace, Consolas, monospace',
|
||||||
|
fontSize: anchor.kind === 'boss' ? '24px' : '19px',
|
||||||
|
fontStyle: '900',
|
||||||
|
stroke: '#050608',
|
||||||
|
strokeThickness: 5,
|
||||||
|
}).setOrigin(0.5).setDepth(75)
|
||||||
|
|
||||||
|
this.floatingCombatTexts.set(event.id, text)
|
||||||
|
this.tweens.add({
|
||||||
|
targets: text,
|
||||||
|
alpha: 0,
|
||||||
|
y: y - (anchor.kind === 'boss' ? 48 : 34),
|
||||||
|
scale: anchor.kind === 'boss' ? 1.18 : 1.08,
|
||||||
|
duration: 850,
|
||||||
|
ease: 'Cubic.easeOut',
|
||||||
|
onComplete: () => {
|
||||||
|
text.destroy()
|
||||||
|
this.floatingCombatTexts.delete(event.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnHealerCastGlow(state: Iwt2ArenaState) {
|
||||||
|
const healer = state.party.find((member) => member.id === 'player-healer' && member.health > 0)
|
||||||
|
if (!healer) return
|
||||||
|
|
||||||
|
const staffHead = staffHeadPosition(healer)
|
||||||
|
const glow = this.add.graphics().setDepth(65)
|
||||||
|
glow.setPosition(staffHead.x, staffHead.y)
|
||||||
|
glow.fillStyle(0x9fffe0, 0.28)
|
||||||
|
glow.fillCircle(0, 0, healer.radius * 1.2)
|
||||||
|
glow.lineStyle(3, 0xf8f5a6, 0.9)
|
||||||
|
glow.strokeCircle(0, 0, healer.radius * 0.9)
|
||||||
|
glow.lineStyle(2, 0xb7f7cf, 0.7)
|
||||||
|
glow.strokeCircle(0, 0, healer.radius * 1.45)
|
||||||
|
this.castGlowEffects.add(glow)
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: glow,
|
||||||
|
alpha: 0,
|
||||||
|
scale: 1.55,
|
||||||
|
duration: 360,
|
||||||
|
ease: 'Cubic.easeOut',
|
||||||
|
onComplete: () => {
|
||||||
|
glow.destroy()
|
||||||
|
this.castGlowEffects.delete(glow)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,11 +432,108 @@ function entityIcon(entity: DrawableEntity): string {
|
|||||||
return IWT2_CLASS_METADATA[entity.classId].icon
|
return IWT2_CLASS_METADATA[entity.classId].icon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bossSpriteKey(bossId: Iwt2BossId): string {
|
||||||
|
return `iwt2-boss-${bossId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function classArenaSpriteKey(classId: Iwt2PlayerClassId): string {
|
||||||
|
return `iwt2-class-${classId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectileSpriteKey(projectileKind: 'arrow' | 'fireball' | 'magic'): string {
|
||||||
|
return projectileKind === 'arrow' ? 'iwt2-projectile-arrow' : 'iwt2-projectile-fireball'
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectileDisplaySize(projectileKind: 'arrow' | 'fireball' | 'magic', radius: number): { width: number; height: number } {
|
||||||
|
if (projectileKind === 'arrow') {
|
||||||
|
return { width: radius * 11, height: radius * 1.9 }
|
||||||
|
}
|
||||||
|
return { width: radius * 5.4, height: radius * 3.15 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldDrawLabel(entity: DrawableEntity): boolean {
|
||||||
|
if (entity.kind === 'hostileAdd') return true
|
||||||
|
return entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSpawnFloatingCombatText(event: Iwt2ArenaEvent): boolean {
|
||||||
|
return (event.type === 'partyHealed' || event.type === 'bossDamaged')
|
||||||
|
&& event.value !== undefined
|
||||||
|
&& event.value > 0
|
||||||
|
&& event.targetId !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityAnchors(state: Iwt2ArenaState): Map<string, FloatingCombatTextAnchor> {
|
||||||
|
const anchors = new Map<string, FloatingCombatTextAnchor>()
|
||||||
|
for (const entity of [...state.party, ...state.hostileAdds, ...state.bosses]) {
|
||||||
|
anchors.set(entity.id, {
|
||||||
|
kind: entity.kind,
|
||||||
|
position: { ...entity.position },
|
||||||
|
radius: entity.radius,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return anchors
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number {
|
||||||
|
if (anchor.kind === 'boss') return anchor.radius * 1.45
|
||||||
|
if (anchor.kind === 'hostileAdd') return anchor.radius * 1.8
|
||||||
|
return anchor.radius * 2.85
|
||||||
|
}
|
||||||
|
|
||||||
|
function staffHeadPosition(entity: Iwt2PartyEntityState): Iwt2Vec2 {
|
||||||
|
const facingSign = entity.facing.x < -0.05 ? -1 : 1
|
||||||
|
return {
|
||||||
|
x: entity.position.x + facingSign * entity.radius * 1.45,
|
||||||
|
y: entity.position.y - entity.radius * 2.15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPartyUnderlay(
|
||||||
|
graphics: Phaser.GameObjects.Graphics,
|
||||||
|
entity: Iwt2PartyEntityState,
|
||||||
|
color: string,
|
||||||
|
alpha: number,
|
||||||
|
) {
|
||||||
|
const colorValue = Number.parseInt(color.slice(1), 16)
|
||||||
|
graphics.fillStyle(0x050608, 0.38 * alpha)
|
||||||
|
graphics.fillEllipse(entity.position.x, entity.position.y + entity.radius * 0.8, entity.radius * 2.25, entity.radius * 0.72)
|
||||||
|
graphics.lineStyle(entity.aiRole === 'player' ? 3 : 2, colorValue, 0.58 * alpha)
|
||||||
|
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
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 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
|
const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34
|
||||||
graphics.fillStyle(0xff7a2f, alpha)
|
const color = hazard.hazardKind === 'poisonPuddle' ? 0x7bd84f : hazard.hazardKind === 'mudPuddle' ? 0x9a6a3a : 0xff7a2f
|
||||||
graphics.lineStyle(2, 0xffc15a, 0.55)
|
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.fillCircle(hazard.position.x, hazard.position.y, hazard.radius)
|
||||||
graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius)
|
graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius)
|
||||||
}
|
}
|
||||||
@@ -237,12 +569,55 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
|
|||||||
return
|
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.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
|
||||||
graphics.fillCircle(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.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6)
|
||||||
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
|
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) {
|
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
|
||||||
const dx = end.x - start.x
|
const dx = end.x - start.x
|
||||||
const dy = end.y - start.y
|
const dy = end.y - start.y
|
||||||
|
|||||||
@@ -1,16 +1,43 @@
|
|||||||
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
|
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
|
||||||
|
import { coinDropQuantity } from '../../../shared/rewardRules.mjs'
|
||||||
|
import { requestGameApiJson } from '../../../gameRepository'
|
||||||
|
import {
|
||||||
|
IWT2_BOSS_PET_DROP_RATE,
|
||||||
|
IWT2_LEGACY_BOSS_MATERIAL_REWARDS,
|
||||||
|
iwt2BossCoinRewardFor,
|
||||||
|
iwt2BossPetRewardFor,
|
||||||
|
} from '../content/bossRewards'
|
||||||
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
|
import {
|
||||||
|
createDefaultIwt2GearProgress,
|
||||||
|
IWT2_GEAR_SLOTS,
|
||||||
|
iwt2GearUpgradeCosts,
|
||||||
|
iwt2InfusionCosts,
|
||||||
|
isIwt2InfusionUnlocked,
|
||||||
|
type Iwt2GearLevel,
|
||||||
|
type Iwt2GearProgress,
|
||||||
|
type Iwt2GearSlotId,
|
||||||
|
} from '../content/gear'
|
||||||
|
import {
|
||||||
|
IWT2_INFUSION_ABILITIES,
|
||||||
|
iwt2InfusionAbilitiesForClass,
|
||||||
|
type Iwt2InfusionAbilityId,
|
||||||
|
} from '../content/infusionAbilities'
|
||||||
|
import { IWT2_PARTY_ORDER, type Iwt2PlayerClassId } from '../content/classes'
|
||||||
|
|
||||||
export type Iwt2InventoryItem = {
|
export type Iwt2InventoryItem = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
quantity: number
|
quantity: number
|
||||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
|
itemLevel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
|
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
|
||||||
|
|
||||||
export type Iwt2CollectionLog = {
|
export type Iwt2CollectionLog = {
|
||||||
bossKills: Record<string, number>
|
bossKills: Record<string, number>
|
||||||
|
bossPets: Record<string, number>
|
||||||
dropsFound: Record<string, number>
|
dropsFound: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,22 +54,54 @@ export type Iwt2CloudSlot = {
|
|||||||
character: Iwt2Character
|
character: Iwt2Character
|
||||||
inventory: Iwt2InventoryItem[]
|
inventory: Iwt2InventoryItem[]
|
||||||
collectionLog: Iwt2CollectionLog
|
collectionLog: Iwt2CollectionLog
|
||||||
|
gearProgress: Iwt2GearProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2BossPetAward = {
|
||||||
|
bossId: Iwt2BossId
|
||||||
|
petId: string
|
||||||
|
petName: string
|
||||||
|
quantity: number
|
||||||
|
duplicate: boolean
|
||||||
|
quantityAfter: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2BossDropAward = {
|
||||||
|
bossId: Iwt2BossId
|
||||||
|
dropId: string
|
||||||
|
dropName: string
|
||||||
|
rarity: Iwt2InventoryItem['rarity']
|
||||||
|
itemLevel: number
|
||||||
|
quantity: number
|
||||||
|
duplicate: boolean
|
||||||
|
quantityAfter: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2BossKillReward = {
|
||||||
|
save: Iwt2Save
|
||||||
|
dropAwarded: Iwt2BossDropAward
|
||||||
|
petAwarded: Iwt2BossPetAward | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2Save = {
|
export type Iwt2Save = {
|
||||||
version: 1
|
version: 2
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
character: Iwt2Character
|
character: Iwt2Character
|
||||||
inventory: Iwt2InventoryItem[]
|
inventory: Iwt2InventoryItem[]
|
||||||
collectionLog: Iwt2CollectionLog
|
collectionLog: Iwt2CollectionLog
|
||||||
|
gearProgress: Iwt2GearProgress
|
||||||
cloudSlot?: Iwt2CloudSlot
|
cloudSlot?: Iwt2CloudSlot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Iwt2OnlineSaveResult = {
|
||||||
|
save: Iwt2Save | null
|
||||||
|
}
|
||||||
|
|
||||||
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
||||||
|
|
||||||
export function createDefaultIwt2Save(): Iwt2Save {
|
export function createDefaultIwt2Save(): Iwt2Save {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 2,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
character: {
|
character: {
|
||||||
name: 'Healer',
|
name: 'Healer',
|
||||||
@@ -54,17 +113,19 @@ export function createDefaultIwt2Save(): Iwt2Save {
|
|||||||
inventory: [],
|
inventory: [],
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
bossKills: {},
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
dropsFound: {},
|
dropsFound: {},
|
||||||
},
|
},
|
||||||
|
gearProgress: createDefaultIwt2GearProgress(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSave(value: unknown): Iwt2Save {
|
function normalizeSave(value: unknown): Iwt2Save {
|
||||||
if (!value || typeof value !== 'object') return createDefaultIwt2Save()
|
if (!value || typeof value !== 'object') return createDefaultIwt2Save()
|
||||||
const candidate = value as Partial<Iwt2Save>
|
const candidate = value as Partial<Omit<Iwt2Save, 'version'>> & { version?: number }
|
||||||
if (candidate.version !== 1) return createDefaultIwt2Save()
|
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 2,
|
||||||
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
|
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
|
||||||
character: {
|
character: {
|
||||||
name: candidate.character?.name || 'Healer',
|
name: candidate.character?.name || 'Healer',
|
||||||
@@ -73,11 +134,13 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
|
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
|
||||||
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
|
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
|
||||||
},
|
},
|
||||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
inventory: normalizeInventory(candidate.inventory),
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
bossKills: candidate.collectionLog?.bossKills ?? {},
|
bossKills: candidate.collectionLog?.bossKills ?? {},
|
||||||
dropsFound: candidate.collectionLog?.dropsFound ?? {},
|
bossPets: candidate.collectionLog?.bossPets ?? {},
|
||||||
|
dropsFound: normalizeDropsFound(candidate.collectionLog?.dropsFound),
|
||||||
},
|
},
|
||||||
|
gearProgress: normalizeGearProgress(candidate.gearProgress),
|
||||||
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
|
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +153,24 @@ export function loadIwt2Save(): Iwt2Save {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadIwt2OnlineSave(): Promise<Iwt2OnlineSaveResult> {
|
||||||
|
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save')
|
||||||
|
return {
|
||||||
|
save: result.save ? normalizeSave(result.save) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeIwt2OnlineSave(save: Iwt2Save): Promise<Iwt2OnlineSaveResult> {
|
||||||
|
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ save }),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
save: result.save ? normalizeSave(result.save) : normalizeSave(save),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function writeIwt2Save(save: Iwt2Save) {
|
export function writeIwt2Save(save: Iwt2Save) {
|
||||||
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
|
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
|
||||||
...save,
|
...save,
|
||||||
@@ -97,37 +178,177 @@ export function writeIwt2Save(save: Iwt2Save) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save {
|
export function recordIwt2BossKill(
|
||||||
const drop = bossDropFor(bossId)
|
save: Iwt2Save,
|
||||||
return {
|
bossId: Iwt2BossId,
|
||||||
|
options?: { difficultySlug?: string, experienceMultiplier?: number },
|
||||||
|
): Iwt2Save {
|
||||||
|
return recordIwt2BossKillReward(save, bossId, options).save
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordIwt2BossKillReward(
|
||||||
|
save: Iwt2Save,
|
||||||
|
bossId: Iwt2BossId,
|
||||||
|
options?: { difficultySlug?: string, experienceMultiplier?: number },
|
||||||
|
): Iwt2BossKillReward {
|
||||||
|
const drop = iwt2BossCoinRewardFor(bossId, options?.difficultySlug)
|
||||||
|
const pet = iwt2BossPetRewardFor(bossId)
|
||||||
|
const awardedPet = Math.random() < IWT2_BOSS_PET_DROP_RATE
|
||||||
|
const previousPetQuantity = save.collectionLog.bossPets[pet.id] ?? 0
|
||||||
|
const experienceReward = Math.round(125 * Math.max(0, options?.experienceMultiplier ?? 1))
|
||||||
|
const quantity = coinDropQuantity()
|
||||||
|
const inventoryResult = addInventoryItem(save.inventory, {
|
||||||
|
id: drop.id,
|
||||||
|
itemLevel: drop.itemLevel,
|
||||||
|
name: drop.name,
|
||||||
|
quantity,
|
||||||
|
rarity: drop.rarity,
|
||||||
|
})
|
||||||
|
const updatedSave: Iwt2Save = {
|
||||||
...save,
|
...save,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
character: {
|
character: {
|
||||||
...save.character,
|
...save.character,
|
||||||
experience: save.character.experience + 125,
|
experience: save.character.experience + experienceReward,
|
||||||
level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + 125) / 500)),
|
level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + experienceReward) / 500)),
|
||||||
},
|
},
|
||||||
inventory: [
|
inventory: inventoryResult.inventory,
|
||||||
...save.inventory,
|
|
||||||
{
|
|
||||||
id: `${drop.id}-${Date.now()}`,
|
|
||||||
name: drop.name,
|
|
||||||
quantity: 1,
|
|
||||||
rarity: 'common',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
...save.collectionLog,
|
...save.collectionLog,
|
||||||
bossKills: {
|
bossKills: {
|
||||||
...save.collectionLog.bossKills,
|
...save.collectionLog.bossKills,
|
||||||
[bossId]: (save.collectionLog.bossKills[bossId] ?? 0) + 1,
|
[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: {
|
dropsFound: {
|
||||||
...save.collectionLog.dropsFound,
|
...save.collectionLog.dropsFound,
|
||||||
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + 1,
|
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + quantity,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
save: updatedSave,
|
||||||
|
dropAwarded: {
|
||||||
|
bossId,
|
||||||
|
dropId: drop.id,
|
||||||
|
dropName: drop.name,
|
||||||
|
duplicate: inventoryResult.duplicate,
|
||||||
|
itemLevel: drop.itemLevel,
|
||||||
|
quantity,
|
||||||
|
quantityAfter: inventoryResult.quantityAfter,
|
||||||
|
rarity: drop.rarity,
|
||||||
|
},
|
||||||
|
petAwarded: awardedPet
|
||||||
|
? {
|
||||||
|
bossId,
|
||||||
|
duplicate: previousPetQuantity > 0,
|
||||||
|
petId: pet.id,
|
||||||
|
petName: pet.name,
|
||||||
|
quantity: 1,
|
||||||
|
quantityAfter: previousPetQuantity + 1,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upgradeIwt2GearSlot(
|
||||||
|
save: Iwt2Save,
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
slotId: Iwt2GearSlotId,
|
||||||
|
): Iwt2Save {
|
||||||
|
const classProgress = save.gearProgress[classId]
|
||||||
|
const slot = classProgress.slots[slotId]
|
||||||
|
if (slot.level >= 5) throw new Error('Gear slot already at +5.')
|
||||||
|
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
||||||
|
const inventory = spendInventoryCosts(save.inventory, costs)
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
inventory,
|
||||||
|
gearProgress: {
|
||||||
|
...save.gearProgress,
|
||||||
|
[classId]: {
|
||||||
|
...classProgress,
|
||||||
|
slots: {
|
||||||
|
...classProgress.slots,
|
||||||
|
[slotId]: {
|
||||||
|
level: (slot.level + 1) as Iwt2GearLevel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setIwt2InfusionAbility(
|
||||||
|
save: Iwt2Save,
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
slotId: Iwt2GearSlotId,
|
||||||
|
abilityId: Iwt2InfusionAbilityId,
|
||||||
|
): Iwt2Save {
|
||||||
|
const classProgress = save.gearProgress[classId]
|
||||||
|
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||||
|
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
||||||
|
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.')
|
||||||
|
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.')
|
||||||
|
if (classProgress.infusionAbilityId === abilityId) return save
|
||||||
|
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
inventory,
|
||||||
|
gearProgress: {
|
||||||
|
...save.gearProgress,
|
||||||
|
[classId]: {
|
||||||
|
...classProgress,
|
||||||
|
infusionAbilityId: abilityId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
||||||
|
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inventoryQuantity(inventory: Iwt2InventoryItem[], itemId: string): number {
|
||||||
|
return inventory.find((item) => item.id === itemId)?.quantity ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInventoryItem(
|
||||||
|
inventory: Iwt2InventoryItem[],
|
||||||
|
item: Iwt2InventoryItem,
|
||||||
|
): { inventory: Iwt2InventoryItem[], duplicate: boolean, quantityAfter: number } {
|
||||||
|
const nextInventory = inventory.map((candidate) => ({ ...candidate }))
|
||||||
|
const existing = nextInventory.find((candidate) => candidate.id === item.id)
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += item.quantity
|
||||||
|
return { inventory: nextInventory, duplicate: true, quantityAfter: existing.quantity }
|
||||||
|
}
|
||||||
|
nextInventory.push({ ...item })
|
||||||
|
return { inventory: nextInventory, duplicate: false, quantityAfter: item.quantity }
|
||||||
|
}
|
||||||
|
|
||||||
|
function spendInventoryCosts(
|
||||||
|
inventory: Iwt2InventoryItem[],
|
||||||
|
costs: Array<{ itemId: string, itemName: string, quantity: number }>,
|
||||||
|
): Iwt2InventoryItem[] {
|
||||||
|
for (const cost of costs) {
|
||||||
|
if (inventoryQuantity(inventory, cost.itemId) < cost.quantity) {
|
||||||
|
throw new Error(`Need ${cost.quantity} ${cost.itemName}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inventory.flatMap((item) => {
|
||||||
|
const cost = costs.find((candidate) => candidate.itemId === item.id)
|
||||||
|
if (!cost) return [{ ...item }]
|
||||||
|
const quantity = item.quantity - cost.quantity
|
||||||
|
return quantity > 0 ? [{ ...item, quantity }] : []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateIwt2CharacterSettings(
|
export function updateIwt2CharacterSettings(
|
||||||
@@ -157,8 +378,10 @@ export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
|||||||
inventory: save.inventory.map((item) => ({ ...item })),
|
inventory: save.inventory.map((item) => ({ ...item })),
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
bossKills: { ...save.collectionLog.bossKills },
|
bossKills: { ...save.collectionLog.bossKills },
|
||||||
|
bossPets: { ...save.collectionLog.bossPets },
|
||||||
dropsFound: { ...save.collectionLog.dropsFound },
|
dropsFound: { ...save.collectionLog.dropsFound },
|
||||||
},
|
},
|
||||||
|
gearProgress: cloneGearProgress(save.gearProgress),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,18 +395,13 @@ export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
|||||||
inventory: save.cloudSlot.inventory.map((item) => ({ ...item })),
|
inventory: save.cloudSlot.inventory.map((item) => ({ ...item })),
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
bossKills: { ...save.cloudSlot.collectionLog.bossKills },
|
bossKills: { ...save.cloudSlot.collectionLog.bossKills },
|
||||||
|
bossPets: { ...save.cloudSlot.collectionLog.bossPets },
|
||||||
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
|
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
|
||||||
},
|
},
|
||||||
|
gearProgress: cloneGearProgress(save.cloudSlot.gearProgress),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bossDropFor(bossId: string): { id: string, name: string } {
|
|
||||||
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
|
|
||||||
if (bossId === 'great-jaggi') return { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' }
|
|
||||||
if (bossId === 'khezu') return { id: 'khezu-pearl', name: 'Khezu Pearl' }
|
|
||||||
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId {
|
function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId {
|
||||||
return value === 'sun_gold'
|
return value === 'sun_gold'
|
||||||
|| value === 'ember_red'
|
|| value === 'ember_red'
|
||||||
@@ -211,10 +429,108 @@ function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined {
|
|||||||
healerStyle: asIwt2HealerId(candidate.character.healerStyle),
|
healerStyle: asIwt2HealerId(candidate.character.healerStyle),
|
||||||
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
|
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
|
||||||
},
|
},
|
||||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
inventory: normalizeInventory(candidate.inventory),
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
bossKills: candidate.collectionLog.bossKills ?? {},
|
bossKills: candidate.collectionLog.bossKills ?? {},
|
||||||
dropsFound: candidate.collectionLog.dropsFound ?? {},
|
bossPets: candidate.collectionLog.bossPets ?? {},
|
||||||
|
dropsFound: normalizeDropsFound(candidate.collectionLog.dropsFound),
|
||||||
},
|
},
|
||||||
|
gearProgress: normalizeGearProgress(candidate.gearProgress),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
||||||
|
const defaults = createDefaultIwt2GearProgress()
|
||||||
|
if (!value || typeof value !== 'object') return defaults
|
||||||
|
const candidate = value as Partial<Record<Iwt2PlayerClassId, unknown>>
|
||||||
|
const next = createDefaultIwt2GearProgress()
|
||||||
|
for (const classId of IWT2_PARTY_ORDER) {
|
||||||
|
const rawClassProgress = candidate[classId]
|
||||||
|
if (!rawClassProgress || typeof rawClassProgress !== 'object') continue
|
||||||
|
const classProgress = rawClassProgress as {
|
||||||
|
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
||||||
|
infusionAbilityId?: unknown
|
||||||
|
}
|
||||||
|
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||||
|
next[classId].slots[slotId] = {
|
||||||
|
level: asGearLevel(classProgress.slots?.[slotId]?.level),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const infusionAbilityId = classProgress.infusionAbilityId
|
||||||
|
next[classId].infusionAbilityId = iwt2InfusionAbilitiesForClass(classId).some((ability) => ability.id === infusionAbilityId)
|
||||||
|
&& isIwt2InfusionUnlocked(next[classId])
|
||||||
|
? infusionAbilityId as Iwt2InfusionAbilityId
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
|
||||||
|
return normalizeGearProgress(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
function asGearLevel(value: unknown): Iwt2GearLevel {
|
||||||
|
const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0)))
|
||||||
|
return level as Iwt2GearLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const byId = new Map<string, Iwt2InventoryItem>()
|
||||||
|
for (const rawItem of value) {
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') continue
|
||||||
|
const item = rawItem as Partial<Iwt2InventoryItem>
|
||||||
|
if (!item.id || !item.name) continue
|
||||||
|
const bossId = legacyBossIdForDropId(item.id)
|
||||||
|
const normalizedItem = bossId
|
||||||
|
? iwt2BossCoinRewardFor(bossId, 'initiate')
|
||||||
|
: {
|
||||||
|
id: item.id,
|
||||||
|
itemLevel: Math.max(1, Math.floor(item.itemLevel ?? 1)),
|
||||||
|
name: item.name,
|
||||||
|
rarity: asItemRarity(item.rarity),
|
||||||
|
}
|
||||||
|
const quantity = Math.max(1, Math.floor(item.quantity ?? 1))
|
||||||
|
const existing = byId.get(normalizedItem.id)
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += quantity
|
||||||
|
} else {
|
||||||
|
byId.set(normalizedItem.id, {
|
||||||
|
id: normalizedItem.id,
|
||||||
|
itemLevel: normalizedItem.itemLevel,
|
||||||
|
name: normalizedItem.name,
|
||||||
|
quantity,
|
||||||
|
rarity: normalizedItem.rarity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDropsFound(value: unknown): Record<string, number> {
|
||||||
|
if (!value || typeof value !== 'object') return {}
|
||||||
|
const next: Record<string, number> = {}
|
||||||
|
for (const [rawId, rawQuantity] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
const bossId = legacyBossIdForDropId(rawId)
|
||||||
|
const id = bossId ? iwt2BossCoinRewardFor(bossId, 'initiate').id : rawId
|
||||||
|
const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0))
|
||||||
|
if (quantity > 0) next[id] = (next[id] ?? 0) + quantity
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyBossIdForDropId(dropId: string): Iwt2BossId | undefined {
|
||||||
|
return (Object.entries(IWT2_LEGACY_BOSS_MATERIAL_REWARDS) as Array<[Iwt2BossId, { id: string }]>)
|
||||||
|
.find(([, legacy]) => dropId === legacy.id || dropId.startsWith(`${legacy.id}-`))
|
||||||
|
?.[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function asItemRarity(value: unknown): Iwt2InventoryItem['rarity'] {
|
||||||
|
return value === 'uncommon'
|
||||||
|
|| value === 'rare'
|
||||||
|
|| value === 'epic'
|
||||||
|
|| value === 'legendary'
|
||||||
|
|| value === 'common'
|
||||||
|
? value
|
||||||
|
: 'common'
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import type { Iwt2EntityId } from '../sim'
|
|||||||
import { castIwt2HealerAbility } from '../sim'
|
import { castIwt2HealerAbility } from '../sim'
|
||||||
import { PhaserArena } from '../render/PhaserArena'
|
import { PhaserArena } from '../render/PhaserArena'
|
||||||
import {
|
import {
|
||||||
recordIwt2BossKill,
|
recordIwt2BossKillReward,
|
||||||
|
type Iwt2BossDropAward,
|
||||||
|
type Iwt2BossPetAward,
|
||||||
type Iwt2Save,
|
type Iwt2Save,
|
||||||
} from '../save/iwt2Repository'
|
} from '../save/iwt2Repository'
|
||||||
import { AbilityBar } from '../components/AbilityBar'
|
import { AbilityBar } from '../components/AbilityBar'
|
||||||
@@ -24,7 +26,8 @@ import { BossHud } from '../components/BossHud'
|
|||||||
import { PartyFrames } from '../components/PartyFrames'
|
import { PartyFrames } from '../components/PartyFrames'
|
||||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
||||||
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
|
import type { Iwt2Difficulty } from '../content/difficulties'
|
||||||
|
import { abilitiesForHealer, type Iwt2HealerAbility, type Iwt2TriggeredAbilityEffect } from '../content/healerAbilities'
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||||
import type {
|
import type {
|
||||||
Iwt2RoguelikeContentType,
|
Iwt2RoguelikeContentType,
|
||||||
@@ -32,24 +35,44 @@ import type {
|
|||||||
Iwt2RoguelikeSelfBuffId,
|
Iwt2RoguelikeSelfBuffId,
|
||||||
Iwt2RoguelikeVariant,
|
Iwt2RoguelikeVariant,
|
||||||
} from '../content/roguelike'
|
} from '../content/roguelike'
|
||||||
|
import {
|
||||||
|
createRoguelikePressureState,
|
||||||
|
roguelikeIncomingDamageScale,
|
||||||
|
} from '../sim/roguelikePressure'
|
||||||
|
import { applyIwt2PveGearStats, applyIwt2PveGearToHealerAbilities } from '../sim/equipmentStats'
|
||||||
|
import {
|
||||||
|
IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID,
|
||||||
|
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
|
||||||
|
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
|
||||||
|
} from '../content/roguelike'
|
||||||
|
|
||||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||||
type OverlayAction = 'primary' | 'menu'
|
type OverlayAction = 'primary' | 'requeue' | 'menu'
|
||||||
type OverlayNavEntry = {
|
type OverlayNavEntry = {
|
||||||
action: OverlayAction
|
action: OverlayAction
|
||||||
row: number
|
row: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0 },
|
{ action: 'primary', row: 0 },
|
||||||
{ action: 'menu', row: 1 },
|
{ action: 'menu', row: 1 },
|
||||||
]
|
]
|
||||||
|
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
|
{ action: 'primary', row: 0 },
|
||||||
|
{ action: 'requeue', row: 1 },
|
||||||
|
{ action: 'menu', row: 2 },
|
||||||
|
]
|
||||||
|
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
|
||||||
|
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
|
||||||
|
|
||||||
type BossArenaScreenProps = {
|
type BossArenaScreenProps = {
|
||||||
bossId: Iwt2BossId
|
bossId: Iwt2BossId
|
||||||
|
bossIds?: Iwt2BossId[]
|
||||||
|
difficulty?: Iwt2Difficulty
|
||||||
modeLabel?: string
|
modeLabel?: string
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
onPvpRequeue?: () => void
|
||||||
onSaveUpdated: (save: Iwt2Save) => void
|
onSaveUpdated: (save: Iwt2Save) => void
|
||||||
roguelikeRun?: {
|
roguelikeRun?: {
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
@@ -61,19 +84,57 @@ type BossArenaScreenProps = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
||||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
|
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
||||||
|
const pveGearActive = roguelikeRun?.variant !== 'pvp'
|
||||||
|
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
|
||||||
|
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
|
||||||
|
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
|
||||||
|
const roguelikeStage = roguelikeRun?.stage
|
||||||
|
const roguelikeContentType = roguelikeRun?.contentType
|
||||||
|
const roguelikeBuffs = roguelikeRun?.buffs ?? EMPTY_ROGUELIKE_BUFFS
|
||||||
|
const roguelikeDamageScale = roguelikeRun
|
||||||
|
? roguelikeIncomingDamageScale(roguelikeRun.stage, roguelikeRun.contentType)
|
||||||
|
: 1
|
||||||
|
const experienceMultiplier = difficulty?.experienceMultiplier ?? 1
|
||||||
|
const difficultySlug = difficulty?.slug ?? 'initiate'
|
||||||
|
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
|
||||||
|
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
|
||||||
|
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
|
||||||
|
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
|
||||||
|
bossId,
|
||||||
|
bossIds,
|
||||||
|
combinedBossHealthScale,
|
||||||
|
combinedDamageScale,
|
||||||
|
roguelikeBuffs,
|
||||||
|
createPressureState(roguelikeStage, roguelikeContentType),
|
||||||
|
pveGearActive ? save.gearProgress : undefined,
|
||||||
|
))
|
||||||
|
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
||||||
|
pvpRoguelike
|
||||||
|
? createInitialIwt2ArenaState(
|
||||||
|
bossId,
|
||||||
|
bossIds,
|
||||||
|
combinedBossHealthScale,
|
||||||
|
combinedDamageScale,
|
||||||
|
createPressureState(roguelikeStage, roguelikeContentType),
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
))
|
||||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||||
const [status, setStatus] = useState<ArenaStatus>('playing')
|
const [status, setStatus] = useState<ArenaStatus>('playing')
|
||||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||||
|
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
|
||||||
|
const [petAwards, setPetAwards] = useState<Iwt2BossPetAward[]>([])
|
||||||
const stateRef = useRef(arenaState)
|
const stateRef = useRef(arenaState)
|
||||||
|
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
|
||||||
const statusRef = useRef(status)
|
const statusRef = useRef(status)
|
||||||
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
|
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
|
||||||
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
||||||
const saveRef = useRef(save)
|
const saveRef = useRef(save)
|
||||||
const killRecordedRef = useRef(false)
|
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
|
||||||
const lastPublishTimeRef = useRef(0)
|
const lastPublishTimeRef = useRef(0)
|
||||||
const lastHudSignatureRef = useRef('')
|
const lastHudSignatureRef = useRef('')
|
||||||
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
||||||
@@ -91,6 +152,10 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
stateRef.current = arenaState
|
stateRef.current = arenaState
|
||||||
}, [arenaState])
|
}, [arenaState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opponentStateRef.current = opponentArenaState
|
||||||
|
}, [opponentArenaState])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
statusRef.current = status
|
statusRef.current = status
|
||||||
}, [status])
|
}, [status])
|
||||||
@@ -108,16 +173,38 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
}, [save])
|
}, [save])
|
||||||
|
|
||||||
const resetArena = useCallback(() => {
|
const resetArena = useCallback(() => {
|
||||||
const next = createInitialIwt2ArenaState(bossId)
|
const pressureState = createPressureState(roguelikeStage, roguelikeContentType)
|
||||||
killRecordedRef.current = false
|
const next = createArenaState(
|
||||||
|
bossId,
|
||||||
|
bossIds,
|
||||||
|
combinedBossHealthScale,
|
||||||
|
combinedDamageScale,
|
||||||
|
roguelikeBuffs,
|
||||||
|
pressureState,
|
||||||
|
pveGearActive ? save.gearProgress : undefined,
|
||||||
|
)
|
||||||
|
const nextOpponentState = pvpRoguelike
|
||||||
|
? createInitialIwt2ArenaState(
|
||||||
|
bossId,
|
||||||
|
bossIds,
|
||||||
|
combinedBossHealthScale,
|
||||||
|
combinedDamageScale,
|
||||||
|
pressureState,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
recordedKillIdsRef.current = new Set()
|
||||||
abilityCooldownsRef.current = {}
|
abilityCooldownsRef.current = {}
|
||||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
lastHudSignatureRef.current = arenaHudSignature(next)
|
||||||
stateRef.current = next
|
stateRef.current = next
|
||||||
|
opponentStateRef.current = nextOpponentState
|
||||||
setArenaState(next)
|
setArenaState(next)
|
||||||
|
setOpponentArenaState(nextOpponentState)
|
||||||
setAbilityCooldowns({})
|
setAbilityCooldowns({})
|
||||||
|
setDropAwards([])
|
||||||
|
setPetAwards([])
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
}, [bossId])
|
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
|
||||||
|
|
||||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
@@ -125,12 +212,21 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const abilities = useMemo(
|
const abilities = useMemo(
|
||||||
() => applyRoguelikeModifiers(
|
() => {
|
||||||
abilitiesForHealer(save.character.healerStyle),
|
const baseAbilities = abilitiesForHealer(
|
||||||
roguelikeRun?.buffs ?? [],
|
save.character.healerStyle,
|
||||||
roguelikeRun?.debuffs ?? [],
|
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
|
||||||
),
|
)
|
||||||
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
|
const gearAbilities = pveGearActive
|
||||||
|
? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress)
|
||||||
|
: baseAbilities
|
||||||
|
return applyRoguelikeModifiers(
|
||||||
|
gearAbilities,
|
||||||
|
roguelikeRun?.buffs ?? [],
|
||||||
|
roguelikeRun?.debuffs ?? [],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[pveGearActive, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle, save.gearProgress],
|
||||||
)
|
)
|
||||||
|
|
||||||
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
||||||
@@ -152,8 +248,9 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
|
|
||||||
const moveOverlaySelection = useCallback((action: string) => {
|
const moveOverlaySelection = useCallback((action: string) => {
|
||||||
setSelectedOverlayAction((current) => {
|
setSelectedOverlayAction((current) => {
|
||||||
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
|
const entries = overlayNavEntriesFor(statusRef.current, pvpRoguelike)
|
||||||
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
|
const active = entries.find((entry) => entry.action === current) ?? entries[0]
|
||||||
|
const candidates = entries.filter((entry) => {
|
||||||
if (entry.action === current) return false
|
if (entry.action === current) return false
|
||||||
if (action === 'navigateUp') return entry.row < active.row
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
if (action === 'navigateDown') return entry.row > active.row
|
if (action === 'navigateDown') return entry.row > active.row
|
||||||
@@ -163,9 +260,13 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
|
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
|
||||||
return candidates[0]?.action ?? current
|
return candidates[0]?.action ?? current
|
||||||
})
|
})
|
||||||
}, [])
|
}, [pvpRoguelike])
|
||||||
|
|
||||||
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
|
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
|
||||||
|
if (overlayAction === 'requeue') {
|
||||||
|
onPvpRequeue?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (overlayAction === 'menu') {
|
if (overlayAction === 'menu') {
|
||||||
onBack()
|
onBack()
|
||||||
return
|
return
|
||||||
@@ -179,7 +280,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
resetArena()
|
resetArena()
|
||||||
}, [onBack, resetArena, roguelikeRun])
|
}, [onBack, onPvpRequeue, resetArena, roguelikeRun])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (device === 'controller' && statusRef.current !== 'playing') {
|
if (device === 'controller' && statusRef.current !== 'playing') {
|
||||||
@@ -193,7 +294,6 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
}
|
}
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
if (statusRef.current === 'paused') setStatus('playing')
|
if (statusRef.current === 'paused') setStatus('playing')
|
||||||
else onBack()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,13 +340,51 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
moveY: movement.y,
|
moveY: movement.y,
|
||||||
}, dtSeconds)
|
}, dtSeconds)
|
||||||
stateRef.current = next
|
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) {
|
const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
|
||||||
killRecordedRef.current = true
|
if (newlyDefeatedBosses.length > 0) {
|
||||||
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
|
const nextRecordedIds = new Set(recordedKillIdsRef.current)
|
||||||
|
let updatedSave = saveRef.current
|
||||||
|
const newDropAwards: Iwt2BossDropAward[] = []
|
||||||
|
const newPetAwards: Iwt2BossPetAward[] = []
|
||||||
|
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||||
|
nextRecordedIds.add(defeatedBoss.bossId)
|
||||||
|
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
||||||
|
difficultySlug,
|
||||||
|
experienceMultiplier,
|
||||||
|
})
|
||||||
|
updatedSave = reward.save
|
||||||
|
newDropAwards.push(reward.dropAwarded)
|
||||||
|
if (reward.petAwarded) newPetAwards.push(reward.petAwarded)
|
||||||
|
}
|
||||||
|
recordedKillIdsRef.current = nextRecordedIds
|
||||||
saveRef.current = updatedSave
|
saveRef.current = updatedSave
|
||||||
|
if (newDropAwards.length > 0) setDropAwards((current) => [...current, ...newDropAwards])
|
||||||
|
if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards])
|
||||||
onSaveUpdated(updatedSave)
|
onSaveUpdated(updatedSave)
|
||||||
showOverlay('victory')
|
}
|
||||||
|
|
||||||
|
if (next.bosses.every((boss) => boss.health <= 0)) {
|
||||||
|
if (pvpRoguelike && roguelikeRun) {
|
||||||
|
statusRef.current = 'victory'
|
||||||
|
setStatus('victory')
|
||||||
|
roguelikeRun.onVictory()
|
||||||
|
} else {
|
||||||
|
showOverlay('victory')
|
||||||
|
}
|
||||||
} else if (next.party.every((member) => member.health <= 0)) {
|
} else if (next.party.every((member) => member.health <= 0)) {
|
||||||
showOverlay('defeat')
|
showOverlay('defeat')
|
||||||
}
|
}
|
||||||
@@ -255,15 +393,16 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
if (
|
if (
|
||||||
hudSignature !== lastHudSignatureRef.current
|
hudSignature !== lastHudSignatureRef.current
|
||||||
|| next.time - lastPublishTimeRef.current >= 0.08
|
|| next.time - lastPublishTimeRef.current >= 0.08
|
||||||
|| next.boss.health <= 0
|
|| next.bosses.some((boss) => boss.health <= 0)
|
||||||
) {
|
) {
|
||||||
lastHudSignatureRef.current = hudSignature
|
lastHudSignatureRef.current = hudSignature
|
||||||
lastPublishTimeRef.current = next.time
|
lastPublishTimeRef.current = next.time
|
||||||
setArenaState(next)
|
setArenaState(next)
|
||||||
|
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
|
||||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
}, [bossId, onSaveUpdated, showOverlay])
|
}, [difficultySlug, experienceMultiplier, onSaveUpdated, pvpRoguelike, roguelikeRun, showOverlay])
|
||||||
|
|
||||||
const targetBindings = directPartyTargeting
|
const targetBindings = directPartyTargeting
|
||||||
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
||||||
@@ -271,13 +410,18 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
|
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
|
||||||
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
||||||
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
||||||
|
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
|
||||||
|
const victoryExperience = Math.round(arenaState.bosses.length * 125 * experienceMultiplier)
|
||||||
|
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
|
||||||
const overlayPrimaryLabel = status === 'paused'
|
const overlayPrimaryLabel = status === 'paused'
|
||||||
? 'Resume'
|
? 'Resume'
|
||||||
: status === 'victory' && roguelikeRun
|
: status === 'victory' && roguelikeRun
|
||||||
? 'Choose Upgrade'
|
? 'Choose Upgrade'
|
||||||
|
: pvpRoguelike && status !== 'playing'
|
||||||
|
? 'Rematch'
|
||||||
: 'Restart'
|
: 'Restart'
|
||||||
const overlayTitle = status === 'victory'
|
const overlayTitle = status === 'victory'
|
||||||
? `${bossMetadata.name} Down`
|
? `${bossTitle} Down`
|
||||||
: status === 'defeat'
|
: status === 'defeat'
|
||||||
? 'Party Defeated'
|
? 'Party Defeated'
|
||||||
: 'Arena Paused'
|
: 'Arena Paused'
|
||||||
@@ -305,12 +449,13 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
abilities,
|
abilities,
|
||||||
arenaState,
|
arenaState,
|
||||||
bindings: activeBindings,
|
bindings: activeBindings,
|
||||||
bossMetadata,
|
|
||||||
cooldowns: abilityCooldowns,
|
cooldowns: abilityCooldowns,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
modeLabel: modeLabel ?? 'Arena',
|
modeLabel: modeLabel ?? 'Arena',
|
||||||
|
opponentArenaState,
|
||||||
playerMana,
|
playerMana,
|
||||||
|
roguelikeRun,
|
||||||
selectedPartyId,
|
selectedPartyId,
|
||||||
status,
|
status,
|
||||||
}),
|
}),
|
||||||
@@ -319,11 +464,12 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
activeBindings,
|
activeBindings,
|
||||||
arenaState,
|
arenaState,
|
||||||
abilityCooldowns,
|
abilityCooldowns,
|
||||||
bossMetadata,
|
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
modeLabel,
|
modeLabel,
|
||||||
|
opponentArenaState,
|
||||||
playerMana,
|
playerMana,
|
||||||
|
roguelikeRun,
|
||||||
selectedPartyId,
|
selectedPartyId,
|
||||||
status,
|
status,
|
||||||
],
|
],
|
||||||
@@ -339,7 +485,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
>
|
>
|
||||||
<section className="iwt2-arena-layout">
|
<section className="iwt2-arena-layout">
|
||||||
<div className="iwt2-arena-stage">
|
<div className="iwt2-arena-stage">
|
||||||
<BossHud boss={arenaState.boss} />
|
<BossHud bosses={arenaState.bosses} />
|
||||||
<PartyFrames
|
<PartyFrames
|
||||||
controllerIconStyle={controllerIconStyle}
|
controllerIconStyle={controllerIconStyle}
|
||||||
onTarget={setSelectedPartyId}
|
onTarget={setSelectedPartyId}
|
||||||
@@ -363,6 +509,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
<button
|
<button
|
||||||
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
|
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||||
onClick={() => activateOverlayAction('primary')}
|
onClick={() => activateOverlayAction('primary')}
|
||||||
onPointerDown={() => setSelectedOverlayAction('primary')}
|
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -372,6 +519,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
<button
|
<button
|
||||||
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||||
onClick={() => activateOverlayAction('menu')}
|
onClick={() => activateOverlayAction('menu')}
|
||||||
onPointerDown={() => setSelectedOverlayAction('menu')}
|
onPointerDown={() => setSelectedOverlayAction('menu')}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -386,7 +534,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
|
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
|
||||||
<div className="iwt2-result-panel">
|
<div className="iwt2-result-panel">
|
||||||
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
|
<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>
|
</div>
|
||||||
<p className="eyebrow">{overlayEyebrow}</p>
|
<p className="eyebrow">{overlayEyebrow}</p>
|
||||||
<h1>{overlayTitle}</h1>
|
<h1>{overlayTitle}</h1>
|
||||||
@@ -404,6 +552,10 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
||||||
Standing
|
Standing
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{defeatedBossCount}/{arenaState.bosses.length}</strong>
|
||||||
|
Bosses
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{Math.round(totalPartyDamage)}</strong>
|
<strong>{Math.round(totalPartyDamage)}</strong>
|
||||||
Damage
|
Damage
|
||||||
@@ -411,9 +563,18 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
</div>
|
</div>
|
||||||
{status === 'victory' && (
|
{status === 'victory' && (
|
||||||
<div className="iwt2-result-reward">
|
<div className="iwt2-result-reward">
|
||||||
<span>+125 XP</span>
|
<span>+{victoryExperience} XP</span>
|
||||||
<span>Common carve</span>
|
<span>Log +{arenaState.bosses.length}</span>
|
||||||
<span>Log +1</span>
|
{dropAwards.map((award) => (
|
||||||
|
<span key={`${award.dropId}:${award.quantityAfter}`}>
|
||||||
|
{award.dropName}{award.quantity > 1 ? ` x${award.quantity}` : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{petAwards.map((award) => (
|
||||||
|
<span key={`${award.petId}:${award.quantityAfter}`}>
|
||||||
|
{award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="iwt2-overlay-actions">
|
<div className="iwt2-overlay-actions">
|
||||||
@@ -421,21 +582,34 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
|||||||
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
||||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||||
onClick={() => activateOverlayAction('primary')}
|
onClick={() => activateOverlayAction('primary')}
|
||||||
|
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{overlayPrimaryLabel}
|
{overlayPrimaryLabel}
|
||||||
</button>
|
</button>
|
||||||
|
{pvpRoguelike && (
|
||||||
|
<button
|
||||||
|
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'requeue' ? 'game-selected' : ''}`}
|
||||||
|
data-game-selected={selectedOverlayAction === 'requeue' ? 'true' : undefined}
|
||||||
|
onClick={() => activateOverlayAction('requeue')}
|
||||||
|
onPointerDown={() => setSelectedOverlayAction('requeue')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Requeue
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||||
onClick={() => activateOverlayAction('menu')}
|
onClick={() => activateOverlayAction('menu')}
|
||||||
|
onPointerDown={() => setSelectedOverlayAction('menu')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Menu
|
Menu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{lastDevice === 'controller' && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -460,11 +634,22 @@ function formatArenaTime(seconds: number): string {
|
|||||||
return `${minutes}:${remainder.toString().padStart(2, '0')}`
|
return `${minutes}:${remainder.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roguelikeBossHealthScale(stage: number): number {
|
||||||
|
return 1 + Math.max(0, stage - 1) * 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
||||||
|
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
||||||
|
return DEFAULT_OVERLAY_NAV_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
function arenaHudSignature(state: Iwt2ArenaState): string {
|
function arenaHudSignature(state: Iwt2ArenaState): string {
|
||||||
return [
|
return [
|
||||||
state.boss.id,
|
...state.bosses.map((boss) => [
|
||||||
Math.ceil(state.boss.health),
|
boss.id,
|
||||||
state.boss.attackPhase,
|
Math.ceil(boss.health),
|
||||||
|
boss.attackPhase,
|
||||||
|
].join(':')),
|
||||||
state.hostileAdds.length,
|
state.hostileAdds.length,
|
||||||
state.hazards.length,
|
state.hazards.length,
|
||||||
...state.party.map((member) => [
|
...state.party.map((member) => [
|
||||||
@@ -478,6 +663,12 @@ function arenaHudSignature(state: Iwt2ArenaState): string {
|
|||||||
].join('|')
|
].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 {
|
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
|
||||||
const living = state.party.filter((member) => member.health > 0)
|
const living = state.party.filter((member) => member.health > 0)
|
||||||
const targets = living.length > 0 ? living : state.party
|
const targets = living.length > 0 ? living : state.party
|
||||||
@@ -497,12 +688,66 @@ function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
|
|||||||
return changed ? next : cooldowns
|
return changed ? next : cooldowns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createArenaState(
|
||||||
|
bossId: Iwt2BossId,
|
||||||
|
bossIds: Iwt2BossId[] | undefined,
|
||||||
|
bossHealthScale: number,
|
||||||
|
partyDamageTakenScale: number,
|
||||||
|
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||||
|
roguelikePressure: ReturnType<typeof createPressureState>,
|
||||||
|
gearProgress?: Iwt2Save['gearProgress'],
|
||||||
|
): Iwt2ArenaState {
|
||||||
|
const baseState = createInitialIwt2ArenaState(
|
||||||
|
bossId,
|
||||||
|
bossIds,
|
||||||
|
bossHealthScale,
|
||||||
|
partyDamageTakenScale,
|
||||||
|
roguelikePressure,
|
||||||
|
)
|
||||||
|
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
|
||||||
|
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
|
||||||
|
const shieldedHotHealingMultiplier = buffs.includes(IWT2_BARKSKIN_HOT_BONUS_BUFF_ID) ? 1.25 : undefined
|
||||||
|
if (shieldedDamageTakenMultiplier === undefined && shieldedHotHealingMultiplier === undefined) return state
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
party: state.party.map((member) => ({
|
||||||
|
...member,
|
||||||
|
shieldedDamageTakenMultiplier,
|
||||||
|
shieldedHotHealingMultiplier,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPressureState(
|
||||||
|
stage: number | undefined,
|
||||||
|
contentType: Iwt2RoguelikeContentType | undefined,
|
||||||
|
) {
|
||||||
|
return stage && contentType ? createRoguelikePressureState(stage, contentType) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function shieldedDamageTakenMultiplierForBuffs(buffs: Iwt2RoguelikeSelfBuffId[]) {
|
||||||
|
const multipliers: number[] = []
|
||||||
|
if (buffs.includes(IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.5)
|
||||||
|
if (buffs.includes(IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.7)
|
||||||
|
return multipliers.length > 0 ? Math.min(...multipliers) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function applyRoguelikeModifiers(
|
function applyRoguelikeModifiers(
|
||||||
abilities: Iwt2HealerAbility[],
|
abilities: Iwt2HealerAbility[],
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[],
|
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[],
|
debuffs: Iwt2RoguelikeOpponentDebuffId[],
|
||||||
): Iwt2HealerAbility[] {
|
): Iwt2HealerAbility[] {
|
||||||
if (buffs.length === 0 && debuffs.length === 0) return abilities
|
if (buffs.length === 0 && debuffs.length === 0) return abilities
|
||||||
|
const abilityById = new Map(abilities.map((ability) => [ability.id, ability]))
|
||||||
|
const renewEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-renew'))
|
||||||
|
const sunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'))
|
||||||
|
const halfSunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'), 0.5)
|
||||||
|
const seedOfLifeEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-seed-of-life'))
|
||||||
|
const barkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'))
|
||||||
|
const halfBarkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'), 0.5)
|
||||||
|
const mendingRuneEffect = triggeredEffectFromAbility(abilityById.get('runesage-mending-rune'))
|
||||||
|
const aegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'))
|
||||||
|
const halfAegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'), 0.5)
|
||||||
return abilities.map((ability) => {
|
return abilities.map((ability) => {
|
||||||
const slot = String(ability.slot)
|
const slot = String(ability.slot)
|
||||||
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
|
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
|
||||||
@@ -510,16 +755,118 @@ function applyRoguelikeModifiers(
|
|||||||
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
|
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
|
||||||
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
|
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
|
||||||
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
|
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
|
||||||
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
|
const triggeredEffects = iwt2TriggeredEffectsForAbility({
|
||||||
|
ability,
|
||||||
|
aegisScriptEffect,
|
||||||
|
barkskinEffect,
|
||||||
|
buffs,
|
||||||
|
halfAegisScriptEffect,
|
||||||
|
halfBarkskinEffect,
|
||||||
|
halfSunWardEffect,
|
||||||
|
mendingRuneEffect,
|
||||||
|
renewEffect,
|
||||||
|
seedOfLifeEffect,
|
||||||
|
sunWardEffect,
|
||||||
|
})
|
||||||
|
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0 && triggeredEffects.length === 0) return ability
|
||||||
return {
|
return {
|
||||||
...ability,
|
...ability,
|
||||||
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
|
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
|
||||||
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
|
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
|
||||||
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
|
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
|
||||||
|
triggeredEffects: triggeredEffects.length > 0 ? triggeredEffects : ability.triggeredEffects,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iwt2TriggeredEffectsForAbility({
|
||||||
|
ability,
|
||||||
|
aegisScriptEffect,
|
||||||
|
barkskinEffect,
|
||||||
|
buffs,
|
||||||
|
halfAegisScriptEffect,
|
||||||
|
halfBarkskinEffect,
|
||||||
|
halfSunWardEffect,
|
||||||
|
mendingRuneEffect,
|
||||||
|
renewEffect,
|
||||||
|
seedOfLifeEffect,
|
||||||
|
sunWardEffect,
|
||||||
|
}: {
|
||||||
|
ability: Iwt2HealerAbility
|
||||||
|
aegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
barkskinEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
|
halfAegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
halfBarkskinEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
halfSunWardEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
mendingRuneEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
renewEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
seedOfLifeEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
sunWardEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
}): Iwt2TriggeredAbilityEffect[] {
|
||||||
|
const effects = [...ability.triggeredEffects ?? []]
|
||||||
|
const addEffect = (effect: Iwt2TriggeredAbilityEffect | undefined) => {
|
||||||
|
if (effect && !effects.some((existing) => existing.id === effect.id && existing.power === effect.power)) {
|
||||||
|
effects.push(effect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ability.id === 'dawnweaver-mend') {
|
||||||
|
if (buffs.includes('dawnweaver-mend-applies-renew')) addEffect(renewEffect)
|
||||||
|
if (buffs.includes('dawnweaver-mend-applies-sun-ward')) addEffect(sunWardEffect)
|
||||||
|
} else if (ability.id === 'dawnweaver-renew') {
|
||||||
|
if (buffs.includes('dawnweaver-renew-applies-sun-ward')) addEffect(sunWardEffect)
|
||||||
|
} else if (ability.id === 'dawnweaver-radiance') {
|
||||||
|
if (buffs.includes('dawnweaver-radiance-applies-renew')) addEffect(renewEffect)
|
||||||
|
if (buffs.includes('dawnweaver-radiance-applies-sun-ward')) addEffect(halfSunWardEffect)
|
||||||
|
} else if (ability.id === 'dawnweaver-sun-ward') {
|
||||||
|
if (buffs.includes('dawnweaver-sun-ward-applies-renew')) addEffect(renewEffect)
|
||||||
|
} else if (ability.id === 'dawnweaver-purify') {
|
||||||
|
if (buffs.includes('dawnweaver-purify-applies-renew')) addEffect(renewEffect)
|
||||||
|
if (buffs.includes('dawnweaver-purify-applies-sun-ward')) addEffect(sunWardEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-verdant-touch') {
|
||||||
|
if (buffs.includes('lifebinder-verdant-touch-applies-seed-of-life')) addEffect(seedOfLifeEffect)
|
||||||
|
if (buffs.includes('lifebinder-verdant-touch-applies-barkskin')) addEffect(halfBarkskinEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-seed-of-life') {
|
||||||
|
if (buffs.includes('lifebinder-seed-of-life-applies-barkskin')) addEffect(barkskinEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-wild-growth') {
|
||||||
|
if (buffs.includes('lifebinder-wild-growth-applies-seed-of-life')) addEffect(seedOfLifeEffect)
|
||||||
|
if (buffs.includes('lifebinder-wild-growth-applies-barkskin')) addEffect(halfBarkskinEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-barkskin') {
|
||||||
|
if (buffs.includes('lifebinder-barkskin-applies-seed-of-life')) addEffect(seedOfLifeEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-purging-sap') {
|
||||||
|
if (buffs.includes('lifebinder-purging-sap-applies-seed-of-life')) addEffect(seedOfLifeEffect)
|
||||||
|
if (buffs.includes('lifebinder-purging-sap-applies-barkskin')) addEffect(barkskinEffect)
|
||||||
|
} else if (ability.id === 'runesage-etched-mend') {
|
||||||
|
if (buffs.includes('runesage-etched-mend-applies-mending-rune')) addEffect(mendingRuneEffect)
|
||||||
|
if (buffs.includes('runesage-etched-mend-applies-aegis-script')) addEffect(halfAegisScriptEffect)
|
||||||
|
} else if (ability.id === 'runesage-mending-rune') {
|
||||||
|
if (buffs.includes('runesage-mending-rune-applies-aegis-script')) addEffect(aegisScriptEffect)
|
||||||
|
} else if (ability.id === 'runesage-concordance') {
|
||||||
|
if (buffs.includes('runesage-concordance-applies-mending-rune')) addEffect(mendingRuneEffect)
|
||||||
|
if (buffs.includes('runesage-concordance-applies-aegis-script')) addEffect(halfAegisScriptEffect)
|
||||||
|
} else if (ability.id === 'runesage-aegis-script') {
|
||||||
|
if (buffs.includes('runesage-aegis-script-applies-mending-rune')) addEffect(mendingRuneEffect)
|
||||||
|
} else if (ability.id === 'runesage-unravel') {
|
||||||
|
if (buffs.includes('runesage-unravel-applies-mending-rune')) addEffect(mendingRuneEffect)
|
||||||
|
if (buffs.includes('runesage-unravel-applies-aegis-script')) addEffect(aegisScriptEffect)
|
||||||
|
}
|
||||||
|
return effects
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggeredEffectFromAbility(
|
||||||
|
ability: Iwt2HealerAbility | undefined,
|
||||||
|
strength = 1,
|
||||||
|
): Iwt2TriggeredAbilityEffect | undefined {
|
||||||
|
if (!ability) return undefined
|
||||||
|
return {
|
||||||
|
effectType: ability.effectType,
|
||||||
|
id: ability.id,
|
||||||
|
kind: ability.kind,
|
||||||
|
name: ability.name,
|
||||||
|
power: Math.max(1, Math.round(ability.power * strength)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function countStacks(items: readonly string[], id: string) {
|
function countStacks(items: readonly string[], id: string) {
|
||||||
return items.filter((item) => item === id).length
|
return items.filter((item) => item === id).length
|
||||||
}
|
}
|
||||||
@@ -550,44 +897,64 @@ function buildIwt2DualScreenCombatState({
|
|||||||
abilities,
|
abilities,
|
||||||
arenaState,
|
arenaState,
|
||||||
bindings,
|
bindings,
|
||||||
bossMetadata,
|
|
||||||
cooldowns,
|
cooldowns,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
modeLabel,
|
modeLabel,
|
||||||
|
opponentArenaState,
|
||||||
playerMana,
|
playerMana,
|
||||||
|
roguelikeRun,
|
||||||
selectedPartyId,
|
selectedPartyId,
|
||||||
status,
|
status,
|
||||||
}: {
|
}: {
|
||||||
abilities: Iwt2HealerAbility[]
|
abilities: Iwt2HealerAbility[]
|
||||||
arenaState: Iwt2ArenaState
|
arenaState: Iwt2ArenaState
|
||||||
bindings: DualScreenCombatState['bindings']
|
bindings: DualScreenCombatState['bindings']
|
||||||
bossMetadata: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
|
|
||||||
cooldowns: Record<string, number>
|
cooldowns: Record<string, number>
|
||||||
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
|
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
|
||||||
directPartyTargeting: boolean
|
directPartyTargeting: boolean
|
||||||
modeLabel: string
|
modeLabel: string
|
||||||
|
opponentArenaState: Iwt2ArenaState | null
|
||||||
playerMana: number
|
playerMana: number
|
||||||
|
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined
|
||||||
selectedPartyId: Iwt2EntityId
|
selectedPartyId: Iwt2EntityId
|
||||||
status: ArenaStatus
|
status: ArenaStatus
|
||||||
}): DualScreenCombatState {
|
}): 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 {
|
return {
|
||||||
|
...pvpOpponentState,
|
||||||
bindings,
|
bindings,
|
||||||
contentName: modeLabel,
|
contentName: modeLabel,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
difficultyName: 'IWT2',
|
difficultyName: 'IWT2',
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
dungeonName: `${bossMetadata.name} Arena`,
|
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
|
||||||
encounterCount: 1,
|
encounterCount: 1,
|
||||||
encounterDescription: arenaState.boss.attackPhase,
|
encounterDescription: arenaState.boss.attackPhase,
|
||||||
encounterHealth: arenaState.boss.health,
|
encounterHealth: arenaBossHealth,
|
||||||
encounterIndex: 0,
|
encounterIndex: 0,
|
||||||
encounterIsBoss: true,
|
encounterIsBoss: true,
|
||||||
encounterMaxHealth: arenaState.boss.maxHealth,
|
encounterMaxHealth: arenaBossMaxHealth,
|
||||||
encounterName: bossMetadata.name,
|
encounterName: formatBossEncounterTitle(arenaState.bosses),
|
||||||
floatingTexts: [],
|
floatingTexts: [],
|
||||||
maxResource: 100,
|
maxResource: 100,
|
||||||
party: arenaState.party.map(toDualScreenPartyMember),
|
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
|
||||||
partySize: arenaState.party.length,
|
partySize: arenaState.party.length,
|
||||||
paused: status === 'paused',
|
paused: status === 'paused',
|
||||||
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
|
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
|
||||||
@@ -601,7 +968,49 @@ function buildIwt2DualScreenCombatState({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number]): PartyMember {
|
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]
|
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||||
return {
|
return {
|
||||||
bounceHeals: [],
|
bounceHeals: [],
|
||||||
@@ -614,14 +1023,19 @@ function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number]): Party
|
|||||||
ticks: Math.ceil(effect.remainingSeconds),
|
ticks: Math.ceil(effect.remainingSeconds),
|
||||||
})),
|
})),
|
||||||
hotTicks: member.hotEffects.length,
|
hotTicks: member.hotEffects.length,
|
||||||
id: member.id,
|
id: opponent ? `opponent-${member.id}` : member.id,
|
||||||
maxHealth: member.maxHealth,
|
maxHealth: member.maxHealth,
|
||||||
name: metadata.name,
|
name: opponent ? opponentPartyName(metadata.name) : metadata.name,
|
||||||
role: toDualScreenRole(metadata.role),
|
role: toDualScreenRole(metadata.role),
|
||||||
shield: member.shield,
|
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 {
|
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
|
||||||
if (role === 'tank') return 'Tank'
|
if (role === 'tank') return 'Tank'
|
||||||
if (role === 'healer') return 'Healer'
|
if (role === 'healer') return 'Healer'
|
||||||
@@ -648,3 +1062,31 @@ function toDualScreenSpell(
|
|||||||
slotIndex,
|
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 { IWT2_CLASS_METADATA } from '../content/classes'
|
||||||
import type {
|
import type {
|
||||||
Iwt2ArenaEvent,
|
Iwt2ArenaEvent,
|
||||||
|
Iwt2ArenaIndicator,
|
||||||
Iwt2ArenaInput,
|
Iwt2ArenaInput,
|
||||||
Iwt2ArenaState,
|
Iwt2ArenaState,
|
||||||
|
Iwt2BossEntityState,
|
||||||
Iwt2EntityId,
|
Iwt2EntityId,
|
||||||
Iwt2GroundHazardState,
|
Iwt2GroundHazardState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
@@ -11,7 +13,9 @@ import type {
|
|||||||
Iwt2PartyEntityId,
|
Iwt2PartyEntityId,
|
||||||
Iwt2PartyEntityState,
|
Iwt2PartyEntityState,
|
||||||
Iwt2ProjectileEntityState,
|
Iwt2ProjectileEntityState,
|
||||||
|
Iwt2RoguelikePressureState,
|
||||||
Iwt2StatusState,
|
Iwt2StatusState,
|
||||||
|
Iwt2Vec2,
|
||||||
} from './types'
|
} from './types'
|
||||||
import type { Iwt2PlayerClassId } from '../content/classes'
|
import type { Iwt2PlayerClassId } from '../content/classes'
|
||||||
import { tickBoss } from './bossAi'
|
import { tickBoss } from './bossAi'
|
||||||
@@ -19,7 +23,9 @@ import { separateCircles } from './collision'
|
|||||||
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
|
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
|
||||||
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
|
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
|
||||||
import { applyPartyDamageInShape } from './mechanics'
|
import { applyPartyDamageInShape } from './mechanics'
|
||||||
|
import { tickRoguelikePressure } from './roguelikePressure'
|
||||||
import {
|
import {
|
||||||
|
addVec2,
|
||||||
clampVec2ToArena,
|
clampVec2ToArena,
|
||||||
distanceVec2,
|
distanceVec2,
|
||||||
normalizeVec2,
|
normalizeVec2,
|
||||||
@@ -33,6 +39,7 @@ const MAX_DT = 1 / 15
|
|||||||
const MAX_EVENTS = 80
|
const MAX_EVENTS = 80
|
||||||
const HEALER_MANA_REGEN_PER_SECOND = 3
|
const HEALER_MANA_REGEN_PER_SECOND = 3
|
||||||
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
|
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
|
||||||
|
const IWT2_ARENA_BOSS_IDS = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||||
|
|
||||||
type InitialPartyMember = {
|
type InitialPartyMember = {
|
||||||
id: Iwt2PartyEntityId
|
id: Iwt2PartyEntityId
|
||||||
@@ -53,44 +60,27 @@ const INITIAL_PARTY: InitialPartyMember[] = [
|
|||||||
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
|
{ 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 {
|
export function createInitialIwt2ArenaState(
|
||||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
bossId: Iwt2BossId = 'bulldrome',
|
||||||
|
bossIds?: Iwt2BossId[],
|
||||||
|
bossHealthScale = 1,
|
||||||
|
partyDamageTakenScale = 1,
|
||||||
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
|
): Iwt2ArenaState {
|
||||||
|
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
||||||
|
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
time: 0,
|
time: 0,
|
||||||
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
||||||
party: INITIAL_PARTY.map(createPartyMember),
|
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
|
||||||
projectiles: [],
|
projectiles: [],
|
||||||
hostileAdds: [],
|
hostileAdds: [],
|
||||||
hazards: [],
|
hazards: [],
|
||||||
indicators: [],
|
indicators: [],
|
||||||
boss: {
|
roguelikePressure,
|
||||||
id: bossId,
|
boss: bosses[0],
|
||||||
kind: 'boss',
|
bosses,
|
||||||
bossId,
|
|
||||||
position: { x: 640, y: 250 },
|
|
||||||
velocity: { x: 0, y: 0 },
|
|
||||||
facing: { x: -1, y: 0 },
|
|
||||||
radius: bossMetadata.radius,
|
|
||||||
health: bossMetadata.maxHealth,
|
|
||||||
maxHealth: bossMetadata.maxHealth,
|
|
||||||
meleeCooldownRemaining: 0.6,
|
|
||||||
chargeCooldownRemaining: initialBossSpecialCooldown(bossId),
|
|
||||||
chargeCount: 0,
|
|
||||||
attackPhase: 'idle',
|
|
||||||
phaseSecondsRemaining: 0,
|
|
||||||
chargeStart: { x: 640, y: 250 },
|
|
||||||
chargeEnd: { x: 640, y: 250 },
|
|
||||||
chargeHitEntityIds: [],
|
|
||||||
slamApplied: false,
|
|
||||||
wallContactSeconds: 0,
|
|
||||||
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
|
|
||||||
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId),
|
|
||||||
fireballTarget: { x: 320, y: 250 },
|
|
||||||
birdWaveThresholdsTriggered: [],
|
|
||||||
mechanicLanes: [],
|
|
||||||
mechanicCircles: [],
|
|
||||||
},
|
|
||||||
nextEventId: 1,
|
nextEventId: 1,
|
||||||
nextProjectileId: 1,
|
nextProjectileId: 1,
|
||||||
nextAddId: 1,
|
nextAddId: 1,
|
||||||
@@ -99,16 +89,87 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
|
||||||
|
const remaining = IWT2_ARENA_BOSS_IDS.filter((id) => id !== primaryBossId)
|
||||||
|
const random = remaining[Math.floor(Math.random() * remaining.length)] ?? primaryBossId
|
||||||
|
return [primaryBossId, random]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState {
|
||||||
|
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
|
const position = initialBossPosition(index)
|
||||||
|
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
|
||||||
|
return {
|
||||||
|
id: bossId,
|
||||||
|
kind: 'boss',
|
||||||
|
bossId,
|
||||||
|
position,
|
||||||
|
velocity: { x: 0, y: 0 },
|
||||||
|
facing: { x: -1, y: 0 },
|
||||||
|
radius: bossMetadata.radius,
|
||||||
|
health: maxHealth,
|
||||||
|
maxHealth,
|
||||||
|
armor: bossMetadata.mudArmor ?? 0,
|
||||||
|
maxArmor: bossMetadata.mudArmor ?? 0,
|
||||||
|
mechanicEnergy: 0,
|
||||||
|
mechanicEnergyMax: bossMetadata.staticEnergyMax ?? 0,
|
||||||
|
meleeCooldownRemaining: 0.6 + index * 0.25,
|
||||||
|
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
|
||||||
|
chargeCount: 0,
|
||||||
|
attackPhase: 'idle',
|
||||||
|
phaseSecondsRemaining: 0,
|
||||||
|
chargeStart: { ...position },
|
||||||
|
chargeEnd: { ...position },
|
||||||
|
chargeHitEntityIds: [],
|
||||||
|
slamApplied: false,
|
||||||
|
wallContactSeconds: 0,
|
||||||
|
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
|
||||||
|
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
|
||||||
|
fireballTarget: { x: 320, y: 250 },
|
||||||
|
birdWaveThresholdsTriggered: [],
|
||||||
|
mechanicLanes: [],
|
||||||
|
mechanicCircles: [],
|
||||||
|
mechanicArcs: [],
|
||||||
|
mechanicLinks: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialBossPosition(index: number) {
|
||||||
|
return {
|
||||||
|
x: index === 0 ? 660 : 760,
|
||||||
|
y: index === 0 ? 190 : 345,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
||||||
if (bossId === 'bulldrome') return 2
|
if (bossId === 'bulldrome') return 2
|
||||||
if (bossId === 'great-jaggi') return 2.4
|
if (bossId === 'great-jaggi') return 2.4
|
||||||
if (bossId === 'khezu') return 3
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
|
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
|
||||||
if (bossId === 'yian-kut-ku') return 1.2
|
if (bossId === 'yian-kut-ku') return 1.2
|
||||||
if (bossId === 'khezu') return 1.6
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,13 +189,24 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
}
|
}
|
||||||
const separatedState = {
|
const separatedState = {
|
||||||
...baseState,
|
...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(
|
const projectileResult = advanceProjectiles(
|
||||||
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
|
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
|
||||||
bossResult.party,
|
postBossParty,
|
||||||
bossResult.boss,
|
bossResult.bosses,
|
||||||
bossResult.hostileAdds ?? separatedState.hostileAdds,
|
bossResult.hostileAdds ?? separatedState.hostileAdds,
|
||||||
bossResult.hazards ?? separatedState.hazards,
|
bossResult.hazards ?? separatedState.hazards,
|
||||||
bossResult.nextHazardId ?? state.nextHazardId,
|
bossResult.nextHazardId ?? state.nextHazardId,
|
||||||
@@ -149,23 +221,38 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
party: projectileResult.party,
|
party: projectileResult.party,
|
||||||
time: separatedState.time,
|
time: separatedState.time,
|
||||||
})
|
})
|
||||||
|
const pressureResult = tickRoguelikePressure({
|
||||||
|
party: hazardResult.party,
|
||||||
|
pressure: separatedState.roguelikePressure,
|
||||||
|
time: separatedState.time,
|
||||||
|
})
|
||||||
const damageResult = applyPartyAttacks(
|
const damageResult = applyPartyAttacks(
|
||||||
hazardResult.party,
|
pressureResult.party,
|
||||||
projectileResult.boss,
|
projectileResult.bosses,
|
||||||
projectileResult.hostileAdds,
|
projectileResult.hostileAdds,
|
||||||
separatedState.time,
|
separatedState.time,
|
||||||
projectileResult.nextProjectileId,
|
projectileResult.nextProjectileId,
|
||||||
)
|
)
|
||||||
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
|
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(
|
const nextEvents = assignEventIds(
|
||||||
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
|
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...pressureResult.events, ...damageResult.events, ...hotResult.events],
|
||||||
state.nextEventId,
|
state.nextEventId,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
...separatedState,
|
...separatedState,
|
||||||
boss: damageResult.boss,
|
boss: getPrimaryBoss(damageResult.bosses),
|
||||||
|
bosses: damageResult.bosses,
|
||||||
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
||||||
|
roguelikePressure: pressureResult.pressure,
|
||||||
party: finalParty,
|
party: finalParty,
|
||||||
hostileAdds: damageResult.hostileAdds,
|
hostileAdds: damageResult.hostileAdds,
|
||||||
hazards: hazardResult.hazards,
|
hazards: hazardResult.hazards,
|
||||||
@@ -194,7 +281,10 @@ function tickPartyHotEffects(
|
|||||||
let nextTickInSeconds = effect.nextTickInSeconds - dt
|
let nextTickInSeconds = effect.nextTickInSeconds - dt
|
||||||
if (member.health > 0 && nextTickInSeconds <= 0) {
|
if (member.health > 0 && nextTickInSeconds <= 0) {
|
||||||
const before = health
|
const before = health
|
||||||
health = Math.min(member.maxHealth, health + effect.power)
|
const healingMultiplier = member.shield > 0
|
||||||
|
? member.shieldedHotHealingMultiplier ?? 1
|
||||||
|
: 1
|
||||||
|
health = Math.min(member.maxHealth, health + effect.power * healingMultiplier)
|
||||||
const healed = health - before
|
const healed = health - before
|
||||||
if (healed > 0) {
|
if (healed > 0) {
|
||||||
events.push({
|
events.push({
|
||||||
@@ -234,8 +324,9 @@ function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2Par
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
|
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number): Iwt2PartyEntityState {
|
||||||
const metadata = IWT2_CLASS_METADATA[initial.classId]
|
const metadata = IWT2_CLASS_METADATA[initial.classId]
|
||||||
|
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
|
||||||
return {
|
return {
|
||||||
id: initial.id,
|
id: initial.id,
|
||||||
kind: 'party',
|
kind: 'party',
|
||||||
@@ -245,8 +336,16 @@ function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
|
|||||||
velocity: { x: 0, y: 0 },
|
velocity: { x: 0, y: 0 },
|
||||||
facing: { x: 1, y: 0 },
|
facing: { x: 1, y: 0 },
|
||||||
radius: metadata.radius,
|
radius: metadata.radius,
|
||||||
|
moveSpeed: metadata.moveSpeed,
|
||||||
|
attackRange: metadata.attackRange,
|
||||||
|
attackDamage: metadata.attackDamage,
|
||||||
|
attackCooldown: metadata.attackCooldown,
|
||||||
|
castTime: metadata.castTime,
|
||||||
|
projectileSpeed: metadata.projectileSpeed,
|
||||||
health: metadata.maxHealth,
|
health: metadata.maxHealth,
|
||||||
maxHealth: metadata.maxHealth,
|
maxHealth: metadata.maxHealth,
|
||||||
|
damageTakenScale: safeDamageTakenScale,
|
||||||
|
stunTakenScale: 1,
|
||||||
shield: 0,
|
shield: 0,
|
||||||
mana: initial.classId === 'healer' ? 100 : 0,
|
mana: initial.classId === 'healer' ? 100 : 0,
|
||||||
maxMana: initial.classId === 'healer' ? 100 : 0,
|
maxMana: initial.classId === 'healer' ? 100 : 0,
|
||||||
@@ -266,6 +365,7 @@ function createEmptyStatus(): Iwt2StatusState {
|
|||||||
stunnedSeconds: 0,
|
stunnedSeconds: 0,
|
||||||
knockedDownSeconds: 0,
|
knockedDownSeconds: 0,
|
||||||
invulnerableSeconds: 0,
|
invulnerableSeconds: 0,
|
||||||
|
slowedSeconds: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,22 +374,187 @@ function clampInputAxis(value: number): number {
|
|||||||
return Math.min(1, Math.max(-1, value))
|
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) => {
|
return party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
const position = separateCircles(
|
let position = member.position
|
||||||
{ position: member.position, radius: member.radius },
|
for (const boss of state.bosses) {
|
||||||
{ position: state.boss.position, radius: state.boss.radius },
|
if (boss.health <= 0) continue
|
||||||
state.bounds,
|
const overlapBeforeSeparation = circleOverlapAmount({ position, radius: member.radius }, boss)
|
||||||
)
|
const separated = separateCircles(
|
||||||
|
{ position, radius: member.radius },
|
||||||
|
{ position: boss.position, radius: boss.radius },
|
||||||
|
state.bounds,
|
||||||
|
)
|
||||||
|
const overlapAfterSeparation = circleOverlapAmount({ position: separated, radius: member.radius }, boss)
|
||||||
|
position = overlapBeforeSeparation > 0.25
|
||||||
|
&& (wallClearance(separated, member, state) <= 2 || overlapAfterSeparation > 0.25)
|
||||||
|
? separateFromBossTowardCenter(member, boss, state)
|
||||||
|
: separated
|
||||||
|
}
|
||||||
return { ...member, position }
|
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(
|
function advanceProjectiles(
|
||||||
projectiles: Iwt2ProjectileEntityState[],
|
projectiles: Iwt2ProjectileEntityState[],
|
||||||
party: Iwt2PartyEntityState[],
|
party: Iwt2PartyEntityState[],
|
||||||
boss: Iwt2ArenaState['boss'],
|
bosses: Iwt2BossEntityState[],
|
||||||
hostileAdds: Iwt2HostileAddState[],
|
hostileAdds: Iwt2HostileAddState[],
|
||||||
hazards: Iwt2GroundHazardState[],
|
hazards: Iwt2GroundHazardState[],
|
||||||
nextHazardId: number,
|
nextHazardId: number,
|
||||||
@@ -298,7 +563,7 @@ function advanceProjectiles(
|
|||||||
time: number,
|
time: number,
|
||||||
dt: number,
|
dt: number,
|
||||||
): {
|
): {
|
||||||
boss: Iwt2ArenaState['boss']
|
bosses: Iwt2BossEntityState[]
|
||||||
party: Iwt2PartyEntityState[]
|
party: Iwt2PartyEntityState[]
|
||||||
hostileAdds: Iwt2HostileAddState[]
|
hostileAdds: Iwt2HostileAddState[]
|
||||||
hazards: Iwt2GroundHazardState[]
|
hazards: Iwt2GroundHazardState[]
|
||||||
@@ -308,7 +573,7 @@ function advanceProjectiles(
|
|||||||
events: Iwt2ArenaEvent[]
|
events: Iwt2ArenaEvent[]
|
||||||
} {
|
} {
|
||||||
const events: Iwt2ArenaEvent[] = []
|
const events: Iwt2ArenaEvent[] = []
|
||||||
let nextBoss = boss
|
let nextBosses = bosses
|
||||||
let nextParty = party
|
let nextParty = party
|
||||||
let nextHostileAdds = hostileAdds
|
let nextHostileAdds = hostileAdds
|
||||||
let nextHazards = hazards
|
let nextHazards = hazards
|
||||||
@@ -334,7 +599,7 @@ function advanceProjectiles(
|
|||||||
continue
|
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 = {
|
const nextPosition = {
|
||||||
x: projectile.position.x + projectile.velocity.x * dt,
|
x: projectile.position.x + projectile.velocity.x * dt,
|
||||||
y: projectile.position.y + projectile.velocity.y * dt,
|
y: projectile.position.y + projectile.velocity.y * dt,
|
||||||
@@ -371,28 +636,32 @@ function advanceProjectiles(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
|
const hitBoss = nextBosses.find((boss) => (
|
||||||
const damage = Math.min(projectile.damage, nextBoss.health)
|
boss.health > 0
|
||||||
nextBoss = {
|
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
|
||||||
...nextBoss,
|
))
|
||||||
health: Math.max(0, nextBoss.health - damage),
|
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)
|
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
|
||||||
events.push({
|
events.push({
|
||||||
id: 0,
|
id: 0,
|
||||||
time,
|
time,
|
||||||
type: 'bossDamaged',
|
type: 'bossDamaged',
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
targetId: nextBoss.id,
|
targetId: hitBoss.id,
|
||||||
value: damage,
|
value: damage,
|
||||||
})
|
})
|
||||||
if (nextBoss.health <= 0) {
|
if (damageResult.boss.health <= 0) {
|
||||||
events.push({
|
events.push({
|
||||||
id: 0,
|
id: 0,
|
||||||
time,
|
time,
|
||||||
type: 'entityDefeated',
|
type: 'entityDefeated',
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
targetId: nextBoss.id,
|
targetId: hitBoss.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -405,7 +674,7 @@ function advanceProjectiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
boss: nextBoss,
|
bosses: nextBosses,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
hostileAdds: nextHostileAdds,
|
hostileAdds: nextHostileAdds,
|
||||||
hazards: nextHazards,
|
hazards: nextHazards,
|
||||||
@@ -536,12 +805,12 @@ function advanceBossProjectile({
|
|||||||
|
|
||||||
function applyPartyAttacks(
|
function applyPartyAttacks(
|
||||||
party: Iwt2PartyEntityState[],
|
party: Iwt2PartyEntityState[],
|
||||||
boss: Iwt2ArenaState['boss'],
|
bosses: Iwt2BossEntityState[],
|
||||||
hostileAdds: Iwt2HostileAddState[],
|
hostileAdds: Iwt2HostileAddState[],
|
||||||
time: number,
|
time: number,
|
||||||
nextProjectileId: number,
|
nextProjectileId: number,
|
||||||
): {
|
): {
|
||||||
boss: Iwt2ArenaState['boss']
|
bosses: Iwt2BossEntityState[]
|
||||||
party: Iwt2PartyEntityState[]
|
party: Iwt2PartyEntityState[]
|
||||||
hostileAdds: Iwt2HostileAddState[]
|
hostileAdds: Iwt2HostileAddState[]
|
||||||
projectiles: Iwt2ProjectileEntityState[]
|
projectiles: Iwt2ProjectileEntityState[]
|
||||||
@@ -550,15 +819,15 @@ function applyPartyAttacks(
|
|||||||
} {
|
} {
|
||||||
const events: Iwt2ArenaEvent[] = []
|
const events: Iwt2ArenaEvent[] = []
|
||||||
const projectiles: Iwt2ProjectileEntityState[] = []
|
const projectiles: Iwt2ProjectileEntityState[] = []
|
||||||
let nextBoss = boss
|
let nextBosses = bosses
|
||||||
let nextHostileAdds = hostileAdds
|
let nextHostileAdds = hostileAdds
|
||||||
let projectileId = nextProjectileId
|
let projectileId = nextProjectileId
|
||||||
const nextParty = party.map((member) => {
|
const nextParty = party.map((member) => {
|
||||||
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
|
const target = getPriorityAttackTarget(member, nextBosses, nextHostileAdds)
|
||||||
if (!target) return member
|
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]
|
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||||
if (metadata.projectileSpeed > 0) {
|
if (member.projectileSpeed > 0) {
|
||||||
if (!member.attackReady) return member
|
if (!member.attackReady) return member
|
||||||
const direction = normalizeVec2(subtractVec2(target.position, member.position))
|
const direction = normalizeVec2(subtractVec2(target.position, member.position))
|
||||||
projectiles.push({
|
projectiles.push({
|
||||||
@@ -566,28 +835,34 @@ function applyPartyAttacks(
|
|||||||
sourceId: member.id,
|
sourceId: member.id,
|
||||||
owner: 'party',
|
owner: 'party',
|
||||||
classId: member.classId,
|
classId: member.classId,
|
||||||
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
|
projectileKind: member.classId === 'mage' ? 'fireball' : 'arrow',
|
||||||
color: metadata.accentColor,
|
color: metadata.accentColor,
|
||||||
position: {
|
position: {
|
||||||
x: member.position.x + direction.x * (member.radius + 4),
|
x: member.position.x + direction.x * (member.radius + 4),
|
||||||
y: member.position.y + direction.y * (member.radius + 4),
|
y: member.position.y + direction.y * (member.radius + 4),
|
||||||
},
|
},
|
||||||
velocity: scaleVec2(direction, metadata.projectileSpeed),
|
velocity: scaleVec2(direction, member.projectileSpeed),
|
||||||
radius: member.classId === 'mage' ? 7 : 4,
|
radius: member.classId === 'mage' ? 7 : 4,
|
||||||
damage: metadata.attackDamage,
|
damage: member.attackDamage,
|
||||||
remainingSeconds: 1.2,
|
remainingSeconds: 1.2,
|
||||||
})
|
})
|
||||||
projectileId += 1
|
projectileId += 1
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
attackCooldownRemaining: metadata.attackCooldown,
|
attackCooldownRemaining: member.attackCooldown,
|
||||||
attackReady: false,
|
attackReady: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (member.attackCooldownRemaining > 0) return member
|
if (member.attackCooldownRemaining > 0) return member
|
||||||
const damage = Math.min(metadata.attackDamage, target.health)
|
let damage = Math.min(member.attackDamage, target.health)
|
||||||
|
let defeated = target.health - damage <= 0
|
||||||
if (target.kind === 'boss') {
|
if (target.kind === 'boss') {
|
||||||
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) }
|
const damageResult = applyDamageToBoss(target, member.attackDamage)
|
||||||
|
damage = damageResult.healthDamage
|
||||||
|
defeated = damageResult.boss.health <= 0
|
||||||
|
nextBosses = nextBosses.map((boss) => boss.id === target.id
|
||||||
|
? damageResult.boss
|
||||||
|
: boss)
|
||||||
} else {
|
} else {
|
||||||
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
|
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
|
||||||
? { ...add, health: Math.max(0, add.health - damage) }
|
? { ...add, health: Math.max(0, add.health - damage) }
|
||||||
@@ -601,7 +876,7 @@ function applyPartyAttacks(
|
|||||||
targetId: target.id,
|
targetId: target.id,
|
||||||
value: damage,
|
value: damage,
|
||||||
})
|
})
|
||||||
if (target.health - damage <= 0) {
|
if (defeated) {
|
||||||
events.push({
|
events.push({
|
||||||
id: 0,
|
id: 0,
|
||||||
time,
|
time,
|
||||||
@@ -612,17 +887,17 @@ function applyPartyAttacks(
|
|||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
damageDone: member.damageDone + damage,
|
damageDone: member.damageDone + damage,
|
||||||
attackCooldownRemaining: metadata.attackCooldown,
|
attackCooldownRemaining: member.attackCooldown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
damageDone: member.damageDone + damage,
|
damageDone: member.damageDone + damage,
|
||||||
attackCooldownRemaining: metadata.attackCooldown,
|
attackCooldownRemaining: member.attackCooldown,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
boss: nextBoss,
|
bosses: nextBosses,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
|
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
|
||||||
projectiles,
|
projectiles,
|
||||||
@@ -633,9 +908,9 @@ function applyPartyAttacks(
|
|||||||
|
|
||||||
function getPriorityAttackTarget(
|
function getPriorityAttackTarget(
|
||||||
member: Iwt2PartyEntityState,
|
member: Iwt2PartyEntityState,
|
||||||
boss: Iwt2ArenaState['boss'],
|
bosses: Iwt2BossEntityState[],
|
||||||
hostileAdds: Iwt2HostileAddState[],
|
hostileAdds: Iwt2HostileAddState[],
|
||||||
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
|
): (Iwt2BossEntityState | Iwt2HostileAddState) | undefined {
|
||||||
if (member.health <= 0) return undefined
|
if (member.health <= 0) return undefined
|
||||||
const livingAdds = hostileAdds.filter((add) => add.health > 0)
|
const livingAdds = hostileAdds.filter((add) => add.health > 0)
|
||||||
if (livingAdds.length > 0) {
|
if (livingAdds.length > 0) {
|
||||||
@@ -643,7 +918,15 @@ function getPriorityAttackTarget(
|
|||||||
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
||||||
), livingAdds[0])
|
), 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(
|
function addDamageDone(
|
||||||
@@ -656,6 +939,31 @@ function addDamageDone(
|
|||||||
: member)
|
: 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[] {
|
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
|
||||||
return events.map((event, index) => ({ ...event, id: firstId + index }))
|
return events.map((event, index) => ({ ...event, id: firstId + index }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
Iwt2BossEntityState,
|
Iwt2BossEntityState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
Iwt2PartyEntityState,
|
Iwt2PartyEntityState,
|
||||||
|
Iwt2RoguelikePressureState,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
|
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
|
||||||
@@ -54,8 +55,14 @@ export type Iwt2ArenaState = Iwt2CoreArenaState & {
|
|||||||
telegraphs: Iwt2ArenaTelegraph[]
|
telegraphs: Iwt2ArenaTelegraph[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState {
|
export function createInitialIwt2ArenaState(
|
||||||
return decorateArenaState(createCoreIwt2ArenaState(bossId))
|
bossId?: Iwt2BossId,
|
||||||
|
bossIds?: Iwt2BossId[],
|
||||||
|
bossHealthScale?: number,
|
||||||
|
partyDamageTakenScale?: number,
|
||||||
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
|
): Iwt2ArenaState {
|
||||||
|
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||||
@@ -69,7 +76,7 @@ export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
|
|||||||
entities: [
|
entities: [
|
||||||
...state.party.map(toArenaEntity),
|
...state.party.map(toArenaEntity),
|
||||||
...state.hostileAdds.map(toHostileAddArenaEntity),
|
...state.hostileAdds.map(toHostileAddArenaEntity),
|
||||||
toBossArenaEntity(state.boss),
|
...state.bosses.map(toBossArenaEntity),
|
||||||
],
|
],
|
||||||
telegraphs: createTelegraphs(state.indicators),
|
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,
|
||||||
|
})]
|
||||||
|
}
|
||||||
@@ -13,8 +13,21 @@ import type {
|
|||||||
} from './types'
|
} from './types'
|
||||||
import { tickGreatJaggi } from './greatJaggiAi'
|
import { tickGreatJaggi } from './greatJaggiAi'
|
||||||
import { tickKhezu } from './khezuAi'
|
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 { tickYianKutKu } from './yianKutKuAi'
|
||||||
import {
|
import {
|
||||||
|
applyPartyDamageToMember,
|
||||||
applyPartyDamageInShape,
|
applyPartyDamageInShape,
|
||||||
createArenaEvent,
|
createArenaEvent,
|
||||||
createCircleIndicator,
|
createCircleIndicator,
|
||||||
@@ -48,6 +61,18 @@ export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult
|
|||||||
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
|
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 === 'great-jaggi') return tickGreatJaggi(state, dt)
|
||||||
if (state.boss.bossId === 'khezu') return tickKhezu(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)
|
return tickBulldrome(state, dt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,14 +282,19 @@ function maybeApplyMelee(
|
|||||||
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
|
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
|
||||||
return { boss, party, events: [] }
|
return { boss, party, events: [] }
|
||||||
}
|
}
|
||||||
|
let appliedDamage = 0
|
||||||
|
let nextTargetHealth = target.health
|
||||||
const nextParty = party.map((member) => {
|
const nextParty = party.map((member) => {
|
||||||
if (member.id !== target.id) return member
|
if (member.id !== target.id) return member
|
||||||
return { ...member, health: Math.max(0, member.health - BULLDROME_BOSS_METADATA.meleeDamage) }
|
const result = applyPartyDamageToMember(member, BULLDROME_BOSS_METADATA.meleeDamage)
|
||||||
|
appliedDamage = result.damage
|
||||||
|
nextTargetHealth = result.member.health
|
||||||
|
return result.member
|
||||||
})
|
})
|
||||||
const events = [
|
const events = [
|
||||||
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, BULLDROME_BOSS_METADATA.meleeDamage),
|
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, appliedDamage),
|
||||||
]
|
]
|
||||||
if (target.health > 0 && target.health - BULLDROME_BOSS_METADATA.meleeDamage <= 0) {
|
if (target.health > 0 && nextTargetHealth <= 0) {
|
||||||
events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id))
|
events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id))
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||