diff --git a/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.asset.json b/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.asset.json new file mode 100644 index 0000000..94e12d8 --- /dev/null +++ b/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.asset.json @@ -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" + ] + } +} diff --git a/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb b/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb new file mode 100644 index 0000000..60ce233 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb differ diff --git a/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/previews/blue-eyes-ultimate-dragon-attack.png b/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/previews/blue-eyes-ultimate-dragon-attack.png new file mode 100644 index 0000000..c738cdb Binary files /dev/null and b/game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/previews/blue-eyes-ultimate-dragon-attack.png differ diff --git a/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.asset.json b/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.asset.json new file mode 100644 index 0000000..d82b553 --- /dev/null +++ b/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.asset.json @@ -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" + ] + } +} diff --git a/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb b/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb new file mode 100644 index 0000000..3c76d27 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb differ diff --git a/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/previews/blue-eyes-white-dragon-attack.png b/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/previews/blue-eyes-white-dragon-attack.png new file mode 100644 index 0000000..d5d23c7 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/previews/blue-eyes-white-dragon-attack.png differ diff --git a/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.asset.json b/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.asset.json new file mode 100644 index 0000000..610dd57 --- /dev/null +++ b/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.asset.json @@ -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" + ] + } +} diff --git a/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb b/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb new file mode 100644 index 0000000..66bdcfb Binary files /dev/null and b/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb differ diff --git a/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/previews/gandora-the-dragon-of-destruction-attack.png b/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/previews/gandora-the-dragon-of-destruction-attack.png new file mode 100644 index 0000000..2f130e6 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/previews/gandora-the-dragon-of-destruction-attack.png differ diff --git a/game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.asset.json b/game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.asset.json new file mode 100644 index 0000000..b215138 --- /dev/null +++ b/game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.asset.json @@ -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" + ] + } +} diff --git a/game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb b/game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb new file mode 100644 index 0000000..83a4ed8 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb differ diff --git a/game_assets/models/downloaded/yugioh/gate-guardian/previews/gate-guardian-attack.png b/game_assets/models/downloaded/yugioh/gate-guardian/previews/gate-guardian-attack.png new file mode 100644 index 0000000..bfe9156 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/gate-guardian/previews/gate-guardian-attack.png differ diff --git a/game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.asset.json b/game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.asset.json new file mode 100644 index 0000000..406b44a --- /dev/null +++ b/game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.asset.json @@ -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" + ] + } +} diff --git a/game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb b/game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb new file mode 100644 index 0000000..8f0ad5a Binary files /dev/null and b/game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb differ diff --git a/game_assets/models/downloaded/yugioh/insect-queen/previews/insect-queen-attack.png b/game_assets/models/downloaded/yugioh/insect-queen/previews/insect-queen-attack.png new file mode 100644 index 0000000..3c05d20 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/insect-queen/previews/insect-queen-attack.png differ diff --git a/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/previews/pumpking-the-king-of-ghosts-attack.png b/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/previews/pumpking-the-king-of-ghosts-attack.png new file mode 100644 index 0000000..ae43b8b Binary files /dev/null and b/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/previews/pumpking-the-king-of-ghosts-attack.png differ diff --git a/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.asset.json b/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.asset.json new file mode 100644 index 0000000..1cad6c1 --- /dev/null +++ b/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.asset.json @@ -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" + ] + } +} diff --git a/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb b/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb new file mode 100644 index 0000000..61c3849 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb differ diff --git a/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/previews/red-eyes-black-dragon-attack.png b/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/previews/red-eyes-black-dragon-attack.png new file mode 100644 index 0000000..03038f3 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/previews/red-eyes-black-dragon-attack.png differ diff --git a/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.asset.json b/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.asset.json new file mode 100644 index 0000000..f0dabd8 --- /dev/null +++ b/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.asset.json @@ -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" + ] + } +} diff --git a/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb b/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb new file mode 100644 index 0000000..084ff07 Binary files /dev/null and b/game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb differ diff --git a/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.asset.json b/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.asset.json new file mode 100644 index 0000000..ad3b1e1 --- /dev/null +++ b/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.asset.json @@ -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 + } + } +} diff --git a/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.blend b/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.blend new file mode 100644 index 0000000..f018448 Binary files /dev/null and b/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.blend differ diff --git a/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.glb b/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.glb new file mode 100644 index 0000000..9fc8c40 Binary files /dev/null and b/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet.glb differ diff --git a/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet_collision.glb b/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet_collision.glb new file mode 100644 index 0000000..9ce94f2 Binary files /dev/null and b/game_assets/models/original/bosses/cinderback-ricochet/cinderback-ricochet_collision.glb differ diff --git a/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_armorslam.png b/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_armorslam.png new file mode 100644 index 0000000..df5a2df Binary files /dev/null and b/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_armorslam.png differ diff --git a/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_side.png b/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_side.png new file mode 100644 index 0000000..f5a75ca Binary files /dev/null and b/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_side.png differ diff --git a/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_three_quarter.png b/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_three_quarter.png new file mode 100644 index 0000000..9901a63 Binary files /dev/null and b/game_assets/models/original/bosses/cinderback-ricochet/previews/cinderback-ricochet_three_quarter.png differ diff --git a/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.asset.json b/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.asset.json new file mode 100644 index 0000000..79298bf --- /dev/null +++ b/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.asset.json @@ -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 + } + } +} diff --git a/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.blend b/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.blend new file mode 100644 index 0000000..358dc1f Binary files /dev/null and b/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.blend differ diff --git a/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.glb b/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.glb new file mode 100644 index 0000000..468856b Binary files /dev/null and b/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem.glb differ diff --git a/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem_collision.glb b/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem_collision.glb new file mode 100644 index 0000000..86baf17 Binary files /dev/null and b/game_assets/models/original/bosses/obsidian-ram-golem/obsidian-ram-golem_collision.glb differ diff --git a/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_quake.png b/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_quake.png new file mode 100644 index 0000000..db6ef78 Binary files /dev/null and b/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_quake.png differ diff --git a/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_side.png b/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_side.png new file mode 100644 index 0000000..44620f7 Binary files /dev/null and b/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_side.png differ diff --git a/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_three_quarter.png b/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_three_quarter.png new file mode 100644 index 0000000..edebc9e Binary files /dev/null and b/game_assets/models/original/bosses/obsidian-ram-golem/previews/obsidian-ram-golem_three_quarter.png differ diff --git a/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_eruption.png b/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_eruption.png new file mode 100644 index 0000000..a1ffacf Binary files /dev/null and b/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_eruption.png differ diff --git a/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_side.png b/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_side.png new file mode 100644 index 0000000..e31209f Binary files /dev/null and b/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_side.png differ diff --git a/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_three_quarter.png b/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_three_quarter.png new file mode 100644 index 0000000..95f32c2 Binary files /dev/null and b/game_assets/models/original/bosses/sandglass-scorpion/previews/sandglass-scorpion_three_quarter.png differ diff --git a/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.asset.json b/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.asset.json new file mode 100644 index 0000000..b401ae0 --- /dev/null +++ b/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.asset.json @@ -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 + } + } +} diff --git a/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.blend b/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.blend new file mode 100644 index 0000000..19f5e16 Binary files /dev/null and b/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.blend differ diff --git a/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb b/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb new file mode 100644 index 0000000..1a3b019 Binary files /dev/null and b/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb differ diff --git a/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion_collision.glb b/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion_collision.glb new file mode 100644 index 0000000..fcdf14c Binary files /dev/null and b/game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion_collision.glb differ diff --git a/package.json b/package.json index 083074f..5e3cef9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.2", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/scripts/blender/build_iwt2_boss_trio.py b/scripts/blender/build_iwt2_boss_trio.py new file mode 100644 index 0000000..18bdabf --- /dev/null +++ b/scripts/blender/build_iwt2_boss_trio.py @@ -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() diff --git a/scripts/blender/build_yugioh_animations.py b/scripts/blender/build_yugioh_animations.py new file mode 100644 index 0000000..17a5dfd --- /dev/null +++ b/scripts/blender/build_yugioh_animations.py @@ -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() diff --git a/scripts/blender/inspect_yugioh_models.py b/scripts/blender/inspect_yugioh_models.py new file mode 100644 index 0000000..d98c8e1 --- /dev/null +++ b/scripts/blender/inspect_yugioh_models.py @@ -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() diff --git a/scripts/blender/validate_yugioh_animations.py b/scripts/blender/validate_yugioh_animations.py new file mode 100644 index 0000000..9ece314 --- /dev/null +++ b/scripts/blender/validate_yugioh_animations.py @@ -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(" bytes: + data = path.read_bytes() + json_length, json_type = struct.unpack_from(" 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() diff --git a/src/App.tsx b/src/App.tsx index a22039d..7a0966f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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()); + 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).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 (
diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx index ca0cc09..943081c 100644 --- a/src/components/BottomScreen.tsx +++ b/src/components/BottomScreen.tsx @@ -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
{rewards.map((reward, index) => {reward.coin.glyph}{reward.coin.name} ×{reward.quantity}{reward.pet ? + {reward.pet.name} : null})}
; +} function HealthBar({ member }: { member: PartyMember }) { const health = Math.max(0, (member.hp / member.maxHp) * 100); @@ -195,6 +202,7 @@ function EndPanel() { Party vitality{Math.round((totalHp / totalMax) * 100)}% Boss{phase === "victory" ? "Defeated" : "Standing"} + {phase === "victory" && }
@@ -203,9 +211,24 @@ function EndPanel() { ); } +function IntermissionStatusPanel() { + const round = useGameStore((state) => state.round); + return ( +
+ + Round {round} cleared +

Choose on top display

+

Next encounter stays locked until one blessing is claimed.

+ + Use D-pad to choose · A to claim +
+ ); +} + function CombatPanel() { const phase = useGameStore((state) => state.phase); if (phase === "briefing") return ; + if (phase === "intermission") return ; if (phase === "victory" || phase === "defeat") return ; return
; } diff --git a/src/components/BuffDraftPanel.tsx b/src/components/BuffDraftPanel.tsx new file mode 100644 index 0000000..0fc9668 --- /dev/null +++ b/src/components/BuffDraftPanel.tsx @@ -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 ( +
+
+ Round {round} cleared +

Choose one blessing

+

Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.

+
+
+ {choices.map((buffId) => { + const buff = RUN_BUFFS[buffId]; + const stacks = countRunBuff(runBuffs, buffId); + return ( + + ); + })} +
+
← / → Choose A / ENTER Claim
+
+ ); +} diff --git a/src/components/DualDisplayFrame.tsx b/src/components/DualDisplayFrame.tsx index e64e9bb..d5847e6 100644 --- a/src/components/DualDisplayFrame.tsx +++ b/src/components/DualDisplayFrame.tsx @@ -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(() => - new URLSearchParams(window.location.search).get("display") === "bottom" ? "bottom" : "top" - ); + const dedicatedSurface = new URLSearchParams(window.location.search).get("display"); + const [activeSurface, setActiveSurface] = useState(() => 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
{dedicatedSurface === "top" ? top : bottom}
; + } return (
diff --git a/src/components/FrontEnd.tsx b/src/components/FrontEnd.tsx index 4bee2fa..98e127b 100644 --- a/src/components/FrontEnd.tsx +++ b/src/components/FrontEnd.tsx @@ -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() {
navigate("profile")}>Hunter ProfileStats & collection log + navigate("gear")}>Gear UpgradeSpend boss coins navigate("settings")}>SettingsAudio, display, controls
@@ -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(() => [ - ...(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() { {drop.icon}{drop.count} {drop.rarity}{drop.name}

{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}

+ {drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""} ))}
@@ -437,9 +464,9 @@ function ProfileScreen() { Allies saved{hunter.stats.alliesSaved} Healing done{hunter.stats.healingDone.toLocaleString()} -
Boss records{hunter.collections.map((boss) => ( +
Boss records{collections.map((boss) => ( setBossId(boss.bossId)}> - {boss.defeated ? "♜" : "?"}{boss.bossName}{hunter.stats.bossKills[boss.bossName] ?? 0} kills{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length} + {boss.defeated ? "♜" : "?"}{boss.bossName}{hunter.stats.bossKills[boss.bossId] ?? 0} kills{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length} ))}
@@ -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(() => [ + ...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 ( + +
Boss coin workshop

Gear & Infusions

selectWorkshopMode("upgrade")}>Upgrade selectWorkshopMode("infusion")}>Infusion
navigate("home")}>B · Back
+
+
+ {GEAR_OWNER_ORDER.map((ownerId) => { + const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level)); + return selectOwner(ownerId)}>{GEAR_OWNER_LABELS[ownerId]}Highest slot +{highest}{ownerId === selectedOwnerId ? "✓" : ""}; + })} +
+
+ {GEAR_SLOT_ORDER.map((slotId) => { + const progress = hunter.gearProgress[selectedOwnerId].slots[slotId]; + const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId]; + return selectSlot(slotId)}>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}{GEAR_SLOT_LABELS[slotId]}{GEAR_STAT_LABELS[slotRecipe.statId]}+{progress.level}; + })} +
+ {workshopMode === "upgrade" ?
+ Selected upgrade +

{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}

+

{GEAR_STAT_LABELS[recipe.statId]} from {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.

+
Current{currentBonus}{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}{nextBonus}
+
:
+ Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} +

{selectedInfusion.icon} {selectedInfusion.name}

+

{selectedInfusion.description} Anchor purchase to a +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} slot.

+
+ {infusionChoices.map((infusion) => selectInfusion(infusion.id)}>{infusion.icon}{infusion.name}{infusion.description}{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""})} +
+ {healerOwner &&
Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}{PASSIVE_INFUSIONS.map((passive) => installPassive(passive.id)}>{passive.icon}{passive.name}{passive.summary}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""})}
} +
} +
+ + + } + bottom={ + +
{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS
+
+ {workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`} + {(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => { + const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0; + return
= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}>{owned >= cost.quantity ? "✓" : "×"}{cost.itemName}{owned} owned · {cost.quantity} needed{owned}/{cost.quantity}
; + }) :
Maximum rank reachedNo more materials required.+{MAX_GEAR_LEVEL}
} +
+ {workshopMode === "upgrade" ? {slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}{canUpgrade ? "Spend coins · autosave" : "Collect required boss coins"} : {infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}{!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"}} +
{notice || "Gear changes save locally and apply when next encounter starts."}
+
+ } + /> + ); +} + 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 {label}{copy}{value ? "ON" : "OFF"}; } @@ -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(() => [ - ...(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={
Game mode

{mode.title}

navigate("home")}>B · Back
-
{mode.eyebrow}

{mode.title}

{mode.description}

{mode.detail}
+ {!isDungeon &&
{mode.eyebrow}

{mode.title}

{mode.description}

{mode.detail}
} {isDungeon && (
Choose encounter - {BOSS_ORDER.map((bossId) => { - const boss = BOSS_DEFINITIONS[bossId]; - return ( - selectBoss(bossId)} - > - {boss.icon}{boss.name}{boss.mechanics.join(" · ")}{selectedBossId === bossId ? "✓" : ""} - - ); - })} +
+ {BOSS_ORDER.map((bossId) => { + const boss = BOSS_DEFINITIONS[bossId]; + return ( + selectBoss(bossId)} + > + {boss.icon}{boss.name}{boss.mechanics.join(" · ")}{selectedBossId === bossId ? "✓" : ""} + + ); + })} +
+
+ )} + {isDungeon && ( +
+ Difficulty + {DIFFICULTIES.map((difficulty) => selectDifficulty(difficulty.slug)}>{difficulty.name}iLvl {difficulty.itemLevel})}
)} {launchLabel}{mode.status} · A @@ -577,21 +775,23 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo } bottom={ -
Run preparation{mode.status.toUpperCase()}
+
Run preparation{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}
{contextRules.map(([title, copy], index) =>
0{index + 1}{title}{copy}
)}
Equipped role{healer.specialization} · Level {progress?.level ?? 1}6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready
+ {isDungeon &&
Guaranteed reward{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}1–3 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500
}
} /> ); } -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 ; if (screen === "saves") return ; if (screen === "home") return ; if (screen === "profile") return ; + if (screen === "gear") return ; if (screen === "settings") return ; if (screen === "mode") return ; return null; diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index 7725f72..7993495 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -4,20 +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 = { - "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 EMBER_MANTIS_URL = new URL("../../game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.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 = { 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, @@ -54,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[] = ["brann", "nia", "orin", "vale"]; type GameStoreState = ReturnType; function encounterBossAt(state: GameStoreState, bossIndex: number) { @@ -72,15 +89,6 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string) return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state); } -const configureSpiderLoader: NonNullable[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 = { @@ -141,9 +149,11 @@ function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" | function PartyCharacterModel({ memberId, animationState, + animationTrigger, }: { memberId: MemberId; animationState: MutableRefObject; + animationTrigger: MutableRefObject; }) { const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true); const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]); @@ -163,6 +173,8 @@ function PartyCharacterModel({ ); const { actions } = useAnimations(gltf.animations, actorScene); const activeClip = useRef(undefined); + const activeState = useRef(undefined); + const activeTrigger = useRef(Number.NaN); useEffect(() => { actorScene.traverse((object) => { @@ -189,6 +201,7 @@ function PartyCharacterModel({ useFrame(() => { const state = animationState.current; + const trigger = animationTrigger.current; const clipName = state === "death" ? "Death_A" : state === "hit" @@ -202,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 ( @@ -270,6 +287,7 @@ function Arena() { + @@ -277,9 +295,32 @@ function Arena() { ); } +function ArenaWalls() { + const walls = useRef(null); + useFrame(({ camera }) => { + if (!walls.current) return; + for (const child of walls.current.children) { + const material = (child as THREE.Mesh).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 ( + + {ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => ( + + + + + ))} + + ); +} + function Character({ memberId, selected = false }: { memberId: Exclude; selected?: boolean }) { const group = useRef(null); const animationState = useRef("idle"); + const animationTrigger = useRef(0); useEffect(() => { const start = useGameStore.getState().partyPositions[memberId]; group.current?.position.set(start[0], 0.025, start[1]); @@ -303,6 +344,13 @@ function Character({ memberId, selected = false }: { memberId: Exclude state.time; + animationTrigger.current = member.hp <= 0 + ? 0 + : knocked + ? member.knockedUntil + : attacking + ? visualAction.startedAt + : 0; animationState.current = member.hp <= 0 ? "death" : knocked @@ -328,7 +376,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude - + {selected && ( @@ -342,6 +390,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude(null); const animationState = useRef("idle"); + const animationTrigger = useRef(0); const keys = useRef(new Set()); const scenePulse = useGameStore((state) => state.scenePulse); const selected = useGameStore((state) => state.selectedMemberId === "aelia"); @@ -349,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(() => { @@ -359,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]); @@ -372,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()); @@ -401,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; @@ -414,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 @@ -422,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); @@ -440,7 +503,7 @@ function PlayerCharacter() { return ( - + {selected && ( @@ -453,16 +516,15 @@ function PlayerCharacter() { } function Party() { - const party = useGameStore((state) => state.party); const selected = useGameStore((state) => state.selectedMemberId); return ( <> - {party.slice(1).map((member) => ( + {PARTY_MEMBER_IDS.map((memberId) => ( } - selected={selected === member.id} + key={memberId} + memberId={memberId} + selected={selected === memberId} /> ))} @@ -492,7 +554,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) { return ( - + ); } @@ -581,44 +643,217 @@ function BullBoss({ bossIndex }: { bossIndex: number }) { ); } -type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist"; +type AlternateBossKind = Exclude["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: EMBER_MANTIS_URL, - scale: 0.78, + url: GATE_GUARDIAN_URL, + scale: 4.3, idle: "Idle", - move: "Sidestep", - attack: "LineSlash", - special: "CrossSlash", + 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["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); @@ -626,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(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(), []); @@ -638,31 +873,9 @@ 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 - : kind === "ember-mantis-duelist" - ? motionMode === "mantis_sidestep" - ? config.move - : motionMode === "mantis_line_telegraph" - ? config.attack - : motionMode === "mantis_cross_telegraph" - ? config.special - : motionMode === "mantis_recover" - ? "Recover" - : config.idle - : 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]; @@ -674,8 +887,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex ? 0.6 : 1; next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play(); - const emberOneShot = kind === "ember-mantis-duelist" && clipName !== config.idle; - if (phase === "victory" || defeated || emberOneShot) { + 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 { @@ -691,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( @@ -707,6 +922,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex )) { 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), @@ -891,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) => ( - }> - {boss.id === "bulldrome" ? : } + <>{bossIds.map((bossId, bossIndex) => ( + }> + {bossId === "bulldrome" ? : } ))} ); } +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([]); + 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(null); const material = useRef(null); @@ -956,12 +1258,11 @@ export function GameScene() { + {PERFORMANCE_PROBE_ENABLED && } ); } -useGLTF.preload(BULL_URL, false, true); -useGLTF.preload(EMBER_MANTIS_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); diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx index 6c6f04e..264d692 100644 --- a/src/components/TopScreen.tsx +++ b/src/components/TopScreen.tsx @@ -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 ; 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 (
@@ -175,7 +187,7 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
-
Objective{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}
+
{runMode === "roguelike" ? `Round ${round}` : "Objective"}{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}
WASD Move Q / E Target 1–6 Cast
diff --git a/src/components/boss/BossMechanicIndicators.tsx b/src/components/boss/BossMechanicIndicators.tsx index ab725e7..5c85cad 100644 --- a/src/components/boss/BossMechanicIndicators.tsx +++ b/src/components/boss/BossMechanicIndicators.tsx @@ -9,6 +9,10 @@ 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; function motionAt(state: GameStoreState, bossIndex: number) { @@ -37,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 ( <> @@ -77,16 +81,12 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: useFrame(({ clock }) => { if (!material.current || !edgeMaterial.current) return; const current = motionAt(useGameStore.getState(), bossIndex); - const active = current?.mode === "mantis_recover"; + 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 visible = motion.mode === "mantis_line_telegraph" - || motion.mode === "mantis_cross_telegraph" - || motion.mode === "mantis_recover"; - if (!visible) return null; const dx = lane.end[0] - lane.start[0]; const dz = lane.end[1] - lane.start[1]; @@ -97,8 +97,8 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: 0.052, (lane.start[1] + lane.end[1]) * 0.5, ]; - const active = motion.mode === "mantis_recover"; - const color = active ? "#ffd36a" : "#ff5128"; + const active = ACTIVE_LANE_MODES.has(motion.mode); + const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR; return ( @@ -114,7 +114,7 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: {active && ( - + )} @@ -209,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 ( @@ -234,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 ( - + {hazard.innerRadius ? ( + + ) : ( + + )} @@ -249,7 +264,7 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss {!active && ( - + )} diff --git a/src/frontend/data.test.ts b/src/frontend/data.test.ts index 918b385..4a9246b 100644 --- a/src/frontend/data.test.ts +++ b/src/frontend/data.test.ts @@ -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,16 +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.7)).toBe("cindermaw"); - expect(selectRandomBoss(() => 0.99)).toBe("ember-mantis-duelist"); + 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), + ); + } + }); }); diff --git a/src/frontend/data.ts b/src/frontend/data.ts index 7616fea..fda61c3 100644 --- a/src/frontend/data.ts +++ b/src/frontend/data.ts @@ -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,52 +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 }, - ], - }, - { - bossId: "ember-mantis-duelist", - bossName: "Ember Mantis Duelist", - defeated: false, - drops: [ - { id: "mantis-chitin", name: "Ember Chitin", icon: "◇", rarity: "Common", count: 0 }, - { id: "mantis-edge", name: "Cinderblade Edge", icon: "⚔", rarity: "Uncommon", count: 0 }, - { id: "mantis-antenna", name: "Duelist Antenna", icon: "⌁", rarity: "Rare", count: 0 }, - { id: "mantis-core", name: "Molten Mantis Core", icon: "✦", rarity: "Mythic", count: 0 }, - ], - }, -]; +const RARITY_LABELS: Record = { + common: "Common", + uncommon: "Uncommon", + rare: "Rare", + epic: "Epic", + legendary: "Legendary", +}; + +export function buildCollections(collectionLog: CollectionLog, bossKills: Record): 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 = { "roguelike-pve": { @@ -69,7 +64,7 @@ export const MODE_COPY: Record 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 { @@ -112,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, "Ember Mantis Duelist": 0 }, + totalBossKills: 0, + flawlessClears: 0, + alliesSaved: 0, + healingDone: 0, + bossKills: {}, }, - collections: structuredClone(DEFAULT_COLLECTIONS), + materials: [] as MaterialStack[], + collectionLog: createEmptyCollectionLog(), + gearProgress: createDefaultGearProgress(), }; } diff --git a/src/frontend/saveRepository.test.ts b/src/frontend/saveRepository.test.ts index 1a38b32..17ac53a 100644 --- a/src/frontend/saveRepository.test.ts +++ b/src/frontend/saveRepository.test.ts @@ -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(); @@ -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; + 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(); }); }); diff --git a/src/frontend/saveRepository.ts b/src/frontend/saveRepository.ts index 1001062..d3d011b 100644 --- a/src/frontend/saveRepository.ts +++ b/src/frontend/saveRepository.ts @@ -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 { - 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 { + if (!value || typeof value !== "object") return {}; + return Object.fromEntries(Object.entries(value as Record).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(); + if (Array.isArray(value)) { + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const item = raw as Partial; + 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; + 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 { + const source = positiveCounts(value); + const result: Record = {}; + 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 & Partial; + 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), }; } diff --git a/src/frontend/store.ts b/src/frontend/store.ts index cd1f91d..b47c03d 100644 --- a/src/frontend/store.ts +++ b/src/frontend/store.ts @@ -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; @@ -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: (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((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((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((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; @@ -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(); diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 432347d..fbecc00 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -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 { diff --git a/src/game/actorAnimation.test.ts b/src/game/actorAnimation.test.ts new file mode 100644 index 0000000..c79d8d3 --- /dev/null +++ b/src/game/actorAnimation.test.ts @@ -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); + }); +}); diff --git a/src/game/actorAnimation.ts b/src/game/actorAnimation.ts new file mode 100644 index 0000000..fd78150 --- /dev/null +++ b/src/game/actorAnimation.ts @@ -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; +} diff --git a/src/game/arena.ts b/src/game/arena.ts new file mode 100644 index 0000000..128ca3d --- /dev/null +++ b/src/game/arena.ts @@ -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; +} diff --git a/src/game/battleSimulation.test.ts b/src/game/battleSimulation.test.ts index fa1901d..d3bb078 100644 --- a/src/game/battleSimulation.test.ts +++ b/src/game/battleSimulation.test.ts @@ -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,14 +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"], - ["bulldrome", "ember-mantis-duelist"], - ["vexa", "cindermaw"], - ["vexa", "ember-mantis-duelist"], - ["cindermaw", "ember-mantis-duelist"], - ]; + 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]); diff --git a/src/game/bossCatalog.ts b/src/game/bossCatalog.ts index 374380a..9a18d5f 100644 --- a/src/game/bossCatalog.ts +++ b/src/game/bossCatalog.ts @@ -16,7 +16,20 @@ export interface BossDefinition { maxHp: number; } -export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw", "ember-mantis-duelist"]; +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 = { bulldrome: { @@ -36,47 +49,167 @@ export const BOSS_DEFINITIONS: Record = { }, 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: "Ember Mantis Duelist", - title: "The Cinderblade", - trial: "Trial IV · Blades in Motion", + name: "Gate Guardian", + title: "The Tri-Element Sentinel", + trial: "Trial IV · Elements in Motion", icon: "⚔", accent: "#ff6a2a", - summary: "Sidesteps across the arena before carving single and crossed slash lanes.", - briefing: "Track each sidestep. Clear the glowing Line Slash, then find a safe quadrant when both scythes form Cross Slash.", - failure: "Do not chase the duelist through a telegraph. Preserve space and move perpendicular to each ember lane.", - mapTitle: "The Cinderblade Court", - mapCopy: "Follow the mantis laterally, but cross glowing cut lanes only after the blades finish their recovery.", - mechanics: ["Line Slash", "Cross Slash"], + 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, + }, }; diff --git a/src/game/bossMechanics.ts b/src/game/bossMechanics.ts index de8ad63..02deb7e 100644 --- a/src/game/bossMechanics.ts +++ b/src/game/bossMechanics.ts @@ -1,6 +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"; @@ -24,7 +31,7 @@ export const BULL_POUNCE = { afterCharges: 3, stackDuration: 5, stackRadius: 2.2, - sharedDamage: 300, + sharedDamage: 200, leapDuration: 0.55, } as const; @@ -41,6 +48,25 @@ 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", @@ -58,6 +84,14 @@ export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionS 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", @@ -80,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) { @@ -352,6 +386,14 @@ export function advanceBossMechanics(context: BossMechanicContext): BossMechanic 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); } @@ -376,6 +418,14 @@ export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: 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 }; } diff --git a/src/game/bosses/cinderbackRicochet.ts b/src/game/bosses/cinderbackRicochet.ts new file mode 100644 index 0000000..b9227f1 --- /dev/null +++ b/src/game/bosses/cinderbackRicochet.ts @@ -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 }; +} diff --git a/src/game/bosses/cindermaw.ts b/src/game/bosses/cindermaw.ts index a8cf614..dd22f39 100644 --- a/src/game/bosses/cindermaw.ts +++ b/src/game/bosses/cindermaw.ts @@ -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 }; } diff --git a/src/game/bosses/claudecraftBossTrio.test.ts b/src/game/bosses/claudecraftBossTrio.test.ts new file mode 100644 index 0000000..8025afd --- /dev/null +++ b/src/game/bosses/claudecraftBossTrio.test.ts @@ -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); + }); +}); diff --git a/src/game/bosses/cragclawCrab.ts b/src/game/bosses/cragclawCrab.ts new file mode 100644 index 0000000..2716e3b --- /dev/null +++ b/src/game/bosses/cragclawCrab.ts @@ -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 }; +} diff --git a/src/game/bosses/crownshardGolem.ts b/src/game/bosses/crownshardGolem.ts new file mode 100644 index 0000000..a21a536 --- /dev/null +++ b/src/game/bosses/crownshardGolem.ts @@ -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 }; +} diff --git a/src/game/bosses/emberMantis.test.ts b/src/game/bosses/emberMantis.test.ts index 53f52a5..fb0f3f4 100644 --- a/src/game/bosses/emberMantis.test.ts +++ b/src/game/bosses/emberMantis.test.ts @@ -36,8 +36,8 @@ function context( }; } -describe("Ember Mantis Duelist mechanics", () => { - it("sidesteps, telegraphs Line Slash, then damages targets left in the lane", () => { +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"); @@ -52,7 +52,7 @@ describe("Ember Mantis Duelist mechanics", () => { 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("Line Slash"))).toBe(true); + expect(impact.events.some((event) => event.message.includes("Elemental Beam"))).toBe(true); }); it("alternates into two crossed slash lanes", () => { diff --git a/src/game/bosses/emberMantis.ts b/src/game/bosses/emberMantis.ts index b76a11d..88ffccb 100644 --- a/src/game/bosses/emberMantis.ts +++ b/src/game/bosses/emberMantis.ts @@ -141,7 +141,7 @@ function resolveSlash( hitIds.push(member.id); events.push({ at: context.time, - message: `${member.name} is caught by ${isCrossSlash ? "Cross Slash" : "Line Slash"}.`, + message: `${member.name} is caught by ${isCrossSlash ? "Guardian Cross" : "Elemental Beam"}.`, tone: "danger", pulseKind: "slash", targetId: member.id, @@ -171,7 +171,7 @@ export function advanceEmberMantisMechanics(context: BossMechanicContext): BossM motion = beginSidestep(motion, party, context.partyPositions, context.time); events.push({ at: context.time, - message: `Ember Mantis sidesteps toward ${memberName(party, motion.chargeTargetId)}. Track the blades.`, + message: `Gate Guardian shifts toward ${memberName(party, motion.chargeTargetId)}. Track its arms.`, tone: "danger", pulseKind: "slash", targetId: motion.chargeTargetId, @@ -184,7 +184,7 @@ export function advanceEmberMantisMechanics(context: BossMechanicContext): BossM const cross = motion.mode === "mantis_cross_telegraph"; events.push({ at: context.time, - message: cross ? "Cross Slash! Find a safe quadrant." : "Line Slash! Clear the glowing lane.", + message: cross ? "Guardian Cross! Find a safe quadrant." : "Elemental Beam! Clear the glowing lane.", tone: "danger", pulseKind: "slash", targetId: motion.chargeTargetId, @@ -225,21 +225,21 @@ export function upcomingEmberMantisMechanic( ): UpcomingMechanic { void boss; if (motion.mode === "mantis_sidestep") { - return { name: "Duelist repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true }; + 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: "Line Slash — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true }; + 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: "Cross Slash — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true }; + 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: "Cinderblade exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false }; + 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 ? "Cross Slash" : "Line Slash", + name: nextIsCross ? "Guardian Cross" : "Elemental Beam", remaining, cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration, urgent: remaining < 2.5, diff --git a/src/game/bosses/iwt2BossTrio.test.ts b/src/game/bosses/iwt2BossTrio.test.ts new file mode 100644 index 0000000..963a25b --- /dev/null +++ b/src/game/bosses/iwt2BossTrio.test.ts @@ -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); + }); +}); diff --git a/src/game/bosses/mournveilGhost.ts b/src/game/bosses/mournveilGhost.ts new file mode 100644 index 0000000..cad5b68 --- /dev/null +++ b/src/game/bosses/mournveilGhost.ts @@ -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 }; +} diff --git a/src/game/bosses/obsidianRamGolem.ts b/src/game/bosses/obsidianRamGolem.ts new file mode 100644 index 0000000..31b6c38 --- /dev/null +++ b/src/game/bosses/obsidianRamGolem.ts @@ -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 }; +} diff --git a/src/game/bosses/sandglassScorpion.ts b/src/game/bosses/sandglassScorpion.ts new file mode 100644 index 0000000..3e44aa6 --- /dev/null +++ b/src/game/bosses/sandglassScorpion.ts @@ -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 }; +} diff --git a/src/game/bosses/shared.ts b/src/game/bosses/shared.ts index 86b93fb..9e40ea6 100644 --- a/src/game/bosses/shared.ts +++ b/src/game/bosses/shared.ts @@ -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(["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, @@ -106,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; @@ -115,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; diff --git a/src/game/bosses/types.ts b/src/game/bosses/types.ts index 72bc9e7..86fce65 100644 --- a/src/game/bosses/types.ts +++ b/src/game/bosses/types.ts @@ -22,7 +22,7 @@ export interface BossMechanicContext { partyPositions: Record; 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 { diff --git a/src/game/bosses/vexa.ts b/src/game/bosses/vexa.ts index 39b04fc..644c319 100644 --- a/src/game/bosses/vexa.ts +++ b/src/game/bosses/vexa.ts @@ -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; diff --git a/src/game/partyBehaviors.test.ts b/src/game/partyBehaviors.test.ts new file mode 100644 index 0000000..304378f --- /dev/null +++ b/src/game/partyBehaviors.test.ts @@ -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 = { + 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]); + }); +}); diff --git a/src/game/partyBehaviors.ts b/src/game/partyBehaviors.ts index 50b63d7..7a816ec 100644 --- a/src/game/partyBehaviors.ts +++ b/src/game/partyBehaviors.ts @@ -1,6 +1,6 @@ import { BULL_CHARGE } from "./bossMechanics"; +import { clampToArena } from "./arena"; import { CINDER_BREATH } from "./bosses/cindermaw"; -import { EMBER_MANTIS_SLASH } from "./bosses/emberMantis"; import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry"; import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types"; @@ -34,25 +34,45 @@ const STACK_OFFSETS: Record = { 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 = { + // 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 { 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 }) => { @@ -126,10 +146,10 @@ export const avoidBreathBehavior: PartyBehavior = { export const evadeSlashLanesBehavior: PartyBehavior = { id: "evade-slash-lanes", decide: ({ memberId, current, formationTarget, bossMotion }) => { - if (bossMotion.mode !== "mantis_line_telegraph" && bossMotion.mode !== "mantis_cross_telegraph") return null; + 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) < EMBER_MANTIS_SLASH.aiClearance, + pointToSegmentDistance(position, lane.start, lane.end) < lane.width * 0.5 + 0.7, ); if (!unsafe(current) && !unsafe(formationTarget)) return null; @@ -155,7 +175,7 @@ export const evadeSlashLanesBehavior: PartyBehavior = { } } } - return { target: best, speed: EMBER_MANTIS_SLASH.aiEvadeSpeed }; + return { target: best, speed: 4.8 }; }, }; @@ -180,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", "mantis_sidestep", "mantis_recover"].includes(bossMotion.mode)) return null; + if (!FORMATION_MODES.includes(bossMotion.mode)) return null; return { target: formationTarget, speed: MOVE_SPEEDS[memberId] }; }, }; @@ -202,6 +222,7 @@ export function updatePartyPositions( time: number, delta: number, behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS, + moveSpeedMultipliers?: Partial>, ) { const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions]; const activeMotions = bossMotions; @@ -214,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]; @@ -238,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; } diff --git a/src/game/partyCombat.test.ts b/src/game/partyCombat.test.ts index 727b624..3e2b044 100644 --- a/src/game/partyCombat.test.ts +++ b/src/game/partyCombat.test.ts @@ -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,14 +142,9 @@ describe("party ability combat", () => { }); describe("dual-boss damage simulations", () => { - const combinations: readonly (readonly [BossId, BossId])[] = [ - ["bulldrome", "vexa"], - ["bulldrome", "cindermaw"], - ["bulldrome", "ember-mantis-duelist"], - ["vexa", "cindermaw"], - ["vexa", "ember-mantis-duelist"], - ["cindermaw", "ember-mantis-duelist"], - ]; + 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]); diff --git a/src/game/partyCombat.ts b/src/game/partyCombat.ts index 046c5b2..8a485d2 100644 --- a/src/game/partyCombat.ts +++ b/src/game/partyCombat.ts @@ -110,6 +110,7 @@ export interface PartyCombatContext { positions: Record; targets: PartyCombatTarget[]; upcomingMechanicRemaining: number; + gearModifiers?: Record; } 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); diff --git a/src/game/performance.test.ts b/src/game/performance.test.ts new file mode 100644 index 0000000..8c1432d --- /dev/null +++ b/src/game/performance.test.ts @@ -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); + }); +}); diff --git a/src/game/performance.ts b/src/game/performance.ts new file mode 100644 index 0000000..83bde98 --- /dev/null +++ b/src/game/performance.ts @@ -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, + }; +} diff --git a/src/game/progression/gear.test.ts b/src/game/progression/gear.test.ts new file mode 100644 index 0000000..6002e8b --- /dev/null +++ b/src/game/progression/gear.test.ts @@ -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); + }); +}); diff --git a/src/game/progression/gear.ts b/src/game/progression/gear.ts new file mode 100644 index 0000000..4ca2ba9 --- /dev/null +++ b/src/game/progression/gear.ts @@ -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; +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; + infusionAbilityId: string | null; + passiveInfusionId: RunBuffId | null; +} + +export type GearProgress = Record; + +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 = { + priest: "Priest", + druid: "Druid", + shaman: "Shaman", + brann: "Brann · Knight", + nia: "Nia · Ranger", + orin: "Orin · Mage", + vale: "Vale · Rogue", +}; + +export const GEAR_SLOT_LABELS: Record = { + weapon: "Weapon", + helmet: "Helmet", + chest: "Chest", + legs: "Legs", + feet: "Feet", +}; + +export const GEAR_STAT_LABELS: Record = { + healingPower: "Healing Power", + damage: "Damage", + attackCooldown: "Cooldown", + maxHealth: "Max Health", + moveSpeed: "Move Speed", + stunResist: "Stun Resist", + hazardDamageTaken: "Hazard Damage Taken", +}; + +type RecipeSeed = Record; + +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 = { + 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>; + +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; + 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): DifficultySlug { + if (level <= 2) return "initiate"; + if (level >= 6) return "veteran"; + if (level === 3) return "veteran"; + if (level === 4) return "champion"; + return "mythic"; +} diff --git a/src/game/progression/gearEffects.ts b/src/game/progression/gearEffects.ts new file mode 100644 index 0000000..641a854 --- /dev/null +++ b/src/game/progression/gearEffects.ts @@ -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; + +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> { + 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; +} diff --git a/src/game/progression/infusions.test.ts b/src/game/progression/infusions.test.ts new file mode 100644 index 0000000..ca05524 --- /dev/null +++ b/src/game/progression/infusions.test.ts @@ -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, 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([]); + }); +}); diff --git a/src/game/progression/infusions.ts b/src/game/progression/infusions.ts new file mode 100644 index 0000000..dacb987 --- /dev/null +++ b/src/game/progression/infusions.ts @@ -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; + +const INFUSION_SEEDS: Record = { + 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; + +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; + +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; +} diff --git a/src/game/progression/loot.test.ts b/src/game/progression/loot.test.ts new file mode 100644 index 0000000..a9b9988 --- /dev/null +++ b/src/game/progression/loot.test.ts @@ -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"); + }); +}); diff --git a/src/game/progression/loot.ts b/src/game/progression/loot.ts new file mode 100644 index 0000000..d604d4b --- /dev/null +++ b/src/game/progression/loot.ts @@ -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; + pet: BossPetDrop; + entries: readonly (BossCoinDrop | BossPetDrop)[]; +} + +export interface CollectionLog { + dropsFound: Record; + petsFound: Record; +} + +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; + +const DIFFICULTY_ID_PREFIX: Record = { + 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; + 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; + +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: {} }; +} diff --git a/src/game/roguelike.test.ts b/src/game/roguelike.test.ts new file mode 100644 index 0000000..392de44 --- /dev/null +++ b/src/game/roguelike.test.ts @@ -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"); + }); +}); diff --git a/src/game/roguelike.ts b/src/game/roguelike.ts new file mode 100644 index 0000000..77c961e --- /dev/null +++ b/src/game/roguelike.ts @@ -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 = { + "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]]; +} diff --git a/src/game/store.test.ts b/src/game/store.test.ts index fddf3ff..a2be377 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -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"); diff --git a/src/game/store.ts b/src/game/store.ts index 161fa92..bf00628 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -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 => ({ @@ -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 => { 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((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((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((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((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((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((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((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((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((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((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((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((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((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((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; 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; diff --git a/src/game/types.ts b/src/game/types.ts index c777f79..e0f19cf 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,7 +1,21 @@ export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale"; export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier"; -export type BossId = "bulldrome" | "vexa" | "cindermaw" | "ember-mantis-duelist"; -export type GamePhase = "briefing" | "combat" | "victory" | "defeat"; +export type BossId = + | "bulldrome" + | "vexa" + | "cindermaw" + | "ember-mantis-duelist" + | "obsidian-ram-golem" + | "cinderback-ricochet" + | "sandglass-scorpion" + | "cragclaw-crab" + | "mournveil-ghost" + | "crownshard-golem" + | "pumpking-king-of-ghosts" + | "blue-eyes-ultimate-dragon"; +export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat"; +export type RunMode = "encounter" | "roguelike"; +export type RunBuffId = "vital-bloom" | "deep-wells" | "restoring-grace"; export type BottomTab = "combat" | "map" | "pack"; export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash"; export type BossMotionMode = @@ -19,15 +33,51 @@ export type BossMotionMode = | "mantis_sidestep" | "mantis_line_telegraph" | "mantis_cross_telegraph" - | "mantis_recover"; + | "mantis_recover" + | "ram_charge_telegraph" + | "ram_charging" + | "ram_quake" + | "ram_shatter" + | "ram_recover" + | "cinderback_curl" + | "cinderback_ricochet" + | "cinderback_slam" + | "cinderback_recover" + | "sandglass_burrow_telegraph" + | "sandglass_burrowing" + | "sandglass_eruption" + | "sandglass_hourglass" + | "sandglass_recover" + | "crab_scuttle_telegraph" + | "crab_scuttling" + | "crab_tidal_burst" + | "crab_recover" + | "ghost_soul_cross" + | "ghost_soul_cross_followup" + | "ghost_haunting" + | "ghost_recover" + | "golem_shockwave" + | "golem_crownfall" + | "golem_recover"; export type WorldPosition = [number, number]; -export type CircleHazardKind = "venom_pool" | "skyfall"; +export type CircleHazardKind = + | "venom_pool" + | "skyfall" + | "quake" + | "lava_pool" + | "stinger_eruption" + | "hourglass" + | "tidal_burst" + | "soul_rift" + | "crownfall" + | "royal_shockwave"; export interface CircleHazard { id: string; kind: CircleHazardKind; center: WorldPosition; + innerRadius?: number; radius: number; activatesAt: number; expiresAt: number; diff --git a/src/game/useGameLoop.ts b/src/game/useGameLoop.ts index 047ca72..aaa1bef 100644 --- a/src/game/useGameLoop.ts +++ b/src/game/useGameLoop.ts @@ -1,9 +1,17 @@ import { useEffect, useRef } from "react"; import { subscribeControllerToken } from "../input/controller"; import { ABILITY_ORDER } from "./data"; +import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick } from "./performance"; import { useGameStore } from "./store"; import type { AbilityId } from "./types"; +function cycleRunBuff(direction: 1 | -1) { + const store = useGameStore.getState(); + const currentIndex = store.draftBuffIds.indexOf(store.selectedRunBuffId); + const nextIndex = (Math.max(0, currentIndex) + direction + store.draftBuffIds.length) % store.draftBuffIds.length; + store.setSelectedRunBuff(store.draftBuffIds[nextIndex]); +} + const gamepadAbilityMap: Record = { 0: "purify", 1: "shield", @@ -23,7 +31,9 @@ export function useGameLoop() { previous = now; accumulator += delta; if (accumulator >= 0.1) { + const tickStartedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0; useGameStore.getState().tick(accumulator); + if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - tickStartedAt); accumulator = 0; } frame = requestAnimationFrame(loop); @@ -53,6 +63,14 @@ export function useActionBindings(enabled = true, onExit?: () => void) { } return; } + if (store.phase === "intermission") { + if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault(); + if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1); + if (key === "arrowright" || key === "arrowdown") cycleRunBuff(1); + if (key === "enter") store.chooseRunBuff(store.selectedRunBuffId); + if (key === "escape") exitRef.current?.(); + return; + } const numberIndex = Number(event.key) - 1; if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) { store.castAbility(ABILITY_ORDER[numberIndex]); @@ -99,6 +117,13 @@ export function useActionBindings(enabled = true, onExit?: () => void) { } return; } + if (store.phase === "intermission") { + if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1); + if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRunBuff(1); + if (!repeat && token === "Button0") store.chooseRunBuff(store.selectedRunBuffId); + if (!repeat && token === "Button1") exitRef.current?.(); + return; + } if (repeat) return; if (token.startsWith("Button")) { const ability = gamepadAbilityMap[Number(token.slice("Button".length))]; diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index 0948396..8033f84 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -6,6 +6,7 @@ import type { AppScreen } from "../frontend/types"; import { FrontEnd } from "../components/FrontEnd"; import { createDualScreenChannel, type DualScreenMessage, type FrontendCommand, type GameCommand } from "./dualScreenSync"; import type { BossId } from "../game/types"; +import type { DifficultySlug } from "../game/progression/loot"; import { useForcedThorDisplays } from "./useThorDualScreen"; import { createRateLimitedPublisher } from "./rateLimitedPublisher"; @@ -18,6 +19,7 @@ function screenTitle(screen: AppScreen) { case "saves": return "Choose hunter save"; case "home": return "Choose expedition"; case "profile": return "Hunter profile"; + case "gear": return "Gear upgrade"; case "settings": return "Field settings"; case "mode": return "Prepare encounter"; case "game": return "Field console"; @@ -64,8 +66,8 @@ export function BottomDisplayApp() { channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage); }, []); - const launchGame = useCallback((bossIds: readonly BossId[]) => { - postFrontendCommand({ name: "launchGame", bossIds }); + const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug) => { + postFrontendCommand({ name: "launchGame", bossIds, difficultySlug }); }, [postFrontendCommand]); useEffect(() => { @@ -115,6 +117,23 @@ export function BottomDisplayApp() { downloadSlot: (slotId) => postFrontend({ name: "downloadSlot", slotId }), selectMode: (mode) => postFrontend({ name: "selectMode", mode }), selectBoss: (bossId) => postFrontend({ name: "selectBoss", bossId }), + selectDifficulty: (difficultySlug) => postFrontend({ name: "selectDifficulty", difficultySlug }), + selectGearOwner: (ownerId) => postFrontend({ name: "selectGearOwner", ownerId }), + selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }), + selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }), + selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }), + upgradeSelectedGear: () => { + postFrontend({ name: "upgradeSelectedGear" }); + return false; + }, + equipSelectedInfusion: () => { + postFrontend({ name: "equipSelectedInfusion" }); + return false; + }, + equipPassiveInfusion: (passiveId) => { + postFrontend({ name: "equipPassiveInfusion", passiveId }); + return false; + }, selectHealerClass: (classId) => postFrontend({ name: "selectHealerClass", classId }), updateSetting: (key, value) => postFrontend({ name: "updateSetting", key, value }), }); @@ -131,6 +150,11 @@ export function BottomDisplayApp() { selectItem: (itemId) => postCommand({ name: "selectItem", itemId }), setPaused: (paused) => postCommand({ name: "setPaused", paused }), setPauseSelection: (selection) => postCommand({ name: "setPauseSelection", selection }), + setSelectedRunBuff: (buffId) => postCommand({ name: "setSelectedRunBuff", buffId }), + chooseRunBuff: (buffId) => { + postCommand({ name: "chooseRunBuff", buffId }); + return false; + }, }); channel.onmessage = (event: MessageEvent) => { if (event.data.type === "authoritative-ready") { diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index 4c02d0f..5bce358 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -3,9 +3,11 @@ import type { FrontendSnapshot } from "../frontend/store"; import { useFrontendStore } from "../frontend/store"; import { emitControllerToken, setExternalControllerMovement, type ControllerMovement, type ControllerTokenEvent } from "../input/controller"; import { getGameSnapshot, type GameSnapshot, useGameStore } from "../game/store"; -import type { AbilityId, BottomTab, MemberId } from "../game/types"; +import type { AbilityId, BottomTab, MemberId, RunBuffId } from "../game/types"; import type { BossId, HealerClassId } from "../game/types"; import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types"; +import type { GearOwnerId, GearSlotId } from "../game/progression/gear"; +import type { DifficultySlug } from "../game/progression/loot"; const CHANNEL_NAME = "i-want-to-heal:thor-dual-screen:v1"; @@ -18,7 +20,9 @@ export type GameCommand = | { name: "setActiveTab"; tab: BottomTab } | { name: "selectItem"; itemId: string } | { name: "setPaused"; paused: boolean } - | { name: "setPauseSelection"; selection: "resume" | "exit" }; + | { name: "setPauseSelection"; selection: "resume" | "exit" } + | { name: "setSelectedRunBuff"; buffId: RunBuffId } + | { name: "chooseRunBuff"; buffId: RunBuffId }; export type FrontendCommand = | { name: "signIn"; username: string; password: string } @@ -35,9 +39,17 @@ export type FrontendCommand = | { name: "downloadSlot"; slotId: SaveSlotId } | { name: "selectMode"; mode: GameModeId } | { name: "selectBoss"; bossId: BossId } + | { name: "selectDifficulty"; difficultySlug: DifficultySlug } + | { name: "selectGearOwner"; ownerId: GearOwnerId } + | { name: "selectGearSlot"; slotId: GearSlotId } + | { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" } + | { name: "selectInfusion"; infusionId: string } + | { name: "upgradeSelectedGear" } + | { name: "equipSelectedInfusion" } + | { name: "equipPassiveInfusion"; passiveId: RunBuffId } | { name: "selectHealerClass"; classId: HealerClassId } | { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] } - | { name: "launchGame"; bossIds: readonly BossId[] }; + | { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug }; export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game"; @@ -68,6 +80,8 @@ export function executeGameCommand(command: GameCommand) { case "selectItem": game.selectItem(command.itemId); break; case "setPaused": game.setPaused(command.paused); break; case "setPauseSelection": game.setPauseSelection(command.selection); break; + case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break; + case "chooseRunBuff": game.chooseRunBuff(command.buffId); break; } } @@ -88,9 +102,17 @@ export function executeFrontendCommand(command: FrontendCommand) { case "downloadSlot": frontend.downloadSlot(command.slotId); break; case "selectMode": frontend.selectMode(command.mode); break; case "selectBoss": frontend.selectBoss(command.bossId); break; + case "selectDifficulty": frontend.selectDifficulty(command.difficultySlug); break; + case "selectGearOwner": frontend.selectGearOwner(command.ownerId); break; + case "selectGearSlot": frontend.selectGearSlot(command.slotId); break; + case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break; + case "selectInfusion": frontend.selectInfusion(command.infusionId); break; + case "upgradeSelectedGear": frontend.upgradeSelectedGear(); break; + case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break; + case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break; case "selectHealerClass": frontend.selectHealerClass(command.classId); break; case "updateSetting": frontend.updateSetting(command.key, command.value); break; - case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: command.bossIds })); break; + case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break; } } diff --git a/src/styles.css b/src/styles.css index 9dca404..44a96f1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -557,6 +557,7 @@ button:focus-visible { } .phase-victory { background: radial-gradient(circle at center, rgba(23, 71, 53, 0.42), rgba(3, 9, 8, 0.82)); } +.phase-intermission { background: radial-gradient(circle at center, rgba(94, 76, 26, 0.38), rgba(3, 9, 8, 0.84)); } .phase-defeat { background: radial-gradient(circle at center, rgba(85, 31, 21, 0.45), rgba(6, 5, 4, 0.86)); } .pause-overlay { position: absolute; z-index: 12; inset: 0; display: grid; place-items: center; background: rgba(2,8,7,0.76); backdrop-filter: blur(5px); pointer-events: auto; } @@ -1066,6 +1067,75 @@ button:focus-visible { .end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; } .end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; } +.buff-draft { + height: 100%; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 3.5%; + padding: 3.7% 4.5% 2.8%; + background: radial-gradient(circle at 50% 0%, rgba(232, 200, 114, 0.14), transparent 48%); +} + +.top-buff-draft { + position: absolute; + z-index: 11; + inset: 0; + height: auto; + padding: 4.5% 8% 3.5%; + background: radial-gradient(circle at 50% 0%, rgba(232, 200, 114, 0.2), rgba(3, 9, 8, 0.96) 58%); + pointer-events: auto; +} + +.top-buff-draft .buff-choice-grid { gap: 3%; } +.top-buff-draft .buff-choice-grid button { padding: 14px; } + +.intermission-status { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8%; + background: radial-gradient(circle at 50% 12%, rgba(232, 200, 114, 0.13), transparent 52%); + text-align: center; +} + +.intermission-status > i { color: var(--gold); font-family: "Cinzel", serif; font-size: 34px; font-style: normal; } +.intermission-status > span { margin-top: 12px; color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; } +.intermission-status h2 { margin: 4px 0; font-family: "Cinzel", serif; font-size: clamp(18px, 4.2cqw, 26px); font-weight: 500; } +.intermission-status p { max-width: 310px; margin: 0; color: #81958d; font-size: clamp(8px, 1.7cqw, 10px); } +.intermission-status small { margin-top: 16px; color: #dce8e3; font-size: 7px; letter-spacing: 0.08em; text-transform: uppercase; } + +.buff-draft > header { text-align: center; } +.buff-draft > header > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; } +.buff-draft > header h2 { margin: 2px 0; font-family: "Cinzel", serif; font-size: clamp(18px, 4.2cqw, 26px); font-weight: 500; } +.buff-draft > header p { margin: 0; color: #81958d; font-size: clamp(8px, 1.7cqw, 10px); } +.buff-choice-grid { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2.5%; } +.buff-choice-grid button { + min-width: 0; + display: grid; + grid-template-columns: 34px 1fr; + grid-template-rows: auto auto 1fr; + align-content: start; + gap: 7px 8px; + padding: 11px; + border: 1px solid color-mix(in srgb, var(--buff-accent), transparent 62%); + border-top: 3px solid var(--buff-accent); + color: #dce8e3; + background: linear-gradient(155deg, color-mix(in srgb, var(--buff-accent), #081310 91%), rgba(5, 13, 11, 0.94)); + text-align: left; +} +.buff-choice-grid button.is-controller-focused { outline: 2px solid var(--gold-strong); outline-offset: 2px; transform: translateY(-2px); } +.buff-choice-grid button > i { grid-row: 1 / 3; width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--buff-accent); color: var(--buff-accent); font-family: "Cinzel", serif; font-size: 16px; font-style: normal; } +.buff-choice-grid button > span { min-width: 0; display: grid; } +.buff-choice-grid button small { color: var(--buff-accent); font-size: 6px; letter-spacing: 0.1em; text-transform: uppercase; } +.buff-choice-grid button strong { overflow: hidden; font-family: "Cinzel", serif; font-size: clamp(9px, 2cqw, 12px); text-overflow: ellipsis; white-space: nowrap; } +.buff-choice-grid button > b { grid-column: 1 / -1; color: #edf5f1; font-size: clamp(8px, 1.7cqw, 10px); } +.buff-choice-grid button > p { grid-column: 1 / -1; margin: 0; color: #71867e; font-size: clamp(7px, 1.45cqw, 9px); line-height: 1.25; } +.buff-draft > footer { display: flex; align-items: center; justify-content: center; gap: 7px; color: #6e827a; font-size: 7px; letter-spacing: 0.08em; text-transform: uppercase; } +.buff-draft > footer b { color: #dce8e3; } +.buff-draft > footer i { width: 2px; height: 2px; border-radius: 50%; background: var(--gold); } + /* Map */ .map-panel { @@ -1339,6 +1409,16 @@ button:focus-visible { display: none; } +.native-platform[data-display-surface] .dedicated-display-surface { + width: 100%; + height: 100%; + min-height: 100%; + display: grid; + place-items: center; + overflow: hidden; + background: #030706; +} + .native-platform[data-display-surface="top"] .top-display, .native-platform[data-display-surface="top"] .front-surface { width: 100%; @@ -1347,13 +1427,14 @@ button:focus-visible { } .native-platform[data-display-surface="bottom"] .bottom-display-root, -.native-platform[data-display-surface="bottom"] .bottom-display-root > .bottom-display { +.native-platform[data-display-surface="bottom"] .dedicated-display-surface, +.native-platform[data-display-surface="bottom"] .bottom-display { width: 100%; height: 100%; min-height: 100%; } -.native-platform[data-display-surface="bottom"] .bottom-display-root > .bottom-display { +.native-platform[data-display-surface="bottom"] .bottom-display { aspect-ratio: auto; } @@ -1815,16 +1896,17 @@ button:focus-visible { .mode-hero h2 { margin: 8px 0; font-family: "Cinzel", serif; font-size: 35px; font-weight: 500; line-height: 1; } .mode-hero p { margin: 0; color: #98aca4; font-size: 13px; line-height: 1.45; } .mode-hero > b { display: block; margin-top: 15px; color: #cbd9d4; font-size: 10px; font-weight: 600; } -.boss-picker { position: absolute; top: 102px; right: 38px; width: 310px; display: grid; gap: 7px; } +.boss-picker { position: absolute; top: 84px; right: 38px; left: 38px; display: grid; gap: 4px; } .boss-picker > span { color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; } -.boss-choice { min-height: 54px; display: grid; grid-template-columns: 34px 1fr 18px; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; } -.boss-choice > i { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 15px; font-style: normal; } +.boss-choice-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; } +.boss-choice { min-height: 40px; display: grid; grid-template-columns: 26px 1fr 14px; align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; } +.boss-choice > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 11px; font-style: normal; } .boss-choice > span { display: grid; min-width: 0; } -.boss-choice strong { font-family: "Cinzel", serif; font-size: 11px; } -.boss-choice small { overflow: hidden; color: #72877f; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; } +.boss-choice strong { font-family: "Cinzel", serif; font-size: 9px; } +.boss-choice small { overflow: hidden; color: #72877f; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; } .boss-choice > b { color: var(--boss-accent); } .boss-choice.is-selected { border-color: var(--boss-accent); box-shadow: inset 3px 0 var(--boss-accent); background: color-mix(in srgb, var(--boss-accent) 10%, rgba(6,18,16,0.9)); } -.mode-launch { position: absolute; right: 38px; bottom: 56px; width: 275px; min-height: 58px; display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-color: var(--gold) !important; color: #182019 !important; background: linear-gradient(110deg, #ffe499, #d1af54) !important; text-align: left; } +.mode-launch { position: absolute; right: 38px; bottom: 18px; width: 275px; min-height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-color: var(--gold) !important; color: #182019 !important; background: linear-gradient(110deg, #ffe499, #d1af54) !important; text-align: left; } .mode-launch span { font-family: "Cinzel", serif; font-size: 13px; font-weight: 600; } .mode-launch small { font-size: 8px; font-weight: 700; text-transform: uppercase; opacity: 0.65; } .mode-surface > .front-notice { position: absolute; right: 38px; bottom: 20px; width: 360px; } @@ -1965,7 +2047,8 @@ button:focus-visible { .mode-hero h2 { margin: 3px 0; font-size: 18px; } .mode-hero p { font-size: 7px; } .mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; } - .boss-picker { top: 54px; right: 14px; width: 36%; gap: 3px; } + .boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; } + .boss-choice-grid { grid-template-rows: repeat(var(--boss-grid-rows), minmax(27px, auto)); gap: 3px; } .boss-picker > span { font-size: 4px; } .boss-choice { min-height: 27px; grid-template-columns: 18px 1fr 9px; gap: 4px; padding: 3px 4px; } .boss-choice > i { width: 16px; height: 16px; font-size: 7px; } @@ -1976,3 +2059,163 @@ button:focus-visible { .mode-launch small { font-size: 4px; } .game-menu-button { padding: 3px 5px; font-size: 6px; } } + +/* Data-driven loot and gear workshop */ + +.home-secondary-actions { grid-template-columns: repeat(3, 1fr); } +.collection-grid { grid-template-columns: repeat(3, 1fr); gap: 9px; } +.collection-drop { height: 154px; padding: 8px 9px; } +.collection-drop.rarity-epic { border-top-color: #a071c1; } +.collection-drop.rarity-legendary { border-top-color: #d79b42; } +.collection-drop .drop-icon { width: 48px; height: 48px; margin: 2px auto 5px; font-size: 20px; } +.collection-drop > strong { min-height: 26px; font-size: 10px; } +.collection-drop > p { right: 9px; bottom: 7px; left: 9px; } + +.difficulty-picker { position: absolute; left: 52px; bottom: 25px; width: 52%; display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; } +.difficulty-picker > span { grid-column: 1 / -1; color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; } +.difficulty-picker button { min-height: 39px; display: grid; padding: 5px 7px; text-align: left; } +.difficulty-picker button strong { font-size: 8px; } +.difficulty-picker button small { color: #71867e; font-size: 6px; } +.difficulty-picker button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(96,76,27,.22); } +.mode-loot-preview { position: absolute; right: 5.5%; bottom: 89px; left: 5.5%; display: grid; padding: 9px 12px; border-left: 2px solid var(--gold); background: rgba(69,55,19,.14); } +.mode-loot-preview span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.mode-loot-preview b { font-size: 10px; } +.mode-loot-preview small { color: #71867e; font-size: 7px; } + +.gear-surface { padding: 0 28px; } +.gear-surface .front-screen-header { grid-template-columns: 190px minmax(0, 1fr) auto auto; } +.gear-mode-tabs { display: flex; gap: 4px; } +.gear-mode-tabs button { padding: 6px 8px; color: #7f958c; font-size: 7px; font-weight: 700; text-transform: uppercase; } +.gear-mode-tabs button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.2); } +.gear-workshop-layout { height: calc(100% - 91px); display: grid; grid-template-columns: 190px 220px 1fr; gap: 11px; padding-top: 12px; } +.gear-owner-list, +.gear-slot-list { display: grid; align-content: start; gap: 5px; } +.gear-owner-list button, +.gear-slot-list button { width: 100%; min-height: 43px; display: grid; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; } +.gear-owner-list button { grid-template-columns: 1fr 15px; } +.gear-slot-list button { grid-template-columns: 27px 1fr 28px; } +.gear-owner-list button > span, +.gear-slot-list button > span { display: grid; } +.gear-owner-list strong, +.gear-slot-list strong { font-size: 9px; } +.gear-owner-list small, +.gear-slot-list small { color: #71867e; font-size: 6px; text-transform: uppercase; } +.gear-owner-list button > b, +.gear-slot-list button > b { color: var(--gold); font-size: 10px; text-align: right; } +.gear-slot-list button > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid #3d554c; color: #8fc4b1; font-style: normal; } +.gear-owner-list button.is-selected, +.gear-slot-list button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: rgba(72,58,21,.16); } +.gear-preview { padding: 16px; border: 1px solid var(--line); background: radial-gradient(circle at 70% 20%, rgba(232,200,114,.1), transparent 45%), rgba(7,18,15,.84); } +.gear-preview > span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; } +.gear-preview h2 { margin: 8px 0; font: 500 17px "Cinzel", serif; } +.gear-preview p { color: #81958d; font-size: 9px; line-height: 1.45; } +.gear-stat-comparison { display: grid; grid-template-columns: 1fr 20px 1fr; align-items: center; gap: 7px; margin-top: 24px; } +.gear-stat-comparison > span { display: grid; padding: 11px; border: 1px solid #354b43; background: rgba(4,13,11,.7); } +.gear-stat-comparison small { color: #71867e; font-size: 6px; text-transform: uppercase; } +.gear-stat-comparison strong { color: var(--gold-strong); font: 500 18px "Cinzel", serif; } +.gear-stat-comparison > i { color: #60766d; font-style: normal; text-align: center; } +.gear-infusion-preview h2 { margin-bottom: 4px; } +.gear-infusion-options, +.gear-passive-options { display: grid; gap: 4px; margin-top: 9px; } +.gear-infusion-options button, +.gear-passive-options button { min-height: 39px; display: grid; grid-template-columns: 24px 1fr 12px; align-items: center; gap: 6px; padding: 4px 6px; text-align: left; } +.gear-infusion-options button > i, +.gear-passive-options button > i { color: #8fc4b1; font-size: 12px; font-style: normal; text-align: center; } +.gear-infusion-options button > span, +.gear-passive-options button > span { min-width: 0; display: grid; } +.gear-infusion-options button strong, +.gear-passive-options button strong { overflow: hidden; color: #dbe8e3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; } +.gear-infusion-options button small, +.gear-passive-options button small { overflow: hidden; color: #71867e; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; } +.gear-infusion-options button > b, +.gear-passive-options button > b { color: var(--gold); font-size: 9px; } +.gear-infusion-options button.is-selected { border-color: var(--gold); background: rgba(72,58,21,.16); } +.gear-infusion-options button.is-equipped, +.gear-passive-options button.is-equipped { box-shadow: inset 3px 0 #67c89e; } +.gear-passive-options { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.gear-passive-options > span { grid-column: 1 / -1; color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.gear-passive-options button { min-width: 0; grid-template-columns: 18px 1fr 10px; } + +.gear-context { padding: 0 5.5% 18px; } +.gear-context .context-header { margin: 0 -5.8%; } +.gear-costs { margin-top: 16px; } +.gear-costs > span { color: #71867e; font-size: 8px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.gear-costs article { min-height: 68px; display: grid; grid-template-columns: 28px 1fr 48px; align-items: center; gap: 10px; margin-top: 8px; padding: 9px 11px; border: 1px solid var(--line); background: rgba(6,17,14,.78); } +.gear-costs article > i { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid currentColor; color: #ba6f68; font-style: normal; } +.gear-costs article.is-met > i { color: #67c89e; } +.gear-costs article > span { display: grid; } +.gear-costs strong { font-size: clamp(9px, 2cqw, 12px); } +.gear-costs small { color: #71867e; font-size: clamp(6px, 1.35cqw, 8px); } +.gear-costs article > b { color: var(--gold); font-size: 12px; text-align: right; } +.gear-upgrade-action { position: absolute; right: 5.5%; bottom: 57px; left: 5.5%; min-height: 56px; display: grid; padding: 9px 13px; border-color: var(--gold); color: #182019; background: linear-gradient(110deg, #ffe499, #d1af54); text-align: left; } +.gear-upgrade-action:disabled { border-color: #42554e; color: #71827c; background: #13201c; } +.gear-upgrade-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; } +.gear-upgrade-action small { font-size: clamp(6px, 1.35cqw, 8px); } + +.reward-summary { display: grid; gap: 4px; margin: 8px 0; } +.reward-summary > span { padding: 5px 7px; border: 1px solid rgba(232,200,114,.25); color: #dbe8e3; background: rgba(68,54,18,.17); font-size: 8px; } +.reward-summary b { margin-right: 6px; color: var(--gold); } +.reward-summary i { color: #d79b42; font-style: normal; } + +@media (max-width: 760px) { + .home-secondary-actions { grid-template-columns: repeat(3, 1fr); } + .collection-grid { grid-template-columns: repeat(3, 1fr); } + .collection-drop { height: 96px; padding: 4px 5px; } + .collection-drop .drop-icon { width: 25px; height: 25px; margin: 1px auto 2px; font-size: 10px; } + .collection-drop > strong { min-height: 12px; font-size: 5px; } + .collection-drop > p { display: none; } + .collection-note { margin-top: 3px; padding: 2px 4px; } + .profile-stats span { padding: 5px 7px; } + .profile-stats strong { font-size: 12px; } + .boss-log { margin-top: 5px; } + .boss-log button { min-height: 40px; gap: 5px; margin-top: 3px; padding: 3px 6px; } + .boss-log button > i { width: 23px; height: 23px; } + .difficulty-picker { left: 16px; bottom: 23px; width: 58%; gap: 2px; } + .difficulty-picker button { min-height: 27px; padding: 2px 3px; } + .difficulty-picker button strong { font-size: 5px; } + .difficulty-picker button small, + .difficulty-picker > span { font-size: 4px; } + .mode-dungeons .mode-hero { display: none; } + .mode-dungeons .boss-picker { top: 49px; right: 12px; left: 12px; width: auto; gap: 2px; } + .mode-dungeons .boss-choice-grid { gap: 2px; } + .mode-dungeons .boss-choice { min-width: 0; min-height: 27px; grid-template-columns: 15px 1fr 7px; gap: 2px; padding: 2px 3px; } + .mode-dungeons .boss-choice > i { width: 14px; height: 14px; font-size: 6px; } + .mode-dungeons .boss-choice strong { overflow: hidden; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; } + .mode-dungeons .boss-choice small { display: none; } + .mode-dungeons .difficulty-picker { right: 39%; bottom: 17px; left: 12px; width: auto; } + .mode-dungeons .mode-launch { right: 12px; bottom: 17px; width: 34%; } + .gear-surface { padding: 0 12px; } + .gear-surface .front-screen-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; } + .gear-mode-tabs { gap: 2px; } + .gear-mode-tabs button { padding: 3px 4px; font-size: 4px; } + .gear-workshop-layout { height: calc(100% - 53px); grid-template-columns: 27% 31% 1fr; gap: 4px; padding-top: 5px; } + .gear-owner-list, + .gear-slot-list { gap: 2px; } + .gear-owner-list button, + .gear-slot-list button { min-height: 27px; gap: 3px; padding: 2px 4px; } + .gear-slot-list button { grid-template-columns: 16px 1fr 17px; } + .gear-owner-list strong, + .gear-slot-list strong { font-size: 5px; } + .gear-owner-list small, + .gear-slot-list small { font-size: 4px; } + .gear-slot-list button > i { width: 14px; height: 14px; font-size: 6px; } + .gear-preview { padding: 6px; } + .gear-preview h2 { margin: 3px 0; font-size: 8px; } + .gear-preview p { font-size: 5px; } + .gear-stat-comparison { gap: 2px; margin-top: 7px; } + .gear-stat-comparison > span { padding: 4px; } + .gear-stat-comparison strong { font-size: 9px; } + .gear-stat-comparison small, + .gear-preview > span { font-size: 4px; } + .gear-infusion-options, + .gear-passive-options { gap: 2px; margin-top: 3px; } + .gear-infusion-options button, + .gear-passive-options button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; } + .gear-infusion-options button > i, + .gear-passive-options button > i { font-size: 6px; } + .gear-infusion-options button strong, + .gear-passive-options button strong { font-size: 4px; } + .gear-infusion-options button small, + .gear-passive-options button small, + .gear-passive-options > span { font-size: 3px; } +}