Compare commits

..
2 Commits
Author SHA1 Message Date
Warren H b48b3a4f8f Release v0.1.3 2026-07-11 2026-07-11 23:23:02 -04:00
Warren H 076f6cf97c Release v0.1.2 2026-07-11 2026-07-11 00:12:57 -04:00
105 changed files with 7179 additions and 390 deletions
@@ -0,0 +1,135 @@
{
"name": "Tri-Headed Ivory Dragon",
"assetId": "blue-eyes-ultimate-dragon",
"source": "MMD_004386.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 5992,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"BEUD_UpperBody",
"BEUD_Lumbar1"
],
"head": [
"BEUD_LHead",
"BEUD_RHead",
"BEUD_Head"
],
"jaw": [
"BEUD_LChin",
"BEUD_RChin",
"BEUD_Chin"
],
"wing_l": [
"BEUD_LWing1",
"BEUD_LWing2",
"BEUD_LWing3",
"BEUD_LWing6",
"BEUD_LWing7",
"BEUD_LWing8",
"BEUD_LWing4",
"BEUD_LWing5"
],
"wing_r": [
"BEUD_RWing1",
"BEUD_RWing2",
"BEUD_RWing3",
"BEUD_RWing4",
"BEUD_RWing5",
"BEUD_RWing6",
"BEUD_RWing7",
"BEUD_RWing8"
],
"arm_l": [
"BEUD_LBicep",
"BEUD_LForearm",
"BEUD_LHand"
],
"arm_r": [
"BEUD_RBicep",
"BEUD_RForearm",
"BEUD_RHand"
],
"leg_l": [
"BEUD_LThigh",
"BEUD_LShin",
"BEUD_LFootBone1",
"BEUD_LFootBone4",
"BEUD_LFootBone2",
"BEUD_LFootBone3"
],
"leg_r": [
"BEUD_RThigh",
"BEUD_RShin",
"BEUD_RFootBone1",
"BEUD_RFootBone2",
"BEUD_RFootBone3",
"BEUD_RFootBone4"
],
"tail": [
"BEUD_Tail1",
"BEUD_Tail2",
"BEUD_Tail3",
"BEUD_Tail4",
"BEUD_Tail5",
"BEUD_Tail6",
"BEUD_Tail7",
"BEUD_Tail8"
],
"head_l": [
"BEUD_LNeck1",
"BEUD_LNeck2",
"BEUD_LNeck3",
"BEUD_LNeck4",
"BEUD_LNeck5",
"BEUD_LNeck6",
"BEUD_LHead"
],
"head_c": [
"BEUD_Neck1",
"BEUD_Neck2",
"BEUD_Neck3",
"BEUD_Neck4",
"BEUD_Neck5",
"BEUD_Neck6",
"BEUD_Head"
],
"head_r": [
"BEUD_RNeck1",
"BEUD_RNeck2",
"BEUD_RNeck3",
"BEUD_RNeck4",
"BEUD_RNeck5",
"BEUD_RNeck6",
"BEUD_RHead"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

@@ -0,0 +1,81 @@
{
"name": "Ivory-Eyed Sky Dragon",
"assetId": "blue-eyes-white-dragon",
"source": "MMD_004007.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 9214,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"MMD_004007_UpperBody",
"MMD_004007_Lumbar1"
],
"head": [
"MMD_004007_Head"
],
"jaw": [
"MMD_004007_chin"
],
"arm_l": [
"MMD_004007_LShoulder",
"MMD_004007_LBicep",
"MMD_004007_LForearm",
"MMD_004007_LHand"
],
"arm_r": [
"MMD_004007_RShoulder",
"MMD_004007_RBicep",
"MMD_004007_RForearm",
"MMD_004007_RHand"
],
"leg_l": [
"MMD_004007_LThigh",
"MMD_004007_LShin",
"MMD_004007_LFootBone1",
"MMD_004007_LFootBone2",
"MMD_004007_LFootBone3"
],
"leg_r": [
"MMD_004007_RThigh",
"MMD_004007_RShin",
"MMD_004007_RFootBone1",
"MMD_004007_RFootBone2",
"MMD_004007_RFootBone3"
],
"tail": [
"MMD_004007_tail1",
"MMD_004007_tail2",
"MMD_004007_tail3",
"MMD_004007_tail4",
"MMD_004007_tail5"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

@@ -0,0 +1,71 @@
{
"name": "Ruin-Orb Black Dragon",
"assetId": "gandora-the-dragon-of-destruction",
"source": "MMD_006076.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 6008,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"wing_l": [
"LWing_Base",
"LWing1",
"LWing_Lower1",
"LWing_Lower2",
"LWing2",
"LWing_Upper1",
"LWing_Upper2",
"LWing_Middle1",
"LWing_Middle2"
],
"leg_l": [
"LThigh",
"LShin",
"LFootBone1",
"LFootBone2"
],
"leg_r": [
"RThigh",
"RShin",
"RFootBone1",
"RFootBone2"
],
"tail": [
"Tail1",
"Tail2",
"Tail3",
"Tail4",
"Tail5",
"Tail6",
"Tail7",
"Tail8"
]
}
}
@@ -0,0 +1,100 @@
{
"name": "Tri-Element Gate Sentinel",
"assetId": "gate-guardian",
"source": "MMD_004380.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 16426,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"Spine"
],
"head": [
"Head_nouse"
],
"jaw": [
"mouth01",
"mouth02"
],
"arm_l": [
"LeftShoulder01",
"LeftArm",
"LeftForeArm",
"LeftHand",
"LeftHandThumb1",
"LeftHandThumb2",
"LeftHandThumb3",
"LeftHandIndex1",
"LeftHandIndex2",
"LeftHandMiddle1",
"LeftHandMiddle2",
"LeftHandRing1",
"LeftHandRing2",
"LeftShoulder02",
"LeftArm01",
"LeftForeArm01",
"LeftHand01",
"LeftHandIndex01",
"LeftHandRing01",
"LeftHandMiddle01"
],
"arm_r": [
"RightShoulder01",
"RightArm",
"RightForeArm",
"RightHand",
"RightHandMiddle1",
"RightHandMiddle2",
"RightHandThumb1",
"RightHandThumb2",
"RightHandThumb3",
"RightHandRing1",
"RightHandRing2",
"RightHandIndex1",
"RightHandIndex2",
"RightShoulder02",
"RightArm01",
"RightForeArm01",
"RightHand01",
"RightHandIndex01",
"RightHandRing01",
"RightHandMiddle01"
],
"leg_l": [
"LeftUpLeg",
"LeftFoot"
],
"leg_r": [
"RightUpLeg",
"RightFoot"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

@@ -0,0 +1,99 @@
{
"name": "Royal Carapace Matriarch",
"assetId": "insect-queen",
"source": "MMD_004768.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 5982,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"UpperBody",
"Spine1"
],
"head": [
"Neck1",
"Neck2",
"Head1",
"LSensor1",
"LSensor2",
"RSensor1",
"RSensor2"
],
"jaw": [
"Mouth3",
"Mandible1",
"Mouth5",
"Mouth6",
"Mouth7",
"Mouth4",
"Mouth1",
"Mouth2"
],
"wing_l": [
"LWing2",
"LWIng3",
"LWing1"
],
"wing_r": [
"RWing1",
"RWing2",
"RWing3"
],
"arm_l": [
"LArm1",
"LArm2",
"LArm3",
"LHand1"
],
"arm_r": [
"RArm1",
"RArm2",
"RArm3",
"RHAnd1"
],
"leg_l": [
"LFoot4",
"LFoot5",
"LFoot6",
"LFoot1",
"LFoot2",
"LFoot3"
],
"leg_r": [
"RFoot1",
"RFoot2",
"RFoot3",
"RFoot4",
"RFoot5",
"RFoot6"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

@@ -0,0 +1,170 @@
{
"name": "Crowned Vine Wraith",
"assetId": "pumpking-the-king-of-ghosts",
"source": "MMD_004105.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 7898,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"hips",
"upper_01",
"upper_02"
],
"head": [
"eye",
"crown",
"eye_upper",
"eye_lower"
],
"jaw": [
"chin_upper",
"chin_lower",
"under"
],
"arm_l": [
"L_arm1_01",
"L_arm1_02",
"L_arm1_03",
"L_arm1_04",
"L_arm1_05",
"L_arm1_06",
"L_arm1_07",
"L_arm1_08",
"L_arm1_09",
"L_arm1_10",
"L_arm1_11",
"L_arm1_12",
"L_arm1_13",
"L_arm1_14",
"L_arm1_15",
"L_arm1_16",
"L_arm2_01",
"L_arm2_02",
"L_arm2_03",
"L_arm2_04",
"L_arm2_05",
"L_arm2_06",
"L_arm2_07",
"L_arm2_08",
"L_arm2_09",
"L_arm2_10",
"L_arm2_11",
"L_arm2_12",
"L_arm2_13",
"L_arm2_14",
"L_arm3_01",
"L_arm3_02",
"L_arm3_03",
"L_arm3_04",
"L_arm3_05",
"L_arm3_06",
"L_arm3_07",
"L_arm3_08",
"L_arm3_09",
"L_arm3_10",
"L_arm3_11",
"L_arm3_12",
"L_arm4_01",
"L_arm4_02",
"L_arm4_03",
"L_arm4_04",
"L_arm4_05",
"L_arm4_06",
"L_arm4_07",
"L_arm4_08",
"L_arm4_09",
"L_arm4_10",
"L_arm4_11",
"L_arm4_12",
"L_arm4_13",
"L_arm4_14"
],
"arm_r": [
"R_arm1_01",
"R_arm1_02",
"R_arm1_03",
"R_arm1_04",
"R_arm1_05",
"R_arm1_06",
"R_arm1_07",
"R_arm1_08",
"R_arm1_09",
"R_arm1_10",
"R_arm1_11",
"R_arm1_12",
"R_arm1_13",
"R_arm1_14",
"R_arm1_15",
"R_arm1_16",
"R_arm2_01",
"R_arm2_02",
"R_arm2_03",
"R_arm2_04",
"R_arm2_05",
"R_arm2_06",
"R_arm2_07",
"R_arm2_08",
"R_arm2_09",
"R_arm2_10",
"R_arm2_11",
"R_arm2_12",
"R_arm2_13",
"R_arm2_14",
"R_arm3_01",
"R_arm3_02",
"R_arm3_03",
"R_arm3_04",
"R_arm3_05",
"R_arm3_06",
"R_arm3_07",
"R_arm3_08",
"R_arm3_09",
"R_arm3_10",
"R_arm3_11",
"R_arm3_12",
"R_arm4_01",
"R_arm4_02",
"R_arm4_03",
"R_arm4_04",
"R_arm4_05",
"R_arm4_06",
"R_arm4_07",
"R_arm4_08",
"R_arm4_09",
"R_arm4_10",
"R_arm4_11",
"R_arm4_12",
"R_arm4_13",
"R_arm4_14"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

@@ -0,0 +1,137 @@
{
"name": "Crimson-Eyed Night Dragon",
"assetId": "red-eyes-black-dragon",
"source": "MMD_004088.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 9730,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"bone0000",
"bone0076",
"bone0009",
"bone0078",
"bone0057"
],
"head": [
"bone0025",
"bone0031",
"bone0047",
"bone0073"
],
"jaw": [
"bone0001",
"bone0002",
"bone0029",
"bone0010",
"bone0020"
],
"wing_l": [
"bone0016",
"bone0006",
"bone0049",
"bone0032",
"bone0003",
"bone0058",
"bone0008",
"bone0063",
"bone0079",
"bone0069",
"bone0013",
"bone0030",
"bone0077"
],
"wing_r": [
"bone0033",
"bone0012",
"bone0055",
"bone0046",
"bone0021",
"bone0045",
"bone0041",
"bone0060",
"bone0075",
"bone0070",
"bone0042",
"bone0024",
"bone0040"
],
"arm_l": [
"bone0048",
"bone0061",
"bone0005",
"bone0015",
"bone0038",
"bone0017",
"bone0028",
"bone0023"
],
"arm_r": [
"bone0059",
"bone0027",
"bone0071",
"bone0022",
"bone0052",
"bone0064",
"bone0056",
"bone0074",
"bone0067"
],
"leg_l": [
"bone0004",
"bone0039",
"bone0051",
"bone0014",
"bone0035",
"bone0050",
"bone0066"
],
"leg_r": [
"bone0007",
"bone0043",
"bone0011",
"bone0036",
"bone0026",
"bone0037",
"bone0062"
],
"tail": [
"bone0034",
"bone0053",
"bone0019",
"bone0044",
"bone0054",
"bone0065",
"bone0072",
"bone0018"
]
}
}
@@ -0,0 +1,61 @@
{
"name": "Cinderback Ricochet",
"assetId": "cinderback-ricochet",
"authoringTool": "5.1.2",
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": "Cinderback_Body",
"armature": "Cinderback_Rig",
"collisionAsset": "cinderback-ricochet_collision.glb",
"materials": [
"M_Charcoal",
"M_Plate",
"M_Underbody",
"M_Lava",
"M_Claw"
],
"trianglesApprox": 788
},
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Walk": {
"frames": 32,
"seconds": 1.067,
"loop": true
},
"Curl": {
"frames": 28,
"seconds": 0.933,
"loop": false
},
"Ricochet": {
"frames": 30,
"seconds": 1.0,
"loop": true
},
"ArmorSlam": {
"frames": 38,
"seconds": 1.267,
"loop": false
},
"Recover": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Stagger": {
"frames": 26,
"seconds": 0.867,
"loop": false
},
"Death": {
"frames": 66,
"seconds": 2.2,
"loop": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

@@ -0,0 +1,55 @@
{
"name": "Obsidian Ram Golem",
"assetId": "obsidian-ram-golem",
"authoringTool": "5.1.2",
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": "ObsidianRam_Body",
"armature": "ObsidianRam_Rig",
"collisionAsset": "obsidian-ram-golem_collision.glb",
"materials": [
"M_Obsidian",
"M_Basalt",
"M_Armor",
"M_Lava"
],
"trianglesApprox": 916
},
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Walk": {
"frames": 32,
"seconds": 1.067,
"loop": true
},
"Charge": {
"frames": 30,
"seconds": 1.0,
"loop": false
},
"Quake": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"ArmorShatter": {
"frames": 42,
"seconds": 1.4,
"loop": false
},
"Stagger": {
"frames": 26,
"seconds": 0.867,
"loop": false
},
"Death": {
"frames": 70,
"seconds": 2.333,
"loop": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

@@ -0,0 +1,61 @@
{
"name": "Sandglass Scorpion",
"assetId": "sandglass-scorpion",
"authoringTool": "5.1.2",
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": "SandglassScorpion_Body",
"armature": "SandglassScorpion_Rig",
"collisionAsset": "sandglass-scorpion_collision.glb",
"materials": [
"M_Obsidian",
"M_Ochre",
"M_Gold",
"M_Glass",
"M_Bone"
],
"trianglesApprox": 1048
},
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Walk": {
"frames": 32,
"seconds": 1.067,
"loop": true
},
"ClawAttack": {
"frames": 28,
"seconds": 0.933,
"loop": false
},
"Burrow": {
"frames": 34,
"seconds": 1.133,
"loop": false
},
"Eruption": {
"frames": 38,
"seconds": 1.267,
"loop": false
},
"Hourglass": {
"frames": 44,
"seconds": 1.467,
"loop": false
},
"Stagger": {
"frames": 26,
"seconds": 0.867,
"loop": false
},
"Death": {
"frames": 70,
"seconds": 2.333,
"loop": false
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.1",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -790,6 +790,7 @@ def write_metadata(body: bpy.types.Object, clips: dict[str, dict]) -> None:
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
bpy.context.preferences.filepaths.save_version = 0
reset_scene()
mats = {
"chitin": make_material("M_Chitin", (0.025, 0.022, 0.026, 1), metallic=0.18, roughness=0.42),
+491
View File
@@ -0,0 +1,491 @@
"""Build three original IWT2-inspired, rigged low-poly boss assets."""
from __future__ import annotations
import json
import math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
OUT_ROOT = ROOT / "game_assets/models/original/bosses"
FPS = 30
PARTS: list[bpy.types.Object] = []
RUNTIME_MATERIALS: list[bpy.types.Material] = []
def reset_scene() -> None:
global PARTS, RUNTIME_MATERIALS
PARTS = []
RUNTIME_MATERIALS = []
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.cameras, bpy.data.lights, bpy.data.actions):
for block in list(blocks):
blocks.remove(block)
def material(name, color, metallic=0.0, roughness=0.5, emission=None, strength=0.0):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
mat.diffuse_color = color
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
if emission:
bsdf.inputs["Emission Color"].default_value = emission
bsdf.inputs["Emission Strength"].default_value = strength
return mat
def prepare_materials(specs):
mats = {name: material(f"M_{name}", **settings) for name, settings in specs.items()}
RUNTIME_MATERIALS.extend(mats.values())
return mats
def finish(obj, name, mat, bone, smooth=True):
obj.name = name
obj.data.name = f"{name}_Mesh"
for runtime_mat in RUNTIME_MATERIALS:
obj.data.materials.append(runtime_mat)
mat_index = next(index for index, candidate in enumerate(RUNTIME_MATERIALS) if candidate.name == mat.name)
for polygon in obj.data.polygons:
polygon.material_index = mat_index
polygon.use_smooth = smooth
bone_group = obj.vertex_groups.new(name=bone)
bone_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
mat_group = obj.vertex_groups.new(name=f"__MAT_{mat_index}")
mat_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
PARTS.append(obj)
return obj
def ellipsoid(name, location, scale, mat, bone, subdivisions=1, rotation=(0, 0, 0)):
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish(obj, name, mat, bone)
def cone(name, start, end, radius_start, radius_end, mat, bone, vertices=7):
start_v, end_v = Vector(start), Vector(end)
direction = end_v - start_v
bpy.ops.mesh.primitive_cone_add(
vertices=vertices,
radius1=radius_start,
radius2=radius_end,
depth=direction.length,
location=(start_v + end_v) * 0.5,
)
obj = bpy.context.object
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = direction.to_track_quat("Z", "Y")
obj.rotation_mode = "XYZ"
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
return finish(obj, name, mat, bone, smooth=False)
def plate(name, location, scale, rotation, mat, bone, vertices=6):
bpy.ops.mesh.primitive_cone_add(vertices=vertices, radius1=1, radius2=0.58, depth=0.42, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish(obj, name, mat, bone, smooth=False)
def armature(name, specs):
data = bpy.data.armatures.new(f"{name}_Rig")
rig = bpy.data.objects.new(f"{name}_Rig", data)
bpy.context.collection.objects.link(rig)
bpy.context.view_layer.objects.active = rig
rig.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
for bone_name, head, tail, parent in specs:
bone = data.edit_bones.new(bone_name)
bone.head, bone.tail = head, tail
if parent:
bone.parent = data.edit_bones[parent]
bpy.ops.object.mode_set(mode="POSE")
for bone in rig.pose.bones:
bone.rotation_mode = "XYZ"
bpy.ops.object.mode_set(mode="OBJECT")
return rig
def join_parts(name, rig):
bpy.ops.object.select_all(action="DESELECT")
for obj in PARTS:
obj.select_set(True)
bpy.context.view_layer.objects.active = PARTS[0]
bpy.ops.object.join()
body = bpy.context.object
body.name = f"{name}_Body"
body.data.name = f"{name}_Body_Mesh"
material_groups = {g.index: int(g.name.removeprefix("__MAT_")) for g in body.vertex_groups if g.name.startswith("__MAT_")}
face_materials = []
for polygon in body.data.polygons:
vertex = body.data.vertices[polygon.vertices[0]]
face_materials.append(next(material_groups[a.group] for a in vertex.groups if a.group in material_groups))
body.data.materials.clear()
for mat in RUNTIME_MATERIALS:
body.data.materials.append(mat)
for polygon, index in zip(body.data.polygons, face_materials, strict=True):
polygon.material_index = index
for group in [g for g in body.vertex_groups if g.name.startswith("__MAT_")]:
body.vertex_groups.remove(group)
modifier = body.modifiers.new(f"{name}_Armature", "ARMATURE")
modifier.object = rig
body.parent = rig
return body
def reset_pose(rig):
for bone in rig.pose.bones:
bone.location = (0, 0, 0)
bone.rotation_euler = (0, 0, 0)
bone.scale = (1, 1, 1)
def key_pose(rig, frame, rotations=None, locations=None, scales=None):
reset_pose(rig)
for name, value in (rotations or {}).items():
rig.pose.bones[name].rotation_euler = tuple(math.radians(component) for component in value)
for name, value in (locations or {}).items():
rig.pose.bones[name].location = value
for name, value in (scales or {}).items():
rig.pose.bones[name].scale = value
for bone in rig.pose.bones:
bone.keyframe_insert("location", frame=frame, group=bone.name)
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
bone.keyframe_insert("scale", frame=frame, group=bone.name)
def actions(rig, definitions):
rig.animation_data_create()
metadata = {}
for name, end_frame, loop, poses in definitions:
action = bpy.data.actions.new(name)
action.use_fake_user = True
action.use_frame_range = True
action.frame_start, action.frame_end, action.use_cyclic = 1, end_frame, loop
rig.animation_data.action = action
for pose in poses:
key_pose(rig, **pose)
rig.animation_data.action = None
metadata[name] = {"frames": end_frame, "seconds": round(end_frame / FPS, 3), "loop": loop}
return metadata
def select_only(objects):
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.hide_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
def collision(asset_id, specs, out_dir):
objects = []
for name, location, scale in specs:
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=1, location=location)
obj = bpy.context.object
obj.name = f"COLLISION_{name}"
obj.scale = scale
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
objects.append(obj)
select_only(objects)
bpy.ops.export_scene.gltf(
filepath=str(out_dir / f"{asset_id}_collision.glb"), export_format="GLB", use_selection=True,
export_animations=False, export_skins=False, export_materials="NONE", export_apply=True, export_yup=True,
)
for obj in objects:
obj.hide_render = True
obj.hide_set(True)
return objects
def look_at(obj, target):
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
def studio():
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = scene.render.resolution_y = 760
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.fps = FPS
scene.world.use_nodes = True
scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.005, 0.007, 0.012, 1)
scene.world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.3
camera_data = bpy.data.cameras.new("PreviewCamera")
camera = bpy.data.objects.new("PreviewCamera", camera_data)
bpy.context.collection.objects.link(camera)
scene.camera = camera
for name, location, color, energy, size in [
("Key", (5, -7, 7), (1, 0.34, 0.1), 1200, 5),
("Fill", (-5, -3, 4), (0.12, 0.32, 1), 800, 4),
("Rim", (1, 6, 6), (1, 0.12, 0.02), 1150, 3),
]:
data = bpy.data.lights.new(name, "AREA")
data.energy, data.color, data.shape, data.size = energy, color, "DISK", size
obj = bpy.data.objects.new(name, data)
obj.location = location
bpy.context.collection.objects.link(obj)
look_at(obj, (0, 0, 1.5))
floor_mat = material("M_PreviewFloor", color=(0.014, 0.018, 0.026, 1), roughness=0.85)
bpy.ops.mesh.primitive_plane_add(size=28, location=(0, 0, 0))
floor = bpy.context.object
floor.data.materials.append(floor_mat)
return camera
def render_previews(rig, camera, out_dir, asset_id, target, distance, special_clip, special_frame):
preview_dir = out_dir / "previews"
preview_dir.mkdir(parents=True, exist_ok=True)
for filename, position, clip, frame in [
(f"{asset_id}_three_quarter.png", (distance * 0.62, -distance, distance * 0.48), "Idle", 15),
(f"{asset_id}_side.png", (distance, 0, distance * 0.42), "Idle", 15),
(f"{asset_id}_{special_clip.lower()}.png", (distance * 0.62, -distance, distance * 0.48), special_clip, special_frame),
]:
rig.animation_data.action = bpy.data.actions[clip]
bpy.context.scene.frame_set(frame)
camera.location = position
camera.data.lens = 58
look_at(camera, target)
bpy.context.scene.render.filepath = str(preview_dir / filename)
bpy.ops.render.render(write_still=True)
rig.animation_data.action = None
def export_asset(asset_id, display_name, rig, body, clips, collision_specs, preview_target, preview_distance, special_clip, special_frame):
out_dir = OUT_ROOT / asset_id
out_dir.mkdir(parents=True, exist_ok=True)
select_only([rig, body])
bpy.ops.export_scene.gltf(
filepath=str(out_dir / f"{asset_id}.glb"), export_format="GLB", use_selection=True,
export_animations=True, export_animation_mode="ACTIONS", export_frame_range=True,
export_skins=True, export_morph=False, export_yup=True, export_apply=False,
export_cameras=False, export_lights=False,
)
collision(asset_id, collision_specs, out_dir)
triangles = sum(len(poly.vertices) - 2 for poly in body.data.polygons)
metadata = {
"name": display_name,
"assetId": asset_id,
"authoringTool": bpy.app.version_string,
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": body.name,
"armature": rig.name,
"collisionAsset": f"{asset_id}_collision.glb",
"materials": [mat.name for mat in body.data.materials],
"trianglesApprox": triangles,
},
"animations": clips,
}
(out_dir / f"{asset_id}.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
camera = studio()
render_previews(rig, camera, out_dir, asset_id, preview_target, preview_distance, special_clip, special_frame)
bpy.ops.wm.save_as_mainfile(filepath=str(out_dir / f"{asset_id}.blend"))
print(f"BUILT={asset_id} TRIANGLES={triangles} CLIPS={','.join(clips)}")
def build_ram():
reset_scene()
mats = prepare_materials({
"Obsidian": {"color": (0.025, 0.028, 0.034, 1), "metallic": 0.28, "roughness": 0.4},
"Basalt": {"color": (0.10, 0.09, 0.09, 1), "metallic": 0.14, "roughness": 0.62},
"Armor": {"color": (0.18, 0.16, 0.16, 1), "metallic": 0.22, "roughness": 0.48},
"Lava": {"color": (1, 0.08, 0.003, 1), "roughness": 0.2, "emission": (1, 0.025, 0, 1), "strength": 9},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.5), None), ("Body", (0, 0, 1.35), (0, 0, 2.5), "Root"),
("Head", (0, -0.75, 2.05), (0, -1.75, 2.05), "Body"), ("Horn.L", (-0.45, -1.45, 2.35), (-1.2, -1.5, 2.55), "Head"),
("Horn.R", (0.45, -1.45, 2.35), (1.2, -1.5, 2.55), "Head"), ("Leg.FL", (-0.7, -0.75, 1.45), (-0.78, -0.8, 0.35), "Body"),
("Leg.FR", (0.7, -0.75, 1.45), (0.78, -0.8, 0.35), "Body"), ("Leg.BL", (-0.7, 0.8, 1.35), (-0.78, 0.8, 0.3), "Body"),
("Leg.BR", (0.7, 0.8, 1.35), (0.78, 0.8, 0.3), "Body"), ("Tail", (0, 1.2, 1.75), (0, 2.25, 1.35), "Body"),
]
rig = armature("ObsidianRam", specs)
ellipsoid("BodyCore", (0, 0.1, 1.95), (1.18, 1.48, 0.86), mats["Basalt"], "Body", 2)
ellipsoid("ShoulderMass", (0, -0.62, 2.08), (1.28, 0.82, 1.0), mats["Obsidian"], "Body", 2)
ellipsoid("Head", (0, -1.48, 1.93), (0.72, 0.78, 0.62), mats["Armor"], "Head", 2)
cone("Snout", (0, -1.7, 1.92), (0, -2.25, 1.72), 0.48, 0.22, mats["Obsidian"], "Head")
ellipsoid("Eye.L", (-0.47, -1.9, 2.08), (0.075, 0.05, 0.075), mats["Lava"], "Head")
ellipsoid("Eye.R", (0.47, -1.9, 2.08), (0.075, 0.05, 0.075), mats["Lava"], "Head")
for side, suffix in [(-1, "L"), (1, "R")]:
points = [(side * 0.48, -1.55, 2.33), (side * 1.05, -1.62, 2.67), (side * 1.45, -1.44, 2.35), (side * 1.43, -1.25, 1.93), (side * 1.02, -1.45, 1.78)]
for index in range(len(points) - 1):
cone(f"Horn{suffix}_{index}", points[index], points[index + 1], 0.19 - index * 0.025, 0.15 - index * 0.025, mats["Obsidian"], f"Horn.{suffix}", 8)
for front, y, bone in [(True, -0.72, f"Leg.F{suffix}"), (False, 0.82, f"Leg.B{suffix}")]:
x = side * 0.78
cone(f"Leg{suffix}_{'F' if front else 'B'}", (x, y, 1.45), (x * 1.05, y, 0.35), 0.34, 0.24, mats["Basalt"], bone, 7)
ellipsoid(f"Hoof{suffix}_{'F' if front else 'B'}", (x * 1.06, y - 0.18, 0.25), (0.34, 0.46, 0.24), mats["Obsidian"], bone)
cone("Tail", (0, 1.1, 1.72), (0, 2.22, 1.3), 0.38, 0.12, mats["Basalt"], "Tail", 7)
for index, y in enumerate((-0.7, -0.15, 0.4, 0.95)):
plate(f"BackPlate{index}", (0, y, 2.72 + index * 0.03), (0.92 - index * 0.08, 0.62, 0.32), (math.radians(82), 0, 0), mats["Armor"], "Body")
cone(f"BackSpike{index}", (0, y, 2.86), (0, y + 0.05, 3.38 - index * 0.06), 0.17, 0, mats["Obsidian"], "Body", 6)
for side in (-1, 1):
cone(f"ShoulderSpike{side}", (side * 0.9, -0.55, 2.55), (side * 1.48, -0.5, 2.82), 0.18, 0, mats["Obsidian"], "Body", 6)
for index, (start, end) in enumerate([((-0.12, -1.96, 2.22), (0.1, -1.98, 1.78)), ((-0.55, -0.98, 2.54), (-0.35, -0.99, 1.93)), ((0.48, 0.42, 2.55), (0.25, 0.45, 1.95))]):
cone(f"LavaCrack{index}", start, end, 0.035, 0.02, mats["Lava"], "Head" if index == 0 else "Body", 5)
body = join_parts("ObsidianRam", rig)
clips = actions(rig, ram_actions())
export_asset("obsidian-ram-golem", "Obsidian Ram Golem", rig, body, clips,
[("Body", (0, 0, 1.7), (1.4, 1.75, 1.35)), ("Head", (0, -1.55, 1.85), (1.45, 0.85, 0.8))],
(0, 0, 1.6), 8.5, "Quake", 16)
def ram_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail": (-4, 0, 0)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail": (-4, 0, 0)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"Leg.FL": (-18, 0, 0), "Leg.BR": (-18, 0, 0), "Leg.FR": (18, 0, 0), "Leg.BL": (18, 0, 0)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 17, "rotations": {"Leg.FL": (18, 0, 0), "Leg.BR": (18, 0, 0), "Leg.FR": (-18, 0, 0), "Leg.BL": (-18, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 32, "rotations": {"Leg.FL": (-18, 0, 0), "Leg.BR": (-18, 0, 0), "Leg.FR": (18, 0, 0), "Leg.BL": (18, 0, 0)}}]),
("Charge", 30, False, [{"frame": 1}, {"frame": 8, "locations": {"Root": (0, 0.08, -0.12)}, "rotations": {"Head": (28, 0, 0), "Body": (8, 0, 0)}}, {"frame": 15, "locations": {"Root": (0, -0.18, 0.03)}, "rotations": {"Head": (18, 0, 0), "Leg.FL": (-24, 0, 0), "Leg.FR": (24, 0, 0)}}, {"frame": 23, "locations": {"Root": (0, -0.12, 0.08)}, "rotations": {"Head": (12, 0, 0), "Leg.FL": (24, 0, 0), "Leg.FR": (-24, 0, 0)}}, {"frame": 30}]),
("Quake", 36, False, [{"frame": 1}, {"frame": 12, "locations": {"Root": (0, 0.08, 0.18)}, "rotations": {"Body": (-18, 0, 0), "Head": (-20, 0, 0)}}, {"frame": 17, "locations": {"Root": (0, -0.08, -0.18)}, "rotations": {"Body": (24, 0, 0), "Head": (34, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.03)}, "rotations": {"Body": (-5, 0, 0)}}, {"frame": 36}]),
("ArmorShatter", 42, False, [{"frame": 1}, {"frame": 10, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Head": (-12, 0, 0)}}, {"frame": 17, "scales": {"Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (0, 0, 10), "Horn.L": (0, -12, 0), "Horn.R": (0, 12, 0)}}, {"frame": 23, "rotations": {"Body": (0, 0, -8)}}, {"frame": 31, "rotations": {"Body": (0, 0, 4)}}, {"frame": 42}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.16, -0.08)}, "rotations": {"Body": (-15, 0, 12), "Head": (20, 0, -10)}}, {"frame": 13, "rotations": {"Body": (7, 0, -6)}}, {"frame": 26}]),
("Death", 70, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.15, 0.1, -0.35)}, "rotations": {"Root": (0, 35, 32), "Head": (26, 0, 10)}}, {"frame": 42, "locations": {"Root": (0.25, 0.1, -1.15)}, "rotations": {"Root": (0, 58, 82), "Head": (42, 0, 18), "Leg.FL": (30, 0, 0), "Leg.BL": (-24, 0, 0)}}, {"frame": 70, "locations": {"Root": (0.25, 0.1, -1.2)}, "rotations": {"Root": (0, 58, 82), "Head": (44, 0, 18), "Leg.FL": (30, 0, 0), "Leg.BL": (-24, 0, 0)}}]),
]
def build_cinderback():
reset_scene()
mats = prepare_materials({
"Charcoal": {"color": (0.035, 0.03, 0.03, 1), "metallic": 0.18, "roughness": 0.55},
"Plate": {"color": (0.16, 0.10, 0.075, 1), "metallic": 0.2, "roughness": 0.48},
"Underbody": {"color": (0.18, 0.018, 0.01, 1), "roughness": 0.66},
"Lava": {"color": (1, 0.07, 0.002, 1), "roughness": 0.18, "emission": (1, 0.02, 0, 1), "strength": 10},
"Claw": {"color": (0.55, 0.32, 0.15, 1), "roughness": 0.4},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.45), None), ("Body", (0, 0, 1.0), (0, 0, 1.95), "Root"),
("Head", (0, -1.0, 1.25), (0, -1.9, 1.18), "Body"), ("Tail.1", (0, 1.05, 1.2), (0, 1.8, 1.05), "Body"),
("Tail.2", (0, 1.75, 1.05), (0, 2.45, 0.8), "Tail.1"),
("Leg.FL", (-0.65, -0.75, 0.95), (-0.82, -0.82, 0.25), "Body"), ("Leg.FR", (0.65, -0.75, 0.95), (0.82, -0.82, 0.25), "Body"),
("Leg.BL", (-0.65, 0.65, 0.95), (-0.82, 0.7, 0.25), "Body"), ("Leg.BR", (0.65, 0.65, 0.95), (0.82, 0.7, 0.25), "Body"),
]
rig = armature("Cinderback", specs)
ellipsoid("Underbody", (0, 0, 1.12), (1.05, 1.62, 0.65), mats["Underbody"], "Body", 2)
ellipsoid("Head", (0, -1.42, 1.2), (0.68, 0.78, 0.54), mats["Plate"], "Head", 2)
cone("Snout", (0, -1.58, 1.2), (0, -2.05, 1.05), 0.4, 0.18, mats["Charcoal"], "Head")
ellipsoid("Eye.L", (-0.42, -1.72, 1.37), (0.07, 0.05, 0.07), mats["Lava"], "Head")
ellipsoid("Eye.R", (0.42, -1.72, 1.37), (0.07, 0.05, 0.07), mats["Lava"], "Head")
for index, y in enumerate((-1.02, -0.58, -0.12, 0.34, 0.78, 1.16)):
width = 0.93 - abs(index - 2.5) * 0.055
plate(f"ShellPlate{index}", (0, y, 1.73 + math.sin(index / 5 * math.pi) * 0.18), (width, 0.53, 0.28), (math.radians(82), 0, 0), mats["Plate"], "Body")
cone(f"ShellSpike{index}", (0, y, 1.9), (0, y, 2.35 + math.sin(index / 5 * math.pi) * 0.15), 0.13, 0, mats["Charcoal"], "Body", 6)
for side, suffix in [(-1, "L"), (1, "R")]:
for front, y, bone in [(True, -0.68, f"Leg.F{suffix}"), (False, 0.68, f"Leg.B{suffix}")]:
x = side * 0.7
ellipsoid(f"LegMass{suffix}{front}", (x, y, 0.78), (0.38, 0.48, 0.42), mats["Charcoal"], bone)
cone(f"Leg{suffix}{front}", (x, y, 0.72), (side * 0.92, y - 0.12, 0.22), 0.22, 0.14, mats["Underbody"], bone)
for toe in (-0.12, 0, 0.12):
cone(f"Toe{suffix}{front}{toe}", (side * 0.92 + toe, y - 0.22, 0.2), (side * 0.95 + toe, y - 0.58, 0.11), 0.05, 0, mats["Claw"], bone, 5)
cone("Tail1", (0, 1.0, 1.12), (0, 1.85, 0.88), 0.42, 0.27, mats["Plate"], "Tail.1")
cone("Tail2", (0, 1.78, 0.88), (0, 2.48, 0.68), 0.29, 0.06, mats["Charcoal"], "Tail.2")
for index, (x, y, z) in enumerate([(-0.48, -0.82, 1.56), (0.45, -0.28, 1.68), (-0.42, 0.28, 1.72), (0.38, 0.8, 1.55)]):
cone(f"LavaCrack{index}", (x, y, z), (x * 0.65, y + 0.18, z - 0.32), 0.035, 0.018, mats["Lava"], "Body", 5)
body = join_parts("Cinderback", rig)
clips = actions(rig, cinderback_actions())
export_asset("cinderback-ricochet", "Cinderback Ricochet", rig, body, clips,
[("Body", (0, 0, 1.05), (1.25, 1.85, 0.95)), ("Roll", (0, 0, 1.2), (1.45, 1.45, 1.45))],
(0, 0, 1.15), 8.3, "ArmorSlam", 15)
def cinderback_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail.2": (-5, 0, 0)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail.2": (-5, 0, 0)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"Leg.FL": (-14, 0, 0), "Leg.BR": (-14, 0, 0), "Leg.FR": (14, 0, 0), "Leg.BL": (14, 0, 0)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.04)}}, {"frame": 17, "rotations": {"Leg.FL": (14, 0, 0), "Leg.BR": (14, 0, 0), "Leg.FR": (-14, 0, 0), "Leg.BL": (-14, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.04)}}, {"frame": 32, "rotations": {"Leg.FL": (-14, 0, 0), "Leg.BR": (-14, 0, 0), "Leg.FR": (14, 0, 0), "Leg.BL": (14, 0, 0)}}]),
("Curl", 28, False, [{"frame": 1}, {"frame": 9, "locations": {"Root": (0, 0, 0.12)}, "rotations": {"Head": (-42, 0, 0), "Tail.1": (50, 0, 0), "Tail.2": (65, 0, 0), "Leg.FL": (45, 0, 0), "Leg.FR": (45, 0, 0), "Leg.BL": (-45, 0, 0), "Leg.BR": (-45, 0, 0)}}, {"frame": 17, "scales": {"Body": (1.02, 1.02, 1.02)}, "rotations": {"Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0), "Leg.FL": (58, 0, 0), "Leg.FR": (58, 0, 0), "Leg.BL": (-58, 0, 0), "Leg.BR": (-58, 0, 0)}}, {"frame": 28, "rotations": {"Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0), "Leg.FL": (58, 0, 0), "Leg.FR": (58, 0, 0), "Leg.BL": (-58, 0, 0), "Leg.BR": (-58, 0, 0)}}]),
("Ricochet", 30, True, [{"frame": 1, "rotations": {"Root": (0, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 8, "rotations": {"Root": (90, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 16, "rotations": {"Root": (180, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 23, "rotations": {"Root": (270, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 30, "rotations": {"Root": (360, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}]),
("ArmorSlam", 38, False, [{"frame": 1}, {"frame": 11, "locations": {"Root": (0, 0.1, 0.22)}, "rotations": {"Body": (-22, 0, 0), "Head": (-18, 0, 0)}}, {"frame": 16, "locations": {"Root": (0, -0.08, -0.16)}, "rotations": {"Body": (28, 0, 0), "Head": (34, 0, 0)}}, {"frame": 24, "rotations": {"Body": (-8, 0, 0)}}, {"frame": 38}]),
("Recover", 24, False, [{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (18, 0, 0)}}, {"frame": 8, "rotations": {"Body": (-5, 0, 0)}}, {"frame": 16, "rotations": {"Body": (2, 0, 0)}}, {"frame": 24}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.14, -0.08)}, "rotations": {"Body": (-12, 0, 11), "Head": (22, 0, -10)}}, {"frame": 13, "rotations": {"Body": (6, 0, -5)}}, {"frame": 26}]),
("Death", 66, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.1, 0.1, -0.25)}, "rotations": {"Root": (0, 28, 35), "Head": (24, 0, 0)}}, {"frame": 42, "locations": {"Root": (0.18, 0.1, -0.8)}, "rotations": {"Root": (0, 50, 78), "Head": (38, 0, 0), "Tail.1": (-25, 0, 0)}}, {"frame": 66, "locations": {"Root": (0.18, 0.1, -0.84)}, "rotations": {"Root": (0, 50, 78), "Head": (40, 0, 0), "Tail.1": (-28, 0, 0)}}]),
]
def build_scorpion():
reset_scene()
mats = prepare_materials({
"Obsidian": {"color": (0.025, 0.025, 0.03, 1), "metallic": 0.2, "roughness": 0.5},
"Ochre": {"color": (0.42, 0.19, 0.045, 1), "metallic": 0.16, "roughness": 0.44},
"Gold": {"color": (0.85, 0.38, 0.035, 1), "metallic": 0.3, "roughness": 0.28},
"Glass": {"color": (1, 0.38, 0.02, 1), "roughness": 0.12, "emission": (1, 0.16, 0.01, 1), "strength": 4},
"Bone": {"color": (0.58, 0.38, 0.17, 1), "roughness": 0.38},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.4), None), ("Body", (0, 0, 0.85), (0, 0, 1.55), "Root"),
("Head", (0, -0.65, 1.0), (0, -1.35, 0.92), "Body"), ("Claw.L", (-0.55, -0.8, 1.0), (-1.45, -1.6, 0.9), "Body"),
("Claw.R", (0.55, -0.8, 1.0), (1.45, -1.6, 0.9), "Body"), ("LegFront.L", (-0.5, -0.25, 0.8), (-1.4, -0.55, 0.25), "Body"),
("LegFront.R", (0.5, -0.25, 0.8), (1.4, -0.55, 0.25), "Body"), ("LegBack.L", (-0.5, 0.45, 0.8), (-1.4, 0.75, 0.25), "Body"),
("LegBack.R", (0.5, 0.45, 0.8), (1.4, 0.75, 0.25), "Body"),
("Tail.1", (0, 0.65, 1.0), (0, 1.35, 1.35), "Body"), ("Tail.2", (0, 1.3, 1.35), (0, 1.75, 2.0), "Tail.1"),
("Tail.3", (0, 1.7, 2.0), (0, 1.5, 2.68), "Tail.2"), ("Tail.4", (0, 1.5, 2.68), (0, 0.82, 3.0), "Tail.3"),
("Stinger", (0, 0.82, 3.0), (0, 0.18, 2.52), "Tail.4"),
]
rig = armature("SandglassScorpion", specs)
ellipsoid("Abdomen", (0, 0.28, 1.0), (0.92, 1.12, 0.54), mats["Obsidian"], "Body", 2)
ellipsoid("Torso", (0, -0.55, 0.98), (0.76, 0.7, 0.48), mats["Ochre"], "Body", 2)
ellipsoid("Head", (0, -1.12, 0.9), (0.52, 0.48, 0.36), mats["Obsidian"], "Head", 1)
ellipsoid("Hourglass", (0, 0.15, 1.48), (0.42, 0.55, 0.24), mats["Glass"], "Body", 2)
ellipsoid("HourglassWaist", (0, 0.15, 1.5), (0.44, 0.13, 0.26), mats["Obsidian"], "Body", 1)
for side, suffix in [(-1, "L"), (1, "R")]:
ellipsoid(f"Eye{suffix}", (side * 0.31, -1.42, 1.02), (0.06, 0.04, 0.06), mats["Glass"], "Head")
cone(f"ClawArm{suffix}", (side * 0.45, -0.65, 1.0), (side * 1.25, -1.3, 0.9), 0.22, 0.16, mats["Ochre"], f"Claw.{suffix}")
ellipsoid(f"ClawBody{suffix}", (side * 1.55, -1.55, 0.9), (0.62, 0.75, 0.42), mats["Gold"], f"Claw.{suffix}", 1, rotation=(0, 0, math.radians(side * 8)))
ellipsoid(f"ClawGlass{suffix}", (side * 1.55, -1.68, 0.95), (0.37, 0.42, 0.22), mats["Glass"], f"Claw.{suffix}")
cone(f"ClawTipOuter{suffix}", (side * 1.62, -1.98, 0.92), (side * 1.9, -2.45, 0.8), 0.2, 0, mats["Bone"], f"Claw.{suffix}")
cone(f"ClawTipInner{suffix}", (side * 1.42, -1.98, 0.88), (side * 1.25, -2.32, 0.76), 0.14, 0, mats["Bone"], f"Claw.{suffix}")
for index, (y, z) in enumerate([(-0.65, 0.7), (-0.18, 0.62), (0.32, 0.62), (0.78, 0.68)]):
bone = f"LegFront.{suffix}" if index < 2 else f"LegBack.{suffix}"
start = (side * 0.52, y, z + 0.18)
joint = (side * (1.08 + index * 0.08), y + (index - 1.5) * 0.12, z)
end = (side * (1.45 + index * 0.09), y + (index - 1.5) * 0.24, 0.16)
cone(f"Leg{suffix}{index}A", start, joint, 0.14, 0.1, mats["Ochre"], bone, 6)
cone(f"Leg{suffix}{index}B", joint, end, 0.1, 0.035, mats["Obsidian"], bone, 6)
tail_points = [(0, 0.62, 1.02), (0, 1.35, 1.36), (0, 1.75, 2.0), (0, 1.5, 2.68), (0, 0.82, 3.0), (0, 0.18, 2.52)]
tail_bones = ["Tail.1", "Tail.2", "Tail.3", "Tail.4", "Stinger"]
for index, bone in enumerate(tail_bones):
cone(f"TailSegment{index}", tail_points[index], tail_points[index + 1], 0.32 - index * 0.045, 0.25 - index * 0.04, mats["Ochre"] if index < 4 else mats["Gold"], bone, 8)
if index < 4:
plate(f"TailPlate{index}", tail_points[index + 1], (0.38 - index * 0.03, 0.32, 0.2), (0, 0, 0), mats["Gold"], bone)
cone("StingerTip", (0, 0.18, 2.52), (0, -0.15, 2.18), 0.18, 0, mats["Glass"], "Stinger", 7)
body = join_parts("SandglassScorpion", rig)
clips = actions(rig, scorpion_actions())
export_asset("sandglass-scorpion", "Sandglass Scorpion", rig, body, clips,
[("Body", (0, -0.1, 0.85), (1.15, 1.45, 0.8)), ("Claws", (0, -1.55, 0.9), (2.15, 1.0, 0.65)), ("Tail", (0, 1.0, 2.0), (0.65, 1.4, 1.35))],
(0, -0.1, 1.3), 9.5, "Eruption", 16)
def scorpion_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Tail.3": (0, 0, 4), "Claw.L": (0, 0, -3), "Claw.R": (0, 0, 3)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Tail.3": (0, 0, -4), "Claw.L": (0, 0, 3), "Claw.R": (0, 0, -3)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"LegFront.L": (-16, 0, 5), "LegBack.R": (-16, 0, -5), "LegFront.R": (16, 0, -5), "LegBack.L": (16, 0, 5)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 17, "rotations": {"LegFront.L": (16, 0, -5), "LegBack.R": (16, 0, 5), "LegFront.R": (-16, 0, 5), "LegBack.L": (-16, 0, -5)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 32, "rotations": {"LegFront.L": (-16, 0, 5), "LegBack.R": (-16, 0, -5), "LegFront.R": (16, 0, -5), "LegBack.L": (16, 0, 5)}}]),
("ClawAttack", 28, False, [{"frame": 1}, {"frame": 9, "rotations": {"Claw.L": (-10, 18, 30), "Claw.R": (-10, -18, -30)}}, {"frame": 14, "locations": {"Root": (0, -0.08, 0)}, "rotations": {"Claw.L": (16, -10, -38), "Claw.R": (16, 10, 38), "Head": (-8, 0, 0)}}, {"frame": 21, "rotations": {"Claw.L": (5, 0, -12), "Claw.R": (5, 0, 12)}}, {"frame": 28}]),
("Burrow", 34, False, [{"frame": 1}, {"frame": 10, "locations": {"Root": (0, 0, -0.15)}, "rotations": {"Claw.L": (35, 0, 0), "Claw.R": (35, 0, 0), "Tail.1": (-20, 0, 0)}}, {"frame": 20, "locations": {"Root": (0, 0, -0.62)}, "scales": {"Body": (0.92, 0.92, 0.92)}, "rotations": {"Claw.L": (48, 0, 0), "Claw.R": (48, 0, 0), "Tail.1": (-32, 0, 0)}}, {"frame": 34, "locations": {"Root": (0, 0, -0.72)}, "rotations": {"Claw.L": (48, 0, 0), "Claw.R": (48, 0, 0), "Tail.1": (-32, 0, 0)}}]),
("Eruption", 38, False, [{"frame": 1}, {"frame": 11, "rotations": {"Tail.2": (-18, 0, 0), "Tail.3": (-22, 0, 0), "Tail.4": (24, 0, 0), "Stinger": (18, 0, 0)}}, {"frame": 16, "locations": {"Root": (0, -0.05, 0.1)}, "rotations": {"Tail.2": (22, 0, 0), "Tail.3": (34, 0, 0), "Tail.4": (-42, 0, 0), "Stinger": (-38, 0, 0)}}, {"frame": 24, "rotations": {"Tail.3": (10, 0, 0), "Tail.4": (-12, 0, 0)}}, {"frame": 38}]),
("Hourglass", 44, False, [{"frame": 1}, {"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "rotations": {"Body": (-10, 0, 0), "Claw.L": (0, 0, -24), "Claw.R": (0, 0, 24), "Tail.3": (-18, 0, 0)}}, {"frame": 20, "scales": {"Body": (1.08, 1.08, 1.08)}, "rotations": {"Claw.L": (0, 0, 38), "Claw.R": (0, 0, -38), "Tail.3": (22, 0, 0)}}, {"frame": 30, "scales": {"Body": (0.96, 0.96, 0.96)}, "rotations": {"Claw.L": (0, 0, -16), "Claw.R": (0, 0, 16)}}, {"frame": 44}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.12, -0.08)}, "rotations": {"Body": (-12, 0, 10), "Claw.L": (-15, 0, -18), "Claw.R": (-15, 0, 18), "Tail.2": (18, 0, 0)}}, {"frame": 13, "rotations": {"Body": (6, 0, -5)}}, {"frame": 26}]),
("Death", 70, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.1, 0.1, -0.3)}, "rotations": {"Root": (0, 30, 38), "Tail.2": (-28, 0, 0), "Claw.L": (25, 0, -30), "Claw.R": (-10, 0, 30)}}, {"frame": 44, "locations": {"Root": (0.18, 0.1, -0.78)}, "rotations": {"Root": (0, 52, 82), "Tail.2": (-48, 0, 0), "Tail.3": (-32, 0, 0), "Claw.L": (38, 0, -42), "Claw.R": (12, 0, 38)}}, {"frame": 70, "locations": {"Root": (0.18, 0.1, -0.82)}, "rotations": {"Root": (0, 52, 82), "Tail.2": (-50, 0, 0), "Tail.3": (-34, 0, 0), "Claw.L": (40, 0, -44), "Claw.R": (14, 0, 40)}}]),
]
def main():
bpy.context.preferences.filepaths.save_version = 0
OUT_ROOT.mkdir(parents=True, exist_ok=True)
build_ram()
build_cinderback()
build_scorpion()
if __name__ == "__main__":
main()
+709
View File
@@ -0,0 +1,709 @@
"""Build animation-ready GLBs for the locally-authored inspired creature set.
The legacy source FBX files use ASCII encoding, which modern Blender cannot read.
Assimp converts each DAE to glTF without changing the source assets; Blender then
adds reusable actions and exports a texture-packed GLB beside each source model.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import math
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
FPS = 30
CLIP_SPECS = {
"Idle": {"frames": 60, "loop": True},
"Move": {"frames": 36, "loop": True},
"Attack": {"frames": 36, "loop": False},
"HitReact": {"frames": 24, "loop": False},
"Death": {"frames": 72, "loop": False},
}
@dataclass(frozen=True)
class AssetProfile:
asset_id: str
display_name: str
body: tuple[str, ...]
creature: str
red_eyes_roles: bool = False
PROFILES = (
AssetProfile("blue-eyes-ultimate-dragon", "Tri-Headed Ivory Dragon", ("BEUD_UpperBody", "BEUD_Lumbar1"), "dragon"),
AssetProfile("blue-eyes-white-dragon", "Ivory-Eyed Sky Dragon", ("MMD_004007_UpperBody", "MMD_004007_Lumbar1"), "dragon"),
AssetProfile("gandora-the-dragon-of-destruction", "Ruin-Orb Black Dragon", ("UpperBody", "Lumber1"), "dragon"),
AssetProfile("gate-guardian", "Tri-Element Gate Sentinel", ("Spine", "Spine1", "Spine2"), "guardian"),
AssetProfile("insect-queen", "Royal Carapace Matriarch", ("UpperBody", "Spine1"), "insect"),
AssetProfile("pumpking-the-king-of-ghosts", "Crowned Vine Wraith", ("hips", "upper_01", "upper_02"), "wraith"),
AssetProfile("red-eyes-black-dragon", "Crimson-Eyed Night Dragon", ("bone0000", "bone0076", "bone0009"), "dragon", True),
)
RED_EYES = {
"body": ("bone0000", "bone0076", "bone0009", "bone0078", "bone0057"),
"head": ("bone0025", "bone0031", "bone0047", "bone0073"),
"jaw": ("bone0001", "bone0002", "bone0029", "bone0010", "bone0020"),
"wing_l": ("bone0016", "bone0006", "bone0049", "bone0032", "bone0003", "bone0058", "bone0008", "bone0063", "bone0079", "bone0069", "bone0013", "bone0030", "bone0077"),
"wing_r": ("bone0033", "bone0012", "bone0055", "bone0046", "bone0021", "bone0045", "bone0041", "bone0060", "bone0075", "bone0070", "bone0042", "bone0024", "bone0040"),
"arm_l": ("bone0048", "bone0061", "bone0005", "bone0015", "bone0038", "bone0017", "bone0028", "bone0023"),
"arm_r": ("bone0059", "bone0027", "bone0071", "bone0022", "bone0052", "bone0064", "bone0056", "bone0074", "bone0067"),
"leg_l": ("bone0004", "bone0039", "bone0051", "bone0014", "bone0035", "bone0050", "bone0066"),
"leg_r": ("bone0007", "bone0043", "bone0011", "bone0036", "bone0026", "bone0037", "bone0062"),
"tail": ("bone0034", "bone0053", "bone0019", "bone0044", "bone0054", "bone0065", "bone0072", "bone0018"),
}
ULTIMATE_HEAD_CHAINS = {
"head_l": tuple(f"BEUD_LNeck{index}" for index in range(1, 7)) + ("BEUD_LHead",),
"head_c": tuple(f"BEUD_Neck{index}" for index in range(1, 7)) + ("BEUD_Head",),
"head_r": tuple(f"BEUD_RNeck{index}" for index in range(1, 7)) + ("BEUD_RHead",),
}
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.images, bpy.data.actions, bpy.data.cameras, bpy.data.lights):
for block in list(blocks):
blocks.remove(block)
def parse_smd_bones(path: Path, count: int) -> list[str]:
nodes: list[tuple[int, str, int]] = []
in_nodes = False
for line in path.read_text(errors="replace").splitlines():
if line == "nodes":
in_nodes = True
continue
if in_nodes and line == "end":
break
if not in_nodes:
continue
match = re.fullmatch(r'(\d+)\s+"(.+)"\s+(-?\d+)', line)
if match:
nodes.append((int(match.group(1)), match.group(2), int(match.group(3))))
if len(nodes) < count:
raise RuntimeError(f"{path}: found {len(nodes)} nodes for {count} Blender bones")
return [name for _, name, _ in nodes[-count:]]
def convert_and_import(profile: AssetProfile) -> tuple[bpy.types.Object, list[bpy.types.Object], Path]:
source_dir = MODEL_ROOT / profile.asset_id
dae_path = next(source_dir.glob("MMD_*.dae"))
smd_path = dae_path.with_suffix(".smd")
with tempfile.TemporaryDirectory(prefix=f"thor-{profile.asset_id}-") as temp_dir_name:
temp_dir = Path(temp_dir_name)
gltf_path = temp_dir / "model.gltf"
subprocess.run(
["assimp", "export", str(dae_path), str(gltf_path), "-f", "gltf2"],
check=True,
capture_output=True,
text=True,
)
for image_path in source_dir.glob("*.png"):
shutil.copy2(image_path, temp_dir / image_path.name)
bpy.ops.import_scene.gltf(filepath=str(gltf_path), import_pack_images=True)
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
if len(armatures) != 1:
raise RuntimeError(f"{profile.asset_id}: expected one armature")
rig = armatures[0]
meshes = [
obj for obj in bpy.context.scene.objects
if obj.type == "MESH" and (
obj.parent == rig
or any(modifier.type == "ARMATURE" and modifier.object == rig for modifier in obj.modifiers)
)
]
if not meshes:
raise RuntimeError(f"{profile.asset_id}: expected one armature and at least one mesh")
for helper in [obj for obj in bpy.context.scene.objects if obj.type == "MESH" and obj not in meshes]:
bpy.data.objects.remove(helper, do_unlink=True)
source_names = parse_smd_bones(smd_path, len(rig.data.bones))
old_names = [bone.name for bone in rig.data.bones]
for old_name, source_name in zip(old_names, source_names, strict=True):
rig.data.bones[old_name].name = source_name
for mesh in meshes:
group = mesh.vertex_groups.get(old_name)
if group:
group.name = source_name
rig.name = f"{profile.asset_id}_Rig"
rig.data.name = f"{profile.asset_id}_Armature"
for index, mesh in enumerate(meshes):
mesh.name = f"{profile.asset_id}_Mesh_{index:02d}"
mesh.data.name = f"{profile.asset_id}_Geometry_{index:02d}"
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
rig.animation_data_clear()
return rig, meshes, dae_path
def side(name: str) -> str | None:
lower = name.lower()
if "left" in lower or re.search(r"(^|[_\.])l(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
return "l"
if "right" in lower or re.search(r"(^|[_\.])r(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
return "r"
if lower.startswith("l") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
return "l"
if lower.startswith("r") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
return "r"
return None
def build_roles(profile: AssetProfile, rig: bpy.types.Object) -> dict[str, list[str]]:
names = [bone.name for bone in rig.data.bones]
if profile.red_eyes_roles:
return {role: [name for name in role_names if name in names] for role, role_names in RED_EYES.items()}
roles = {key: [] for key in ("body", "head", "jaw", "wing_l", "wing_r", "arm_l", "arm_r", "leg_l", "leg_r", "tail")}
roles["body"] = [name for name in profile.body if name in names]
for name in names:
lower = name.lower()
bone_side = side(name)
if "wing" in lower:
if bone_side:
roles[f"wing_{bone_side}"].append(name)
elif any(token in lower for token in ("arm", "hand", "shoulder", "bicep", "forearm")):
if bone_side:
roles[f"arm_{bone_side}"].append(name)
elif any(token in lower for token in ("foot", "thigh", "shin", "upleg")) or (profile.creature == "insect" and "foot" in lower):
if bone_side:
roles[f"leg_{bone_side}"].append(name)
if "tail" in lower:
roles["tail"].append(name)
if "head" in lower or (profile.creature == "insect" and lower in {"neck1", "neck2"}):
roles["head"].append(name)
if any(token in lower for token in ("jaw", "chin", "mandible", "mouth")):
roles["jaw"].append(name)
if profile.creature == "wraith":
roles["arm_l"] = [name for name in names if name.lower().startswith("l_arm")]
roles["arm_r"] = [name for name in names if name.lower().startswith("r_arm")]
roles["head"].extend(name for name in ("eye", "crown", "eye_upper", "eye_lower") if name in names)
roles["jaw"].extend(name for name in ("chin_upper", "chin_lower", "under") if name in names)
if profile.creature == "insect":
roles["head"].extend(name for name in names if "sensor" in name.lower())
if profile.asset_id == "blue-eyes-ultimate-dragon":
for role, chain in ULTIMATE_HEAD_CHAINS.items():
roles[role] = [name for name in chain if name in names]
return {role: list(dict.fromkeys(role_names)) for role, role_names in roles.items()}
def rotate_role(roles: dict[str, list[str]], role: str, degrees: tuple[float, float, float], limit: int = 99, falloff: float = 0.72) -> dict[str, tuple[float, float, float]]:
result = {}
for index, name in enumerate(roles.get(role, [])[:limit]):
factor = falloff ** index
result[name] = tuple(component * factor for component in degrees)
return result
def merge(*maps: dict[str, tuple[float, float, float]]) -> dict[str, tuple[float, float, float]]:
merged: dict[str, tuple[float, float, float]] = {}
for values in maps:
for name, rotation in values.items():
previous = merged.get(name, (0.0, 0.0, 0.0))
merged[name] = tuple(previous[index] + rotation[index] for index in range(3))
return merged
def dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
wings_up = merge(rotate_role(roles, "wing_l", (0, -14, 18), 5), rotate_role(roles, "wing_r", (0, 14, -18), 5))
wings_down = merge(rotate_role(roles, "wing_l", (0, 18, -14), 5), rotate_role(roles, "wing_r", (0, -18, 14), 5))
tail_l = rotate_role(roles, "tail", (0, 0, 8), 8, 0.82)
tail_r = rotate_role(roles, "tail", (0, 0, -8), 8, 0.82)
legs_a = merge(rotate_role(roles, "leg_l", (10, 0, 0), 2), rotate_role(roles, "leg_r", (-10, 0, 0), 2))
legs_b = merge(rotate_role(roles, "leg_l", (-10, 0, 0), 2), rotate_role(roles, "leg_r", (10, 0, 0), 2))
return {
"Idle": [(1, neutral), (16, merge(wings_up, tail_l, rotate_role(roles, "head", (2, 0, 0), 3))), (31, neutral), (46, merge(wings_down, tail_r, rotate_role(roles, "head", (-2, 0, 0), 3))), (60, neutral)],
"Move": [(1, merge(wings_up, legs_a, tail_l)), (10, neutral), (19, merge(wings_down, legs_b, tail_r)), (28, neutral), (36, merge(wings_up, legs_a, tail_l))],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), wings_up, rotate_role(roles, "head", (-14, 0, 0), 4), rotate_role(roles, "jaw", (-8, 0, 0), 3))), (16, merge(rotate_role(roles, "body", (17, 0, 0), 2), wings_down, rotate_role(roles, "head", (24, 0, 0), 5), rotate_role(roles, "jaw", (24, 0, 0), 4), rotate_role(roles, "arm_l", (18, 0, -14), 3), rotate_role(roles, "arm_r", (18, 0, 14), 3))), (25, merge(rotate_role(roles, "head", (5, 0, 0), 4), rotate_role(roles, "jaw", (8, 0, 0), 3))), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), rotate_role(roles, "head", (-22, 0, -10), 5), wings_up)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), rotate_role(roles, "head", (18, 0, 8), 5), wings_down)), (44, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (28, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (30, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5)))],
}
def ultimate_head_pose(
roles: dict[str, list[str]],
left: tuple[float, float, float],
center: tuple[float, float, float],
right: tuple[float, float, float],
) -> dict[str, tuple[float, float, float]]:
"""Bend each long neck as a chain; rotating only head bones reads as static."""
result: dict[str, tuple[float, float, float]] = {}
weights = (0.10, 0.14, 0.17, 0.18, 0.16, 0.12, 0.22)
for role, motion in zip(("head_l", "head_c", "head_r"), (left, center, right), strict=True):
pitch, yaw, roll = motion
for index, name in enumerate(roles.get(role, [])):
weight = weights[min(index, len(weights) - 1)]
result[name] = (roll * weight, pitch * weight, yaw * weight)
return result
def ultimate_living_heads(
roles: dict[str, list[str]],
progress: float,
intensity: float = 1.0,
) -> dict[str, tuple[float, float, float]]:
"""Use distinct harmonic timing so three heads never bob in lockstep."""
phase = math.tau * progress
left = (
intensity * (2.8 * math.sin(phase) + 0.8 * math.sin(phase * 2 + 0.35)),
intensity * (2.0 * math.sin(phase * 2 + 0.75)),
intensity * 0.8 * math.sin(phase + 0.2),
)
center = (
intensity * (2.5 * math.sin(phase + 2.05) + 0.65 * math.sin(phase * 3 + 0.25)),
intensity * (1.5 * math.sin(phase * 3 + 1.4)),
intensity * 0.65 * math.sin(phase + 2.6),
)
right = (
intensity * (2.7 * math.sin(phase + 4.15) + 0.75 * math.sin(phase * 2 + 1.15)),
intensity * (1.9 * math.sin(phase * 2 + 3.7)),
intensity * 0.75 * math.sin(phase + 4.55),
)
return ultimate_head_pose(roles, left, center, right)
def ultimate_wings(flare: float) -> dict[str, tuple[float, float, float]]:
"""Flap around local Z only; local Y sweeps these wings around body."""
result: dict[str, tuple[float, float, float]] = {}
for prefix, sign in (("BEUD_LWing", 1), ("BEUD_RWing", -1)):
for index, weight in ((1, 1.0), (2, 0.34), (3, 0.12)):
result[f"{prefix}{index}"] = (0.0, 0.0, sign * flare * weight)
return result
def ultimate_jaws(left: float, center: float, right: float) -> dict[str, tuple[float, float, float]]:
"""Jaw hinges use local Y; local X would twist along snout."""
return {
"BEUD_LChin": (0.0, left, 0.0),
"BEUD_Chin": (0.0, center, 0.0),
"BEUD_RChin": (0.0, right, 0.0),
}
def ultimate_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
idle = []
for frame in (1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56, 60):
progress = (frame - 1) / 59
idle.append((frame, merge(
ultimate_living_heads(roles, progress),
ultimate_wings(1.8 * math.sin(math.tau * progress)),
rotate_role(roles, "tail", (0, 0, 4.0 * math.sin(math.tau * progress + 0.6)), 8, 0.82),
)))
move = []
for frame in (1, 5, 9, 13, 17, 21, 25, 29, 33, 36):
progress = (frame - 1) / 35
stride = math.sin(math.tau * progress)
move.append((frame, merge(
ultimate_living_heads(roles, progress, 1.2),
ultimate_wings(7.0 * stride),
rotate_role(roles, "tail", (0, 0, 5.5 * math.sin(math.tau * progress + 0.8)), 8, 0.82),
rotate_role(roles, "leg_l", (5.0 * stride, 0, 0), 2),
rotate_role(roles, "leg_r", (-5.0 * stride, 0, 0), 2),
)))
attack_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(6, (-4, 2, -1), (-2, -1, 0), (-1, -2, 1), (0, 0, 0), 2),
(10, (-8, 3, -1), (-5, 0, 0), (-3, -3, 1), (4, 0, 0), 4),
(14, (11, -2, 1), (-8, 1, 0), (-5, -1, 1), (24, 3, 0), -3),
(17, (6, 1, 0), (12, 0, 0), (-8, 2, -1), (8, 25, 3), -4),
(21, (3, -1, 0), (7, -1, 0), (13, 2, -1), (2, 9, 25), -2),
(28, (-1, 1, 0), (2, 0, 0), (5, -1, 0), (0, 2, 8), 1),
(36, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
)
attack = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
ultimate_jaws(*jaws),
ultimate_wings(wing),
))
for frame, left, center, right, jaws, wing in attack_specs
]
hit_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(4, (-10, -3, 2), (-6, 2, -1), (-4, 3, -2), (-14, 0, 10), 4),
(7, (-5, 2, -1), (-11, -2, 1), (-7, -3, 2), (-8, 0, 6), 2),
(11, (3, -1, 0), (-4, 1, 0), (-10, 2, -1), (5, 0, -4), -1),
(16, (-1, 1, 0), (3, -1, 0), (-2, 0, 0), (2, 0, -2), 0),
(24, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
)
hit_react = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
rotate_role(roles, "body", body, 2),
ultimate_wings(wing),
))
for frame, left, center, right, body, wing in hit_specs
]
death_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(14, (-5, -3, 2), (-3, 2, -1), (-7, 2, -2), (-10, 0, 8), 2),
(25, (-14, -7, 4), (-8, 4, -2), (-18, 6, -5), (-16, 8, 18), -3),
(42, (-23, -10, 7), (-17, 6, -4), (-28, 9, -8), (8, 48, 58), -7),
(58, (-30, -13, 9), (-24, 8, -6), (-34, 11, -10), (10, 62, 70), -9),
(72, (-32, -14, 10), (-27, 9, -7), (-36, 12, -11), (10, 62, 70), -9),
)
death = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
rotate_role(roles, "body", body, 2),
ultimate_wings(wing),
))
for frame, left, center, right, body, wing in death_specs
]
return {"Idle": idle, "Move": move, "Attack": attack, "HitReact": hit_react, "Death": death}
def red_eyes_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
"""Author Red-Eyes motion around its unusually oriented local bone axes.
This rig's wing bones do not share the local axes used by the named dragon
rigs. Rotating every wing segment as one role makes each membrane chain curl
forward like an arm. Instead, rotate the two shoulder roots in mirrored
armature-space directions and add only a small elbow fold. Both sides use
identical keyframe timing so they read as one aerodynamic flap.
"""
neutral: dict[str, tuple[float, float, float]] = {}
wings_up = {
"bone0016": (38.0, 32.8, 38.3),
"bone0033": (-38.0, -32.8, 38.3),
"bone0006": (-11.3, 14.0, -0.4),
"bone0012": (11.3, -14.0, -0.4),
}
wings_down = {
"bone0016": (-12.8, -28.5, -13.1),
"bone0033": (12.8, 28.5, -13.1),
"bone0006": (7.6, -9.3, -1.3),
"bone0012": (-7.6, 9.3, -1.3),
}
wings_recover = {
"bone0016": (15.5, 16.0, 15.7),
"bone0033": (-15.5, -16.0, 15.7),
"bone0006": (-5.0, 6.0, -0.2),
"bone0012": (5.0, -6.0, -0.2),
}
tail_l = rotate_role(roles, "tail", (0, 0, 7), 5, 0.78)
tail_r = rotate_role(roles, "tail", (0, 0, -7), 5, 0.78)
legs_a = merge(rotate_role(roles, "leg_l", (8, 0, 0), 2), rotate_role(roles, "leg_r", (-8, 0, 0), 2))
legs_b = merge(rotate_role(roles, "leg_l", (-8, 0, 0), 2), rotate_role(roles, "leg_r", (8, 0, 0), 2))
# The neck runs forward/down in armature space. Its local Z axis is the
# pitch axis: negative lifts the head for anticipation; positive drives the
# snout down through the bite. Jaw roots open during the windup, then close
# at impact instead of swinging the forelimbs as the attack silhouette.
head_lift = {"bone0025": (0, 0, 45), "bone0031": (0, 0, 15)}
head_bite = {"bone0025": (0, 0, -32), "bone0031": (0, 0, -10)}
head_follow_through = {"bone0025": (0, 0, -18), "bone0031": (0, 0, -6)}
jaw_open = {"bone0001": (24, 0, 0), "bone0029": (24, 0, 0), "bone0020": (20, 0, 0)}
jaw_closed = {"bone0001": (-4, 0, 0), "bone0029": (-4, 0, 0), "bone0020": (-3, 0, 0)}
return {
"Idle": [
(1, neutral),
(14, wings_up),
(28, neutral),
(42, wings_down),
(54, wings_recover),
(60, neutral),
],
"Move": [
(1, merge(wings_up, legs_a, tail_l)),
(9, neutral),
(18, merge(wings_down, legs_b, tail_r)),
(27, neutral),
(36, merge(wings_up, legs_a, tail_l)),
],
"Attack": [
(1, neutral),
(8, merge(wings_up, head_lift, jaw_open)),
(14, merge(wings_down, head_bite, jaw_open)),
(17, merge(wings_down, head_bite, jaw_closed)),
(23, merge(wings_recover, head_follow_through)),
(30, neutral),
(36, neutral),
],
"HitReact": [
(1, neutral),
(5, merge(rotate_role(roles, "body", (-18, 0, 12), 2), head_lift, wings_up)),
(13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)),
(24, neutral),
],
"Death": [
(1, neutral),
(18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), head_follow_through, wings_down)),
(44, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down, rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))),
(72, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down)),
],
}
def guardian_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
arms_open = merge(rotate_role(roles, "arm_l", (0, -8, -12), 3), rotate_role(roles, "arm_r", (0, 8, 12), 3))
arms_close = merge(rotate_role(roles, "arm_l", (0, 5, 8), 3), rotate_role(roles, "arm_r", (0, -5, -8), 3))
step_a = merge(rotate_role(roles, "leg_l", (-20, 0, 0), 3), rotate_role(roles, "leg_r", (20, 0, 0), 3), rotate_role(roles, "arm_l", (14, 0, 0), 3), rotate_role(roles, "arm_r", (-14, 0, 0), 3))
step_b = merge(rotate_role(roles, "leg_l", (20, 0, 0), 3), rotate_role(roles, "leg_r", (-20, 0, 0), 3), rotate_role(roles, "arm_l", (-14, 0, 0), 3), rotate_role(roles, "arm_r", (14, 0, 0), 3))
return {
"Idle": [(1, neutral), (16, merge(arms_open, rotate_role(roles, "body", (2, 0, 0), 2))), (31, neutral), (46, merge(arms_close, rotate_role(roles, "body", (-2, 0, 0), 2))), (60, neutral)],
"Move": [(1, step_a), (10, neutral), (19, step_b), (28, neutral), (36, step_a)],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (0, -18, -8), 3), rotate_role(roles, "arm_r", (-35, 15, 26), 5))), (15, merge(rotate_role(roles, "body", (8, 20, 8), 3), rotate_role(roles, "arm_r", (42, -12, -35), 5), rotate_role(roles, "arm_l", (12, 0, 8), 3), rotate_role(roles, "jaw", (16, 0, 0), 2))), (25, rotate_role(roles, "arm_r", (8, 0, -8), 4)), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), arms_open)), (13, merge(rotate_role(roles, "body", (8, 0, -6), 2), arms_close)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-24, 0, 18), 3), arms_open)), (44, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (24, 0, -38), 5), rotate_role(roles, "arm_r", (-18, 0, 34), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (26, 0, -40), 5), rotate_role(roles, "arm_r", (-20, 0, 36), 5)))],
}
def insect_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
legs_a = merge(rotate_role(roles, "leg_l", (12, 0, -10), 4), rotate_role(roles, "leg_r", (-12, 0, 10), 4))
legs_b = merge(rotate_role(roles, "leg_l", (-12, 0, 10), 4), rotate_role(roles, "leg_r", (12, 0, -10), 4))
wings_up = merge(rotate_role(roles, "wing_l", (0, -18, 20), 3), rotate_role(roles, "wing_r", (0, 18, -20), 3))
wings_down = merge(rotate_role(roles, "wing_l", (0, 22, -16), 3), rotate_role(roles, "wing_r", (0, -22, 16), 3))
return {
"Idle": [(1, neutral), (16, merge(wings_up, rotate_role(roles, "head", (0, 0, 5), 4))), (31, neutral), (46, merge(wings_down, rotate_role(roles, "head", (0, 0, -5), 4))), (60, neutral)],
"Move": [(1, merge(legs_a, wings_up)), (10, neutral), (19, merge(legs_b, wings_down)), (28, neutral), (36, merge(legs_a, wings_up))],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), rotate_role(roles, "arm_l", (-20, 0, -20), 3), rotate_role(roles, "arm_r", (-20, 0, 20), 3), wings_up)), (15, merge(rotate_role(roles, "body", (18, 0, 0), 2), rotate_role(roles, "head", (22, 0, 0), 4), rotate_role(roles, "jaw", (25, 0, 0), 4), rotate_role(roles, "arm_l", (28, 0, 22), 3), rotate_role(roles, "arm_r", (28, 0, -22), 3))), (25, rotate_role(roles, "jaw", (8, 0, 0), 3)), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), wings_up, legs_b)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-18, 0, 16), 3), wings_down)), (44, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5), rotate_role(roles, "wing_l", (0, 32, -28), 3), rotate_role(roles, "wing_r", (0, -32, 28), 3))), (72, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5)))],
}
def wraith_tentacles(
roles: dict[str, list[str]],
sweep: float,
curl: float,
lift: float = 0,
phase: float = 0,
) -> dict[str, tuple[float, float, float]]:
"""Pose every independently rigged vine chain instead of one flattened pair."""
result: dict[str, tuple[float, float, float]] = {}
for role, side_sign in (("arm_l", -1), ("arm_r", 1)):
chains: dict[str, list[str]] = {}
for name in roles.get(role, []):
match = re.match(r"([LR]_arm\d+)_", name, re.IGNORECASE)
if match:
chains.setdefault(match.group(1).lower(), []).append(name)
for chain_index, chain_name in enumerate(sorted(chains)):
chain = chains[chain_name]
pair_direction = -1 if chain_index % 2 else 1
chain_phase = phase + chain_index * 0.85
for bone_index, name in enumerate(chain):
progress = bone_index / max(1, len(chain) - 1)
tip_weight = 0.38 + progress * 0.62
wave = math.sin(chain_phase + progress * math.pi * 1.65)
result[name] = (
lift * pair_direction * (1 - progress * 0.45),
side_sign * sweep * tip_weight * 0.42,
side_sign * (curl * wave + sweep * pair_direction * 0.22) * tip_weight,
)
return result
def wraith_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
idle_a = wraith_tentacles(roles, sweep=3.5, curl=5.5, lift=1.5, phase=0.2)
idle_b = wraith_tentacles(roles, sweep=-3.5, curl=5.5, lift=-1.5, phase=math.pi + 0.2)
drift_a = wraith_tentacles(roles, sweep=6, curl=8, lift=2.5, phase=0.65)
drift_b = wraith_tentacles(roles, sweep=-6, curl=8, lift=-2.5, phase=math.pi + 0.65)
windup = wraith_tentacles(roles, sweep=-8, curl=10, lift=-4, phase=1.1)
lash = wraith_tentacles(roles, sweep=15, curl=17, lift=7, phase=2.35)
recoil = wraith_tentacles(roles, sweep=-11, curl=13, lift=-6, phase=1.75)
collapse = wraith_tentacles(roles, sweep=-14, curl=7, lift=18, phase=math.pi * 0.5)
return {
"Idle": [(1, neutral), (16, merge(idle_a, rotate_role(roles, "body", (0, 0, 2), 3))), (31, neutral), (46, merge(idle_b, rotate_role(roles, "body", (0, 0, -2), 3))), (60, neutral)],
"Move": [(1, drift_a), (10, merge(idle_a, rotate_role(roles, "body", (0, 0, 5), 3))), (19, drift_b), (28, merge(idle_b, rotate_role(roles, "body", (0, 0, -5), 3))), (36, drift_a)],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-12, 0, 0), 3), windup)), (16, merge(rotate_role(roles, "body", (18, 0, 0), 3), lash, rotate_role(roles, "jaw", (24, 0, 0), 3))), (25, drift_a), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), recoil)), (13, merge(rotate_role(roles, "body", (7, 0, -6), 3), idle_a)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-22, 0, 20), 3), recoil)), (44, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse, rotate_role(roles, "jaw", (30, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse))],
}
def create_actions(profile: AssetProfile, rig: bpy.types.Object, roles: dict[str, list[str]]) -> dict[str, dict[str, object]]:
pose_factory = {
"dragon": dragon_poses,
"guardian": guardian_poses,
"insect": insect_poses,
"wraith": wraith_poses,
}[profile.creature]
if profile.asset_id == "blue-eyes-ultimate-dragon":
definitions = ultimate_dragon_poses(roles)
elif profile.red_eyes_roles:
definitions = red_eyes_dragon_poses(roles)
else:
definitions = pose_factory(roles)
rig.animation_data_create()
metadata = {}
for clip_name, poses in definitions.items():
action = bpy.data.actions.new(clip_name)
action.use_fake_user = True
action.use_frame_range = True
action.frame_start = 1
action.frame_end = CLIP_SPECS[clip_name]["frames"]
action.use_cyclic = CLIP_SPECS[clip_name]["loop"]
rig.animation_data.action = action
animated_names = sorted({name for _, rotations in poses for name in rotations})
for frame, rotations in poses:
for name in animated_names:
bone = rig.pose.bones.get(name)
if not bone:
continue
bone.rotation_mode = "XYZ"
degrees = rotations.get(name, (0.0, 0.0, 0.0))
bone.rotation_euler = tuple(math.radians(component) for component in degrees)
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
metadata[clip_name] = {
"frames": CLIP_SPECS[clip_name]["frames"],
"seconds": CLIP_SPECS[clip_name]["frames"] / FPS,
"loop": CLIP_SPECS[clip_name]["loop"],
}
rig.animation_data.action = None
for bone in rig.pose.bones:
bone.rotation_euler = (0, 0, 0)
return metadata
def select_only(objects: list[bpy.types.Object]) -> None:
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
def world_bounds(meshes: list[bpy.types.Object]) -> tuple[Vector, Vector]:
depsgraph = bpy.context.evaluated_depsgraph_get()
points: list[Vector] = []
for mesh in meshes:
evaluated = mesh.evaluated_get(depsgraph)
evaluated_mesh = evaluated.to_mesh()
points.extend(evaluated.matrix_world @ vertex.co for vertex in evaluated_mesh.vertices)
evaluated.to_mesh_clear()
minimum = Vector(tuple(min(point[index] for point in points) for index in range(3)))
maximum = Vector(tuple(max(point[index] for point in points) for index in range(3)))
return minimum, maximum
def look_at(obj: bpy.types.Object, target: Vector) -> None:
obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()
def render_preview(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], out_path: Path, frame: int = 17) -> None:
rig.animation_data.action = bpy.data.actions["Attack"]
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
minimum, maximum = world_bounds(meshes)
center = (minimum + maximum) * 0.5
radius = (maximum - minimum).length * 0.5
size = radius * 2
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 640
scene.render.resolution_y = 640
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.world.color = (0.008, 0.012, 0.02)
camera_data = bpy.data.cameras.new("PreviewCamera")
camera = bpy.data.objects.new("PreviewCamera", camera_data)
bpy.context.collection.objects.link(camera)
camera.data.lens = 65
view_direction = Vector((0.8, -1.05, 0.42)).normalized()
half_fov = math.atan(camera.data.sensor_width / (2 * camera.data.lens))
camera.location = center + view_direction * (radius / math.sin(half_fov) * 0.45)
look_at(camera, center)
scene.camera = camera
for name, offset, color, energy, radius in (
("Key", (1.4, -1.2, 1.7), (1.0, 0.52, 0.28), 1000, 5.0),
("Fill", (-1.3, -0.4, 0.8), (0.22, 0.42, 1.0), 800, 4.0),
("Rim", (0.3, 1.4, 1.3), (0.65, 0.22, 1.0), 900, 3.5),
):
light_data = bpy.data.lights.new(name, "AREA")
light_data.energy = energy
light_data.color = color
light_data.shape = "DISK"
light_data.size = size * radius
light = bpy.data.objects.new(name, light_data)
light.location = center + Vector(offset) * size
bpy.context.collection.objects.link(light)
look_at(light, center)
out_path.parent.mkdir(parents=True, exist_ok=True)
scene.render.filepath = str(out_path)
bpy.ops.render.render(write_still=True)
rig.animation_data.action = None
def export_asset(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], source_path: Path, clips: dict[str, dict[str, object]], roles: dict[str, list[str]]) -> None:
source_dir = MODEL_ROOT / profile.asset_id
output_path = source_dir / f"{profile.asset_id}-animated.glb"
select_only([rig, *meshes])
bpy.ops.export_scene.gltf(
filepath=str(output_path),
export_format="GLB",
use_selection=True,
export_animations=True,
export_animation_mode="ACTIONS",
export_frame_range=True,
export_skins=True,
export_morph=True,
export_yup=True,
export_apply=False,
export_cameras=False,
export_lights=False,
export_image_format="AUTO",
)
triangles = sum(len(polygon.vertices) - 2 for mesh in meshes for polygon in mesh.data.polygons)
metadata = {
"name": profile.display_name,
"assetId": profile.asset_id,
"source": source_path.name,
"format": "glTF 2.0 binary (GLB)",
"authoringTool": bpy.app.version_string,
"fps": FPS,
"trianglesApprox": triangles,
"animations": clips,
"animatedBones": {role: names for role, names in roles.items() if names},
}
(source_dir / f"{profile.asset_id}-animated.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
render_preview(profile, rig, meshes, source_dir / "previews" / f"{profile.asset_id}-attack.png")
print(f"BUILT={profile.asset_id} BONES={len(rig.data.bones)} TRIANGLES={triangles} CLIPS={','.join(clips)}")
def main() -> None:
bpy.context.preferences.filepaths.save_version = 0
bpy.context.scene.render.fps = FPS
requested_ids = set(sys.argv[sys.argv.index("--") + 1:]) if "--" in sys.argv else set()
unknown_ids = requested_ids - {profile.asset_id for profile in PROFILES}
if unknown_ids:
raise RuntimeError(f"Unknown asset ids: {', '.join(sorted(unknown_ids))}")
selected_profiles = [profile for profile in PROFILES if not requested_ids or profile.asset_id in requested_ids]
for profile in selected_profiles:
reset_scene()
rig, meshes, source_path = convert_and_import(profile)
roles = build_roles(profile, rig)
clips = create_actions(profile, rig, roles)
export_asset(profile, rig, meshes, source_path, clips, roles)
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
"""Print rig and mesh details for the locally-authored inspired creature models."""
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import tempfile
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
def inspect_model(path: Path) -> dict[str, object]:
reset_scene()
source_path = path.with_suffix(".smd")
with tempfile.TemporaryDirectory(prefix="thor-yugioh-inspect-") as temp_dir:
converted_path = Path(temp_dir) / "model.glb"
subprocess.run(
["assimp", "export", str(source_path), str(converted_path), "-f", "glb2"],
check=True,
capture_output=True,
text=True,
)
bpy.ops.import_scene.gltf(filepath=str(converted_path))
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
bounds = []
for mesh in meshes:
bounds.extend(mesh.matrix_world @ Vector(corner) for corner in mesh.bound_box)
return {
"asset": path.parent.name,
"source": source_path.name,
"armatures": [
{
"name": rig.name,
"bones": [bone.name for bone in rig.data.bones],
"actions": [action.name for action in bpy.data.actions],
}
for rig in armatures
],
"meshes": [mesh.name for mesh in meshes],
"materials": sorted({slot.material.name for mesh in meshes for slot in mesh.material_slots if slot.material}),
"bounds": {
"min": [round(min(point[index] for point in bounds), 4) for index in range(3)],
"max": [round(max(point[index] for point in bounds), 4) for index in range(3)],
} if bounds else None,
}
def main() -> None:
report = [inspect_model(path) for path in sorted(MODEL_ROOT.glob("*/MMD_*.fbx"))]
print("YUGIOH_MODEL_REPORT=" + json.dumps(report, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,240 @@
"""Re-import and validate every generated creature animation GLB."""
from __future__ import annotations
import json
from pathlib import Path
import re
import struct
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
EXPECTED_CLIPS = {"Idle", "Move", "Attack", "HitReact", "Death"}
def read_glb_json(path: Path) -> dict[str, object]:
data = path.read_bytes()
magic, version, total_length = struct.unpack_from("<4sII", data, 0)
if magic != b"glTF" or version != 2 or total_length != len(data):
raise RuntimeError(f"{path}: invalid GLB header")
chunk_length, chunk_type = struct.unpack_from("<II", data, 12)
if chunk_type != 0x4E4F534A:
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
return json.loads(data[20:20 + chunk_length].decode("utf-8"))
def read_glb_binary(path: Path) -> bytes:
data = path.read_bytes()
json_length, json_type = struct.unpack_from("<II", data, 12)
if json_type != 0x4E4F534A:
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
binary_header = 20 + json_length
binary_length, binary_type = struct.unpack_from("<II", data, binary_header)
if binary_type != 0x004E4942:
raise RuntimeError(f"{path}: binary data is not the second GLB chunk")
binary_start = binary_header + 8
return data[binary_start:binary_start + binary_length]
def float_accessor(document: dict[str, object], binary: bytes, index: int) -> list[tuple[float, ...]]:
accessor = document["accessors"][index]
if accessor["componentType"] != 5126:
raise RuntimeError(f"Accessor {index}: expected float data")
component_counts = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}
component_count = component_counts[accessor["type"]]
view = document["bufferViews"][accessor["bufferView"]]
element_size = component_count * 4
stride = view.get("byteStride", element_size)
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
return [
struct.unpack_from(f"<{component_count}f", binary, start + item * stride)
for item in range(accessor["count"])
]
def values_vary(values: list[tuple[float, ...]], tolerance: float = 1e-5) -> bool:
first = values[0]
return any(
abs(component - first[index]) > tolerance
for value in values[1:]
for index, component in enumerate(value)
)
def validate_pumpking_tentacles(path: Path) -> int:
document = read_glb_json(path)
nodes = document.get("nodes", [])
animations = document.get("animations", [])
tentacle_nodes = {
index for index, node in enumerate(nodes)
if re.fullmatch(r"[LR]_arm\d+_\d+", node.get("name", ""), re.IGNORECASE)
}
if not tentacle_nodes:
raise RuntimeError(f"{path}: no Pumpking tentacle nodes")
for animation in animations:
targeted = {channel["target"]["node"] for channel in animation.get("channels", [])}
missing = tentacle_nodes - targeted
if missing:
missing_names = [nodes[index].get("name", str(index)) for index in sorted(missing)]
raise RuntimeError(f"{path}: {animation.get('name')} misses tentacles {missing_names}")
return len(tentacle_nodes)
def validate_ultimate_dragon(path: Path) -> dict[str, int]:
document = read_glb_json(path)
binary = read_glb_binary(path)
nodes = document.get("nodes", [])
animations = document.get("animations", [])
head_chains = (
{f"BEUD_LNeck{index}" for index in range(1, 7)} | {"BEUD_LHead"},
{f"BEUD_Neck{index}" for index in range(1, 7)} | {"BEUD_Head"},
{f"BEUD_RNeck{index}" for index in range(1, 7)} | {"BEUD_RHead"},
)
animated_wings = {
f"BEUD_{side}Wing{index}"
for side in ("L", "R")
for index in range(1, 4)
}
static_wings = {
f"BEUD_{side}Wing{index}"
for side in ("L", "R")
for index in range(4, 9)
}
head_tips = {"BEUD_LHead", "BEUD_Head", "BEUD_RHead"}
for animation in animations:
variation = {}
rotation_values = {}
for channel in animation.get("channels", []):
if channel["target"].get("path") != "rotation":
continue
name = nodes[channel["target"]["node"]].get("name", "")
accessor_index = animation["samplers"][channel["sampler"]]["output"]
values = float_accessor(document, binary, accessor_index)
rotation_values[name] = values
variation[name] = values_vary(values)
for chain in head_chains:
static = {name for name in chain if not variation.get(name, False)}
if static:
raise RuntimeError(f"{path}: {animation.get('name')} has static head-chain bones {sorted(static)}")
static_roots = {name for name in animated_wings if not variation.get(name, False)}
moving_tips = {name for name in static_wings if variation.get(name, False)}
if static_roots or moving_tips:
raise RuntimeError(
f"{path}: {animation.get('name')} static wing roots {sorted(static_roots)}, "
f"moving wing tips {sorted(moving_tips)}"
)
if animation.get("name") in {"Idle", "Move", "Attack"}:
peak_frames = {}
for name in head_tips:
values = rotation_values[name]
start = values[0]
distances = [1 - min(1.0, abs(sum(component * start[index] for index, component in enumerate(value)))) for value in values]
peak_frames[name] = distances.index(max(distances))
if len(set(peak_frames.values())) != len(head_tips):
raise RuntimeError(f"{path}: {animation.get('name')} head cadences overlap at {peak_frames}")
return {
"headChainBonesPerClip": sum(len(chain) for chain in head_chains),
"wingBonesPerClip": len(animated_wings),
"staggeredHeadClips": 3,
}
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
for image in list(bpy.data.images):
bpy.data.images.remove(image)
def validate_red_eyes_motion(rig: bpy.types.Object) -> dict[str, float]:
"""Regression-check the paired flap and bite silhouette in the shipped GLB."""
rig.animation_data.action = bpy.data.actions["Attack"]
def sample(frame: int) -> dict[str, Vector]:
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
return {
"wing_l": rig.pose.bones["bone0079"].tail.copy(),
"wing_r": rig.pose.bones["bone0075"].tail.copy(),
"head": rig.pose.bones["bone0073"].tail.copy(),
}
windup = sample(8)
downstroke = sample(14)
impact = sample(17)
left_flap = windup["wing_l"].z - downstroke["wing_l"].z
right_flap = windup["wing_r"].z - downstroke["wing_r"].z
head_drop = windup["head"].z - impact["head"].z
head_lunge = windup["head"].y - impact["head"].y
if left_flap < 0.2 or right_flap < 0.2:
raise RuntimeError(f"Red-Eyes wings do not complete a paired flap: {left_flap=:.4f}, {right_flap=:.4f}")
if head_drop < 0.01 or head_lunge < 0.05:
raise RuntimeError(f"Red-Eyes bite lacks head lift/lunge: {head_drop=:.4f}, {head_lunge=:.4f}")
return {
"leftWingDrop": round(left_flap, 4),
"rightWingDrop": round(right_flap, 4),
"headDrop": round(head_drop, 4),
"headLunge": round(head_lunge, 4),
}
def validate(path: Path) -> dict[str, object]:
reset_scene()
bpy.context.scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(path))
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
meshes = [
obj for obj in bpy.context.scene.objects
if obj.type == "MESH" and any(
modifier.type == "ARMATURE" and modifier.object in armatures for modifier in obj.modifiers
)
]
clips = {action.name: tuple(round(value, 3) for value in action.frame_range) for action in bpy.data.actions}
if len(armatures) != 1:
raise RuntimeError(f"{path}: expected one armature, found {len(armatures)}")
if not meshes:
raise RuntimeError(f"{path}: no render meshes")
if set(clips) != EXPECTED_CLIPS:
raise RuntimeError(f"{path}: clips {sorted(clips)} != {sorted(EXPECTED_CLIPS)}")
if any(end <= start for start, end in clips.values()):
raise RuntimeError(f"{path}: empty animation range in {clips}")
if not bpy.data.images:
raise RuntimeError(f"{path}: no packed texture images")
corners = [mesh.matrix_world @ Vector(corner) for mesh in meshes for corner in mesh.bound_box]
dimensions = [round(max(point[index] for point in corners) - min(point[index] for point in corners), 4) for index in range(3)]
report = {
"asset": path.parent.name,
"sizeBytes": path.stat().st_size,
"bones": len(armatures[0].data.bones),
"meshes": len(meshes),
"images": len(bpy.data.images),
"dimensions": dimensions,
"clips": clips,
}
if path.parent.name == "pumpking-the-king-of-ghosts":
report["tentacleBonesPerClip"] = validate_pumpking_tentacles(path)
if path.parent.name == "blue-eyes-ultimate-dragon":
report.update(validate_ultimate_dragon(path))
if path.parent.name == "red-eyes-black-dragon":
report["motion"] = validate_red_eyes_motion(armatures[0])
return report
def main() -> None:
paths = sorted(MODEL_ROOT.glob("*/*-animated.glb"))
if len(paths) != 7:
raise RuntimeError(f"Expected 7 generated GLBs, found {len(paths)}")
report = [validate(path) for path in paths]
print("YUGIOH_ANIMATION_VALIDATION=" + json.dumps(report, indent=2))
if __name__ == "__main__":
main()
+49 -14
View File
@@ -5,6 +5,7 @@ import { FrontEnd } from "./components/FrontEnd";
import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store";
import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings, useGameLoop } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
@@ -32,25 +33,35 @@ export default function App() {
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const phase = useGameStore((state) => state.phase);
const boss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const victoryRecorded = useRef(false);
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
const rewardedBossInstances = useRef(new Set<string>());
const screenRef = useRef(screen);
screenRef.current = screen;
const leaveGame = useCallback(() => {
updateActiveHealerInventory(useGameStore.getState().inventory);
touchActiveSave();
navigate("home");
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[]) => {
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId];
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds);
const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter";
const launchDifficulty = runMode === "roguelike"
? "initiate"
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
rewardedBossInstances.current.clear();
clearRecentRewards();
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty);
touchActiveSave();
navigate("game");
}, [hunter, navigate, touchActiveSave]);
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
useEffect(() => {
const onDualScreenLaunch = (event: Event) => launchGame((event as CustomEvent<readonly BossId[]>).detail);
const onDualScreenLaunch = (event: Event) => {
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
else launchGame(detail);
};
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
}, [launchGame]);
@@ -63,12 +74,36 @@ export default function App() {
}, [settings.largeText, settings.reducedMotion]);
useEffect(() => {
if (phase === "combat") victoryRecorded.current = false;
if (screen === "game" && phase === "victory" && !victoryRecorded.current) {
victoryRecorded.current = true;
for (const bossName of [boss.name, ...additionalBosses.map((entry) => entry.boss.name)]) recordBossVictory(bossName);
}
}, [additionalBosses, boss.name, phase, recordBossVictory, screen]);
return useGameStore.subscribe((state, previousState) => {
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|| previousState.phase === "intermission" && state.phase === "combat";
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
rewardedBossInstances.current.clear();
}
if (startedFreshEncounter) clearRecentRewards();
if (screenRef.current !== "game") return;
const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = `boss-0-${state.boss.id}`;
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
rewardedBossInstances.current.add(primaryInstanceId);
const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty);
}
}
for (let index = 0; index < state.additionalBosses.length; index += 1) {
const entry = state.additionalBosses[index];
const previous = previousState.additionalBosses[index];
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
rewardedBossInstances.current.add(entry.instanceId);
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(entry.boss.id, rewardDifficulty);
}
});
}, [clearRecentRewards, recordBossVictory]);
return (
<main className="prototype-shell">
+23
View File
@@ -4,6 +4,13 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
if (!rewards.length) return null;
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.coin.id}-${index}`}><b>{reward.coin.glyph}</b>{reward.coin.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
}
function HealthBar({ member }: { member: PartyMember }) {
const health = Math.max(0, (member.hp / member.maxHp) * 100);
@@ -195,6 +202,7 @@ function EndPanel() {
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{phase === "victory" && <RewardSummary />}
<div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={restart}>Return to briefing</button>
@@ -203,9 +211,24 @@ function EndPanel() {
);
}
function IntermissionStatusPanel() {
const round = useGameStore((state) => state.round);
return (
<div className="intermission-status" aria-label={`Round ${round} cleared. Choose a blessing on the top display.`}>
<i></i>
<span>Round {round} cleared</span>
<h2>Choose on top display</h2>
<p>Next encounter stays locked until one blessing is claimed.</p>
<RewardSummary />
<small>Use D-pad to choose · A to claim</small>
</div>
);
}
function CombatPanel() {
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
+44
View File
@@ -0,0 +1,44 @@
import { RUN_BUFFS, bossHealthMultiplier, countRunBuff } from "../game/roguelike";
import { useGameStore } from "../game/store";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
const runBuffs = useGameStore((state) => state.runBuffs);
const choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId);
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff);
const nextRound = round + 1;
return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
<header>
<span>Round {round} cleared</span>
<h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header>
<div className="buff-choice-grid">
{choices.map((buffId) => {
const buff = RUN_BUFFS[buffId];
const stacks = countRunBuff(runBuffs, buffId);
return (
<button
key={buffId}
className={selected === buffId ? "is-controller-focused" : ""}
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)}
aria-pressed={selected === buffId}
>
<i>{buff.icon}</i>
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span>
<b>{buff.summary}</b>
<p>{buff.detail}</p>
</button>
);
})}
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Claim</footer>
</div>
);
}
+8 -6
View File
@@ -3,16 +3,14 @@ import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displa
import { subscribeControllerToken } from "../input/controller";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() =>
new URLSearchParams(window.location.search).get("display") === "bottom" ? "bottom" : "top"
);
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
const activeSurfaceRef = useRef(activeSurface);
activeSurfaceRef.current = activeSurface;
useEffect(() => {
if (!document.documentElement.classList.contains("native-platform")) return;
const dedicatedSurface = new URLSearchParams(window.location.search).has("display");
if (dedicatedSurface) return;
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
@@ -29,7 +27,11 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
unsubscribeSurface();
unsubscribeController();
};
}, []);
}, [dedicatedSurface]);
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
}
return (
<div className={`device-frame active-${activeSurface}`}>
+242 -42
View File
@@ -1,12 +1,35 @@
import { useMemo, useRef, useState } from "react";
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data";
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { BossCollection, GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import {
GEAR_OWNER_LABELS,
GEAR_OWNER_ORDER,
GEAR_RECIPES,
GEAR_SLOT_LABELS,
GEAR_SLOT_ORDER,
GEAR_STAT_LABELS,
MAX_GEAR_LEVEL,
canAffordGearUpgrade,
gearBonusText,
gearUpgradeCosts,
} from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossCoinDrop } from "../game/progression/loot";
import {
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
PASSIVE_INFUSIONS,
PASSIVE_INFUSION_MIN_GEAR_LEVEL,
activeInfusionUnlocked,
infusionCosts,
infusionsForOwner,
passiveInfusionUnlocked,
} from "../game/progression/infusions";
import { requestDisplaySurface } from "../platform/displayRouting";
import { DualDisplayFrame } from "./DualDisplayFrame";
@@ -330,15 +353,16 @@ function HomeScreen() {
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "roguelike-pve", down: "stadium-pvp" } },
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "roguelike-pve", right: "stadium-pvp", up: "roguelike-pve", down: "profile" } },
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "dungeons", down: "settings" } },
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "settings", down: "class-priest" } },
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "profile", down: "class-shaman" } },
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "class-priest" } },
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "roguelike-pvp", left: "profile", right: "settings", down: "class-druid" } },
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } },
...HEALER_CLASS_ORDER.map((classId, index) => ({
id: `class-${classId}`,
run: () => selectHealerClass(classId),
neighbors: {
left: `class-${HEALER_CLASS_ORDER[(index + HEALER_CLASS_ORDER.length - 1) % HEALER_CLASS_ORDER.length]}`,
right: `class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
up: index === 2 ? "settings" : "profile",
up: index === 0 ? "profile" : index === 1 ? "gear" : "settings",
down: "change-save",
},
})),
@@ -364,6 +388,7 @@ function HomeScreen() {
</div>
<div className="home-secondary-actions">
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i></i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend boss coins</small></span><b></b></FocusButton>
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i></i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b></b></FocusButton>
</div>
<ControllerLegend back />
@@ -398,12 +423,13 @@ function HomeScreen() {
function ProfileScreen() {
const hunter = useActiveHunter();
const navigate = useFrontendStore((state) => state.navigate);
const [bossId, setBossId] = useState(hunter?.collections[0].bossId ?? "");
const collection = hunter?.collections.find((boss) => boss.bossId === bossId) ?? hunter?.collections[0];
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const [bossId, setBossId] = useState(collections[0]?.bossId ?? "");
const collection = collections.find((boss) => boss.bossId === bossId) ?? collections[0];
const actions = useMemo<MenuAction[]>(() => [
...(hunter?.collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })) ?? []),
...collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })),
{ id: "back", run: () => navigate("home") },
], [hunter?.collections, navigate]);
], [collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
if (!hunter || !collection) return null;
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
@@ -422,6 +448,7 @@ function ProfileScreen() {
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
@@ -437,9 +464,9 @@ function ProfileScreen() {
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
</div>
<div className="boss-log"><span>Boss records</span>{hunter.collections.map((boss) => (
<div className="boss-log"><span>Boss records</span>{collections.map((boss) => (
<FocusButton key={boss.bossId} id={boss.bossId} focusedId={controller.focusedId} focus={controller.focus} className={boss.bossId === collection.bossId ? "is-selected" : ""} onClick={() => setBossId(boss.bossId)}>
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossName] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossId] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
</FocusButton>
))}</div>
</FrontSurface>
@@ -448,6 +475,140 @@ function ProfileScreen() {
);
}
function GearScreen() {
const hunter = useActiveHunter();
const navigate = useFrontendStore((state) => state.navigate);
const notice = useFrontendStore((state) => state.notice);
const selectedOwnerId = useFrontendStore((state) => state.selectedGearOwnerId);
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
const recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
const canUpgrade = Boolean(hunter && slot && slot.level < MAX_GEAR_LEVEL && canAffordGearUpgrade(hunter.materials, costs));
const infusionChoices = infusionsForOwner(selectedOwnerId);
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
const activeUnlocked = Boolean(hunter && activeInfusionUnlocked(hunter.gearProgress[selectedOwnerId]));
const anchorUnlocked = Boolean(slot && slot.level >= ACTIVE_INFUSION_MIN_GEAR_LEVEL);
const infusionEquipped = hunter?.gearProgress[selectedOwnerId].infusionAbilityId === selectedInfusion.id;
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
const actions = useMemo<MenuAction[]>(() => [
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
id: `owner-${ownerId}`,
run: () => selectOwner(ownerId),
neighbors: {
up: index === 0 ? "back" : `owner-${GEAR_OWNER_ORDER[index - 1]}`,
down: index === GEAR_OWNER_ORDER.length - 1 ? previewEntryId : `owner-${GEAR_OWNER_ORDER[index + 1]}`,
right: `slot-${selectedSlotId}`,
},
})),
...GEAR_SLOT_ORDER.map((slotId, index) => ({
id: `slot-${slotId}`,
run: () => selectSlot(slotId),
neighbors: {
up: index === 0 ? "back" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
left: `owner-${selectedOwnerId}`,
right: previewEntryId,
},
})),
{ id: "workshop-upgrade", run: () => selectWorkshopMode("upgrade"), neighbors: { right: "workshop-infusion", down: "slot-weapon" } },
{ id: "workshop-infusion", run: () => selectWorkshopMode("infusion"), neighbors: { left: "workshop-upgrade", down: `infusion-${infusionChoices[0].id}` } },
...infusionChoices.map((infusion, index) => ({
id: `infusion-${infusion.id}`,
run: () => selectInfusion(infusion.id),
neighbors: {
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
},
})),
...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({
id: `passive-${passive.id}`,
run: () => installPassive(passive.id),
enabled: passiveUnlocked,
neighbors: {
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`,
left: `slot-${selectedSlotId}`,
},
})) : []),
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } },
{ id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
if (!hunter || !slot) return null;
const currentBonus = gearBonusText(recipe.statId, slot.level);
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
return (
<DualDisplayFrame
top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Boss coin workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level));
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} className={ownerId === selectedOwnerId ? "is-selected" : ""} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></FocusButton>;
})}
</section>
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
{GEAR_SLOT_ORDER.map((slotId) => {
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId];
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} className={slotId === selectedSlotId ? "is-selected" : ""} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></FocusButton>;
})}
</section>
{workshopMode === "upgrade" ? <article className="gear-preview">
<span>Selected upgrade</span>
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
<p>{GEAR_STAT_LABELS[recipe.statId]} from {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.</p>
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i></i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
</article> : <article className="gear-preview gear-infusion-preview">
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
<h2>{selectedInfusion.icon} {selectedInfusion.name}</h2>
<p>{selectedInfusion.description} Anchor purchase to a +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} slot.</p>
<div className="gear-infusion-options">
{infusionChoices.map((infusion) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></FocusButton>)}
</div>
{healerOwner && <div className="gear-passive-options"><span>Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>{PASSIVE_INFUSIONS.map((passive) => <FocusButton key={passive.id} id={`passive-${passive.id}`} focusedId={controller.focusedId} focus={controller.focus} disabled={!passiveUnlocked} className={hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""} onClick={() => installPassive(passive.id)}><i>{passive.icon}</i><span><strong>{passive.name}</strong><small>{passive.summary}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}</div>}
</article>}
</div>
<ControllerLegend back />
</FrontSurface>
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend coins · autosave" : "Collect required boss coins"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend coins · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required boss coins"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
/>
);
}
function SettingToggle({ id, label, copy, value, focusedId, focus, onClick }: { id: string; label: string; copy: string; value: boolean; focusedId: string; focus: (id: string) => void; onClick: () => void }) {
return <FocusButton id={id} focusedId={focusedId} focus={focus} className="setting-row" onClick={onClick}><span><strong>{label}</strong><small>{copy}</small></span><b className={value ? "is-on" : ""}>{value ? "ON" : "OFF"}</b></FocusButton>;
}
@@ -494,36 +655,65 @@ function SettingsScreen() {
);
}
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
const hunter = useActiveHunter();
const modeId = useFrontendStore((state) => state.selectedMode);
const selectedBossId = useFrontendStore((state) => state.selectedBossId);
const selectedDifficultySlug = useFrontendStore((state) => state.selectedDifficultySlug);
const selectBoss = useFrontendStore((state) => state.selectBoss);
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons";
const bossGridRows = Math.min(8, Math.ceil(BOSS_ORDER.length / 3));
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair());
if (isDungeon) return onLaunch([selectedBossId]);
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking connects here when game server is configured.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? BOSS_ORDER.map((bossId, index) => ({
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
...(isDungeon ? BOSS_ORDER.map((bossId, index) => {
const column = Math.floor(index / bossGridRows);
const row = index % bossGridRows;
const neighborInColumn = (targetColumn: number) => {
const columnStart = targetColumn * bossGridRows;
if (columnStart >= BOSS_ORDER.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, BOSS_ORDER.length) - 1;
return `boss-${BOSS_ORDER[Math.min(columnStart + row, columnEnd)]}`;
};
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, BOSS_ORDER.length)
? `boss-${BOSS_ORDER[index + 1]}`
: `difficulty-${DIFFICULTIES[0].slug}`,
left: neighborInColumn(column - 1),
right: neighborInColumn(column + 1),
},
};
}) : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
neighbors: {
up: index > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
down: index < BOSS_ORDER.length - 1 ? `boss-${BOSS_ORDER[index + 1]}` : "launch",
left: index > 0 ? `difficulty-${DIFFICULTIES[index - 1].slug}` : `boss-${selectedBossId}`,
right: index < DIFFICULTIES.length - 1 ? `difficulty-${DIFFICULTIES[index + 1].slug}` : "launch",
up: `boss-${selectedBossId}`,
down: "launch",
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `boss-${BOSS_ORDER[BOSS_ORDER.length - 1]}` } : { up: "back" } },
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectedBossId]);
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
@@ -536,7 +726,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
? [
["Randomized pair", "Two distinct bosses are selected only when the run begins."],
["Dual-boss pressure", "Both guardians fight simultaneously and must be defeated."],
["Roguelike foundation", "Three-choice buff drafts are next in development."],
["Buff intermission", "Choose one of three stacking buffs after every cleared round."],
]
: [
["Draft a healing path", "Choose rites after every completed room."],
@@ -548,27 +738,35 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
top={
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<span>Choose encounter</span>
{BOSS_ORDER.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
return (
<FocusButton
key={bossId}
id={`boss-${bossId}`}
focusedId={controller.focusedId}
focus={controller.focus}
className={`boss-choice ${selectedBossId === bossId ? "is-selected" : ""}`}
style={{ "--boss-accent": boss.accent } as React.CSSProperties}
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows } as React.CSSProperties}>
{BOSS_ORDER.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
return (
<FocusButton
key={bossId}
id={`boss-${bossId}`}
focusedId={controller.focusedId}
focus={controller.focus}
className={`boss-choice ${selectedBossId === bossId ? "is-selected" : ""}`}
style={{ "--boss-accent": boss.accent } as React.CSSProperties}
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
</div>
</div>
)}
{isDungeon && (
<div className="difficulty-picker" aria-label="Choose encounter difficulty">
<span>Difficulty</span>
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
</div>
)}
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
@@ -577,21 +775,23 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
}
bottom={
<FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}>
<header className="context-header"><span>Run preparation</span><b>{mode.status.toUpperCase()}</b></header>
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
</FrontSurface>
}
/>
);
}
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
const screen = useFrontendStore((state) => state.screen);
if (screen === "login") return <LoginScreen />;
if (screen === "saves") return <SaveScreen />;
if (screen === "home") return <HomeScreen />;
if (screen === "profile") return <ProfileScreen />;
if (screen === "gear") return <GearScreen />;
if (screen === "settings") return <SettingsScreen />;
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
return null;
+414 -77
View File
@@ -4,19 +4,29 @@ import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "rea
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, ARENA_WALL_RADIUS, clampToArena } from "../game/arena";
import {
isActorAnimationOneShot,
shouldStartActorAnimation,
type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
import { useGameStore } from "../game/store";
import type { MemberId, PulseKind } from "../game/types";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href;
const SPIDER_TEXTURE_URLS: Record<string, string> = {
"Spinnen_Bein_tex_2.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_2.jpg", import.meta.url).href,
"SH3.png": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/SH3.png", import.meta.url).href,
"Spinnen_Bein_tex_COLOR_.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_COLOR_.jpg", import.meta.url).href,
"haar_detail_NRM.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/haar_detail_NRM.jpg", import.meta.url).href,
};
const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href;
const INSECT_QUEEN_URL = new URL("../../game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb", import.meta.url).href;
const BLUE_EYES_WHITE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb", import.meta.url).href;
const GATE_GUARDIAN_URL = new URL("../../game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb", import.meta.url).href;
const GANDORA_URL = new URL("../../game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb", import.meta.url).href;
const RED_EYES_BLACK_URL = new URL("../../game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb", import.meta.url).href;
const PUMPKING_URL = new URL("../../game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb", import.meta.url).href;
const BLUE_EYES_ULTIMATE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../../game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../../game_assets/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../../game_assets/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../../game_assets/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
@@ -53,6 +63,14 @@ const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
});
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
const ARENA_WALL_SEGMENTS = Array.from({ length: 16 }, (_, index) => {
const angle = (index / 16) * Math.PI * 2;
return {
angle,
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 1.15, ARENA_CENTER[1] + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
};
});
const PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia">[] = ["brann", "nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) {
@@ -71,15 +89,6 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
}
const configureSpiderLoader: NonNullable<Parameters<typeof useGLTF>[3]> = (loader) => {
loader.manager.setURLModifier((url) => {
const fileName = url.slice(url.lastIndexOf("/") + 1);
return SPIDER_TEXTURE_URLS[fileName] ?? url;
});
};
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
const PARTY_WEAPON_GRIPS: Record<MemberId, { right: WeaponGrip; left?: WeaponGrip }> = {
@@ -140,9 +149,11 @@ function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" |
function PartyCharacterModel({
memberId,
animationState,
animationTrigger,
}: {
memberId: MemberId;
animationState: MutableRefObject<ActorAnimationState>;
animationTrigger: MutableRefObject<number>;
}) {
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
@@ -162,6 +173,8 @@ function PartyCharacterModel({
);
const { actions } = useAnimations(gltf.animations, actorScene);
const activeClip = useRef<string | undefined>(undefined);
const activeState = useRef<ActorAnimationState | undefined>(undefined);
const activeTrigger = useRef(Number.NaN);
useEffect(() => {
actorScene.traverse((object) => {
@@ -188,6 +201,7 @@ function PartyCharacterModel({
useFrame(() => {
const state = animationState.current;
const trigger = animationTrigger.current;
const clipName = state === "death"
? "Death_A"
: state === "hit"
@@ -201,20 +215,24 @@ function PartyCharacterModel({
: state === "attack"
? PARTY_ATTACK_CLIPS[memberId]
: "Idle";
if (activeClip.current === clipName) return;
if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return;
const next = actions[clipName];
if (!next) return;
if (activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1).fadeIn(0.16);
if (state === "death" || state === "hit" || state === "attack" || state === "cast") {
const clipChanged = activeClip.current !== clipName;
if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1);
if (clipChanged) next.fadeIn(0.16);
if (isActorAnimationOneShot(state)) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = state === "death";
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
next.clampWhenFinished = false;
}
next.play();
activeClip.current = clipName;
activeState.current = state;
activeTrigger.current = trigger;
});
return (
@@ -269,6 +287,7 @@ function Arena() {
<octahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial />
</instancedMesh>
<ArenaWalls />
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
@@ -276,9 +295,32 @@ function Arena() {
);
}
function ArenaWalls() {
const walls = useRef<THREE.Group>(null);
useFrame(({ camera }) => {
if (!walls.current) return;
for (const child of walls.current.children) {
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
const cameraDistance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
material.opacity = THREE.MathUtils.smoothstep(cameraDistance, 2.5, 7.5) * 0.52 + 0.06;
}
});
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => (
<mesh key={index} position={position} rotation={[0, angle, 0]} receiveShadow>
<boxGeometry args={[3.86, 2.3, 0.18]} />
<meshStandardMaterial color="#263a34" roughness={0.9} transparent opacity={0.58} depthWrite={false} />
</mesh>
))}
</group>
);
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
useEffect(() => {
const start = useGameStore.getState().partyPositions[memberId];
group.current?.position.set(start[0], 0.025, start[1]);
@@ -302,6 +344,13 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
const attacking = state.phase === "combat"
&& visualAction !== null
&& visualAction.endsAt > state.time;
animationTrigger.current = member.hp <= 0
? 0
: knocked
? member.knockedUntil
: attacking
? visualAction.startedAt
: 0;
animationState.current = member.hp <= 0
? "death"
: knocked
@@ -327,7 +376,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId={memberId} animationState={animationState} />
<PartyCharacterModel memberId={memberId} animationState={animationState} animationTrigger={animationTrigger} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
@@ -341,6 +390,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
function PlayerCharacter() {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
const keys = useRef(new Set<string>());
const scenePulse = useGameStore((state) => state.scenePulse);
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
@@ -348,6 +398,7 @@ function PlayerCharacter() {
const { camera } = useThree();
const broadcastTimer = useRef(0);
const castingUntil = useRef(0);
const instantCastTrigger = useRef(0);
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
@@ -358,6 +409,7 @@ function PlayerCharacter() {
useEffect(() => {
if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
castingUntil.current = performance.now() + 700;
instantCastTrigger.current = scenePulse.id;
}
}, [scenePulse]);
@@ -371,8 +423,9 @@ function PlayerCharacter() {
const nudgeX = Number(key === "d") - Number(key === "a");
const nudgeZ = Number(key === "s") - Number(key === "w");
if (!nudgeX && !nudgeZ) return;
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + nudgeX * 0.18, -7.2, 7.2);
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + nudgeZ * 0.18, -4.8, 7.2);
const next = clampToArena([group.current.position.x + nudgeX * 0.18, group.current.position.z + nudgeZ * 0.18]);
group.current.position.x = next[0];
group.current.position.z = next[1];
setPlayerPosition([group.current.position.x, group.current.position.z]);
};
const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
@@ -400,9 +453,10 @@ function PlayerCharacter() {
}
const length = Math.hypot(inputX, inputZ);
if (length > 0.05) {
const speed = 4.6 * delta / Math.max(1, length);
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + inputX * speed, -7.2, 7.2);
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + inputZ * speed, -4.8, 7.2);
const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
const next = clampToArena([group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]);
group.current.position.x = next[0];
group.current.position.z = next[1];
group.current.rotation.y = Math.atan2(inputX, inputZ);
} else if (state.phase === "combat" && player.hp > 0 && !knocked) {
const boss = targetBossMotion(state).position;
@@ -413,6 +467,16 @@ function PlayerCharacter() {
);
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
}
const instantCasting = performance.now() < castingUntil.current;
animationTrigger.current = player.hp <= 0
? 0
: knocked
? player.knockedUntil
: state.activeCast
? state.activeCast.startedAt
: instantCasting
? instantCastTrigger.current
: 0;
animationState.current = player.hp <= 0
? "death"
: knocked
@@ -421,7 +485,7 @@ function PlayerCharacter() {
? "cast"
: length > 0.05
? "run"
: performance.now() < castingUntil.current
: instantCasting
? "cast"
: "idle";
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
@@ -439,7 +503,7 @@ function PlayerCharacter() {
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId="aelia" animationState={animationState} />
<PartyCharacterModel memberId="aelia" animationState={animationState} animationTrigger={animationTrigger} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
@@ -452,16 +516,15 @@ function PlayerCharacter() {
}
function Party() {
const party = useGameStore((state) => state.party);
const selected = useGameStore((state) => state.selectedMemberId);
return (
<>
<PlayerCharacter />
{party.slice(1).map((member) => (
{PARTY_MEMBER_IDS.map((memberId) => (
<Character
key={member.id}
memberId={member.id as Exclude<MemberId, "aelia">}
selected={selected === member.id}
key={memberId}
memberId={memberId}
selected={selected === memberId}
/>
))}
</>
@@ -491,7 +554,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "sandglass-scorpion" ? "#b78b32" : bossId === "ember-mantis-duelist" || bossId === "cinderback-ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
);
}
@@ -580,33 +643,217 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
);
}
type AlternateBossKind = "vexa" | "cindermaw";
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
const ALTERNATE_BOSS_CONFIG = {
vexa: {
url: SPIDER_URL,
scale: 0.022,
idle: "Spider_Armature|warte_pose",
move: "Spider_Armature|run_ani_vor",
attack: "Spider_Armature|Attack",
special: "Spider_Armature|Jump",
death: "Spider_Armature|die",
url: INSECT_QUEEN_URL,
scale: 9,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#bb67ff",
rotationOffset: Math.PI,
rotationOffset: 0,
prototype: true,
},
cindermaw: {
url: DRAGON_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Headbutt",
special: "Punch",
url: BLUE_EYES_WHITE_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8742",
rotationOffset: 0,
prototype: true,
},
"ember-mantis-duelist": {
url: GATE_GUARDIAN_URL,
scale: 4.3,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff5a24",
rotationOffset: 0,
prototype: true,
},
"obsidian-ram-golem": {
url: GANDORA_URL,
scale: 6.2,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff7438",
rotationOffset: 0,
prototype: true,
},
"cinderback-ricochet": {
url: RED_EYES_BLACK_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8b3d",
rotationOffset: 0,
prototype: true,
},
"sandglass-scorpion": {
url: SANDGLASS_URL,
scale: 0.7,
idle: "Idle",
move: "Burrow",
attack: "Eruption",
special: "Hourglass",
death: "Death",
light: "#e9b94f",
rotationOffset: 0,
},
"cragclaw-crab": {
url: CRAGCLAW_URL,
scale: 1.2,
idle: "Idle",
move: "Walk",
attack: "Bite_Front",
special: "Bite_InPlace",
death: "Death",
light: "#49d5df",
rotationOffset: 0,
},
"pumpking-king-of-ghosts": {
url: PUMPKING_URL,
scale: 1.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#d87842",
rotationOffset: 0,
prototype: true,
},
"blue-eyes-ultimate-dragon": {
url: BLUE_EYES_ULTIMATE_URL,
scale: 4.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#8fc8ff",
rotationOffset: 0,
prototype: true,
},
"mournveil-ghost": {
url: MOURNVEIL_URL,
scale: 1.1,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Punch",
special: "Headbutt",
death: "Death",
light: "#9d72ff",
rotationOffset: 0,
},
"crownshard-golem": {
url: CROWNSHARD_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Punch",
special: "Headbutt",
death: "Death",
light: "#e0bd45",
rotationOffset: 0,
},
} as const;
const PROTOTYPE_MOVE_MODES = [
"skyfall",
"mantis_sidestep",
"ram_charging",
"cinderback_ricochet",
] as const;
const PROTOTYPE_ATTACK_MODES = [
"tethering",
"venom_cast",
"breath_telegraph",
"breath_sweeping",
"mantis_line_telegraph",
"mantis_cross_telegraph",
"ram_charge_telegraph",
"ram_quake",
"ram_shatter",
"cinderback_curl",
"cinderback_slam",
"ghost_soul_cross",
"ghost_soul_cross_followup",
"ghost_haunting",
"golem_shockwave",
"golem_crownfall",
] as const;
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
const config = ALTERNATE_BOSS_CONFIG[kind];
if ("prototype" in config && config.prototype) {
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
return config.idle;
}
if (kind === "ember-mantis-duelist") {
if (motionMode === "mantis_sidestep") return config.move;
if (motionMode === "mantis_line_telegraph") return config.attack;
if (motionMode === "mantis_cross_telegraph") return config.special;
if (motionMode === "mantis_recover") return "Recover";
}
if (kind === "obsidian-ram-golem") {
if (motionMode === "ram_charge_telegraph" || motionMode === "ram_charging") return config.attack;
if (motionMode === "ram_quake") return config.special;
if (motionMode === "ram_shatter") return "ArmorShatter";
if (motionMode === "ram_recover") return "Stagger";
}
if (kind === "cinderback-ricochet") {
if (motionMode === "cinderback_curl") return config.attack;
if (motionMode === "cinderback_ricochet") return config.move;
if (motionMode === "cinderback_slam") return config.special;
if (motionMode === "cinderback_recover") return "Recover";
}
if (kind === "sandglass-scorpion") {
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
if (motionMode === "sandglass_eruption") return config.attack;
if (motionMode === "sandglass_hourglass") return config.special;
if (motionMode === "sandglass_recover") return "Stagger";
}
if (kind === "cragclaw-crab") {
if (motionMode === "crab_scuttling") return config.move;
if (motionMode === "crab_scuttle_telegraph") return config.attack;
if (motionMode === "crab_tidal_burst") return config.special;
}
if (kind === "mournveil-ghost") {
if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack;
if (motionMode === "ghost_haunting") return config.special;
}
if (kind === "crownshard-golem") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
}
if (kind === "cindermaw") {
if (motionMode === "skyfall") return config.move;
if (motionMode === "breath_telegraph" || motionMode === "breath_sweeping") return config.special;
}
if (kind === "vexa" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
return config.idle;
}
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind];
const phase = useGameStore((state) => state.phase);
@@ -614,7 +861,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const gltf = useGLTF(config.url, false, true, kind === "vexa" ? configureSpiderLoader : undefined);
const gltf = useGLTF(config.url, false, true);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
@@ -626,35 +873,29 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
object.receiveShadow = true;
}
});
if (kind === "vexa") {
const authoredHelperBox = model.getObjectByName("Box");
if (authoredHelperBox) authoredHelperBox.visible = false;
}
}, [kind, model]);
const clipName = phase === "victory" || defeated
? config.death
: motionMode === "skyfall"
? config.move
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
? config.special
: motionMode === "tethering" || motionMode === "venom_cast"
? config.attack
: config.idle;
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode);
useEffect(() => {
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).fadeIn(0.16).play();
if (phase === "victory" || defeated) {
const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
? 0.55
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
? 0.6
: 1;
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
if (phase === "victory" || defeated || authoredOneShot) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.16); };
}, [actions, clipName, defeated, phase]);
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
useFrame((_, delta) => {
if (!group.current) return;
@@ -663,7 +904,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
if (!current) return;
const motion = current.motion;
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
targetPosition.set(motion.position[0], airborne ? 3.2 : 0.03, motion.position[1]);
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
let targetAngle = Math.atan2(
@@ -672,6 +915,15 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
);
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
} else if (kind === "ember-mantis-duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
)) {
const target = state.partyPositions[motion.chargeTargetId];
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
} else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) {
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
}
const difference = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
@@ -856,19 +1108,104 @@ function RangedProjectiles() {
function BossActor() {
const phase = useGameStore((state) => state.phase);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const primaryBossId = useGameStore((state) => state.boss.id);
const additionalBossIds = useGameStore((state) => state.additionalBosses.map((entry) => entry.boss.id).join("|"));
if (phase === "briefing") return null;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const bossIds = additionalBossIds ? [primaryBossId, ...additionalBossIds.split("|")] : [primaryBossId];
return (
<>{bosses.map((boss, bossIndex) => (
<Suspense key={`${boss.id}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
{boss.id === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={boss.id} bossIndex={bossIndex} />}
<>{bossIds.map((bossId, bossIndex) => (
<Suspense key={`${bossId}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
{bossId === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={bossIndex} />}
</Suspense>
))}</>
);
}
type PerformanceMemory = Performance & {
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
};
function percentile(sorted: readonly number[], ratio: number) {
if (!sorted.length) return 0;
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
}
function PerformanceProbe() {
const { gl } = useThree();
const frameSamples = useRef<number[]>([]);
const longTaskCount = useRef(0);
const longTaskDuration = useRef(0);
const lastPublishAt = useRef(0);
useEffect(() => {
if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return;
let observer: PerformanceObserver | undefined;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTaskCount.current += 1;
longTaskDuration.current += entry.duration;
}
});
observer.observe({ type: "longtask", buffered: true });
} catch {
// Long Tasks API is optional on Android WebView implementations.
}
return () => {
observer?.disconnect();
delete document.documentElement.dataset.gamePerf;
};
}, []);
useFrame(({ clock }, delta) => {
if (!PERFORMANCE_PROBE_ENABLED) return;
const samples = frameSamples.current;
if (samples.length === 300) samples.shift();
samples.push(delta * 1000);
if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
lastPublishAt.current = clock.elapsedTime;
const sorted = [...samples].sort((left, right) => left - right);
let total = 0;
let overBudget = 0;
for (const duration of samples) {
total += duration;
if (duration > 16.67) overBudget += 1;
}
const memory = performance as PerformanceMemory;
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
let transferredBytes = 0;
let decodedBytes = 0;
for (const resource of resources) {
transferredBytes += resource.transferSize;
decodedBytes += resource.decodedBodySize;
}
document.documentElement.dataset.gamePerf = JSON.stringify({
frame: {
averageMs: total / samples.length,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
overBudget,
samples: samples.length,
},
renderer: {
calls: gl.info.render.calls,
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
},
simulation: simulationTickSnapshot(),
memory: memory.memory ? {
usedJSHeapSize: memory.memory.usedJSHeapSize,
totalJSHeapSize: memory.memory.totalJSHeapSize,
jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
} : null,
resources: { transferredBytes, decodedBytes, count: resources.length },
longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
});
});
return null;
}
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const ring = useRef<THREE.Mesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
@@ -877,7 +1214,7 @@ function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const state = useGameStore.getState();
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
useFrame((_, delta) => {
age.current += delta;
if (!ring.current || !material.current) return;
@@ -921,11 +1258,11 @@ export function GameScene() {
<BossActor />
<RangedProjectiles />
<CombatFx />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
</Canvas>
);
}
useGLTF.preload(BULL_URL, false, true);
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
useGLTF.preload(loadout.right, false, true);
+15 -3
View File
@@ -3,6 +3,7 @@ import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GameScene } from "./GameScene";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
function CompactParty() {
const party = useGameStore((state) => state.party);
@@ -106,12 +107,21 @@ function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
if (phase === "combat") return null;
const title = phase === "briefing" ? definitions.map((boss) => boss.title).join(" & ") : phase === "victory" ? `${bossNames} Broken` : "Party Broken";
const eyebrow = phase === "briefing" ? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial) : phase === "victory" ? "Encounter Complete" : "Encounter Failed";
const title = phase === "briefing"
? definitions.map((boss) => boss.title).join(" & ")
: phase === "victory"
? `${bossNames} Broken`
: "Party Broken";
const eyebrow = phase === "briefing"
? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial)
: phase === "victory"
? "Encounter Complete"
: "Encounter Failed";
const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
@@ -167,6 +177,8 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
export function TopScreen({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
@@ -175,7 +187,7 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>Objective</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<div className="objective-chip"><span>{runMode === "roguelike" ? `Round ${round}` : "Objective"}</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
+80 -6
View File
@@ -8,6 +8,11 @@ import { useGameStore } from "../../game/store";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
const DANGER_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
@@ -36,7 +41,7 @@ export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
0.045,
(motion.chargeStart[1] + motion.chargeEnd[1]) / 2,
];
const color = motionMode === "charging" ? "#ffb04a" : "#ff4f37";
const color = motionMode === "charging" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
const laneWidth = BULL_CHARGE.hitRadius * 2;
return (
<>
@@ -68,6 +73,59 @@ export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
);
}
function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
const material = useRef<THREE.MeshBasicMaterial>(null);
const edgeMaterial = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!material.current || !edgeMaterial.current) return;
const current = motionAt(useGameStore.getState(), bossIndex);
const active = current ? ACTIVE_LANE_MODES.has(current.mode) : false;
material.current.opacity = active ? 0.5 : 0.14 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.09;
edgeMaterial.current.opacity = active ? 1 : 0.62 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.15;
});
const lane = motion?.slashLanes.find((candidate) => candidate.id === laneId);
if (!lane || phase !== "combat") return null;
const dx = lane.end[0] - lane.start[0];
const dz = lane.end[1] - lane.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const midpoint: [number, number, number] = [
(lane.start[0] + lane.end[0]) * 0.5,
0.052,
(lane.start[1] + lane.end[1]) * 0.5,
];
const active = ACTIVE_LANE_MODES.has(motion.mode);
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
);
}
export function SlashLaneIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
const lanes = useGameStore((state) => motionAt(state, bossIndex)?.slashLanes ?? EMPTY_SLASH_LANES);
return <>{lanes.map((lane) => <SlashLaneIndicator key={lane.id} laneId={lane.id} bossIndex={bossIndex} />)}</>;
}
export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
@@ -151,7 +209,7 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
if (material.current) material.current.opacity = 0.2 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.07;
});
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
const color = motion.mode === "breath_sweeping" ? "#ff7b2e" : "#ffb04f";
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - CINDER_BREATH.halfAngle]}>
@@ -176,12 +234,27 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
});
if (!hazard) return null;
const active = time >= hazard.activatesAt;
const venom = hazard.kind === "venom_pool";
const color = venom ? "#a94ee6" : active ? "#ff642d" : "#ffc14d";
const colors = {
venom_pool: "#a94ee6",
skyfall: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
quake: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
lava_pool: "#ff5a24",
stinger_eruption: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
hourglass: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
tidal_burst: active ? DANGER_ACTIVE_COLOR : "#39c9df",
soul_rift: active ? "#7446d8" : "#aa78ff",
crownfall: active ? DANGER_ACTIVE_COLOR : "#e4c548",
royal_shockwave: active ? DANGER_ACTIVE_COLOR : "#f0ca4d",
} as const;
const color = colors[hazard.kind];
return (
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[hazard.radius, 40]} />
{hazard.innerRadius ? (
<ringGeometry args={[hazard.innerRadius, hazard.radius, 48]} />
) : (
<circleGeometry args={[hazard.radius, 40]} />
)}
<meshBasicMaterial ref={material} color={color} transparent opacity={active ? 0.24 : 0.16} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
@@ -191,7 +264,7 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
{!active && (
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.22, 0.34, 24]} />
<meshBasicMaterial color="#fff0b0" transparent opacity={0.95} depthWrite={false} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
</mesh>
)}
</group>
@@ -205,6 +278,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
ChargeLaneIndicator,
SlashLaneIndicators,
PounceStackIndicator,
BindingWebIndicator,
BreathConeIndicator,
+18 -5
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { MODE_COPY, selectRandomBoss, selectRandomBossPair } from "./data";
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
import { selectRandomBossPair } from "../game/roguelike";
import { BOSS_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
import { BOSS_ORDER } from "../game/bossCatalog";
describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => {
@@ -8,15 +11,25 @@ describe("game mode configuration", () => {
});
it("selects a boss across the full encounter pool", () => {
expect(selectRandomBoss(() => 0)).toBe("bulldrome");
expect(selectRandomBoss(() => 0.34)).toBe("vexa");
expect(selectRandomBoss(() => 0.99)).toBe("cindermaw");
for (let index = 0; index < BOSS_ORDER.length; index += 1) {
expect(selectRandomBoss(() => (index + 0.5) / BOSS_ORDER.length)).toBe(BOSS_ORDER[index]);
}
});
it("selects two distinct bosses for PVE", () => {
const values = [0, 0];
const pair = selectRandomBossPair(() => values.shift() ?? 0);
const pair = selectRandomBossPair([], () => values.shift() ?? 0);
expect(pair).toEqual(["bulldrome", "vexa"]);
expect(new Set(pair)).toHaveLength(2);
});
it("derives collection entries from canonical boss drop tables", () => {
const collections = buildCollections(createEmptyCollectionLog(), {});
expect(collections.map((boss) => boss.bossId)).toEqual(BOSS_ORDER);
for (const collection of collections) {
expect(collection.drops.map((drop) => drop.id)).toEqual(
BOSS_DROP_TABLES[collection.bossId as keyof typeof BOSS_DROP_TABLES].entries.map((drop) => drop.id),
);
}
});
});
+54 -52
View File
@@ -1,7 +1,15 @@
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
import { BOSS_ORDER } from "../game/bossCatalog";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { createClassInventory } from "../game/healers";
import type { BossId } from "../game/types";
import { createDefaultGearProgress } from "../game/progression/gear";
import {
BOSS_DROP_TABLES,
createEmptyCollectionLog,
type CollectionLog,
type LootRarity,
type MaterialStack,
} from "../game/progression/loot";
export const DEFAULT_SETTINGS: GameSettings = {
masterVolume: 80,
@@ -10,41 +18,39 @@ export const DEFAULT_SETTINGS: GameSettings = {
largeText: false,
};
export const DEFAULT_COLLECTIONS: BossCollection[] = [
{
bossId: "bulldrome",
bossName: "Bulldrome",
defeated: true,
drops: [
{ id: "bull-horn", name: "Cinder Horn", icon: "♜", rarity: "Common", count: 7 },
{ id: "bull-hide", name: "Ember Hide", icon: "▧", rarity: "Uncommon", count: 3 },
{ id: "bull-idol", name: "Vault Idol", icon: "◇", rarity: "Rare", count: 1 },
{ id: "bull-heart", name: "Furnace Heart", icon: "✦", rarity: "Mythic", count: 0 },
],
},
{
bossId: "vexa",
bossName: "Vexa",
defeated: false,
drops: [
{ id: "vexa-silk", name: "Living Silk", icon: "⌁", rarity: "Common", count: 0 },
{ id: "vexa-venom", name: "Widow Venom", icon: "✣", rarity: "Uncommon", count: 0 },
{ id: "vexa-eye", name: "Loom Eye", icon: "◉", rarity: "Rare", count: 0 },
{ id: "vexa-heart", name: "Webmother Heart", icon: "✦", rarity: "Mythic", count: 0 },
],
},
{
bossId: "cindermaw",
bossName: "Cindermaw",
defeated: true,
drops: [
{ id: "maw-scale", name: "Soot Scale", icon: "◈", rarity: "Common", count: 4 },
{ id: "maw-gland", name: "Mending Gland", icon: "+", rarity: "Uncommon", count: 2 },
{ id: "maw-crest", name: "Ashen Crest", icon: "⌁", rarity: "Rare", count: 0 },
{ id: "maw-breath", name: "Bottled Breath", icon: "☀", rarity: "Mythic", count: 0 },
],
},
];
const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"]> = {
common: "Common",
uncommon: "Uncommon",
rare: "Rare",
epic: "Epic",
legendary: "Legendary",
};
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossCollection[] {
return BOSS_ORDER.map((bossId) => {
const table = BOSS_DROP_TABLES[bossId];
return {
bossId,
bossName: BOSS_DEFINITIONS[bossId].name,
defeated: (bossKills[bossId] ?? 0) > 0,
drops: table.entries.map((drop) => ({
id: drop.id,
name: drop.name,
icon: drop.glyph,
rarity: RARITY_LABELS[drop.rarity],
count: drop.kind === "coin"
? collectionLog.dropsFound[drop.id] ?? 0
: collectionLog.petsFound[drop.id] ?? 0,
chance: drop.chanceLabel,
itemLevel: drop.kind === "coin" ? drop.itemLevel : undefined,
kind: drop.kind,
})),
};
});
}
export const DEFAULT_COLLECTION_LOG: CollectionLog = createEmptyCollectionLog();
export const DEFAULT_COLLECTIONS: BossCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
"roguelike-pve": {
@@ -58,7 +64,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
eyebrow: "14 hunters · chosen encounter",
title: "Dungeons",
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
detail: "Bulldrome · Vexa · Cindermaw",
detail: "Ten prototype guardians available",
status: "Playable now",
},
"roguelike-pvp": {
@@ -81,12 +87,6 @@ export function selectRandomBoss(random: () => number = Math.random): BossId {
return BOSS_ORDER[Math.floor(random() * BOSS_ORDER.length)] ?? BOSS_ORDER[0];
}
export function selectRandomBossPair(random: () => number = Math.random): readonly [BossId, BossId] {
const firstIndex = Math.floor(random() * BOSS_ORDER.length) % BOSS_ORDER.length;
const secondOffset = 1 + Math.floor(random() * (BOSS_ORDER.length - 1));
return [BOSS_ORDER[firstIndex], BOSS_ORDER[(firstIndex + secondOffset) % BOSS_ORDER.length]];
}
export const MAX_HUNTER_NAME_LENGTH = 20;
export function normalizeHunterName(value: string): string {
@@ -101,25 +101,27 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
const normalizedName = normalizeHunterName(hunterName);
if (!normalizedName) throw new Error("Hunter name is required.");
return {
schemaVersion: 2,
schemaVersion: 4,
slotId,
hunterName: normalizedName,
activeClassId: "priest",
healers: {
priest: { level: 12, inventory: createClassInventory("priest") },
priest: { level: 1, inventory: createClassInventory("priest") },
druid: { level: 1, inventory: createClassInventory("druid") },
shaman: { level: 1, inventory: createClassInventory("shaman") },
},
location: "Ember Vault Approach",
playSeconds: 8 * 60 * 60 + 42 * 60,
playSeconds: 0,
updatedAt: now,
stats: {
totalBossKills: 16,
flawlessClears: 5,
alliesSaved: 143,
healingDone: 284_650,
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4 },
totalBossKills: 0,
flawlessClears: 0,
alliesSaved: 0,
healingDone: 0,
bossKills: {},
},
collections: structuredClone(DEFAULT_COLLECTIONS),
materials: [] as MaterialStack[],
collectionLog: createEmptyCollectionLog(),
gearProgress: createDefaultGearProgress(),
};
}
+30 -9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { SaveRepository, type StorageAdapter } from "./saveRepository";
import { buildCollections, DEFAULT_COLLECTIONS } from "./data";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
@@ -50,11 +51,11 @@ describe("SaveRepository", () => {
}));
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(40);
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(12);
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(1);
now = "2026-07-10T14:00:00.000Z";
repository.download(1, "healer@example.com");
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(12);
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1);
expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now);
});
@@ -85,7 +86,7 @@ describe("SaveRepository", () => {
expect(save.hunterName).toBe("Aelia");
expect(save.activeClassId).toBe("druid");
expect(save.healers.druid.level).toBe(8);
expect(save.healers.priest.level).toBe(12);
expect(save.healers.priest.level).toBe(1);
expect(save.healers.druid.inventory).toHaveLength(4);
expect(save.healers.priest.inventory).toHaveLength(4);
});
@@ -100,7 +101,7 @@ describe("SaveRepository", () => {
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.list(null)[0].local!;
expect(migrated.schemaVersion).toBe(2);
expect(migrated.schemaVersion).toBe(4);
expect(migrated.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest");
expect(migrated.healers.priest.level).toBe(27);
@@ -108,15 +109,35 @@ describe("SaveRepository", () => {
expect(migrated.healers.shaman.inventory.length).toBeGreaterThan(0);
});
it("adds newly shipped bosses to existing schema v2 collection logs", () => {
it("derives newly shipped bosses from drop tables after migrating schema v2 collections", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Veteran");
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({
1: { ...created, collections: created.collections.filter((boss) => boss.bossId !== "vexa") },
}));
const legacy = { ...created, schemaVersion: 2, collections: DEFAULT_COLLECTIONS.filter((boss) => boss.bossId !== "vexa") } as Record<string, unknown>;
delete legacy.collectionLog;
delete legacy.materials;
delete legacy.gearProgress;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.list(null)[0].local!;
expect(migrated.collections.some((boss) => boss.bossId === "vexa")).toBe(true);
expect(buildCollections(migrated.collectionLog, migrated.stats.bossKills).some((boss) => boss.bossId === "vexa")).toBe(true);
});
it("migrates valid infusion choices and discards stale ids", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Infused");
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
created.gearProgress.brann.passiveInfusionId = "deep-wells";
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 3 } }));
const migrated = repository.list(null)[0].local!;
expect(migrated.schemaVersion).toBe(4);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull();
});
});
+121 -40
View File
@@ -1,7 +1,11 @@
import { createHunterSave, DEFAULT_COLLECTIONS } from "./data";
import { createHunterSave } from "./data";
import { createClassInventory } from "../game/healers";
import type { HealerClassId } from "../game/types";
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear";
import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions";
import { BOSS_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type MaterialStack } from "../game/progression/loot";
import type { BossId, HealerClassId } from "../game/types";
import type { BossCollection, HunterSave, SaveSlotId, SaveSlotState } from "./types";
export interface StorageAdapter {
getItem(key: string): string | null;
@@ -29,54 +33,131 @@ function browserStorage(): StorageAdapter {
return fallbackStorage;
}
interface LegacyHunterSave extends Omit<HunterSave, "schemaVersion" | "activeClassId" | "healers"> {
schemaVersion: 1;
level: number;
interface LegacyHunterSave {
schemaVersion?: number;
slotId?: SaveSlotId;
hunterName?: string;
activeClassId?: HealerClassId;
healers?: HunterSave["healers"];
level?: number;
location?: string;
playSeconds?: number;
updatedAt?: string;
stats?: HunterSave["stats"];
collections?: BossCollection[];
materials?: MaterialStack[];
collectionLog?: CollectionLog;
gearProgress?: GearProgress;
}
const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"];
function normalizeCollections(collections: HunterSave["collections"] | undefined) {
const source = collections ?? [];
const knownIds = new Set(DEFAULT_COLLECTIONS.map((boss) => boss.bossId));
const current = DEFAULT_COLLECTIONS.map((fallback) => source.find((boss) => boss.bossId === fallback.bossId) ?? structuredClone(fallback));
return [...current, ...source.filter((boss) => !knownIds.has(boss.bossId))];
function positiveCounts(value: unknown): Record<string, number> {
if (!value || typeof value !== "object") return {};
return Object.fromEntries(Object.entries(value as Record<string, unknown>).flatMap(([id, rawQuantity]) => {
const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0));
return quantity > 0 ? [[id, quantity]] : [];
}));
}
function normalizeCollectionLog(candidate: LegacyHunterSave): CollectionLog {
if (candidate.collectionLog) {
return {
dropsFound: positiveCounts(candidate.collectionLog.dropsFound),
petsFound: positiveCounts(candidate.collectionLog.petsFound),
};
}
const result = createEmptyCollectionLog();
for (const legacyBoss of candidate.collections ?? []) {
if (!BOSS_ORDER.includes(legacyBoss.bossId as BossId)) continue;
const bossId = legacyBoss.bossId as BossId;
const quantity = legacyBoss.drops.reduce((sum, drop) => sum + Math.max(0, Math.floor(drop.count || 0)), 0);
if (quantity > 0) result.dropsFound[BOSS_DROP_TABLES[bossId].coins.initiate.id] = quantity;
}
return result;
}
function knownMaterial(id: string) {
for (const bossId of BOSS_ORDER) {
const coin = Object.values(BOSS_DROP_TABLES[bossId].coins).find((candidate) => candidate.id === id);
if (coin) return coin;
}
return undefined;
}
function normalizeMaterials(value: unknown, collectionLog: CollectionLog): MaterialStack[] {
const quantities = new Map<string, number>();
if (Array.isArray(value)) {
for (const raw of value) {
if (!raw || typeof raw !== "object") continue;
const item = raw as Partial<MaterialStack>;
if (!item.id) continue;
const quantity = Math.max(0, Math.floor(Number(item.quantity) || 0));
if (quantity > 0) quantities.set(item.id, (quantities.get(item.id) ?? 0) + quantity);
}
} else {
for (const [id, quantity] of Object.entries(collectionLog.dropsFound)) quantities.set(id, quantity);
}
return [...quantities].flatMap(([id, quantity]) => {
const coin = knownMaterial(id);
return coin ? [{ id, quantity, name: coin.name, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }] : [];
});
}
function normalizeGearProgress(value: unknown): GearProgress {
const defaults = createDefaultGearProgress();
if (!value || typeof value !== "object") return defaults;
const candidate = value as Partial<GearProgress>;
for (const ownerId of GEAR_OWNER_ORDER) {
for (const slotId of GEAR_SLOT_ORDER) {
const level = Math.max(0, Math.min(MAX_GEAR_LEVEL, Math.floor(Number(candidate[ownerId]?.slots?.[slotId]?.level) || 0)));
defaults[ownerId].slots[slotId].level = level as GearProgress[typeof ownerId]["slots"][typeof slotId]["level"];
}
defaults[ownerId].infusionAbilityId = normalizeActiveInfusionId(ownerId, candidate[ownerId]?.infusionAbilityId);
defaults[ownerId].passiveInfusionId = normalizePassiveInfusionId(ownerId, candidate[ownerId]?.passiveInfusionId);
}
return defaults;
}
function normalizeBossKills(value: unknown): Record<string, number> {
const source = positiveCounts(value);
const result: Record<string, number> = {};
for (const [key, quantity] of Object.entries(source)) {
const bossId = BOSS_ORDER.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
result[bossId ?? key] = (result[bossId ?? key] ?? 0) + quantity;
}
return result;
}
function normalizeSave(value: unknown): HunterSave | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<HunterSave> & Partial<LegacyHunterSave>;
const candidate = value as LegacyHunterSave;
if (!candidate.slotId || !candidate.hunterName) return null;
if (candidate.schemaVersion === 2 && candidate.healers) {
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
return {
...(candidate as HunterSave),
activeClassId,
collections: normalizeCollections(candidate.collections),
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
level: Math.max(1, candidate.healers?.[classId]?.level ?? 1),
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
}])) as HunterSave["healers"],
};
}
const legacy = candidate as LegacyHunterSave;
const collectionLog = normalizeCollectionLog(candidate);
const bossKills = normalizeBossKills(candidate.stats?.bossKills);
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
return {
schemaVersion: 2,
slotId: legacy.slotId,
hunterName: legacy.hunterName,
activeClassId: "priest",
healers: {
priest: { level: Math.max(1, legacy.level || 1), inventory: createClassInventory("priest") },
druid: { level: 1, inventory: createClassInventory("druid") },
shaman: { level: 1, inventory: createClassInventory("shaman") },
schemaVersion: 4,
slotId: candidate.slotId,
hunterName: candidate.hunterName,
activeClassId,
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
level: Math.max(1, candidate.healers?.[classId]?.level ?? (classId === "priest" ? candidate.level ?? 1 : 1)),
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
}])) as HunterSave["healers"],
location: candidate.location ?? "Ember Vault Approach",
playSeconds: Math.max(0, candidate.playSeconds ?? 0),
updatedAt: candidate.updatedAt ?? new Date(0).toISOString(),
stats: {
totalBossKills: Math.max(0, candidate.stats?.totalBossKills ?? Object.values(bossKills).reduce((sum, count) => sum + count, 0)),
flawlessClears: Math.max(0, candidate.stats?.flawlessClears ?? 0),
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
bossKills,
},
location: legacy.location,
playSeconds: legacy.playSeconds,
updatedAt: legacy.updatedAt,
stats: legacy.stats,
collections: normalizeCollections(legacy.collections),
materials: normalizeMaterials(candidate.materials, collectionLog),
collectionLog,
gearProgress: normalizeGearProgress(candidate.gearProgress),
};
}
+136 -9
View File
@@ -4,6 +4,14 @@ import { SaveRepository } from "./saveRepository";
import { AccountRepository, type AccountResult } from "./accountRepository";
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
import {
equipActiveInfusion,
equipPassiveInfusion,
infusionsForOwner,
} from "../game/progression/infusions";
import type { RunBuffId } from "../game/types";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
const repository = new SaveRepository();
const accounts = new AccountRepository();
@@ -46,6 +54,12 @@ export interface FrontendState {
activeSlotId: SaveSlotId | null;
selectedMode: GameModeId;
selectedBossId: BossId;
selectedDifficultySlug: DifficultySlug;
selectedGearOwnerId: GearOwnerId;
selectedGearSlotId: GearSlotId;
gearWorkshopMode: "upgrade" | "infusion";
selectedInfusionId: string;
recentRewards: BossRewardAward[];
settings: GameSettings;
notice: string;
signIn: (username: string, password: string) => Promise<boolean>;
@@ -62,11 +76,20 @@ export interface FrontendState {
downloadSlot: (slotId: SaveSlotId) => void;
selectMode: (mode: GameModeId) => void;
selectBoss: (bossId: BossId) => void;
selectDifficulty: (difficultySlug: DifficultySlug) => void;
selectGearOwner: (ownerId: GearOwnerId) => void;
selectGearSlot: (slotId: GearSlotId) => void;
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
selectInfusion: (infusionId: string) => void;
upgradeSelectedGear: () => boolean;
equipSelectedInfusion: () => boolean;
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
selectHealerClass: (classId: HealerClassId) => void;
updateActiveHealerInventory: (inventory: InventoryItem[]) => void;
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
touchActiveSave: () => void;
recordBossVictory: (bossName: string) => void;
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
clearRecentRewards: () => void;
clearNotice: () => void;
}
@@ -82,6 +105,12 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
activeSlotId: null,
selectedMode: "roguelike-pve",
selectedBossId: "bulldrome",
selectedDifficultySlug: "initiate",
selectedGearOwnerId: "priest",
selectedGearSlotId: "weapon",
gearWorkshopMode: "upgrade",
selectedInfusionId: infusionsForOwner("priest")[0].id,
recentRewards: [],
settings: loadSettings(),
notice: "",
@@ -149,12 +178,84 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
},
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
selectDifficulty: (selectedDifficultySlug) => set({ selectedDifficultySlug: normalizeDifficultySlug(selectedDifficultySlug), notice: "" }),
selectGearOwner: (selectedGearOwnerId) => set({
selectedGearOwnerId,
selectedInfusionId: infusionsForOwner(selectedGearOwnerId)[0].id,
notice: "",
}),
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
upgradeSelectedGear: () => {
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
if (!activeSlotId) return false;
let message = "Gear upgrade failed.";
let upgraded = false;
repository.updateLocal(activeSlotId, (save) => {
try {
const result = upgradeGearSlot(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId);
upgraded = true;
message = `${selectedGearOwnerId} ${selectedGearSlotId} upgraded to +${result.gearProgress[selectedGearOwnerId].slots[selectedGearSlotId].level}.`;
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
} catch (error) {
message = error instanceof Error ? error.message : message;
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
return upgraded;
},
equipSelectedInfusion: () => {
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId } = get();
if (!activeSlotId) return false;
let message = "Infusion failed.";
let equipped = false;
repository.updateLocal(activeSlotId, (save) => {
try {
const wasEquipped = save.gearProgress[selectedGearOwnerId].infusionAbilityId === selectedInfusionId;
const result = equipActiveInfusion(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId);
equipped = true;
message = wasEquipped ? "Infusion already equipped." : `${selectedGearOwnerId} infusion equipped.`;
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
} catch (error) {
message = error instanceof Error ? error.message : message;
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
return equipped;
},
equipPassiveInfusion: (passiveId) => {
const { activeSlotId, accountId, selectedGearOwnerId } = get();
if (!activeSlotId) return false;
let message = "Passive infusion failed.";
let equipped = false;
repository.updateLocal(activeSlotId, (save) => {
try {
const gearProgress = equipPassiveInfusion(save.gearProgress, selectedGearOwnerId, passiveId);
equipped = true;
message = "Passive infusion equipped. Applies next encounter.";
return { ...save, gearProgress };
} catch (error) {
message = error instanceof Error ? error.message : message;
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
return equipped;
},
selectHealerClass: (classId) => {
const { activeSlotId, accountId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
if (!updated) return;
set({ slots: repository.list(accountId), notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.` });
set({
slots: repository.list(accountId),
selectedGearOwnerId: classId,
selectedInfusionId: infusionsForOwner(classId)[0].id,
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
});
},
updateActiveHealerInventory: (inventory) => {
const { activeSlotId, accountId } = get();
@@ -179,21 +280,29 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
repository.touch(activeSlotId);
set({ slots: repository.list(accountId) });
},
recordBossVictory: (bossName) => {
recordBossVictory: (bossId, difficultySlug) => {
const { activeSlotId, accountId } = get();
if (!activeSlotId) return;
if (!activeSlotId) return null;
let awarded: BossRewardAward | null = null;
repository.updateLocal(activeSlotId, (save) => {
const bossKills = { ...save.stats.bossKills, [bossName]: (save.stats.bossKills[bossName] ?? 0) + 1 };
const reward = rollBossReward(bossId, difficultySlug, save.materials, save.collectionLog);
awarded = reward.award;
const bossKills = { ...save.stats.bossKills, [bossId]: (save.stats.bossKills[bossId] ?? 0) + 1 };
return {
...save,
stats: { ...save.stats, totalBossKills: save.stats.totalBossKills + 1, flawlessClears: save.stats.flawlessClears + 1, bossKills },
collections: save.collections.map((boss) => boss.bossName === bossName
? { ...boss, defeated: true, drops: boss.drops.map((drop, index) => index === 0 ? { ...drop, count: drop.count + 1 } : drop) }
: boss),
materials: reward.inventory,
collectionLog: reward.collectionLog,
};
});
set({ slots: repository.list(accountId), notice: `${bossName} clear saved offline.` });
set((state) => ({
slots: repository.list(accountId),
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
notice: awarded ? `${awarded.coin.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
}));
return awarded;
},
clearRecentRewards: () => set({ recentRewards: [] }),
clearNotice: () => set({ notice: "" }),
}));
@@ -212,11 +321,20 @@ export type FrontendSnapshot = Omit<FrontendState,
| "downloadSlot"
| "selectMode"
| "selectBoss"
| "selectDifficulty"
| "selectGearOwner"
| "selectGearSlot"
| "selectGearWorkshopMode"
| "selectInfusion"
| "upgradeSelectedGear"
| "equipSelectedInfusion"
| "equipPassiveInfusion"
| "selectHealerClass"
| "updateActiveHealerInventory"
| "updateSetting"
| "touchActiveSave"
| "recordBossVictory"
| "clearRecentRewards"
| "clearNotice"
>;
@@ -236,11 +354,20 @@ export function getFrontendSnapshot(): FrontendSnapshot {
downloadSlot: _downloadSlot,
selectMode: _selectMode,
selectBoss: _selectBoss,
selectDifficulty: _selectDifficulty,
selectGearOwner: _selectGearOwner,
selectGearSlot: _selectGearSlot,
selectGearWorkshopMode: _selectGearWorkshopMode,
selectInfusion: _selectInfusion,
upgradeSelectedGear: _upgradeSelectedGear,
equipSelectedInfusion: _equipSelectedInfusion,
equipPassiveInfusion: _equipPassiveInfusion,
selectHealerClass: _selectHealerClass,
updateActiveHealerInventory: _updateActiveHealerInventory,
updateSetting: _updateSetting,
touchActiveSave: _touchActiveSave,
recordBossVictory: _recordBossVictory,
clearRecentRewards: _clearRecentRewards,
clearNotice: _clearNotice,
...snapshot
} = useFrontendStore.getState();
+11 -4
View File
@@ -1,15 +1,20 @@
import type { HealerClassId, InventoryItem } from "../game/types";
import type { GearProgress } from "../game/progression/gear";
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
export type SaveSlotId = 1 | 2 | 3;
export type AppScreen = "login" | "saves" | "home" | "profile" | "settings" | "mode" | "game";
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
export type GameModeId = "roguelike-pve" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
export interface CollectionDrop {
id: string;
name: string;
icon: string;
rarity: "Common" | "Uncommon" | "Rare" | "Mythic";
rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary";
count: number;
chance: string;
itemLevel?: number;
kind: "coin" | "pet";
}
export interface BossCollection {
@@ -33,7 +38,7 @@ export interface HealerProgress {
}
export interface HunterSave {
schemaVersion: 2;
schemaVersion: 4;
slotId: SaveSlotId;
hunterName: string;
activeClassId: HealerClassId;
@@ -42,7 +47,9 @@ export interface HunterSave {
playSeconds: number;
updatedAt: string;
stats: HunterStats;
collections: BossCollection[];
materials: MaterialStack[];
collectionLog: CollectionLog;
gearProgress: GearProgress;
}
export interface SaveSlotState {
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { isActorAnimationOneShot, shouldStartActorAnimation } from "./actorAnimation";
describe("actor animation playback", () => {
it("restarts a repeated attack when a new combat action begins", () => {
expect(shouldStartActorAnimation("attack", 4.2, "attack", 5.3)).toBe(true);
});
it("does not restart the same combat action every frame", () => {
expect(shouldStartActorAnimation("attack", 4.2, "attack", 4.2)).toBe(false);
});
it("keeps looping locomotion stable while its trigger changes", () => {
expect(shouldStartActorAnimation("walk", 0, "walk", 1)).toBe(false);
expect(shouldStartActorAnimation("run", 0, "run", 1)).toBe(false);
});
it("starts when the actor changes animation state", () => {
expect(shouldStartActorAnimation("attack", 4.2, "walk", 0)).toBe(true);
expect(isActorAnimationOneShot("death")).toBe(true);
});
});
+16
View File
@@ -0,0 +1,16 @@
export type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
export function isActorAnimationOneShot(state: ActorAnimationState) {
return state === "attack" || state === "cast" || state === "hit" || state === "death";
}
export function shouldStartActorAnimation(
activeState: ActorAnimationState | undefined,
activeTrigger: number,
nextState: ActorAnimationState,
nextTrigger: number,
) {
if (activeState !== nextState) return true;
if (nextState === "death" || !isActorAnimationOneShot(nextState)) return false;
return activeTrigger !== nextTrigger;
}
+29
View File
@@ -0,0 +1,29 @@
import type { BossMotionState, WorldPosition } from "./types";
export const ARENA_CENTER: WorldPosition = [0, -1];
export const ARENA_RADIUS = 8.35;
export const ARENA_WALL_RADIUS = 9.55;
export function clampToArena(position: WorldPosition, padding = 0): WorldPosition {
const radius = Math.max(0, ARENA_RADIUS - padding);
const dx = position[0] - ARENA_CENTER[0];
const dz = position[1] - ARENA_CENTER[1];
const distance = Math.hypot(dx, dz);
if (distance <= radius || distance < 0.0001) return [position[0], position[1]];
const scale = radius / distance;
return [ARENA_CENTER[0] + dx * scale, ARENA_CENTER[1] + dz * scale];
}
export function constrainBossMotion(motion: BossMotionState): BossMotionState {
return {
...motion,
position: clampToArena(motion.position),
chargeStart: clampToArena(motion.chargeStart),
chargeEnd: clampToArena(motion.chargeEnd),
pounceCenter: clampToArena(motion.pounceCenter),
};
}
export function isInsideArena(position: WorldPosition, tolerance = 0.001) {
return Math.hypot(position[0] - ARENA_CENTER[0], position[1] - ARENA_CENTER[1]) <= ARENA_RADIUS + tolerance;
}
+4 -5
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { BOSS_ORDER } from "./bossCatalog";
import { useGameStore } from "./store";
import type { BossId } from "./types";
@@ -25,11 +26,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
}
describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"],
["bulldrome", "cindermaw"],
["vexa", "cindermaw"],
];
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
);
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
const result = simulateControlledBattle([first, second]);
+167 -19
View File
@@ -16,7 +16,20 @@ export interface BossDefinition {
maxHp: number;
}
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw"];
export const BOSS_ORDER: readonly BossId[] = [
"bulldrome",
"vexa",
"cindermaw",
"ember-mantis-duelist",
"obsidian-ram-golem",
"cinderback-ricochet",
"sandglass-scorpion",
"cragclaw-crab",
"pumpking-king-of-ghosts",
"blue-eyes-ultimate-dragon",
"mournveil-ghost",
"crownshard-golem",
];
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
bulldrome: {
@@ -36,32 +49,167 @@ export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
},
vexa: {
id: "vexa",
name: "Vexa",
title: "The Webmother",
trial: "Trial II · Tangled Remedy",
name: "Insect Queen",
title: "The Hive Sovereign",
trial: "Trial II · Royal Brood",
icon: "✣",
accent: "#b56cff",
summary: "Binds allies together and weaponizes every cleanse.",
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its pool poisons the formation.",
failure: "Break purple tethers quickly. Cleanse venom only after its target reaches open ground.",
mapTitle: "The Tangled Loom",
mapCopy: "Spread tethered allies toward opposite edges. Keep dropped venom pools away from the center lane.",
mechanics: ["Binding Web", "Venom Purge"],
summary: "Pins prey with silk before flooding safe ground with venom.",
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its brood pool poisons formation.",
failure: "Break royal tethers quickly. Cleanse venom only after its target reaches open ground.",
mapTitle: "The Royal Hive",
mapCopy: "Spread tethered allies toward opposite edges. Keep venom brood pools away from center.",
mechanics: ["Binding Web", "Venom Brood"],
maxHp: 535,
},
cindermaw: {
id: "cindermaw",
name: "Cindermaw",
title: "The Sky Tyrant",
trial: "Trial III · Ashen Orbit",
name: "Blue-Eyes White Dragon",
title: "The White Lightning",
trial: "Trial III · Burststream Orbit",
icon: "◆",
accent: "#ff9b45",
summary: "Sweeps the arena with flame and removes safe ground.",
briefing: "Rotate behind Searing Sweep. During Skyfall, leave each numbered impact circle before it becomes persistent fire.",
failure: "Follow the safe side of the breath cone. Keep moving as Skyfall removes sections of the arena.",
mapTitle: "The Ashen Crown",
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between Skyfall impacts.",
mechanics: ["Searing Sweep", "Skyfall"],
summary: "Sweeps the arena with white lightning and dives through targeted ground.",
briefing: "Rotate behind Burst Stream. During White Skyfall, leave each numbered impact before it becomes charged ground.",
failure: "Follow the safe side of the breath cone. Keep moving as White Skyfall removes sections of arena.",
mapTitle: "The Ivory Crown",
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between skyfall impacts.",
mechanics: ["Burst Stream", "White Skyfall"],
maxHp: 410,
},
"ember-mantis-duelist": {
id: "ember-mantis-duelist",
name: "Gate Guardian",
title: "The Tri-Element Sentinel",
trial: "Trial IV · Elements in Motion",
icon: "⚔",
accent: "#ff6a2a",
summary: "Repositions its stacked body before firing single and crossed elemental lanes.",
briefing: "Track each sidestep. Clear Elemental Beam, then find a safe quadrant when three powers form Guardian Cross.",
failure: "Do not chase the guardian through a telegraph. Preserve space and move perpendicular to each beam lane.",
mapTitle: "The Sealed Gate",
mapCopy: "Follow the guardian laterally, but cross glowing lanes only after its arms finish firing.",
mechanics: ["Elemental Beam", "Guardian Cross"],
maxHp: 520,
},
"obsidian-ram-golem": {
id: "obsidian-ram-golem",
name: "Gandora the Dragon of Destruction",
title: "The Ruin Orb",
trial: "Trial V · Destruction March",
icon: "♞",
accent: "#ff7a38",
summary: "Breaks formation with armored rushes, ruin quakes, and radial destruction beams.",
briefing: "Clear Destruction Rush, leave Ruin Quake, then step between Gandora's radial destruction lines.",
failure: "Do not remain in front of the armored dragon. Treat every glowing orb as an active strike lane.",
mapTitle: "The Ruined Causeway",
mapCopy: "Hold open flanks for Destruction Rush. Spread between radial fractures when its armor vents.",
mechanics: ["Destruction Rush", "Ruin Quake"],
maxHp: 540,
},
"cinderback-ricochet": {
id: "cinderback-ricochet",
name: "Red-Eyes Black Dragon",
title: "The Black Flare",
trial: "Trial VI · Inferno Rebound",
icon: "⬢",
accent: "#ff9345",
summary: "Rebounds through marked flight lanes and leaves black-flame impact pools.",
briefing: "Clear both Inferno Rush lanes. Meteor Slam blooms into black-flame pools around its landing zone.",
failure: "Watch the second rebound before returning to formation. Preserve a clean route around black flame.",
mapTitle: "The Inferno Circuit",
mapCopy: "Bait the dragon along arena edges. Never cross a marked flight lane before second impact.",
mechanics: ["Inferno Rush", "Meteor Slam"],
maxHp: 505,
},
"sandglass-scorpion": {
id: "sandglass-scorpion",
name: "Sandglass Scorpion",
title: "The Dune Chronarch",
trial: "Trial VII · Hour of Venom",
icon: "⌛",
accent: "#e9b94f",
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.",
briefing: "Cross the Burrow Rush lane before it dives. Leave Stinger Eruptions, then outrun the active Hourglass zone.",
failure: "Move before each timer completes. Sand warnings become damaging ground the instant they fill.",
mapTitle: "The Sunken Hour",
mapCopy: "Keep the center open. Burrow paths split formation while hourglass zones close escape routes.",
mechanics: ["Burrow Rush", "Hourglass Eruption"],
maxHp: 515,
},
"cragclaw-crab": {
id: "cragclaw-crab",
name: "Cragclaw",
title: "The Breakwater Tyrant",
trial: "Trial VIII · Tide in the Claws",
icon: "♋",
accent: "#49c7d4",
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.",
briefing: "Clear Sidewinder Rush, then leave every Crushing Tide circle before the claws close.",
failure: "Cross the scuttle lane only after Cragclaw passes. Spread targeted circles away from formation.",
mapTitle: "The Drowned Breakwater",
mapCopy: "Keep open water between party lanes. Tidal marks punish overlapping escape routes.",
mechanics: ["Sidewinder Rush", "Crushing Tide"],
maxHp: 505,
},
"pumpking-king-of-ghosts": {
id: "pumpking-king-of-ghosts",
name: "Pumpking the King of Ghosts",
title: "The Haunted Harvest",
trial: "Trial XI · Vines Unbound",
icon: "♚",
accent: "#d87842",
summary: "Whips the arena twice with spectral vines and grows hungry rifts beneath allies.",
briefing: "Dodge both Vine Scissor patterns. Carry Haunting Rifts away before they sprout.",
failure: "The second vine cross rotates. Do not return to the first safe quadrant early.",
mapTitle: "The Haunted Patch",
mapCopy: "Read both crossing vine patterns, then preserve clear ground for persistent ghost rifts.",
mechanics: ["Vine Scissors", "Haunting Rifts"],
maxHp: 505,
},
"blue-eyes-ultimate-dragon": {
id: "blue-eyes-ultimate-dragon",
name: "Blue-Eyes Ultimate Dragon",
title: "The Three-Headed Tyrant",
trial: "Trial XII · Ultimate Evolution",
icon: "♕",
accent: "#8fc8ff",
summary: "Three heads fire expanding burst rings before marking allies for ultimate skyfall.",
briefing: "Move through each Tri-Burst ring, then spread targeted Ultimate Skyfall circles.",
failure: "Three shockwaves expand in sequence. Commit to each safe band before the next head fires.",
mapTitle: "The Ultimate Aerie",
mapCopy: "Follow expanding safe bands. Spread triple-head skyfall marks toward separate arena edges.",
mechanics: ["Tri-Burst Rings", "Ultimate Skyfall"],
maxHp: 500,
},
"mournveil-ghost": {
id: "mournveil-ghost",
name: "Mournveil",
title: "The Hollow Choir",
trial: "Trial IX · Echoes Unbound",
icon: "◉",
accent: "#9d72ff",
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.",
briefing: "Dodge both Soul Scissor patterns. Carry Haunting Rifts away before they open.",
failure: "The second spectral cross rotates. Do not return to the first safe quadrant early.",
mapTitle: "The Silent Reliquary",
mapCopy: "Read both crossing patterns, then preserve clear ground for persistent soul rifts.",
mechanics: ["Soul Scissors", "Haunting Rifts"],
maxHp: 505,
},
"crownshard-golem": {
id: "crownshard-golem",
name: "Crownshard Golem",
title: "The Fallen Idol",
trial: "Trial X · Edict of Stone",
icon: "♛",
accent: "#e0bd45",
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.",
briefing: "Move through each Royal Shockwave ring, then clear targeted Crownfall circles.",
failure: "Shockwaves expand in three steps. Commit to each safe band before the next ring fires.",
mapTitle: "The Broken Coronation",
mapCopy: "Follow the expanding safe bands. Spread Crownfall marks toward separate arena edges.",
mechanics: ["Royal Shockwave", "Crownfall"],
maxHp: 500,
},
};
+60 -5
View File
@@ -1,5 +1,13 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { clampToArena } from "./arena";
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/cinderbackRicochet";
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
@@ -23,7 +31,7 @@ export const BULL_POUNCE = {
afterCharges: 3,
stackDuration: 5,
stackRadius: 2.2,
sharedDamage: 300,
sharedDamage: 200,
leapDuration: 0.55,
} as const;
@@ -39,6 +47,26 @@ const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale"
export function createBossState(bossId: BossId = "bulldrome"): BossState {
if (bossId === "vexa") return createVexaState();
if (bossId === "cindermaw") return createCindermawState();
if (bossId === "ember-mantis-duelist") return createEmberMantisState();
if (bossId === "obsidian-ram-golem") return createObsidianRamState();
if (bossId === "cinderback-ricochet") return createCinderbackState();
if (bossId === "sandglass-scorpion") return createSandglassState();
if (bossId === "cragclaw-crab") return createCragclawState();
if (bossId === "mournveil-ghost") return createMournveilState();
if (bossId === "crownshard-golem") return createCrownshardState();
if (bossId === "pumpking-king-of-ghosts" || bossId === "blue-eyes-ultimate-dragon") {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: bossId === "pumpking-king-of-ghosts" ? 2.35 : 2.4,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
const definition = BOSS_DEFINITIONS.bulldrome;
return {
id: "bulldrome",
@@ -55,6 +83,15 @@ export function createBossState(bossId: BossId = "bulldrome"): BossState {
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
if (bossId === "vexa") return createVexaMotion();
if (bossId === "cindermaw") return createCindermawMotion();
if (bossId === "ember-mantis-duelist") return createEmberMantisMotion();
if (bossId === "obsidian-ram-golem") return createObsidianRamMotion();
if (bossId === "cinderback-ricochet") return createCinderbackMotion();
if (bossId === "sandglass-scorpion") return createSandglassMotion();
if (bossId === "cragclaw-crab") return createCragclawMotion();
if (bossId === "mournveil-ghost") return createMournveilMotion();
if (bossId === "crownshard-golem") return createCrownshardMotion();
if (bossId === "pumpking-king-of-ghosts") return { ...createMournveilMotion(), bossId };
if (bossId === "blue-eyes-ultimate-dragon") return { ...createCrownshardMotion(), bossId };
return {
...createBaseMotion("bulldrome"),
mode: "holding",
@@ -77,10 +114,10 @@ function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosit
const dx = target[0] - start[0];
const dz = target[1] - start[1];
const length = Math.max(0.001, Math.hypot(dx, dz));
return [
Math.max(-7.3, Math.min(7.3, start[0] + (dx / length) * BULL_CHARGE.distance)),
Math.max(-8.8, Math.min(7.1, start[1] + (dz / length) * BULL_CHARGE.distance)),
];
return clampToArena([
start[0] + (dx / length) * BULL_CHARGE.distance,
start[1] + (dz / length) * BULL_CHARGE.distance,
]);
}
function livingMember(party: PartyMember[], memberId: MemberId) {
@@ -348,6 +385,15 @@ function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicRe
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
if (context.boss.id === "ember-mantis-duelist") return advanceEmberMantisMechanics(context);
if (context.boss.id === "obsidian-ram-golem") return advanceObsidianRamMechanics(context);
if (context.boss.id === "cinderback-ricochet") return advanceCinderbackMechanics(context);
if (context.boss.id === "sandglass-scorpion") return advanceSandglassMechanics(context);
if (context.boss.id === "cragclaw-crab") return advanceCragclawMechanics(context);
if (context.boss.id === "mournveil-ghost") return advanceMournveilMechanics(context);
if (context.boss.id === "crownshard-golem") return advanceCrownshardMechanics(context);
if (context.boss.id === "pumpking-king-of-ghosts") return advanceMournveilMechanics(context);
if (context.boss.id === "blue-eyes-ultimate-dragon") return advanceCrownshardMechanics(context);
return advanceBulldromeMechanics(context);
}
@@ -371,6 +417,15 @@ export function handleBossDispel(
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time);
if (boss.id === "ember-mantis-duelist") return upcomingEmberMantisMechanic(boss, motion, time);
if (boss.id === "obsidian-ram-golem") return upcomingObsidianRamMechanic(boss, motion, time);
if (boss.id === "cinderback-ricochet") return upcomingCinderbackMechanic(boss, motion, time);
if (boss.id === "sandglass-scorpion") return upcomingSandglassMechanic(boss, motion, time);
if (boss.id === "cragclaw-crab") return upcomingCragclawMechanic(boss, motion, time);
if (boss.id === "mournveil-ghost") return upcomingMournveilMechanic(boss, motion, time);
if (boss.id === "crownshard-golem") return upcomingCrownshardMechanic(boss, motion, time);
if (boss.id === "pumpking-king-of-ghosts") return upcomingMournveilMechanic(boss, motion, time);
if (boss.id === "blue-eyes-ultimate-dragon") return upcomingCrownshardMechanic(boss, motion, time);
if (motion.mode === "telegraph") {
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
}
+115
View File
@@ -0,0 +1,115 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CINDERBACK = {
firstAt: 5,
repeatDelay: 3.6,
curlWarning: 1.25,
speed: 12.5,
distance: 13,
laneWidth: 2.25,
rushDamage: 22,
slamWarning: 1.3,
slamRadius: 3.1,
slamDamage: 29,
lavaRadius: 1.5,
lavaDamage: 5,
lavaDuration: 4.5,
recoverDuration: 0.75,
} as const;
const TARGETS: readonly MemberId[] = ["orin", "nia", "aelia", "vale", "brann"];
export function createCinderbackState(): BossState {
const definition = BOSS_DEFINITIONS["cinderback-ricochet"];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.4, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createCinderbackMotion(): BossMotionState {
return { ...createBaseMotion("cinderback-ricochet"), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
}
function rushEnd(start: WorldPosition, target: WorldPosition) {
const angle = angleTo(start, target);
return clampToArena([start[0] + Math.sin(angle) * CINDERBACK.distance, start[1] + Math.cos(angle) * CINDERBACK.distance] as WorldPosition);
}
function rushLane(id: string, start: WorldPosition, end: WorldPosition): SlashLane {
return { id, start: [...start], end: [...end], width: CINDERBACK.laneWidth, damage: CINDERBACK.rushDamage };
}
function lavaPool(id: string, center: WorldPosition, at: number): CircleHazard {
return { id, kind: "lava_pool", center: [...center], radius: CINDERBACK.lavaRadius, activatesAt: at, expiresAt: at + CINDERBACK.lavaDuration, damage: CINDERBACK.lavaDamage, tickInterval: 0.8, nextDamageAt: {}, resolved: false, hitIds: [] };
}
export function advanceCinderbackMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2 * context.delta);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = rushEnd(motion.position, context.partyPositions[targetId]);
motion = { ...motion, mode: "cinderback_curl", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], chargeCount: 0, phaseStartedAt: context.time, phaseEndsAt: context.time + CINDERBACK.curlWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [rushLane(`ricochet-${count}-0`, motion.position, end)] };
events.push({ at: context.time, message: `Red-Eyes dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + CINDERBACK.slamWarning;
const pools = [0, 1, 2].map((index) => {
const angle = (index / 3) * Math.PI * 2;
return lavaPool(`slam-lava-${count}-${index}`, clampToArena([motion.position[0] + Math.sin(angle) * 3.7, motion.position[1] + Math.cos(angle) * 3.7]), activatesAt);
});
motion = { ...motion, mode: "cinderback_slam", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.25, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, { id: `armor-slam-${count}`, kind: "quake", center: [...motion.position], radius: CINDERBACK.slamRadius, activatesAt, expiresAt: activatesAt + 0.3, damage: CINDERBACK.slamDamage, nextDamageAt: {}, resolved: false, hitIds: [] }, ...pools] };
events.push({ at: context.time, message: "Meteor Slam! Leave Red-Eyes and spreading black flame.", tone: "danger", pulseKind: "boss" });
}
}
} else if (motion.mode === "cinderback_curl" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "cinderback_ricochet", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CINDERBACK.speed };
} else if (motion.mode === "cinderback_ricochet") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, CINDERBACK.speed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CINDERBACK.laneWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, CINDERBACK.rushDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.4 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion.hazards.push(lavaPool(`ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, motion.chargeEnd, context.time));
if (motion.chargeCount === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount + 2);
const start = [...motion.chargeEnd] as WorldPosition;
const end = rushEnd(start, context.partyPositions[targetId]);
motion = { ...motion, position: start, chargeStart: start, chargeEnd: end, chargeTargetId: targetId, chargeHitIds: [], chargeCount: 1, phaseEndsAt: context.time + distance(start, end) / CINDERBACK.speed, slashLanes: [rushLane(`ricochet-${motion.mechanicCount}-1`, start, end)] };
events.push({ at: context.time, message: `Inferno Rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
} else {
motion = { ...motion, position: [...motion.chargeEnd], mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
}
}
} else if (motion.mode === "cinderback_slam" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
} else if (motion.mode === "cinderback_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", nextMechanicAt: context.time + CINDERBACK.repeatDelay, phaseEndsAt: 0, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.1, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCinderbackMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "cinderback_curl") return { name: "Inferno Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
if (motion.mode === "cinderback_ricochet") return { name: motion.chargeCount === 0 ? "First rebound" : "Second rebound", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
if (motion.mode === "cinderback_slam") return { name: "Meteor Slam — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.slamWarning, urgent: true };
if (motion.mode === "cinderback_recover") return { name: "Red-Eyes exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Inferno Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
}
+7 -7
View File
@@ -78,7 +78,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
mechanicHitIds: [],
mechanicNextDamageAt: {},
};
events.push({ at: context.time, message: "Cindermaw draws a sweeping breath. Rotate behind it!", tone: "danger", pulseKind: "breath" });
events.push({ at: context.time, message: "Blue-Eyes draws a sweeping Burst Stream. Rotate behind it!", tone: "danger", pulseKind: "breath" });
} else {
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
const hazards = set.map((memberId, index) => {
@@ -105,7 +105,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
mechanicCount: motion.mechanicCount + 1,
hazards: [...motion.hazards, ...hazards],
};
events.push({ at: context.time, message: "Cindermaw takes flight. Three Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
events.push({ at: context.time, message: "Blue-Eyes takes flight. Three White Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
}
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
@@ -115,7 +115,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
phaseEndsAt: context.time + CINDER_BREATH.sweepDuration,
breathAngle: motion.breathStartAngle,
};
events.push({ at: context.time, message: "Searing Sweep crosses the arena!", tone: "danger", pulseKind: "breath" });
events.push({ at: context.time, message: "Burst Stream crosses the arena!", tone: "danger", pulseKind: "breath" });
} else if (motion.mode === "breath_sweeping") {
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / CINDER_BREATH.sweepDuration));
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
@@ -135,7 +135,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
motion.mechanicNextDamageAt[member.id] = tickAt;
if (!motion.mechanicHitIds.includes(member.id)) {
motion.mechanicHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is scorched by Searing Sweep.`, tone: "danger", pulseKind: "breath", targetId: member.id });
events.push({ at: context.time, message: `${member.name} is struck by Burst Stream.`, tone: "danger", pulseKind: "breath", targetId: member.id });
}
return next;
});
@@ -152,10 +152,10 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
export function upcomingCindermawMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "breath_telegraph") return { name: "Searing Sweep", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_telegraph") return { name: "Burst Stream", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.sweepDuration, urgent: true };
if (motion.mode === "skyfall") return { name: "Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
if (motion.mode === "skyfall") return { name: "White Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
const nextIsBreath = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsBreath ? "Searing Sweep" : "Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
return { name: nextIsBreath ? "Burst Stream" : "White Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCragclawMechanics, CRAGCLAW, createCragclawMotion, createCragclawState } from "./cragclawCrab";
import { advanceCrownshardMechanics, CROWNSHARD, createCrownshardMotion, createCrownshardState } from "./crownshardGolem";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, MOURNVEIL } from "./mournveilGhost";
import type { BossMechanicContext } from "./types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: BossState,
motion: BossMotionState,
time: number,
delta = 0.1,
positions = POSITIONS,
party = freshParty(),
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(positions),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("ClaudeCraft boss trio mechanics", () => {
it("telegraphs and resolves Cragclaw Sidewinder Rush", () => {
const start = advanceCragclawMechanics(context(createCragclawState(), createCragclawMotion(), CRAGCLAW.firstAt));
expect(start.motion.mode).toBe("crab_scuttle_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
const active = advanceCragclawMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(active.motion.mode).toBe("crab_scuttling");
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
const impact = advanceCragclawMechanics(context(active.boss, active.motion, active.motion.phaseEndsAt, 2, POSITIONS, active.party));
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - CRAGCLAW.scuttleDamage);
});
it("places three Crushing Tide warnings on party positions", () => {
const motion = { ...createCragclawMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceCragclawMechanics(context(createCragclawState(), motion, 0));
expect(start.motion.mode).toBe("crab_tidal_burst");
expect(start.motion.hazards.filter((hazard) => hazard.kind === "tidal_burst")).toHaveLength(3);
const positions = structuredClone(POSITIONS);
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
const hpBefore = start.party.find((member) => member.id === "aelia")!.hp;
const impact = advanceCragclawMechanics(context(start.boss, start.motion, CRAGCLAW.tidalWarning + 0.05, 0.1, positions, start.party));
expect(impact.party.find((member) => member.id === "aelia")!.hp).toBe(hpBefore - CRAGCLAW.tidalDamage);
});
it("rotates Mournveil Soul Scissors for a second crossing pattern", () => {
const start = advanceMournveilMechanics(context(createMournveilState(), createMournveilMotion(), MOURNVEIL.firstAt));
const firstLaneIds = start.motion.slashLanes.map((lane) => lane.id);
expect(start.motion.mode).toBe("ghost_soul_cross");
expect(start.motion.slashLanes).toHaveLength(2);
const followup = advanceMournveilMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(followup.motion.mode).toBe("ghost_soul_cross_followup");
expect(followup.motion.slashLanes).toHaveLength(2);
expect(followup.motion.slashLanes.map((lane) => lane.id)).not.toEqual(firstLaneIds);
const resolved = advanceMournveilMechanics(context(followup.boss, followup.motion, followup.motion.phaseEndsAt, 0.1, POSITIONS, followup.party));
expect(resolved.motion.mode).toBe("ghost_recover");
});
it("opens two persistent Haunting Rifts", () => {
const motion = { ...createMournveilMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceMournveilMechanics(context(createMournveilState(), motion, 0));
const rifts = start.motion.hazards.filter((hazard) => hazard.kind === "soul_rift");
expect(start.motion.mode).toBe("ghost_haunting");
expect(rifts).toHaveLength(2);
expect(rifts[0].expiresAt - rifts[0].activatesAt).toBe(MOURNVEIL.riftDuration);
});
it("builds three non-overlapping Crownshard shockwave bands", () => {
const start = advanceCrownshardMechanics(context(createCrownshardState(), createCrownshardMotion(), CROWNSHARD.firstAt));
const rings = start.motion.hazards.filter((hazard) => hazard.kind === "royal_shockwave");
expect(start.motion.mode).toBe("golem_shockwave");
expect(rings).toHaveLength(3);
expect(rings.map((ring) => [ring.innerRadius ?? 0, ring.radius])).toEqual([[0, 2.35], [2.35, 4.7], [4.7, 7.05]]);
const positions = structuredClone(POSITIONS);
positions.aelia = [...rings[0].center];
positions.nia = [rings[0].center[0] + 3, rings[0].center[1]];
const first = advanceCrownshardMechanics(context(start.boss, start.motion, rings[0].activatesAt + 0.05, 0.1, positions, start.party));
const aeliaAfterFirst = first.party.find((member) => member.id === "aelia")!.hp;
const niaAfterFirst = first.party.find((member) => member.id === "nia")!.hp;
expect(aeliaAfterFirst).toBe(start.party.find((member) => member.id === "aelia")!.hp - CROWNSHARD.shockwaveDamage);
expect(niaAfterFirst).toBe(start.party.find((member) => member.id === "nia")!.hp);
const second = advanceCrownshardMechanics(context(first.boss, first.motion, rings[1].activatesAt + 0.05, 0.1, positions, first.party));
expect(second.party.find((member) => member.id === "aelia")!.hp).toBe(aeliaAfterFirst);
expect(second.party.find((member) => member.id === "nia")!.hp).toBe(niaAfterFirst - CROWNSHARD.shockwaveDamage);
});
it("marks three allies with Crownfall", () => {
const motion = { ...createCrownshardMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const result = advanceCrownshardMechanics(context(createCrownshardState(), motion, 0));
expect(result.motion.mode).toBe("golem_crownfall");
expect(result.motion.hazards.filter((hazard) => hazard.kind === "crownfall")).toHaveLength(3);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CRAGCLAW = {
firstAt: 5.1,
repeatDelay: 3.7,
scuttleWarning: 1.35,
scuttleSpeed: 11.8,
scuttleDistance: 13,
scuttleWidth: 2.35,
scuttleDamage: 24,
tidalWarning: 1.45,
tidalRadius: 1.7,
tidalDamage: 26,
recoverDuration: 0.72,
} as const;
const SCUTTLE_TARGETS: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
const TIDAL_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia", "orin"],
["brann", "vale", "aelia"],
["nia", "orin", "vale"],
];
export function createCragclawState(): BossState {
const definition = BOSS_DEFINITIONS["cragclaw-crab"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.3);
}
export function createCragclawMotion(): BossMotionState {
return { ...createBaseMotion("cragclaw-crab"), position: [0, -6.4], nextMechanicAt: CRAGCLAW.firstAt };
}
function scuttleEnd(start: WorldPosition, target: WorldPosition): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * CRAGCLAW.scuttleDistance,
start[1] + Math.cos(angle) * CRAGCLAW.scuttleDistance,
]);
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(context.party, SCUTTLE_TARGETS, motion.mechanicCount);
const end = scuttleEnd(motion.position, context.partyPositions[targetId]);
const lane: SlashLane = {
id: `cragclaw-scuttle-${mechanicCount}`,
start: [...motion.position],
end,
width: CRAGCLAW.scuttleWidth,
damage: CRAGCLAW.scuttleDamage,
};
events.push({ at: context.time, message: `Cragclaw lines up Sidewinder Rush on ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return {
...motion,
mode: "crab_scuttle_telegraph" as const,
chargeTargetId: targetId,
chargeStart: [...motion.position] as WorldPosition,
chargeEnd: end,
chargeHitIds: [],
slashLanes: [lane],
phaseStartedAt: context.time,
phaseEndsAt: context.time + CRAGCLAW.scuttleWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
};
}
const activatesAt = context.time + CRAGCLAW.tidalWarning;
const targetSet = TIDAL_TARGETS[Math.floor(motion.mechanicCount / 2) % TIDAL_TARGETS.length];
events.push({ at: context.time, message: "Crushing Tide marks three allies. Spread before the claws close.", tone: "danger", pulseKind: "skyfall" });
return {
...motion,
mode: "crab_tidal_burst" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.3,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `cragclaw-tide-${mechanicCount}-${index}`,
kind: "tidal_burst",
center: context.partyPositions[targetId],
radius: CRAGCLAW.tidalRadius,
activatesAt,
duration: 0.3,
damage: CRAGCLAW.tidalDamage,
})),
],
};
}
export function advanceCragclawMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.05 * context.delta);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "crab_scuttle_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "crab_scuttling",
phaseStartedAt: context.time,
phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CRAGCLAW.scuttleSpeed,
};
events.push({ at: context.time, message: "Sidewinder Rush! Clear the surf lane.", tone: "danger", pulseKind: "charge" });
} else if (motion.mode === "crab_scuttling") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, CRAGCLAW.scuttleSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CRAGCLAW.scuttleWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is crushed by Sidewinder Rush.`, tone: "danger", pulseKind: "charge", targetId: member.id });
return { ...context.damageMember(member, CRAGCLAW.scuttleDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.42 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion = { ...motion, position: [...motion.chargeEnd], mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
}
} else if (motion.mode === "crab_tidal_burst" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
} else if (motion.mode === "crab_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CRAGCLAW.repeatDelay, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCragclawMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "crab_scuttle_telegraph" || motion.mode === "crab_scuttling") return { name: "Sidewinder Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.scuttleWarning, urgent: true };
if (motion.mode === "crab_tidal_burst") return { name: "Crushing Tide — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.tidalWarning, urgent: true };
if (motion.mode === "crab_recover") return { name: "Cragclaw exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Sidewinder Rush" : "Crushing Tide", remaining, cycle: CRAGCLAW.repeatDelay + CRAGCLAW.scuttleWarning, urgent: remaining < 2.5 };
}
+119
View File
@@ -0,0 +1,119 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { moveToward } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CROWNSHARD = {
firstAt: 5.5,
repeatDelay: 4,
shockwaveWarning: 1.2,
shockwaveInterval: 0.65,
shockwaveDamage: 19,
crownfallWarning: 1.5,
crownfallRadius: 1.85,
crownfallDamage: 28,
recoverDuration: 0.78,
} as const;
const CROWNFALL_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia", "orin"],
["brann", "vale", "aelia"],
["nia", "orin", "vale"],
];
export function createCrownshardState(): BossState {
const definition = BOSS_DEFINITIONS["crownshard-golem"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.4);
}
export function createCrownshardMotion(): BossMotionState {
return { ...createBaseMotion("crownshard-golem"), position: [0, -6.7], nextMechanicAt: CROWNSHARD.firstAt };
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const bands = [
{ innerRadius: 0, radius: 2.35 },
{ innerRadius: 2.35, radius: 4.7 },
{ innerRadius: 4.7, radius: 7.05 },
];
const firstActivation = context.time + CROWNSHARD.shockwaveWarning;
events.push({ at: context.time, message: "Tri-Burst expands in three rings. Move with each head's safe band.", tone: "danger", pulseKind: "boss" });
return {
...motion,
mode: "golem_shockwave" as const,
phaseStartedAt: context.time,
phaseEndsAt: firstActivation + CROWNSHARD.shockwaveInterval * (bands.length - 1) + 0.32,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...bands.map((band, index) => createCircleHazard({
id: `crownshard-shockwave-${mechanicCount}-${index}`,
kind: "royal_shockwave",
center: motion.position,
innerRadius: band.innerRadius,
radius: band.radius,
activatesAt: firstActivation + index * CROWNSHARD.shockwaveInterval,
duration: 0.3,
damage: CROWNSHARD.shockwaveDamage,
})),
],
};
}
const activatesAt = context.time + CROWNSHARD.crownfallWarning;
const targetSet = CROWNFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % CROWNFALL_TARGETS.length];
events.push({ at: context.time, message: "Ultimate Skyfall marks three allies. Break formation before impact.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
return {
...motion,
mode: "golem_crownfall" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.32,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `crownshard-fall-${mechanicCount}-${index}`,
kind: "crownfall",
center: context.partyPositions[targetId],
radius: CROWNSHARD.crownfallRadius,
activatesAt,
duration: 0.3,
damage: CROWNSHARD.crownfallDamage,
})),
],
};
}
export function advanceCrownshardMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.55 * context.delta);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if ((motion.mode === "golem_shockwave" || motion.mode === "golem_crownfall") && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "golem_recover", phaseEndsAt: context.time + CROWNSHARD.recoverDuration };
} else if (motion.mode === "golem_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CROWNSHARD.repeatDelay };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.3, 15, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCrownshardMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "golem_shockwave") return { name: "Tri-Burst — follow rings", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.shockwaveWarning + CROWNSHARD.shockwaveInterval * 2, urgent: true };
if (motion.mode === "golem_crownfall") return { name: "Ultimate Skyfall — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.crownfallWarning, urgent: true };
if (motion.mode === "golem_recover") return { name: "Ultimate Dragon exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Tri-Burst" : "Ultimate Skyfall", remaining, cycle: CROWNSHARD.repeatDelay + CROWNSHARD.shockwaveWarning, urgent: remaining < 2.5 };
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import { pointToSegmentDistance } from "../geometry";
import { evadeSlashLanesBehavior } from "../partyBehaviors";
import type { BossMechanicContext } from "./types";
import {
advanceEmberMantisMechanics,
createEmberMantisMotion,
createEmberMantisState,
EMBER_MANTIS_SLASH,
} from "./emberMantis";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: ReturnType<typeof createEmberMantisState>,
motion: ReturnType<typeof createEmberMantisMotion>,
party = freshParty(),
time = 0,
delta = 0.1,
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(POSITIONS),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("Gate Guardian mechanics", () => {
it("sidesteps, telegraphs Elemental Beam, then damages targets left in the lane", () => {
const boss = createEmberMantisState();
const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1));
expect(sidestep.motion.mode).toBe("mantis_sidestep");
expect(sidestep.motion.chargeTargetId).toBe("nia");
const telegraph = advanceEmberMantisMechanics(context(sidestep.boss, sidestep.motion, sidestep.party, 5.6, 0.6));
expect(telegraph.motion.mode).toBe("mantis_line_telegraph");
expect(telegraph.motion.slashLanes).toHaveLength(1);
const niaBefore = telegraph.party.find((member) => member.id === "nia")!.hp;
const impact = advanceEmberMantisMechanics(context(telegraph.boss, telegraph.motion, telegraph.party, 6.51, 0.91));
expect(impact.motion.mode).toBe("mantis_recover");
expect(impact.motion.mechanicHitIds).toContain("nia");
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - EMBER_MANTIS_SLASH.lineDamage);
expect(impact.events.some((event) => event.message.includes("Elemental Beam"))).toBe(true);
});
it("alternates into two crossed slash lanes", () => {
const motion = {
...createEmberMantisMotion(),
mode: "mantis_sidestep" as const,
mechanicCount: 1,
chargeTargetId: "orin" as const,
chargeEnd: [0, -6.6] as [number, number],
phaseEndsAt: 0,
};
const result = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1));
expect(result.motion.mode).toBe("mantis_cross_telegraph");
expect(result.motion.slashLanes).toHaveLength(2);
expect(result.motion.slashLanes[0].id).toContain("cross");
});
it("gives mobile allies a target outside crossed lanes", () => {
const motion = {
...createEmberMantisMotion(),
mode: "mantis_sidestep" as const,
mechanicCount: 1,
chargeTargetId: "orin" as const,
chargeEnd: [0, -6.6] as [number, number],
phaseEndsAt: 0,
};
const telegraph = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1)).motion;
const decision = evadeSlashLanesBehavior.decide({
memberId: "orin",
current: POSITIONS.orin,
formationTarget: POSITIONS.orin,
bossMotion: telegraph,
partyPositions: POSITIONS,
time: 1,
});
expect(decision).not.toBeNull();
const minimumLaneDistance = Math.min(...telegraph.slashLanes.map((lane) =>
pointToSegmentDistance(decision!.target, lane.start, lane.end),
));
expect(minimumLaneDistance).toBeGreaterThan(EMBER_MANTIS_SLASH.aiClearance);
});
});
+247
View File
@@ -0,0 +1,247 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const EMBER_MANTIS_SLASH = {
firstAt: 5,
repeatDelay: 3.4,
sidestepDuration: 0.55,
sidestepDistance: 3.8,
sidestepSpeed: 7.2,
telegraphDuration: 0.9,
recoverDuration: 0.7,
laneLength: 18,
lineWidth: 1.65,
crossWidth: 1.45,
lineDamage: 32,
crossDamage: 25,
crossAngle: Math.PI * 0.18,
staggerDuration: 0.35,
aiClearance: 1.35,
aiEvadeSpeed: 4.8,
} as const;
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
const MIN_BOSS_X = -5.8;
const MAX_BOSS_X = 5.8;
export function createEmberMantisState(): BossState {
const definition = BOSS_DEFINITIONS["ember-mantis-duelist"];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.2,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createEmberMantisMotion(): BossMotionState {
return {
...createBaseMotion("ember-mantis-duelist"),
position: [0, -6.6],
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
};
}
function clampBossX(value: number) {
return Math.max(MIN_BOSS_X, Math.min(MAX_BOSS_X, value));
}
function createLane(
id: string,
center: WorldPosition,
angle: number,
width: number,
damage: number,
): SlashLane {
const halfLength = EMBER_MANTIS_SLASH.laneLength * 0.5;
const dx = Math.sin(angle) * halfLength;
const dz = Math.cos(angle) * halfLength;
return {
id,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width,
damage,
};
}
function beginSidestep(
motion: BossMotionState,
party: BossMechanicContext["party"],
partyPositions: BossMechanicContext["partyPositions"],
time: number,
) {
const targetId = chooseLivingTarget(party, TARGET_ORDER, motion.mechanicCount);
const direction = motion.mechanicCount % 2 === 0 ? 1 : -1;
let targetX = clampBossX(motion.position[0] + direction * EMBER_MANTIS_SLASH.sidestepDistance);
if (Math.abs(targetX - motion.position[0]) < 1) {
targetX = clampBossX(motion.position[0] - direction * EMBER_MANTIS_SLASH.sidestepDistance);
}
return {
...motion,
mode: "mantis_sidestep" as const,
chargeTargetId: targetId,
chargeEnd: [targetX, motion.position[1]] as WorldPosition,
phaseStartedAt: time,
phaseEndsAt: time + EMBER_MANTIS_SLASH.sidestepDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
slashLanes: [],
mechanicHitIds: [],
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]] as WorldPosition,
};
}
function beginSlashTelegraph(
motion: BossMotionState,
partyPositions: BossMechanicContext["partyPositions"],
time: number,
) {
const target = partyPositions[motion.chargeTargetId];
const aimedAngle = angleTo(motion.position, target);
const isCrossSlash = motion.mechanicCount % 2 === 1;
const slashNumber = motion.mechanicCount + 1;
const lanes = isCrossSlash
? [
createLane(`cross-${slashNumber}-left`, target, aimedAngle - EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
createLane(`cross-${slashNumber}-right`, target, aimedAngle + EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
]
: [createLane(`line-${slashNumber}`, target, aimedAngle, EMBER_MANTIS_SLASH.lineWidth, EMBER_MANTIS_SLASH.lineDamage)];
return {
...motion,
mode: isCrossSlash ? "mantis_cross_telegraph" as const : "mantis_line_telegraph" as const,
phaseStartedAt: time,
phaseEndsAt: time + EMBER_MANTIS_SLASH.telegraphDuration,
mechanicCount: slashNumber,
slashLanes: lanes,
mechanicHitIds: [],
};
}
function resolveSlash(
motion: BossMotionState,
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const isCrossSlash = motion.mode === "mantis_cross_telegraph";
const hitIds: MemberId[] = [];
const party = context.party.map((member) => {
if (member.hp <= 0) return member;
const lane = motion.slashLanes.find((candidate) =>
pointToSegmentDistance(context.partyPositions[member.id], candidate.start, candidate.end) <= candidate.width * 0.5,
);
if (!lane) return member;
hitIds.push(member.id);
events.push({
at: context.time,
message: `${member.name} is caught by ${isCrossSlash ? "Guardian Cross" : "Elemental Beam"}.`,
tone: "danger",
pulseKind: "slash",
targetId: member.id,
});
return {
...context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time),
knockedUntil: Math.max(member.knockedUntil, context.time + EMBER_MANTIS_SLASH.staggerDuration),
};
});
return { party, hitIds };
}
export function advanceEmberMantisMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(
motion.position,
[tank[0] + motion.formationOffsetX, tank[1] - 4.25],
2.5 * context.delta,
);
if (context.time >= motion.nextMechanicAt) {
motion = beginSidestep(motion, party, context.partyPositions, context.time);
events.push({
at: context.time,
message: `Gate Guardian shifts toward ${memberName(party, motion.chargeTargetId)}. Track its arms.`,
tone: "danger",
pulseKind: "slash",
targetId: motion.chargeTargetId,
});
}
} else if (motion.mode === "mantis_sidestep") {
motion.position = moveToward(motion.position, motion.chargeEnd, EMBER_MANTIS_SLASH.sidestepSpeed * context.delta);
if (context.time >= motion.phaseEndsAt) {
motion = beginSlashTelegraph(motion, context.partyPositions, context.time);
const cross = motion.mode === "mantis_cross_telegraph";
events.push({
at: context.time,
message: cross ? "Guardian Cross! Find a safe quadrant." : "Elemental Beam! Clear the glowing lane.",
tone: "danger",
pulseKind: "slash",
targetId: motion.chargeTargetId,
});
}
} else if (motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph") {
if (context.time >= motion.phaseEndsAt) {
const resolved = resolveSlash(motion, context, events);
party = resolved.party;
motion = {
...motion,
mode: "mantis_recover",
phaseStartedAt: context.time,
phaseEndsAt: context.time + EMBER_MANTIS_SLASH.recoverDuration,
mechanicHitIds: resolved.hitIds,
};
}
} else if (motion.mode === "mantis_recover" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "holding",
phaseStartedAt: context.time,
phaseEndsAt: 0,
nextMechanicAt: context.time + EMBER_MANTIS_SLASH.repeatDelay,
slashLanes: [],
mechanicHitIds: [],
};
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 1.9, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingEmberMantisMechanic(
boss: BossState,
motion: BossMotionState,
time: number,
): UpcomingMechanic {
void boss;
if (motion.mode === "mantis_sidestep") {
return { name: "Guardian repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
}
if (motion.mode === "mantis_line_telegraph") {
return { name: "Elemental Beam — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_cross_telegraph") {
return { name: "Guardian Cross — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_recover") {
return { name: "Gate Guardian exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
}
const nextIsCross = motion.mechanicCount % 2 === 1;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return {
name: nextIsCross ? "Guardian Cross" : "Elemental Beam",
remaining,
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
urgent: remaining < 2.5,
};
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./cinderbackRicochet";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, OBSIDIAN_RAM } from "./obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, SANDGLASS } from "./sandglassScorpion";
import type { BossMechanicContext } from "./types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: BossState,
motion: BossMotionState,
time: number,
delta = 0.1,
positions = POSITIONS,
party = freshParty(),
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(positions),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("IWT2 boss trio mechanics", () => {
it("telegraphs and resolves Obsidian Ram Plate Charge", () => {
const start = advanceObsidianRamMechanics(context(createObsidianRamState(), createObsidianRamMotion(), OBSIDIAN_RAM.firstAt));
expect(start.motion.mode).toBe("ram_charge_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
const active = advanceObsidianRamMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(active.motion.mode).toBe("ram_charging");
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
const impact = advanceObsidianRamMechanics(context(active.boss, active.motion, active.motion.phaseStartedAt + 1, 1, POSITIONS, active.party));
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - OBSIDIAN_RAM.chargeDamage);
});
it("builds three Armor Shatter fault lanes", () => {
const motion = { ...createObsidianRamMotion(), mechanicCount: 2, nextMechanicAt: 0 };
const result = advanceObsidianRamMechanics(context(createObsidianRamState(), motion, 0));
expect(result.motion.mode).toBe("ram_shatter");
expect(result.motion.slashLanes).toHaveLength(3);
});
it("executes both Cinderback rebounds and leaves lava at each impact", () => {
const start = advanceCinderbackMechanics(context(createCinderbackState(), createCinderbackMotion(), CINDERBACK.firstAt));
const firstRush = advanceCinderbackMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
const firstImpact = advanceCinderbackMechanics(context(firstRush.boss, firstRush.motion, firstRush.motion.phaseStartedAt + 2, 2, POSITIONS, firstRush.party));
expect(firstImpact.motion.mode).toBe("cinderback_ricochet");
expect(firstImpact.motion.chargeCount).toBe(1);
expect(firstImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(1);
const secondImpact = advanceCinderbackMechanics(context(firstImpact.boss, firstImpact.motion, firstImpact.motion.phaseEndsAt, 2, POSITIONS, firstImpact.party));
expect(secondImpact.motion.mode).toBe("cinderback_recover");
expect(secondImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(2);
});
it("turns Sandglass stinger warnings into an active hourglass zone", () => {
const motion = { ...createSandglassMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceSandglassMechanics(context(createSandglassState(), motion, 0));
expect(start.motion.mode).toBe("sandglass_eruption");
expect(start.motion.hazards.filter((hazard) => hazard.kind === "stinger_eruption")).toHaveLength(3);
const positions = structuredClone(POSITIONS);
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
const aeliaBefore = start.party[0].hp;
const eruption = advanceSandglassMechanics(context(start.boss, start.motion, SANDGLASS.eruptionWarning + 0.05, 0.1, positions, start.party));
expect(eruption.party[0].hp).toBe(aeliaBefore - SANDGLASS.eruptionDamage);
const hourglass = advanceSandglassMechanics(context(eruption.boss, eruption.motion, eruption.motion.phaseEndsAt + 0.01, 0.1, POSITIONS, eruption.party));
expect(hourglass.motion.mode).toBe("sandglass_hourglass");
expect(hourglass.motion.hazards.some((hazard) => hazard.kind === "hourglass")).toBe(true);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const MOURNVEIL = {
firstAt: 5.3,
repeatDelay: 3.9,
crossWarning: 1.35,
followupWarning: 1.05,
laneWidth: 1.55,
laneDamage: 22,
riftWarning: 1.45,
riftRadius: 1.75,
riftDamage: 5,
riftDuration: 4.2,
recoverDuration: 0.75,
} as const;
const RIFT_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia"],
["orin", "vale"],
["brann", "aelia"],
];
export function createMournveilState(): BossState {
const definition = BOSS_DEFINITIONS["mournveil-ghost"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.35);
}
export function createMournveilMotion(): BossMotionState {
return { ...createBaseMotion("mournveil-ghost"), position: [0, -6.6], nextMechanicAt: MOURNVEIL.firstAt };
}
function crossLanes(center: WorldPosition, angle: number, mechanicCount: number, phase: number): SlashLane[] {
return [angle, angle + Math.PI / 2].map((laneAngle, index) => {
const dx = Math.sin(laneAngle) * 9;
const dz = Math.cos(laneAngle) * 9;
return {
id: `mournveil-cross-${mechanicCount}-${phase}-${index}`,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: MOURNVEIL.laneWidth,
damage: MOURNVEIL.laneDamage,
};
});
}
function resolveCross(motion: BossMotionState, context: BossMechanicContext, party: BossMechanicContext["party"], events: BossMechanicResult["events"]) {
const hitIds: MemberId[] = [];
const nextParty = party.map((member) => {
if (member.hp <= 0) return member;
const hit = motion.slashLanes.some((lane) => pointToSegmentDistance(context.partyPositions[member.id], lane.start, lane.end) <= lane.width * 0.5);
if (!hit) return member;
hitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is cut by Vine Scissors.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return context.damageMember(member, MOURNVEIL.laneDamage, context.partyPositions[member.id], context.time);
});
return { party: nextParty, hitIds };
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = (["aelia", "nia", "orin", "vale", "brann"] as const)[motion.mechanicCount % 5];
const angle = angleTo(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: "Vine Scissors carve a spectral cross. A second cut will rotate.", tone: "danger", pulseKind: "slash", targetId });
return {
...motion,
mode: "ghost_soul_cross" as const,
breathStartAngle: angle,
chargeCount: 0,
phaseStartedAt: context.time,
phaseEndsAt: context.time + MOURNVEIL.crossWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
mechanicHitIds: [],
slashLanes: crossLanes(motion.position, angle, mechanicCount, 0),
};
}
const activatesAt = context.time + MOURNVEIL.riftWarning;
const targetSet = RIFT_TARGETS[Math.floor(motion.mechanicCount / 2) % RIFT_TARGETS.length];
events.push({ at: context.time, message: "Haunting Rifts follow two allies. Carry them away from formation.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
return {
...motion,
mode: "ghost_haunting" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.35,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `mournveil-rift-${mechanicCount}-${index}`,
kind: "soul_rift",
center: context.partyPositions[targetId],
radius: MOURNVEIL.riftRadius,
activatesAt,
duration: MOURNVEIL.riftDuration,
damage: MOURNVEIL.riftDamage,
tickInterval: 0.8,
})),
],
};
}
export function advanceMournveilMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.7 * context.delta);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ghost_soul_cross" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
party = resolved.party;
const followupAngle = motion.breathStartAngle + Math.PI / 4;
motion = {
...motion,
mode: "ghost_soul_cross_followup",
chargeCount: 1,
mechanicHitIds: resolved.hitIds,
slashLanes: crossLanes(motion.position, followupAngle, motion.mechanicCount, 1),
phaseStartedAt: context.time,
phaseEndsAt: context.time + MOURNVEIL.followupWarning,
};
events.push({ at: context.time, message: "Vine Scissors rotate. Find the new safe quadrant.", tone: "danger", pulseKind: "slash" });
} else if (motion.mode === "ghost_soul_cross_followup" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
party = resolved.party;
motion = { ...motion, mode: "ghost_recover", mechanicHitIds: [...new Set([...motion.mechanicHitIds, ...resolved.hitIds])], phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
} else if (motion.mode === "ghost_haunting" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ghost_recover", phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
} else if (motion.mode === "ghost_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + MOURNVEIL.repeatDelay, slashLanes: [], mechanicHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.25, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingMournveilMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ghost_soul_cross") return { name: "Vine Scissors — first cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.crossWarning, urgent: true };
if (motion.mode === "ghost_soul_cross_followup") return { name: "Vine Scissors — rotated cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.followupWarning, urgent: true };
if (motion.mode === "ghost_haunting") return { name: "Haunting Rifts — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.riftWarning, urgent: true };
if (motion.mode === "ghost_recover") return { name: "Pumpking exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Vine Scissors" : "Haunting Rifts", remaining, cycle: MOURNVEIL.repeatDelay + MOURNVEIL.crossWarning, urgent: remaining < 2.5 };
}
+183
View File
@@ -0,0 +1,183 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const OBSIDIAN_RAM = {
firstAt: 5.5,
repeatDelay: 3.8,
chargeWarning: 1.45,
chargeSpeed: 11.5,
chargeDistance: 14,
chargeWidth: 2.5,
chargeDamage: 27,
quakeWarning: 1.35,
quakeRadius: 3.6,
quakeDamage: 31,
shatterWarning: 1.2,
shatterWidth: 1.25,
shatterDamage: 24,
recoverDuration: 0.7,
} as const;
const TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
export function createObsidianRamState(): BossState {
const definition = BOSS_DEFINITIONS["obsidian-ram-golem"];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.3,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createObsidianRamMotion(): BossMotionState {
return { ...createBaseMotion("obsidian-ram-golem"), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
}
function laneAt(center: WorldPosition, angle: number, id: string): SlashLane {
const half = 9;
const dx = Math.sin(angle) * half;
const dz = Math.cos(angle) * half;
return {
id,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: OBSIDIAN_RAM.shatterWidth,
damage: OBSIDIAN_RAM.shatterDamage,
};
}
function endpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * OBSIDIAN_RAM.chargeDistance,
start[1] + Math.cos(angle) * OBSIDIAN_RAM.chargeDistance,
]);
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const index = motion.mechanicCount % 3;
const mechanicCount = motion.mechanicCount + 1;
if (index === 0) {
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
const end = endpoint(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: `Destruction Rush locks onto ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return {
...motion,
mode: "ram_charge_telegraph" as const,
chargeTargetId: targetId,
chargeStart: [...motion.position] as WorldPosition,
chargeEnd: end,
chargeHitIds: [],
slashLanes: [{ id: `ram-charge-${mechanicCount}`, start: [...motion.position] as WorldPosition, end, width: OBSIDIAN_RAM.chargeWidth, damage: OBSIDIAN_RAM.chargeDamage }],
phaseStartedAt: context.time,
phaseEndsAt: context.time + OBSIDIAN_RAM.chargeWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
};
}
if (index === 1) {
const activatesAt = context.time + OBSIDIAN_RAM.quakeWarning;
events.push({ at: context.time, message: "Ruin Quake! Leave the destruction circle.", tone: "danger", pulseKind: "boss" });
return {
...motion,
mode: "ram_quake" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.25,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [...motion.hazards, {
id: `ram-quake-${mechanicCount}`,
kind: "quake" as const,
center: [...motion.position] as WorldPosition,
radius: OBSIDIAN_RAM.quakeRadius,
activatesAt,
expiresAt: activatesAt + 0.3,
damage: OBSIDIAN_RAM.quakeDamage,
nextDamageAt: {},
resolved: false,
hitIds: [],
}],
};
}
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
const aimed = angleTo(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: "Destruction Pulse! Step between the radial beams.", tone: "danger", pulseKind: "slash", targetId });
return {
...motion,
mode: "ram_shatter" as const,
phaseStartedAt: context.time,
phaseEndsAt: context.time + OBSIDIAN_RAM.shatterWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
mechanicHitIds: [],
slashLanes: [0, Math.PI / 3, -Math.PI / 3].map((offset, laneIndex) => laneAt(motion.position, aimed + offset, `ram-shatter-${mechanicCount}-${laneIndex}`)),
};
}
function resolveShatter(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const hitIds: MemberId[] = [];
const party = context.party.map((member) => {
const lane = motion.slashLanes.find((entry) => pointToSegmentDistance(context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
if (member.hp <= 0 || !lane) return member;
hitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is struck by Destruction Pulse.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time);
});
return { party, hitIds };
}
export function advanceObsidianRamMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * context.delta);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ram_charge_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_charging", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / OBSIDIAN_RAM.chargeSpeed };
events.push({ at: context.time, message: "Destruction Rush! Clear the lane.", tone: "danger", pulseKind: "charge" });
} else if (motion.mode === "ram_charging") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, OBSIDIAN_RAM.chargeSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > OBSIDIAN_RAM.chargeWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, OBSIDIAN_RAM.chargeDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.55 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_shatter" && context.time >= motion.phaseEndsAt) {
const resolved = resolveShatter(motion, { ...context, party }, events);
party = resolved.party;
motion = { ...motion, mode: "ram_recover", mechanicHitIds: resolved.hitIds, phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_quake" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + OBSIDIAN_RAM.repeatDelay, slashLanes: [], chargeHitIds: [], mechanicHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 15, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingObsidianRamMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ram_charge_telegraph" || motion.mode === "ram_charging") return { name: "Destruction Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.chargeWarning, urgent: true };
if (motion.mode === "ram_quake") return { name: "Ruin Quake — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.quakeWarning, urgent: true };
if (motion.mode === "ram_shatter") return { name: "Destruction Pulse — find gap", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.shatterWarning, urgent: true };
if (motion.mode === "ram_recover") return { name: "Gandora exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
const names = ["Destruction Rush", "Ruin Quake", "Destruction Pulse"];
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: names[motion.mechanicCount % 3], remaining, cycle: OBSIDIAN_RAM.repeatDelay + OBSIDIAN_RAM.chargeWarning, urgent: remaining < 2.5 };
}
+104
View File
@@ -0,0 +1,104 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const SANDGLASS = {
firstAt: 5.4,
repeatDelay: 3.8,
burrowWarning: 1.3,
burrowSpeed: 10.8,
burrowDistance: 13,
burrowWidth: 2,
burrowDamage: 25,
eruptionWarning: 1.55,
eruptionRadius: 1.75,
eruptionDamage: 27,
hourglassRadius: 2.55,
hourglassDamage: 6,
hourglassDuration: 3.6,
recoverDuration: 0.7,
} as const;
const TARGETS: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
const ERUPTION_TARGETS: readonly MemberId[][] = [["aelia", "nia", "orin"], ["brann", "vale", "aelia"], ["nia", "orin", "vale"]];
export function createSandglassState(): BossState {
const definition = BOSS_DEFINITIONS["sandglass-scorpion"];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.2, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createSandglassMotion(): BossMotionState {
return { ...createBaseMotion("sandglass-scorpion"), position: [0, -6.5], nextMechanicAt: SANDGLASS.firstAt };
}
function burrowEnd(start: WorldPosition, target: WorldPosition) {
const angle = angleTo(start, target);
return clampToArena([start[0] + Math.sin(angle) * SANDGLASS.burrowDistance, start[1] + Math.cos(angle) * SANDGLASS.burrowDistance] as WorldPosition);
}
function eruption(id: string, center: WorldPosition, activatesAt: number): CircleHazard {
return { id, kind: "stinger_eruption", center: [...center], radius: SANDGLASS.eruptionRadius, activatesAt, expiresAt: activatesAt + 0.32, damage: SANDGLASS.eruptionDamage, nextDamageAt: {}, resolved: false, hitIds: [] };
}
export function advanceSandglassMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = burrowEnd(motion.position, context.partyPositions[targetId]);
const lane: SlashLane = { id: `burrow-${count}`, start: [...motion.position], end, width: SANDGLASS.burrowWidth, damage: SANDGLASS.burrowDamage };
motion = { ...motion, mode: "sandglass_burrow_telegraph", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], phaseStartedAt: context.time, phaseEndsAt: context.time + SANDGLASS.burrowWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [lane] };
events.push({ at: context.time, message: `Burrow Rush tracks ${memberName(party, targetId)}. Cross the sand trail.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + SANDGLASS.eruptionWarning;
const targets = ERUPTION_TARGETS[Math.floor(motion.mechanicCount / 2) % ERUPTION_TARGETS.length];
motion = { ...motion, mode: "sandglass_eruption", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.3, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, ...targets.map((targetId, index) => eruption(`stinger-${count}-${index}`, context.partyPositions[targetId], activatesAt))] };
events.push({ at: context.time, message: "Stinger Eruption! Leave the timed sand circles.", tone: "danger", pulseKind: "skyfall" });
}
}
} else if (motion.mode === "sandglass_burrow_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "sandglass_burrowing", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / SANDGLASS.burrowSpeed };
} else if (motion.mode === "sandglass_burrowing") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, SANDGLASS.burrowSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > SANDGLASS.burrowWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, SANDGLASS.burrowDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.35 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
} else if (motion.mode === "sandglass_eruption" && context.time >= motion.phaseEndsAt) {
const center = clampToArena([motion.position[0], motion.position[1] + 3]);
const activatesAt = context.time + 0.9;
motion = { ...motion, mode: "sandglass_hourglass", phaseStartedAt: context.time, phaseEndsAt: activatesAt + SANDGLASS.hourglassDuration, hazards: [...motion.hazards, { id: `hourglass-${motion.mechanicCount}`, kind: "hourglass", center, radius: SANDGLASS.hourglassRadius, activatesAt, expiresAt: activatesAt + SANDGLASS.hourglassDuration, damage: SANDGLASS.hourglassDamage, tickInterval: 0.75, nextDamageAt: {}, resolved: false, hitIds: [] }] };
events.push({ at: context.time, message: "Hourglass zone turns active. Keep moving.", tone: "danger", pulseKind: "skyfall" });
} else if (motion.mode === "sandglass_hourglass" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
} else if (motion.mode === "sandglass_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + SANDGLASS.repeatDelay, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.15, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingSandglassMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "sandglass_burrow_telegraph" || motion.mode === "sandglass_burrowing") return { name: "Burrow Rush — clear trail", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.burrowWarning, urgent: true };
if (motion.mode === "sandglass_eruption") return { name: "Stinger Eruption — move", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.eruptionWarning, urgent: true };
if (motion.mode === "sandglass_hourglass") return { name: "Hourglass zone active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.hourglassDuration, urgent: true };
if (motion.mode === "sandglass_recover") return { name: "Chronarch exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Burrow Rush" : "Hourglass Eruption", remaining, cycle: SANDGLASS.repeatDelay + SANDGLASS.burrowWarning, urgent: remaining < 2.5 };
}
+79 -8
View File
@@ -1,7 +1,71 @@
import { distance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "../types";
import type { BossId, BossMotionState, BossState, CircleHazard, CircleHazardKind, MemberId, PartyMember, WorldPosition } from "../types";
import type { BossMechanicContext, BossMechanicEvent } from "./types";
const PERSISTENT_HAZARD_KINDS = new Set<CircleHazardKind>(["venom_pool", "lava_pool", "hourglass", "soul_rift"]);
const HAZARD_LABELS = {
venom_pool: "Venom pool",
skyfall: "Skyfall",
quake: "Fracture Quake",
lava_pool: "Lava pool",
stinger_eruption: "Stinger Eruption",
hourglass: "Hourglass zone",
tidal_burst: "Crushing Tide",
soul_rift: "Soul rift",
crownfall: "Crownfall",
royal_shockwave: "Royal Shockwave",
} as const;
export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState {
return {
id: bossId,
name,
maxHp,
hp: maxHp,
nextMeleeAt,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createCircleHazard({
id,
kind,
center,
innerRadius,
radius,
activatesAt,
duration,
damage,
tickInterval,
}: {
id: string;
kind: CircleHazardKind;
center: WorldPosition;
innerRadius?: number;
radius: number;
activatesAt: number;
duration: number;
damage: number;
tickInterval?: number;
}): CircleHazard {
return {
id,
kind,
center: [...center],
innerRadius,
radius,
activatesAt,
expiresAt: activatesAt + duration,
damage,
tickInterval,
nextDamageAt: {},
resolved: false,
hitIds: [],
};
}
export function createBaseMotion(bossId: BossId): BossMotionState {
return {
bossId,
@@ -30,6 +94,7 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
breathStartAngle: 0,
breathEndAngle: 0,
hazards: [],
slashLanes: [],
};
}
@@ -50,6 +115,11 @@ export function cloneMotion(source: BossMotionState): BossMotionState {
nextDamageAt: { ...hazard.nextDamageAt },
hitIds: [...hazard.hitIds],
})),
slashLanes: source.slashLanes.map((lane) => ({
...lane,
start: [lane.start[0], lane.start[1]],
end: [lane.end[0], lane.end[1]],
})),
};
}
@@ -100,7 +170,8 @@ export function resolveCircleHazards(
const newlyHit: MemberId[] = [];
updatedParty = updatedParty.map((member) => {
if (member.hp <= 0) return member;
const inside = distance(positions[member.id], hazard.center) <= hazard.radius;
const hazardDistance = distance(positions[member.id], hazard.center);
const inside = hazardDistance <= hazard.radius && hazardDistance >= (hazard.innerRadius ?? 0);
if (!inside) {
delete hazard.nextDamageAt[member.id];
return member;
@@ -109,26 +180,26 @@ export function resolveCircleHazards(
hazard.hitIds.push(member.id);
newlyHit.push(member.id);
}
if (hazard.kind !== "venom_pool") {
if (!PERSISTENT_HAZARD_KINDS.has(hazard.kind)) {
return hazard.resolved || hazard.hitIds.includes(member.id) && !newlyHit.includes(member.id)
? member
: damageMember(member, hazard.damage, positions[member.id], time);
: damageMember(member, hazard.damage, positions[member.id], time, "hazard");
}
let next = member;
let tickAt = hazard.nextDamageAt[member.id] ?? time;
while (tickAt <= time + 0.001) {
next = damageMember(next, hazard.damage, positions[member.id], tickAt);
next = damageMember(next, hazard.damage, positions[member.id], tickAt, "hazard");
tickAt += hazard.tickInterval ?? 1;
}
hazard.nextDamageAt[member.id] = tickAt;
return next;
});
if (newlyHit.length && !hazard.resolved) {
const label = hazard.kind === "skyfall" ? "Skyfall" : "Venom pool";
events.push({ at: time, message: `${label} catches ${newlyHit.length} ally${newlyHit.length === 1 ? "" : "ies"}.`, tone: "danger", pulseKind: hazard.kind === "skyfall" ? "skyfall" : "venom" });
const pulseKind = hazard.kind === "venom_pool" ? "venom" : hazard.kind === "skyfall" || hazard.kind === "stinger_eruption" || hazard.kind === "hourglass" ? "skyfall" : "boss";
events.push({ at: time, message: `${HAZARD_LABELS[hazard.kind]} catches ${newlyHit.length} ally${newlyHit.length === 1 ? "" : "ies"}.`, tone: "danger", pulseKind });
}
if (newlyHit.length || hazard.kind === "skyfall" && time >= hazard.activatesAt) hazard.resolved = true;
if (!PERSISTENT_HAZARD_KINDS.has(hazard.kind) && (newlyHit.length || time >= hazard.activatesAt)) hazard.resolved = true;
}
motion.hazards = motion.hazards.filter((hazard) => hazard.expiresAt > time);
return updatedParty;
+1 -1
View File
@@ -22,7 +22,7 @@ export interface BossMechanicContext {
partyPositions: Record<MemberId, WorldPosition>;
time: number;
delta: number;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number) => PartyMember;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
}
export interface UpcomingMechanic {
+2 -2
View File
@@ -78,7 +78,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
tetherBreakDistance: VEXA_TETHER.breakDistance,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: `Vexa binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
events.push({ at: context.time, message: `Insect Queen binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
}
} else {
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
@@ -103,7 +103,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: "Vexa injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
events.push({ at: context.time, message: "Insect Queen injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
}
} else if (motion.mode === "tethering") {
const [first, second] = motion.tetherIds;
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { createBaseMotion } from "./bosses/shared";
import { freshParty } from "./data";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import type { MemberId, WorldPosition } from "./types";
describe("party boss positioning", () => {
it("places Brann in front of the boss and Vale behind it", () => {
const formation = combatFormation([2, -1]);
expect(formation.brann).toEqual([2, 3.25]);
expect(formation.vale).toEqual([2, -2.7]);
});
it("moves the tank toward the front and the rogue toward the rear during uptime", () => {
const sharedStart: WorldPosition = [0, 0];
const positions: Record<MemberId, WorldPosition> = {
aelia: [0, 4],
brann: [...sharedStart],
nia: [-3, 4],
orin: [3, 4],
vale: [...sharedStart],
};
const motion = { ...createBaseMotion("bulldrome"), position: [0, -1] as WorldPosition };
const next = updatePartyPositions(positions, motion, freshParty(), 1, 0.5);
expect(next.brann[1]).toBeGreaterThan(sharedStart[1]);
expect(next.vale[1]).toBeLessThan(sharedStart[1]);
});
});
+77 -19
View File
@@ -1,4 +1,5 @@
import { BULL_CHARGE } from "./bossMechanics";
import { clampToArena } from "./arena";
import { CINDER_BREATH } from "./bosses/cindermaw";
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
@@ -33,25 +34,45 @@ const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
orin: [0.45, -0.4],
vale: [0, 0.65],
};
const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [
"mantis_line_telegraph",
"mantis_cross_telegraph",
"ram_charge_telegraph",
"ram_charging",
"ram_shatter",
"cinderback_curl",
"cinderback_ricochet",
"sandglass_burrow_telegraph",
"sandglass_burrowing",
"crab_scuttle_telegraph",
"crab_scuttling",
"ghost_soul_cross",
"ghost_soul_cross_followup",
];
const FORMATION_MODES: readonly BossMotionState["mode"][] = [
"holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover",
"ram_quake", "ram_recover", "cinderback_slam", "cinderback_recover", "sandglass_eruption", "sandglass_hourglass", "sandglass_recover",
"crab_tidal_burst", "crab_recover", "ghost_haunting", "ghost_recover", "golem_shockwave", "golem_crownfall", "golem_recover",
];
const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging", "ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"];
const ARENA_BOUNDS = { minX: -7.2, maxX: 7.2, minZ: -4.8, maxZ: 7.2 } as const;
const FORMATION_SLOTS: Record<AiMemberId, WorldPosition> = {
// Bosses face Brann during normal uptime, making positive Z their front.
brann: [0, 4.25],
nia: [-3.3, 7.2],
orin: [3.3, 7.2],
vale: [0, -1.7],
};
export function combatFormation(boss: WorldPosition): Record<AiMemberId, WorldPosition> {
return {
brann: [boss[0], boss[1] + 4.25],
nia: [boss[0] - 3.3, boss[1] + 7.2],
orin: [boss[0] + 3.3, boss[1] + 7.2],
vale: [boss[0] + 1.75, boss[1] + 3.4],
brann: [boss[0] + FORMATION_SLOTS.brann[0], boss[1] + FORMATION_SLOTS.brann[1]],
nia: [boss[0] + FORMATION_SLOTS.nia[0], boss[1] + FORMATION_SLOTS.nia[1]],
orin: [boss[0] + FORMATION_SLOTS.orin[0], boss[1] + FORMATION_SLOTS.orin[1]],
vale: [boss[0] + FORMATION_SLOTS.vale[0], boss[1] + FORMATION_SLOTS.vale[1]],
};
}
function clampToArena(position: WorldPosition): WorldPosition {
return [
Math.max(ARENA_BOUNDS.minX, Math.min(ARENA_BOUNDS.maxX, position[0])),
Math.max(ARENA_BOUNDS.minZ, Math.min(ARENA_BOUNDS.maxZ, position[1])),
];
}
export const stackForPounceBehavior: PartyBehavior = {
id: "stack-for-pounce",
decide: ({ memberId, bossMotion }) => {
@@ -122,6 +143,42 @@ export const avoidBreathBehavior: PartyBehavior = {
},
};
export const evadeSlashLanesBehavior: PartyBehavior = {
id: "evade-slash-lanes",
decide: ({ memberId, current, formationTarget, bossMotion }) => {
if (!LANE_EVADE_MODES.includes(bossMotion.mode)) return null;
if (!bossMotion.slashLanes.length) return null;
const unsafe = (position: WorldPosition) => bossMotion.slashLanes.some((lane) =>
pointToSegmentDistance(position, lane.start, lane.end) < lane.width * 0.5 + 0.7,
);
if (!unsafe(current) && !unsafe(formationTarget)) return null;
const origin = unsafe(formationTarget) ? formationTarget : current;
const memberOffset = AI_MEMBER_IDS.indexOf(memberId) * (Math.PI / 4);
let best = current;
let bestScore = Number.NEGATIVE_INFINITY;
for (const radius of [2.2, 3.4]) {
for (let index = 0; index < 8; index += 1) {
const angle = memberOffset + (index / 8) * Math.PI * 2;
const candidate = clampToArena([
origin[0] + Math.sin(angle) * radius,
origin[1] + Math.cos(angle) * radius,
]);
const laneDistance = Math.min(...bossMotion.slashLanes.map((lane) =>
pointToSegmentDistance(candidate, lane.start, lane.end),
));
const travelPenalty = Math.hypot(candidate[0] - current[0], candidate[1] - current[1]) * 0.08;
const score = laneDistance - travelPenalty;
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
}
return { target: best, speed: 4.8 };
},
};
export const avoidCircleHazardsBehavior: PartyBehavior = {
id: "avoid-circle-hazards",
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
@@ -143,7 +200,7 @@ export const avoidCircleHazardsBehavior: PartyBehavior = {
export const maintainFormationBehavior: PartyBehavior = {
id: "maintain-formation",
decide: ({ formationTarget, bossMotion, memberId }) => {
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall"].includes(bossMotion.mode)) return null;
if (!FORMATION_MODES.includes(bossMotion.mode)) return null;
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
},
};
@@ -152,6 +209,7 @@ export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
breakTetherBehavior,
stackForPounceBehavior,
evadeChargeBehavior,
evadeSlashLanesBehavior,
avoidBreathBehavior,
avoidCircleHazardsBehavior,
maintainFormationBehavior,
@@ -164,6 +222,7 @@ export function updatePartyPositions(
time: number,
delta: number,
behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS,
moveSpeedMultipliers?: Partial<Record<AiMemberId, number>>,
) {
const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions];
const activeMotions = bossMotions;
@@ -176,13 +235,10 @@ export function updatePartyPositions(
};
if (!activeMotions.length) return next;
const formationOrigin: WorldPosition = [
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
];
const formation = combatFormation(formationOrigin);
// Vale fights from the boss-cluster midpoint so short-range cleaves can
// connect with both targets when their hit volumes overlap.
formation.vale = [formationOrigin[0], formationOrigin[1] + 1.7];
for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) {
const memberId = AI_MEMBER_IDS[index];
@@ -200,12 +256,14 @@ export function updatePartyPositions(
time,
});
if (!decision) continue;
next[memberId] = moveToward(next[memberId], decision.target, decision.speed * delta);
next[memberId] = clampToArena(moveToward(next[memberId], decision.target, decision.speed * (moveSpeedMultipliers?.[memberId] ?? 1) * delta));
handled = true;
break;
}
if (handled) break;
}
}
for (const memberId of AI_MEMBER_IDS) next[memberId] = clampToArena(next[memberId]);
next.aelia = clampToArena(next.aelia);
return next;
}
+4 -5
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { BOSS_ORDER } from "./bossCatalog";
import { freshParty } from "./data";
import {
advancePartyCombat,
@@ -141,11 +142,9 @@ describe("party ability combat", () => {
});
describe("dual-boss damage simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"],
["bulldrome", "cindermaw"],
["vexa", "cindermaw"],
];
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
);
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
const result = simulate([first, second]);
+11 -1
View File
@@ -110,6 +110,7 @@ export interface PartyCombatContext {
positions: Record<MemberId, WorldPosition>;
targets: PartyCombatTarget[];
upcomingMechanicRemaining: number;
gearModifiers?: Record<AiCombatantId, { damage: number; cooldown: number }>;
}
interface AbilitySpec {
@@ -377,7 +378,16 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
const startAt = Math.max(context.oldTime, actor.readyAt);
if (startAt > context.time + 0.001) break;
const spec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
const baseSpec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
const modifier = context.gearModifiers?.[id];
const spec = baseSpec && modifier ? {
...baseSpec,
duration: baseSpec.duration * modifier.cooldown,
impactOffsets: baseSpec.impactOffsets.map((offset) => offset * modifier.cooldown),
damage: baseSpec.damage * modifier.damage,
gcd: baseSpec.gcd * modifier.cooldown,
cooldown: baseSpec.cooldown === undefined ? undefined : baseSpec.cooldown * modifier.cooldown,
} : baseSpec;
if (!spec) { actor.readyAt = context.time + 0.1; break; }
const range = id === "vale" ? VALE_MELEE_RANGE : id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
const target = targetFor(id, { ...context, targets }, range);
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { useGameStore } from "./store";
describe("runtime performance budgets", () => {
it("keeps ten minutes of dual-boss simulation bounded", () => {
const store = useGameStore.getState();
store.configureHealer("priest", "Perf", [], ["cinderback-ricochet", "sandglass-scorpion"]);
store.startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 },
})),
party: state.party.map((member) => ({ ...member, absorb: 1_000_000_000 })),
}));
let maxHazards = 0;
let maxDamageEvents = 0;
let maxCombatLog = 0;
const startedAt = performance.now();
for (let step = 0; step < 6_000; step += 1) {
useGameStore.getState().tick(0.1);
const state = useGameStore.getState();
const hazards = state.bossMotion.hazards.length
+ state.additionalBosses.reduce((sum, entry) => sum + entry.motion.hazards.length, 0);
maxHazards = Math.max(maxHazards, hazards);
maxDamageEvents = Math.max(maxDamageEvents, state.partyDamageEvents.length);
maxCombatLog = Math.max(maxCombatLog, state.combatLog.length);
}
const elapsedMs = performance.now() - startedAt;
const result = useGameStore.getState();
expect(result.phase).toBe("combat");
expect(result.time).toBeCloseTo(600, 5);
expect(maxHazards).toBeLessThanOrEqual(64);
expect(maxDamageEvents).toBeLessThanOrEqual(24);
expect(maxCombatLog).toBeLessThanOrEqual(12);
expect(elapsedMs).toBeLessThan(3_500);
});
});
+26
View File
@@ -0,0 +1,26 @@
export const PERFORMANCE_PROBE_ENABLED = typeof window !== "undefined"
&& new URLSearchParams(window.location.search).has("perf");
const MAX_TICK_SAMPLES = 300;
const simulationTickSamples: number[] = [];
export function recordSimulationTick(durationMs: number) {
if (!PERFORMANCE_PROBE_ENABLED) return;
if (simulationTickSamples.length === MAX_TICK_SAMPLES) simulationTickSamples.shift();
simulationTickSamples.push(durationMs);
}
export function simulationTickSnapshot() {
if (!simulationTickSamples.length) return { averageMs: 0, maximumMs: 0, samples: 0 };
let total = 0;
let maximumMs = 0;
for (const duration of simulationTickSamples) {
total += duration;
if (duration > maximumMs) maximumMs = duration;
}
return {
averageMs: total / simulationTickSamples.length,
maximumMs,
samples: simulationTickSamples.length,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { createEncounterGearModifiers } from "./gearEffects";
import {
GEAR_RECIPES,
canAffordGearUpgrade,
createDefaultGearProgress,
gearUpgradeCosts,
upgradeGearSlot,
} from "./gear";
import { bossCoinDrop, type MaterialStack } from "./loot";
describe("IWT2-style gear progression", () => {
it("uses exact rank-one and rank-two boss coin costs", () => {
const rankOne = gearUpgradeCosts("priest", "weapon", 0);
const rankTwo = gearUpgradeCosts("priest", "weapon", 1);
expect(rankOne.map((cost) => cost.quantity)).toEqual([2]);
expect(rankTwo.map((cost) => cost.quantity)).toEqual([3, 1]);
});
it("preserves IWT2 Veteran coin parity for ranks six through ten", () => {
const recipe = GEAR_RECIPES.nia.weapon;
const costs = gearUpgradeCosts("nia", "weapon", 5);
expect(costs[0].itemId).toBe(bossCoinDrop(recipe.primaryBossId, "veteran").id);
expect(costs.map((cost) => cost.quantity)).toEqual([6, 5]);
});
it("spends all costs atomically and advances one rank", () => {
const progress = createDefaultGearProgress();
const costs = gearUpgradeCosts("brann", "weapon", 0);
const coin = bossCoinDrop(GEAR_RECIPES.brann.weapon.primaryBossId, "initiate");
const inventory: MaterialStack[] = [{ id: coin.id, name: coin.name, quantity: 3, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
expect(canAffordGearUpgrade(inventory, costs)).toBe(true);
const result = upgradeGearSlot(progress, inventory, "brann", "weapon");
expect(result.gearProgress.brann.slots.weapon.level).toBe(1);
expect(result.inventory[0].quantity).toBe(1);
expect(progress.brann.slots.weapon.level).toBe(0);
});
it("projects rank bonuses without mutating saved progress", () => {
const progress = createDefaultGearProgress();
progress.priest.slots.weapon.level = 10;
progress.priest.slots.chest.level = 10;
progress.brann.slots.weapon.level = 10;
progress.brann.slots.helmet.level = 10;
const modifiers = createEncounterGearModifiers(progress, "priest");
expect(modifiers.aelia.healingPower).toBeCloseTo(1.4);
expect(modifiers.aelia.maxHealth).toBeCloseTo(1.4);
expect(modifiers.brann.damage).toBeCloseTo(1.4);
expect(modifiers.brann.stunDuration).toBeCloseTo(0.2);
});
});
+214
View File
@@ -0,0 +1,214 @@
import type { HealerClassId, MemberId, BossId, RunBuffId } from "../types";
import { bossCoinDrop, type DifficultySlug, type MaterialStack } from "./loot";
export type GearOwnerId = HealerClassId | Exclude<MemberId, "aelia">;
export type GearSlotId = "weapon" | "helmet" | "chest" | "legs" | "feet";
export type GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
export type GearStatId = "healingPower" | "damage" | "attackCooldown" | "maxHealth" | "moveSpeed" | "stunResist" | "hazardDamageTaken";
export interface GearSlotProgress {
level: GearLevel;
}
export interface ClassGearProgress {
slots: Record<GearSlotId, GearSlotProgress>;
infusionAbilityId: string | null;
passiveInfusionId: RunBuffId | null;
}
export type GearProgress = Record<GearOwnerId, ClassGearProgress>;
export interface GearRecipe {
ownerId: GearOwnerId;
slotId: GearSlotId;
statId: GearStatId;
primaryBossId: BossId;
secondaryBossId: BossId;
}
export interface GearUpgradeCost {
itemId: string;
itemName: string;
quantity: number;
}
export const GEAR_OWNER_ORDER: readonly GearOwnerId[] = ["priest", "druid", "shaman", "brann", "nia", "orin", "vale"];
export const GEAR_SLOT_ORDER: readonly GearSlotId[] = ["weapon", "helmet", "chest", "legs", "feet"];
export const MAX_GEAR_LEVEL: GearLevel = 10;
export const GEAR_OWNER_LABELS: Record<GearOwnerId, string> = {
priest: "Priest",
druid: "Druid",
shaman: "Shaman",
brann: "Brann · Knight",
nia: "Nia · Ranger",
orin: "Orin · Mage",
vale: "Vale · Rogue",
};
export const GEAR_SLOT_LABELS: Record<GearSlotId, string> = {
weapon: "Weapon",
helmet: "Helmet",
chest: "Chest",
legs: "Legs",
feet: "Feet",
};
export const GEAR_STAT_LABELS: Record<GearStatId, string> = {
healingPower: "Healing Power",
damage: "Damage",
attackCooldown: "Cooldown",
maxHealth: "Max Health",
moveSpeed: "Move Speed",
stunResist: "Stun Resist",
hazardDamageTaken: "Hazard Damage Taken",
};
type RecipeSeed = Record<GearSlotId, readonly [BossId, BossId]>;
const HEALER_RECIPES: RecipeSeed = {
weapon: ["vexa", "cindermaw"],
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
chest: ["obsidian-ram-golem", "bulldrome"],
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
feet: ["vexa", "sandglass-scorpion"],
};
const OWNER_RECIPE_SEEDS: Record<GearOwnerId, RecipeSeed> = {
priest: HEALER_RECIPES,
druid: HEALER_RECIPES,
shaman: HEALER_RECIPES,
brann: {
weapon: ["obsidian-ram-golem", "bulldrome"],
helmet: ["ember-mantis-duelist", "sandglass-scorpion"],
chest: ["obsidian-ram-golem", "cindermaw"],
legs: ["bulldrome", "cinderback-ricochet"],
feet: ["sandglass-scorpion", "vexa"],
},
nia: {
weapon: ["cinderback-ricochet", "cindermaw"],
helmet: ["vexa", "sandglass-scorpion"],
chest: ["bulldrome", "obsidian-ram-golem"],
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
feet: ["sandglass-scorpion", "vexa"],
},
orin: {
weapon: ["cindermaw", "vexa"],
helmet: ["sandglass-scorpion", "cinderback-ricochet"],
chest: ["vexa", "obsidian-ram-golem"],
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
feet: ["sandglass-scorpion", "bulldrome"],
},
vale: {
weapon: ["ember-mantis-duelist", "cindermaw"],
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
chest: ["bulldrome", "obsidian-ram-golem"],
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
feet: ["vexa", "sandglass-scorpion"],
},
};
function statFor(ownerId: GearOwnerId, slotId: GearSlotId): GearStatId {
if (slotId === "weapon") return ownerId === "priest" || ownerId === "druid" || ownerId === "shaman" ? "healingPower" : "damage";
if (slotId === "helmet") return ownerId === "brann" ? "stunResist" : "attackCooldown";
if (slotId === "chest") return "maxHealth";
if (slotId === "legs") return "moveSpeed";
return "hazardDamageTaken";
}
export const GEAR_RECIPES = Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [
ownerId,
Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => {
const [primaryBossId, secondaryBossId] = OWNER_RECIPE_SEEDS[ownerId][slotId];
return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryBossId, secondaryBossId }];
})),
])) as Record<GearOwnerId, Record<GearSlotId, GearRecipe>>;
export function createDefaultGearProgress(): GearProgress {
return Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [
ownerId,
{
slots: Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => [slotId, { level: 0 }])),
infusionAbilityId: null,
passiveInfusionId: null,
},
])) as GearProgress;
}
export function gearUpgradeCosts(ownerId: GearOwnerId, slotId: GearSlotId, currentLevel: GearLevel): GearUpgradeCost[] {
if (currentLevel >= MAX_GEAR_LEVEL) return [];
const nextLevel = currentLevel + 1 as Exclude<GearLevel, 0>;
const recipe = GEAR_RECIPES[ownerId][slotId];
const difficultySlug = upgradeDifficultySlug(nextLevel);
const primary = bossCoinDrop(recipe.primaryBossId, difficultySlug);
const secondary = bossCoinDrop(recipe.secondaryBossId, difficultySlug);
if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }];
const primaryQuantity = nextLevel <= 3 ? 3 : nextLevel <= 5 ? nextLevel : nextLevel;
const secondaryQuantity = nextLevel === 2 ? 1 : nextLevel === 3 ? 2 : nextLevel - 1;
return [
{ itemId: primary.id, itemName: primary.name, quantity: primaryQuantity },
{ itemId: secondary.id, itemName: secondary.name, quantity: secondaryQuantity },
];
}
export function canAffordGearUpgrade(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): boolean {
return costs.every((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) >= cost.quantity);
}
export function spendGearCosts(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): MaterialStack[] {
if (!canAffordGearUpgrade(inventory, costs)) {
const missing = costs.find((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) < cost.quantity);
throw new Error(missing ? `Need ${missing.quantity} ${missing.itemName}.` : "Missing gear materials.");
}
return inventory.flatMap((item) => {
const cost = costs.find((entry) => entry.itemId === item.id);
if (!cost) return [{ ...item }];
const quantity = item.quantity - cost.quantity;
return quantity > 0 ? [{ ...item, quantity }] : [];
});
}
export function upgradeGearSlot(
progress: GearProgress,
inventory: readonly MaterialStack[],
ownerId: GearOwnerId,
slotId: GearSlotId,
): { gearProgress: GearProgress; inventory: MaterialStack[] } {
const currentLevel = progress[ownerId].slots[slotId].level;
if (currentLevel >= MAX_GEAR_LEVEL) throw new Error(`${GEAR_SLOT_LABELS[slotId]} already at +${MAX_GEAR_LEVEL}.`);
const costs = gearUpgradeCosts(ownerId, slotId, currentLevel);
const inventoryAfter = spendGearCosts(inventory, costs);
return {
inventory: inventoryAfter,
gearProgress: {
...progress,
[ownerId]: {
...progress[ownerId],
slots: {
...progress[ownerId].slots,
[slotId]: { level: currentLevel + 1 as GearLevel },
},
},
},
};
}
export function gearBonusText(statId: GearStatId, level: number): string {
if (statId === "moveSpeed") return `+${formatPercent(level * 2.5)}%`;
if (statId === "attackCooldown" || statId === "hazardDamageTaken") return `-${level * 3}%`;
if (statId === "stunResist") return `-${level * 8}%`;
return `+${level * 4}%`;
}
function formatPercent(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
// IWT2 parity: +6 through +10 return to Veteran coins while infusion costs use higher tiers.
function upgradeDifficultySlug(level: Exclude<GearLevel, 0>): DifficultySlug {
if (level <= 2) return "initiate";
if (level >= 6) return "veteran";
if (level === 3) return "veteran";
if (level === 4) return "champion";
return "mythic";
}
+80
View File
@@ -0,0 +1,80 @@
import type { HealerClassId, MemberId, PartyMember } from "../types";
import type { AiCombatantId } from "../partyCombat";
import { GEAR_RECIPES, GEAR_SLOT_ORDER, type GearOwnerId, type GearProgress } from "./gear";
import { ACTIVE_INFUSIONS, activeInfusionUnlocked } from "./infusions";
export interface MemberGearModifiers {
maxHealth: number;
moveSpeed: number;
healingPower: number;
damage: number;
cooldown: number;
hazardDamageTaken: number;
stunDuration: number;
}
export type EncounterGearModifiers = Record<MemberId, MemberGearModifiers>;
export const EMPTY_MEMBER_GEAR_MODIFIERS: MemberGearModifiers = {
maxHealth: 1,
moveSpeed: 1,
healingPower: 1,
damage: 1,
cooldown: 1,
hazardDamageTaken: 1,
stunDuration: 1,
};
export function createEncounterGearModifiers(progress: GearProgress, healerClassId: HealerClassId): EncounterGearModifiers {
return {
aelia: modifiersForOwner(progress, healerClassId),
brann: modifiersForOwner(progress, "brann"),
nia: modifiersForOwner(progress, "nia"),
orin: modifiersForOwner(progress, "orin"),
vale: modifiersForOwner(progress, "vale"),
};
}
export function applyGearHealth(party: PartyMember[], modifiers: EncounterGearModifiers): PartyMember[] {
return party.map((member) => {
const maxHp = Math.round(member.maxHp * modifiers[member.id].maxHealth);
return { ...member, maxHp, hp: maxHp };
});
}
export function aiCombatModifiers(modifiers: EncounterGearModifiers): Record<AiCombatantId, Pick<MemberGearModifiers, "damage" | "cooldown">> {
return {
brann: modifiers.brann,
nia: modifiers.nia,
orin: modifiers.orin,
vale: modifiers.vale,
};
}
function modifiersForOwner(progress: GearProgress, ownerId: GearOwnerId): MemberGearModifiers {
const result = { ...EMPTY_MEMBER_GEAR_MODIFIERS };
for (const slotId of GEAR_SLOT_ORDER) {
const level = progress[ownerId].slots[slotId].level;
if (level <= 0) continue;
const statId = GEAR_RECIPES[ownerId][slotId].statId;
if (statId === "maxHealth") result.maxHealth *= 1 + level * 0.04;
else if (statId === "moveSpeed") result.moveSpeed *= 1 + level * 0.025;
else if (statId === "healingPower") result.healingPower *= 1 + level * 0.04;
else if (statId === "damage") result.damage *= 1 + level * 0.04;
else if (statId === "attackCooldown") result.cooldown *= 1 - level * 0.03;
else if (statId === "hazardDamageTaken") result.hazardDamageTaken *= 1 - level * 0.03;
else if (statId === "stunResist") result.stunDuration *= Math.max(0, 1 - level * 0.08);
}
const ownerProgress = progress[ownerId];
const infusion = ownerProgress.infusionAbilityId ? ACTIVE_INFUSIONS[ownerProgress.infusionAbilityId] : undefined;
if (infusion?.ownerId === ownerId && activeInfusionUnlocked(ownerProgress)) {
if (infusion.effectKey === "max-health") result.maxHealth *= 1.08;
else if (infusion.effectKey === "move-speed") result.moveSpeed *= 1.08;
else if (infusion.effectKey === "healing-power") result.healingPower *= 1.08;
else if (infusion.effectKey === "damage") result.damage *= 1.08;
else if (infusion.effectKey === "cooldown") result.cooldown *= 0.92;
else if (infusion.effectKey === "hazard-shield") result.hazardDamageTaken *= 0.85;
else if (infusion.effectKey === "stun-immune") result.stunDuration = 0;
}
return result;
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "../healers";
import { useGameStore } from "../store";
import { createEncounterGearModifiers } from "./gearEffects";
import { createDefaultGearProgress } from "./gear";
import {
equipActiveInfusion,
equipPassiveInfusion,
infusionCosts,
passiveInfusionUnlocked,
} from "./infusions";
import { bossCoinDrop, type MaterialStack } from "./loot";
function stack(item: ReturnType<typeof bossCoinDrop>, quantity: number): MaterialStack {
return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity };
}
describe("IWT2-style gear infusions", () => {
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic coins", () => {
const progress = createDefaultGearProgress();
progress.brann.slots.weapon.level = 5;
const costs = infusionCosts("brann", "weapon", "brann-unbreakable");
const inventory = [
stack(bossCoinDrop("obsidian-ram-golem", "ascendant"), 6),
stack(bossCoinDrop("obsidian-ram-golem", "mythic"), 6),
];
const result = equipActiveInfusion(progress, inventory, "brann", "weapon", "brann-unbreakable");
expect(costs.map((cost) => cost.quantity)).toEqual([5, 5]);
expect(result.inventory.map((item) => item.quantity)).toEqual([1, 1]);
expect(result.gearProgress.brann.infusionAbilityId).toBe("brann-unbreakable");
expect(progress.brann.infusionAbilityId).toBeNull();
});
it("rejects locked anchors and infusions owned by another hero", () => {
const progress = createDefaultGearProgress();
progress.brann.slots.chest.level = 5;
expect(() => equipActiveInfusion(progress, [], "brann", "weapon", "brann-unbreakable")).toThrow("Selected anchor");
expect(() => equipActiveInfusion(progress, [], "brann", "chest", "nia-quickdraw")).toThrow("does not belong");
});
it("compiles active effects into encounter modifiers once", () => {
const progress = createDefaultGearProgress();
progress.brann.slots.weapon.level = 5;
progress.brann.infusionAbilityId = "brann-unbreakable";
progress.nia.slots.weapon.level = 5;
progress.nia.infusionAbilityId = "nia-hunters-mark";
const modifiers = createEncounterGearModifiers(progress, "priest");
expect(modifiers.brann.stunDuration).toBe(0);
expect(modifiers.nia.damage).toBeCloseTo(1.08 * 1.2);
});
it("unlocks one free healer passive globally at +10 and applies it next encounter", () => {
const progress = createDefaultGearProgress();
progress.vale.slots.feet.level = 10;
expect(passiveInfusionUnlocked(progress)).toBe(true);
const infused = equipPassiveInfusion(progress, "priest", "deep-wells");
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "bulldrome", "encounter", infused);
expect(useGameStore.getState().maxMana).toBe(120);
expect(useGameStore.getState().runBuffs).toEqual([]);
});
});
+146
View File
@@ -0,0 +1,146 @@
import { RUN_BUFFS, RUN_BUFF_ORDER } from "../roguelike";
import type { BossId, RunBuffId } from "../types";
import {
GEAR_RECIPES,
GEAR_SLOT_ORDER,
spendGearCosts,
type ClassGearProgress,
type GearOwnerId,
type GearProgress,
type GearSlotId,
type GearUpgradeCost,
} from "./gear";
import { bossCoinDrop, type MaterialStack } from "./loot";
export const ACTIVE_INFUSION_MIN_GEAR_LEVEL = 5;
export const PASSIVE_INFUSION_MIN_GEAR_LEVEL = 10;
export type InfusionEffectKey =
| "healing-power"
| "hazard-shield"
| "max-health"
| "stun-immune"
| "cooldown"
| "move-speed"
| "damage";
export interface ActiveInfusionDefinition {
id: string;
ownerId: GearOwnerId;
name: string;
icon: string;
description: string;
linkedBossId: BossId;
effectKey: InfusionEffectKey;
}
type InfusionSeed = Omit<ActiveInfusionDefinition, "ownerId">;
const INFUSION_SEEDS: Record<GearOwnerId, readonly InfusionSeed[]> = {
priest: [
{ id: "priest-sanctuary", name: "Sanctuary", icon: "✦", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
{ id: "priest-guardian-grace", name: "Guardian Grace", icon: "✚", description: "8% more healing power.", linkedBossId: "cindermaw", effectKey: "healing-power" },
{ id: "priest-miracle-ward", name: "Miracle Ward", icon: "◇", description: "8% more maximum health.", linkedBossId: "obsidian-ram-golem", effectKey: "max-health" },
],
druid: [
{ id: "druid-barkskin", name: "Barkskin", icon: "♧", description: "8% more maximum health.", linkedBossId: "obsidian-ram-golem", effectKey: "max-health" },
{ id: "druid-verdant-pulse", name: "Verdant Pulse", icon: "❈", description: "8% more healing power.", linkedBossId: "ember-mantis-duelist", effectKey: "healing-power" },
{ id: "druid-wildstep", name: "Wildstep", icon: "⌁", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" },
],
shaman: [
{ id: "shaman-spirit-ward", name: "Spirit Ward", icon: "◈", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
{ id: "shaman-ancestral-surge", name: "Ancestral Surge", icon: "ϟ", description: "8% more healing power.", linkedBossId: "cinderback-ricochet", effectKey: "healing-power" },
{ id: "shaman-windwalk", name: "Windwalk", icon: "≋", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" },
],
brann: [
{ id: "brann-unbreakable", name: "Unbreakable", icon: "▣", description: "Immune to stuns.", linkedBossId: "obsidian-ram-golem", effectKey: "stun-immune" },
{ id: "brann-bulwark", name: "Bulwark", icon: "⬡", description: "8% more maximum health.", linkedBossId: "bulldrome", effectKey: "max-health" },
{ id: "brann-vanguard", name: "Vanguard", icon: "➶", description: "8% faster movement.", linkedBossId: "cinderback-ricochet", effectKey: "move-speed" },
],
nia: [
{ id: "nia-hunters-mark", name: "Hunter's Mark", icon: "◎", description: "8% more damage.", linkedBossId: "cinderback-ricochet", effectKey: "damage" },
{ id: "nia-quickdraw", name: "Quickdraw", icon: "➳", description: "8% faster attacks.", linkedBossId: "ember-mantis-duelist", effectKey: "cooldown" },
{ id: "nia-decoy", name: "Decoy", icon: "♙", description: "8% more maximum health.", linkedBossId: "vexa", effectKey: "max-health" },
],
orin: [
{ id: "orin-overcharge", name: "Overcharge", icon: "ϟ", description: "8% more damage.", linkedBossId: "cindermaw", effectKey: "damage" },
{ id: "orin-temporal-flow", name: "Temporal Flow", icon: "◷", description: "8% faster attacks.", linkedBossId: "sandglass-scorpion", effectKey: "cooldown" },
{ id: "orin-arcane-barrier", name: "Arcane Barrier", icon: "◇", description: "8% more maximum health.", linkedBossId: "vexa", effectKey: "max-health" },
],
vale: [
{ id: "vale-expose", name: "Expose", icon: "†", description: "8% more damage.", linkedBossId: "ember-mantis-duelist", effectKey: "damage" },
{ id: "vale-shadow-step", name: "Shadow Step", icon: "⌁", description: "8% faster movement.", linkedBossId: "cinderback-ricochet", effectKey: "move-speed" },
{ id: "vale-vanish", name: "Vanish", icon: "◌", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
],
};
export const ACTIVE_INFUSIONS = Object.fromEntries(Object.entries(INFUSION_SEEDS).flatMap(([ownerId, choices]) =>
choices.map((choice) => [choice.id, { ...choice, ownerId: ownerId as GearOwnerId }]),
)) as Record<string, ActiveInfusionDefinition>;
const ACTIVE_INFUSIONS_BY_OWNER = Object.fromEntries(Object.entries(INFUSION_SEEDS).map(([ownerId, choices]) => [
ownerId,
choices.map((choice) => ACTIVE_INFUSIONS[choice.id]),
])) as unknown as Record<GearOwnerId, readonly ActiveInfusionDefinition[]>;
export const PASSIVE_INFUSIONS = RUN_BUFF_ORDER.map((id) => RUN_BUFFS[id]);
export function infusionsForOwner(ownerId: GearOwnerId): readonly ActiveInfusionDefinition[] {
return ACTIVE_INFUSIONS_BY_OWNER[ownerId];
}
export function activeInfusionUnlocked(progress: ClassGearProgress): boolean {
return GEAR_SLOT_ORDER.some((slotId) => progress.slots[slotId].level >= ACTIVE_INFUSION_MIN_GEAR_LEVEL);
}
export function passiveInfusionUnlocked(progress: GearProgress): boolean {
return Object.values(progress).some((owner) => GEAR_SLOT_ORDER.some((slotId) => owner.slots[slotId].level >= PASSIVE_INFUSION_MIN_GEAR_LEVEL));
}
export function infusionCosts(ownerId: GearOwnerId, slotId: GearSlotId, infusionId: string): GearUpgradeCost[] {
const definition = ACTIVE_INFUSIONS[infusionId];
if (!definition || definition.ownerId !== ownerId) return [];
const ascendant = bossCoinDrop(definition.linkedBossId, "ascendant");
const mythic = bossCoinDrop(GEAR_RECIPES[ownerId][slotId].primaryBossId, "mythic");
return [
{ itemId: ascendant.id, itemName: ascendant.name, quantity: 5 },
{ itemId: mythic.id, itemName: mythic.name, quantity: 5 },
];
}
export function equipActiveInfusion(
progress: GearProgress,
inventory: readonly MaterialStack[],
ownerId: GearOwnerId,
slotId: GearSlotId,
infusionId: string,
): { gearProgress: GearProgress; inventory: MaterialStack[] } {
const definition = ACTIVE_INFUSIONS[infusionId];
if (!definition || definition.ownerId !== ownerId) throw new Error("Infusion does not belong to selected hero.");
if (!activeInfusionUnlocked(progress[ownerId])) throw new Error(`Raise any ${ownerId} gear slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}.`);
if (progress[ownerId].slots[slotId].level < ACTIVE_INFUSION_MIN_GEAR_LEVEL) throw new Error(`Selected anchor must be +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}.`);
if (progress[ownerId].infusionAbilityId === infusionId) return { gearProgress: progress, inventory: [...inventory] };
return {
inventory: spendGearCosts(inventory, infusionCosts(ownerId, slotId, infusionId)),
gearProgress: {
...progress,
[ownerId]: { ...progress[ownerId], infusionAbilityId: infusionId },
},
};
}
export function equipPassiveInfusion(progress: GearProgress, ownerId: GearOwnerId, passiveId: RunBuffId): GearProgress {
if (ownerId !== "priest" && ownerId !== "druid" && ownerId !== "shaman") throw new Error("Passive infusions belong to healer gear.");
if (!RUN_BUFF_ORDER.includes(passiveId)) throw new Error("Unknown passive infusion.");
if (!passiveInfusionUnlocked(progress)) throw new Error(`Raise any gear slot to +${PASSIVE_INFUSION_MIN_GEAR_LEVEL}.`);
return { ...progress, [ownerId]: { ...progress[ownerId], passiveInfusionId: passiveId } };
}
export function normalizeActiveInfusionId(ownerId: GearOwnerId, value: unknown): string | null {
return typeof value === "string" && ACTIVE_INFUSIONS[value]?.ownerId === ownerId ? value : null;
}
export function normalizePassiveInfusionId(ownerId: GearOwnerId, value: unknown): RunBuffId | null {
if (ownerId !== "priest" && ownerId !== "druid" && ownerId !== "shaman") return null;
return typeof value === "string" && RUN_BUFF_ORDER.includes(value as RunBuffId) ? value as RunBuffId : null;
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { BOSS_ORDER } from "../bossCatalog";
import { BOSS_DROP_TABLES, coinDropQuantity, createEmptyCollectionLog, rollBossReward } from "./loot";
describe("boss drop tables", () => {
it("defines tiered coins and a pet for every shipped boss", () => {
for (const bossId of BOSS_ORDER) {
const table = BOSS_DROP_TABLES[bossId];
expect(Object.keys(table.coins)).toEqual(["initiate", "veteran", "champion", "mythic", "ascendant"]);
expect(table.pet.dropRate).toBe(1 / 500);
}
});
it("rolls IWT2 coin quantities at exact boundaries", () => {
expect(coinDropQuantity(() => 0)).toBe(3);
expect(coinDropQuantity(() => 0.149999)).toBe(3);
expect(coinDropQuantity(() => 0.15)).toBe(2);
expect(coinDropQuantity(() => 0.499999)).toBe(2);
expect(coinDropQuantity(() => 0.5)).toBe(1);
});
it("stacks duplicate coins while collection counts remain lifetime totals", () => {
const rolls = [0.1, 1, 0.2, 0];
const random = () => rolls.shift() ?? 1;
const first = rollBossReward("bulldrome", "initiate", [], createEmptyCollectionLog(), random);
const second = rollBossReward("bulldrome", "initiate", first.inventory, first.collectionLog, random);
expect(first.award.quantity).toBe(3);
expect(second.award.quantity).toBe(2);
expect(second.award.duplicate).toBe(true);
expect(second.inventory[0].quantity).toBe(5);
expect(second.collectionLog.dropsFound[second.award.coin.id]).toBe(5);
expect(second.award.pet?.id).toBe("bulldrome-pet");
});
});
+194
View File
@@ -0,0 +1,194 @@
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../bossCatalog";
import type { BossId } from "../types";
export type LootRarity = "common" | "uncommon" | "rare" | "epic" | "legendary";
export type DifficultySlug = "initiate" | "veteran" | "champion" | "mythic" | "ascendant";
export interface DifficultyDefinition {
slug: DifficultySlug;
name: string;
itemLevel: number;
rarity: LootRarity;
glyph: string;
coinPrefix: string;
healthMultiplier: number;
damageMultiplier: number;
}
export interface MaterialStack {
id: string;
name: string;
quantity: number;
rarity: LootRarity;
itemLevel: number;
glyph: string;
}
export interface BossCoinDrop {
kind: "coin";
id: string;
bossId: BossId;
difficultySlug: DifficultySlug;
name: string;
rarity: LootRarity;
itemLevel: number;
glyph: string;
chanceLabel: string;
}
export interface BossPetDrop {
kind: "pet";
id: string;
bossId: BossId;
name: string;
rarity: "legendary";
glyph: string;
dropRate: number;
chanceLabel: string;
}
export interface BossDropTable {
bossId: BossId;
coins: Record<DifficultySlug, BossCoinDrop>;
pet: BossPetDrop;
entries: readonly (BossCoinDrop | BossPetDrop)[];
}
export interface CollectionLog {
dropsFound: Record<string, number>;
petsFound: Record<string, number>;
}
export interface BossRewardAward {
bossId: BossId;
coin: BossCoinDrop;
quantity: number;
quantityAfter: number;
duplicate: boolean;
pet: BossPetDrop | null;
petQuantityAfter: number;
}
export const BOSS_PET_DROP_RATE = 1 / 500;
export const DIFFICULTIES: readonly DifficultyDefinition[] = [
{ slug: "initiate", name: "Initiate", itemLevel: 1, rarity: "common", glyph: "R", coinPrefix: "Raw", healthMultiplier: 1, damageMultiplier: 1 },
{ slug: "veteran", name: "Veteran", itemLevel: 10, rarity: "uncommon", glyph: "G", coinPrefix: "Green", healthMultiplier: 1.45, damageMultiplier: 1.25 },
{ slug: "champion", name: "Champion", itemLevel: 15, rarity: "rare", glyph: "B", coinPrefix: "Blue", healthMultiplier: 1.7, damageMultiplier: 1.45 },
{ slug: "mythic", name: "Mythic", itemLevel: 20, rarity: "epic", glyph: "P", coinPrefix: "Purple", healthMultiplier: 2.25, damageMultiplier: 1.85 },
{ slug: "ascendant", name: "Ascendant", itemLevel: 25, rarity: "legendary", glyph: "O", coinPrefix: "Orange", healthMultiplier: 2.8, damageMultiplier: 2.25 },
] as const;
export const DIFFICULTY_BY_SLUG = Object.fromEntries(
DIFFICULTIES.map((difficulty) => [difficulty.slug, difficulty]),
) as Record<DifficultySlug, DifficultyDefinition>;
const DIFFICULTY_ID_PREFIX: Record<DifficultySlug, string> = {
initiate: "raw",
veteran: "green",
champion: "blue",
mythic: "purple",
ascendant: "orange",
};
function createBossDropTable(bossId: BossId): BossDropTable {
const boss = BOSS_DEFINITIONS[bossId];
const coins = Object.fromEntries(DIFFICULTIES.map((difficulty) => [
difficulty.slug,
{
kind: "coin" as const,
id: `${DIFFICULTY_ID_PREFIX[difficulty.slug]}-${bossId}-coin`,
bossId,
difficultySlug: difficulty.slug,
name: `${difficulty.coinPrefix} ${boss.name} Coin`,
rarity: difficulty.rarity,
itemLevel: difficulty.itemLevel,
glyph: difficulty.glyph,
chanceLabel: "Guaranteed 1-3",
},
])) as Record<DifficultySlug, BossCoinDrop>;
const pet: BossPetDrop = {
kind: "pet",
id: `${bossId}-pet`,
bossId,
name: `${boss.name} Pet`,
rarity: "legendary",
glyph: boss.icon,
dropRate: BOSS_PET_DROP_RATE,
chanceLabel: "1 in 500",
};
return {
bossId,
coins,
pet,
entries: [...DIFFICULTIES.map((difficulty) => coins[difficulty.slug]), pet],
};
}
export const BOSS_DROP_TABLES = Object.fromEntries(
BOSS_ORDER.map((bossId) => [bossId, createBossDropTable(bossId)]),
) as Record<BossId, BossDropTable>;
export function bossDropTable(bossId: BossId): BossDropTable {
return BOSS_DROP_TABLES[bossId];
}
export function bossCoinDrop(bossId: BossId, difficultySlug: DifficultySlug): BossCoinDrop {
return BOSS_DROP_TABLES[bossId].coins[difficultySlug];
}
export function coinDropQuantity(random: () => number = Math.random): number {
const roll = random();
if (roll < 0.15) return 3;
if (roll < 0.5) return 2;
return 1;
}
export function rollBossReward(
bossId: BossId,
difficultySlug: DifficultySlug,
inventory: readonly MaterialStack[],
collectionLog: CollectionLog,
random: () => number = Math.random,
): { award: BossRewardAward; inventory: MaterialStack[]; collectionLog: CollectionLog } {
const table = bossDropTable(bossId);
const coin = table.coins[difficultySlug];
const quantity = coinDropQuantity(random);
const existing = inventory.find((item) => item.id === coin.id);
const inventoryAfter = existing
? inventory.map((item) => item.id === coin.id ? { ...item, quantity: item.quantity + quantity } : { ...item })
: [...inventory.map((item) => ({ ...item })), { id: coin.id, name: coin.name, quantity, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
const petAwarded = random() < table.pet.dropRate;
const petQuantityAfter = (collectionLog.petsFound[table.pet.id] ?? 0) + Number(petAwarded);
return {
award: {
bossId,
coin,
quantity,
quantityAfter: (existing?.quantity ?? 0) + quantity,
duplicate: Boolean(existing),
pet: petAwarded ? table.pet : null,
petQuantityAfter,
},
inventory: inventoryAfter,
collectionLog: {
dropsFound: {
...collectionLog.dropsFound,
[coin.id]: (collectionLog.dropsFound[coin.id] ?? 0) + quantity,
},
petsFound: petAwarded
? { ...collectionLog.petsFound, [table.pet.id]: petQuantityAfter }
: { ...collectionLog.petsFound },
},
};
}
export function normalizeDifficultySlug(value: unknown): DifficultySlug {
return typeof value === "string" && value in DIFFICULTY_BY_SLUG
? value as DifficultySlug
: "initiate";
}
export function createEmptyCollectionLog(): CollectionLog {
return { dropsFound: {}, petsFound: {} };
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "./data";
import {
applyRunBuffsToParty,
bossHealthMultiplier,
runHealingMultiplier,
runMaxMana,
selectRandomBossPair,
} from "./roguelike";
describe("roguelike progression", () => {
it("adds 10% base boss HP per completed round", () => {
expect(bossHealthMultiplier(1)).toBe(1);
expect(bossHealthMultiplier(2)).toBe(1.1);
expect(bossHealthMultiplier(5)).toBe(1.4);
});
it("stacks each persistent buff independently", () => {
const buffs = ["vital-bloom", "vital-bloom", "deep-wells", "restoring-grace"] as const;
const party = applyRunBuffsToParty(freshParty("priest", "Aelia"), buffs);
expect(party[0].maxHp).toBe(124);
expect(party[1].maxHp).toBe(186);
expect(runMaxMana(buffs)).toBe(120);
expect(runHealingMultiplier(buffs)).toBe(1.15);
});
it("selects a distinct pair that excludes both bosses from the prior round", () => {
const values = [0, 0];
const pair = selectRandomBossPair(["bulldrome", "vexa"], () => values.shift() ?? 0);
expect(pair).toEqual(["cindermaw", "ember-mantis-duelist"]);
expect(new Set(pair)).toHaveLength(2);
expect(pair).not.toContain("bulldrome");
expect(pair).not.toContain("vexa");
});
});
+76
View File
@@ -0,0 +1,76 @@
import { BOSS_ORDER } from "./bossCatalog";
import type { BossId, PartyMember, RunBuffId } from "./types";
export interface RunBuffDefinition {
id: RunBuffId;
name: string;
icon: string;
summary: string;
detail: string;
accent: string;
}
export const RUN_BUFF_ORDER: readonly RunBuffId[] = ["vital-bloom", "deep-wells", "restoring-grace"];
export const RUN_BUFFS: Record<RunBuffId, RunBuffDefinition> = {
"vital-bloom": {
id: "vital-bloom",
name: "Vital Bloom",
icon: "♧",
summary: "+12% party max HP",
detail: "Stacks each time chosen. New round starts at full health.",
accent: "#74d18c",
},
"deep-wells": {
id: "deep-wells",
name: "Deep Wells",
icon: "◇",
summary: "+20 maximum mana",
detail: "Stacks each time chosen. New round starts with full mana.",
accent: "#69baff",
},
"restoring-grace": {
id: "restoring-grace",
name: "Restoring Grace",
icon: "✦",
summary: "+15% healing done",
detail: "Strengthens Mend, Renew, and Radiance. Stacks additively.",
accent: "#f1d479",
},
};
export function countRunBuff(buffs: readonly RunBuffId[], buffId: RunBuffId) {
return buffs.reduce((count, current) => count + Number(current === buffId), 0);
}
export function applyRunBuffsToParty(party: PartyMember[], buffs: readonly RunBuffId[]) {
const vitalityMultiplier = 1 + countRunBuff(buffs, "vital-bloom") * 0.12;
return party.map((member) => {
const maxHp = Math.round(member.maxHp * vitalityMultiplier);
return { ...member, maxHp, hp: maxHp };
});
}
export function runMaxMana(buffs: readonly RunBuffId[]) {
return 100 + countRunBuff(buffs, "deep-wells") * 20;
}
export function runHealingMultiplier(buffs: readonly RunBuffId[]) {
return 1 + countRunBuff(buffs, "restoring-grace") * 0.15;
}
export function bossHealthMultiplier(round: number) {
return 1 + Math.max(0, round - 1) * 0.1;
}
export function selectRandomBossPair(
excludedBossIds: readonly BossId[] = [],
random: () => number = Math.random,
): readonly [BossId, BossId] {
const excluded = new Set(excludedBossIds);
const eligibleBosses = BOSS_ORDER.filter((bossId) => !excluded.has(bossId));
const pool = eligibleBosses.length >= 2 ? eligibleBosses : BOSS_ORDER;
const firstIndex = Math.floor(random() * pool.length) % pool.length;
const secondOffset = 1 + (Math.floor(random() * (pool.length - 1)) % (pool.length - 1));
return [pool[firstIndex], pool[(firstIndex + secondOffset) % pool.length]];
}
+81 -4
View File
@@ -4,6 +4,8 @@ import { distance, pointToSegmentDistance } from "./geometry";
import { barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
import { isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
describe("Disc Priest combat simulation", () => {
beforeEach(() => {
@@ -148,7 +150,7 @@ describe("Disc Priest combat simulation", () => {
expect(barrierProtects(barrier.center, barrier, 8)).toBe(false);
});
it("keeps ranged allies stable while Vale closes to melee range", () => {
it("keeps ranged allies stable while Vale holds behind the boss", () => {
const start = structuredClone(useGameStore.getState().partyPositions);
const bossPosition = useGameStore.getState().bossMotion.position;
useGameStore.getState().tick(1);
@@ -157,7 +159,8 @@ describe("Disc Priest combat simulation", () => {
expect(moved.brann).toEqual(start.brann);
expect(moved.nia).toEqual(start.nia);
expect(moved.orin).toEqual(start.orin);
expect(distance(moved.vale, bossPosition)).toBeLessThan(distance(start.vale, bossPosition));
expect(moved.brann[1]).toBeGreaterThan(bossPosition[1]);
expect(moved.vale[1]).toBeLessThan(bossPosition[1]);
});
it("freezes authoritative simulation while paused", () => {
@@ -255,7 +258,7 @@ describe("Disc Priest combat simulation", () => {
for (const memberId of exposedAtWarning) expect(hitIds).not.toContain(memberId);
});
it("marks a stack target after three charges and splits 300 pounce damage", () => {
it("marks a stack target after three charges and splits 200 pounce damage", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
playerPosition: [0, 0],
@@ -291,7 +294,7 @@ describe("Disc Priest combat simulation", () => {
const impacted = useGameStore.getState();
expect(impacted.bossMotion.mode).toBe("returning");
for (const member of impacted.party) {
expect(member.hp).toBeCloseTo(startingHp[member.id] - 42, 3);
expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
}
expect(impacted.partyCombat.tankAura.expiresAt).toBeGreaterThan(impacted.time);
});
@@ -412,6 +415,80 @@ describe("PVE dual-boss encounter", () => {
});
});
describe("Roguelike rounds", () => {
beforeEach(() => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "vexa"],
"roguelike",
);
useGameStore.getState().startEncounter();
});
it("blocks progression for a buff, then starts round two with new bosses at 110% base HP", () => {
const roundOne = useGameStore.getState();
const previousBossIds = [roundOne.boss.id, roundOne.additionalBosses[0].boss.id];
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("intermission");
const intermissionTime = useGameStore.getState().time;
useGameStore.getState().tick(10);
expect(useGameStore.getState().phase).toBe("intermission");
expect(useGameStore.getState().round).toBe(1);
expect(useGameStore.getState().time).toBe(intermissionTime);
expect(useGameStore.getState().chooseRunBuff("vital-bloom")).toBe(true);
const roundTwo = useGameStore.getState();
const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id];
expect(roundTwo.phase).toBe("combat");
expect(roundTwo.round).toBe(2);
expect(roundTwo.runBuffs).toEqual(["vital-bloom"]);
expect(nextBossIds.every((bossId) => !previousBossIds.includes(bossId))).toBe(true);
expect(roundTwo.boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.boss.id].maxHp * 1.1));
expect(roundTwo.additionalBosses[0].boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.additionalBosses[0].boss.id].maxHp * 1.1));
expect(roundTwo.party[0].maxHp).toBe(112);
});
it("rejects buff claims outside intermission", () => {
expect(useGameStore.getState().chooseRunBuff("deep-wells")).toBe(false);
expect(useGameStore.getState().round).toBe(1);
});
});
describe("shared arena boundary", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
useGameStore.getState().startEncounter();
});
it("constrains player, party, and boss positions to the same room", () => {
useGameStore.getState().setPlayerPosition([100, 100]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
bossMotion: { ...state.bossMotion, position: [100, -100], nextChargeAt: 999 },
partyPositions: {
...state.partyPositions,
brann: [50, 50],
nia: [-50, 50],
orin: [50, -50],
vale: [-50, -50],
},
}));
useGameStore.getState().tick(0.1);
const state = useGameStore.getState();
expect(isInsideArena(state.playerPosition)).toBe(true);
for (const position of Object.values(state.partyPositions)) expect(isInsideArena(position)).toBe(true);
expect(isInsideArena(state.bossMotion.position)).toBe(true);
});
});
describe("Cindermaw encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "cindermaw");
+140 -26
View File
@@ -7,12 +7,26 @@ import {
upcomingMechanic,
} from "./bossMechanics";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { clampToArena, constrainBossMotion } from "./arena";
import { cloneMotion } from "./bosses/shared";
import { freshParty } from "./data";
import { distance } from "./geometry";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import {
RUN_BUFF_ORDER,
RUN_BUFFS,
applyRunBuffsToParty,
bossHealthMultiplier,
runHealingMultiplier,
runMaxMana,
selectRandomBossPair,
} from "./roguelike";
import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects";
import { passiveInfusionUnlocked } from "./progression/infusions";
import { DIFFICULTY_BY_SLUG, normalizeDifficultySlug, type DifficultySlug } from "./progression/loot";
import type {
ActiveCast,
AbilityId,
@@ -26,6 +40,8 @@ import type {
InventoryItem,
MemberId,
PartyMember,
RunBuffId,
RunMode,
ScenePulse,
WorldPosition,
} from "./types";
@@ -50,6 +66,16 @@ export interface GameState {
healerClassId: HealerClassId;
playerName: string;
phase: GamePhase;
runMode: RunMode;
round: number;
runBuffs: RunBuffId[];
draftBuffIds: RunBuffId[];
selectedRunBuffId: RunBuffId;
healingMultiplier: number;
difficultySlug: DifficultySlug;
difficultyDamageMultiplier: number;
gearProgress: GearProgress;
gearModifiers: EncounterGearModifiers;
time: number;
party: PartyMember[];
boss: BossState;
@@ -71,7 +97,7 @@ export interface GameState {
playerPosition: [number, number];
activeCast: ActiveCast | null;
barrier: BarrierState;
configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[]) => void;
configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[], runMode?: RunMode, gearProgress?: GearProgress, difficultySlug?: DifficultySlug) => void;
startEncounter: () => void;
restart: () => void;
tick: (delta: number) => void;
@@ -84,6 +110,8 @@ export interface GameState {
setPaused: (paused: boolean) => void;
togglePause: () => void;
setPauseSelection: (selection: "resume" | "exit") => void;
setSelectedRunBuff: (buffId: RunBuffId) => void;
chooseRunBuff: (buffId: RunBuffId) => boolean;
}
const emptyCooldowns = (): Record<AbilityId, number> => ({
@@ -117,11 +145,13 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo
const stagger = index * 2.4;
if (Number.isFinite(motion.nextChargeAt)) motion.nextChargeAt += stagger;
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
return motion;
return constrainBossMotion(motion);
}
function createEncounterBoss(bossId: BossId, index: number, count: number): AdditionalBossState {
function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number): AdditionalBossState {
const boss = createBossState(bossId);
boss.maxHp = Math.round(boss.maxHp * healthMultiplier);
boss.hp = boss.maxHp;
const stagger = index * 0.8;
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
if (Number.isFinite(boss.nextNovaAt)) boss.nextNovaAt += index * 2.4;
@@ -132,7 +162,14 @@ function createEncounterBoss(bossId: BossId, index: number, count: number): Addi
const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
const bossPosition = createBossMotionState(bossIds[0]).position;
if (bossIds.length > 1) bossPosition[0] = 0;
return { aelia: [0, 4.5], ...combatFormation(bossPosition) };
const formation = combatFormation(bossPosition);
return {
aelia: clampToArena([0, 4.5]),
brann: clampToArena(formation.brann),
nia: clampToArena(formation.nia),
orin: clampToArena(formation.orin),
vale: clampToArena(formation.vale),
};
};
export function damageMember(member: PartyMember, amount: number): PartyMember {
@@ -161,7 +198,12 @@ function damageMemberAt(
time: number,
partyCombat?: PartyCombatState,
tankPosition?: WorldPosition,
incomingDamageMultiplier = 1,
gearModifiers?: EncounterGearModifiers,
kind: "direct" | "hazard" = "direct",
) {
amount *= incomingDamageMultiplier;
if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1;
const protectedByTank = partyCombat && tankPosition
? tankAuraProtects(position, tankPosition, partyCombat.tankAura, time)
: false;
@@ -188,11 +230,29 @@ function initialState(
playerName = "Aelia",
inventory: InventoryItem[] = createClassInventory(healerClassId),
requestedBossIds: BossId | readonly BossId[] = "bulldrome",
runMode: RunMode = "encounter",
round = 1,
runBuffs: RunBuffId[] = [],
gearProgress: GearProgress = createDefaultGearProgress(),
requestedDifficultySlug: DifficultySlug = "initiate",
) {
const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug);
const difficulty = DIFFICULTY_BY_SLUG[difficultySlug];
const bossIds = normalizeBossIds(requestedBossIds);
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(bossId, index, bossIds.length));
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
bossId,
index,
bossIds.length,
bossHealthMultiplier(round) * difficulty.healthMultiplier,
));
const primary = encounterBosses[0];
const party = freshParty(healerClassId, playerName);
const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId);
const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null;
const effectiveRunBuffs = passiveInfusionId && !runBuffs.includes(passiveInfusionId)
? [passiveInfusionId, ...runBuffs]
: runBuffs;
const party = applyGearHealth(applyRunBuffsToParty(freshParty(healerClassId, playerName), effectiveRunBuffs), gearModifiers);
const maxMana = runMaxMana(effectiveRunBuffs);
return {
bossId: primary.boss.id,
paused: false,
@@ -200,6 +260,16 @@ function initialState(
healerClassId,
playerName,
phase: "briefing" as GamePhase,
runMode,
round,
runBuffs: [...runBuffs],
draftBuffIds: [...RUN_BUFF_ORDER],
selectedRunBuffId: RUN_BUFF_ORDER[0],
healingMultiplier: runHealingMultiplier(effectiveRunBuffs) * gearModifiers.aelia.healingPower,
difficultySlug,
difficultyDamageMultiplier: difficulty.damageMultiplier,
gearProgress,
gearModifiers,
time: 0,
party,
boss: primary.boss,
@@ -208,8 +278,8 @@ function initialState(
bossMotion: primary.motion,
partyCombat: createPartyCombatState(party),
partyDamageEvents: [] as PartyDamageEvent[],
mana: 100,
maxMana: 100,
mana: maxMana,
maxMana,
selectedMemberId: "brann" as MemberId,
cooldowns: emptyCooldowns(),
globalCooldownUntil: 0,
@@ -227,13 +297,15 @@ function initialState(
export const useGameStore = create<GameState>((set, get) => ({
...initialState(),
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome") => set(initialState(healerClassId, playerName, inventory, bossIds)),
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => {
set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, [], gearProgress, difficultySlug));
},
startEncounter: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffs, gearProgress, difficultySlug } = get();
const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
set({
...initialState(healerClassId, playerName, inventory, bossIds),
...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffs, gearProgress, difficultySlug),
phase: "combat",
activeTab: "combat",
combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
@@ -241,8 +313,8 @@ export const useGameStore = create<GameState>((set, get) => ({
},
restart: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]));
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get();
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)], runMode, 1, [], gearProgress, difficultySlug));
},
selectMember: (selectedMemberId) => set({ selectedMemberId }),
@@ -261,7 +333,33 @@ export const useGameStore = create<GameState>((set, get) => ({
setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
setPauseSelection: (pauseSelection) => set({ pauseSelection }),
setSelectedRunBuff: (selectedRunBuffId) => set((state) =>
state.phase === "intermission" && state.draftBuffIds.includes(selectedRunBuffId)
? { selectedRunBuffId }
: state
),
chooseRunBuff: (buffId) => {
const state = get();
if (state.phase !== "intermission" || !state.draftBuffIds.includes(buffId)) return false;
const runBuffs = [...state.runBuffs, buffId];
const round = state.round + 1;
const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
const bossIds = selectRandomBossPair(previousBossIds);
set({
...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, runBuffs, state.gearProgress, state.difficultySlug),
phase: "combat",
activeTab: "combat",
combatLog: [{
id: Date.now(),
time: 0,
message: `${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
tone: "good",
}],
});
return true;
},
setPlayerPosition: (playerPosition) => set((state) => {
playerPosition = clampToArena(playerPosition);
const current = state.playerPosition;
const partyCurrent = state.partyPositions.aelia;
if (current[0] === playerPosition[0]
@@ -331,9 +429,9 @@ export const useGameStore = create<GameState>((set, get) => ({
case "shield":
party[selectedIndex] = {
...party[selectedIndex],
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36),
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36 * state.gearModifiers.aelia.healingPower),
};
message = `${selected.name} gains 36 absorption.`;
message = `${selected.name} gains ${Math.round(36 * state.gearModifiers.aelia.healingPower)} absorption.`;
break;
case "purify":
{
@@ -354,7 +452,7 @@ export const useGameStore = create<GameState>((set, get) => ({
party[selectedIndex] = { ...party[selectedIndex], debuffs: [] };
break;
case "radiance":
party = party.map((member) => healMember(member, 22));
party = party.map((member) => healMember(member, 22 * state.healingMultiplier));
message = `${ability.name} heals the full party.`;
break;
case "barrier":
@@ -365,7 +463,7 @@ export const useGameStore = create<GameState>((set, get) => ({
const cooldowns = {
...state.cooldowns,
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown : 0,
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown * state.gearModifiers.aelia.cooldown : 0,
};
const pulse: ScenePulse = {
id: state.scenePulse.id + 1,
@@ -413,9 +511,10 @@ export const useGameStore = create<GameState>((set, get) => ({
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
const target = party[targetIndex];
if (target?.hp > 0) {
party[targetIndex] = healMember(target, 38);
const healing = 38 * state.healingMultiplier;
party[targetIndex] = healMember(target, healing);
const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for 38.`, "good");
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good");
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
}
activeCast = null;
@@ -427,7 +526,7 @@ export const useGameStore = create<GameState>((set, get) => ({
let tickAt = next.renewNextTickAt;
const lastTickAt = Math.min(time, next.renewExpiresAt);
while (tickAt <= lastTickAt + 0.001) {
next = healMember(next, 7);
next = healMember(next, 7 * state.healingMultiplier);
tickAt += 1;
}
next = {
@@ -441,7 +540,7 @@ export const useGameStore = create<GameState>((set, get) => ({
.map((debuff) => {
let updated = { ...debuff };
while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) {
next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann);
next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers);
updated.nextTickAt += 1;
}
return updated;
@@ -454,7 +553,12 @@ export const useGameStore = create<GameState>((set, get) => ({
...(boss.hp > 0 ? [bossMotion] : []),
...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
];
partyPositions = updatePartyPositions(state.partyPositions, livingMotions, party, time, time - oldTime);
partyPositions = updatePartyPositions(state.partyPositions, livingMotions, party, time, time - oldTime, undefined, {
brann: state.gearModifiers.brann.moveSpeed,
nia: state.gearModifiers.nia.moveSpeed,
orin: state.gearModifiers.orin.moveSpeed,
vale: state.gearModifiers.vale.moveSpeed,
});
const encounterBosses: AdditionalBossState[] = [
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion },
@@ -463,6 +567,7 @@ export const useGameStore = create<GameState>((set, get) => ({
for (let index = 0; index < encounterBosses.length; index += 1) {
const encounterBoss = encounterBosses[index];
if (encounterBoss.boss.hp <= 0) continue;
const partyBeforeMechanic = party;
const mechanicResult = advanceBossMechanics({
boss: encounterBoss.boss,
motion: encounterBoss.motion,
@@ -470,10 +575,14 @@ export const useGameStore = create<GameState>((set, get) => ({
partyPositions,
time,
delta: time - oldTime,
damageMember: (member, amount, position, at) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann),
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind),
});
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
party = mechanicResult.party.map((member) => {
const previous = partyBeforeMechanic.find((candidate) => candidate.id === member.id);
if (!previous || member.knockedUntil <= previous.knockedUntil || member.knockedUntil <= time) return member;
return { ...member, knockedUntil: time + (member.knockedUntil - time) * state.gearModifiers[member.id].stunDuration };
});
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: mechanicResult.motion };
party = mechanicResult.party;
for (const event of mechanicResult.events) {
combatLog = addLog(combatLog, event.at, event.message, event.tone);
if (event.pulseKind) {
@@ -493,6 +602,7 @@ export const useGameStore = create<GameState>((set, get) => ({
positions: partyPositions,
targets: encounterBosses,
upcomingMechanicRemaining: mechanicRemaining.length ? Math.min(...mechanicRemaining) : Number.POSITIVE_INFINITY,
gearModifiers: aiCombatModifiers(state.gearModifiers),
});
partyCombat = partyCombatResult.state;
partyDamageEvents = [...partyCombatResult.events].reverse().concat(partyDamageEvents).slice(0, 24);
@@ -507,7 +617,7 @@ export const useGameStore = create<GameState>((set, get) => ({
const healer = party.find((member) => member.id === "aelia")!;
let phase: GamePhase = state.phase;
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
phase = "victory";
phase = state.runMode === "roguelike" ? "intermission" : "victory";
combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
} else if (tank.hp <= 0 || healer.hp <= 0) {
phase = "defeat";
@@ -546,6 +656,8 @@ export type GameSnapshot = Omit<GameState,
| "setPaused"
| "togglePause"
| "setPauseSelection"
| "setSelectedRunBuff"
| "chooseRunBuff"
>;
export function getGameSnapshot(): GameSnapshot {
@@ -563,6 +675,8 @@ export function getGameSnapshot(): GameSnapshot {
setPaused: _setPaused,
togglePause: _togglePause,
setPauseSelection: _setPauseSelection,
setSelectedRunBuff: _setSelectedRunBuff,
chooseRunBuff: _chooseRunBuff,
...snapshot
} = useGameStore.getState();
return snapshot;

Some files were not shown because too many files have changed in this diff Show More