Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e48bb4fb6 | ||
|
|
fb41147197 |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.9",
|
"version": "0.1.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "pnpm assets:sync-basis-transcoder",
|
"predev": "pnpm assets:sync-basis-transcoder",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useGameStore } from "../game/store";
|
|||||||
import type { BossId, MemberId, PulseKind } from "../game/types";
|
import type { BossId, MemberId, PulseKind } from "../game/types";
|
||||||
import { BossRoom } from "./BossRoom";
|
import { BossRoom } from "./BossRoom";
|
||||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||||
|
import { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals";
|
||||||
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
|
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
|
||||||
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";
|
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";
|
||||||
|
|
||||||
@@ -130,8 +131,11 @@ function createBossRenderModel(source: THREE.Object3D) {
|
|||||||
? object.material.map(cloneMaterial)
|
? object.material.map(cloneMaterial)
|
||||||
: cloneMaterial(object.material);
|
: cloneMaterial(object.material);
|
||||||
});
|
});
|
||||||
|
model.updateMatrixWorld(true);
|
||||||
|
const modelTopY = new THREE.Box3().setFromObject(model).max.y;
|
||||||
return {
|
return {
|
||||||
model,
|
model,
|
||||||
|
modelTopY,
|
||||||
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
|
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
|
||||||
material,
|
material,
|
||||||
baseOpacity: material.opacity,
|
baseOpacity: material.opacity,
|
||||||
@@ -834,9 +838,10 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
|||||||
const light = useRef<THREE.PointLight>(null);
|
const light = useRef<THREE.PointLight>(null);
|
||||||
const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
|
const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
|
||||||
const gltf = useGameGLTF(assetUrl);
|
const gltf = useGameGLTF(assetUrl);
|
||||||
const { model, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
|
const { model, modelTopY, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
|
||||||
const { actions } = useAnimations(gltf.animations, model);
|
const { actions } = useAnimations(gltf.animations, model);
|
||||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||||
|
const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
|
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
|
||||||
@@ -873,9 +878,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
|||||||
if (!current) return;
|
if (!current) return;
|
||||||
const motion = current.motion;
|
const motion = current.motion;
|
||||||
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
|
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
|
||||||
const burrowed = archetype === "burrower" && motion.activeMechanicId === "burrow-rush" && motion.mode === "charging";
|
const burrowing = archetype === "burrower" && bossIsBurrowing(motion.activeMechanicId, motion.mode);
|
||||||
const floatingHeight = config.floating ? 0.2 : 0.03;
|
const floatingHeight = config.floating ? 0.2 : 0.03;
|
||||||
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
|
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowing ? burrowPositionY : floatingHeight, motion.position[1]);
|
||||||
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
|
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
|
||||||
if (!bossCanTrackTarget(current.boss.hp)) return;
|
if (!bossCanTrackTarget(current.boss.hp)) return;
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { Html } from "@react-three/drei";
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
|
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
|
||||||
|
import { bossHazardVfxProfile } from "../../game/bossHazardVisuals";
|
||||||
import { angleTo } from "../../game/geometry";
|
import { angleTo } from "../../game/geometry";
|
||||||
import { useGameStore } from "../../game/store";
|
import { useGameStore } from "../../game/store";
|
||||||
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
|
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
|
||||||
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
|
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
|
||||||
|
import { CircleHazardVfx } from "./CircleHazardVfx";
|
||||||
|
|
||||||
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
|
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 STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
|
||||||
@@ -328,19 +330,8 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
|
|||||||
});
|
});
|
||||||
if (!hazard) return null;
|
if (!hazard) return null;
|
||||||
const active = time >= hazard.activatesAt;
|
const active = time >= hazard.activatesAt;
|
||||||
const colors = {
|
const visual = bossHazardVfxProfile(hazard.kind);
|
||||||
venom_pool: "#a94ee6",
|
const color = active ? visual.activeColor : visual.warningColor;
|
||||||
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 (
|
return (
|
||||||
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
@@ -358,9 +349,10 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
|
|||||||
{!active && (
|
{!active && (
|
||||||
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<ringGeometry args={[0.22, 0.34, 24]} />
|
<ringGeometry args={[0.22, 0.34, 24]} />
|
||||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
|
<meshBasicMaterial color={visual.highlightColor} transparent opacity={0.95} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
<CircleHazardVfx hazard={hazard} active={active} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { bossHazardVfxProfile, bossHazardVfxSeed, type BossHazardVfxShape } from "../../game/bossHazardVisuals";
|
||||||
|
import { useGameStore } from "../../game/store";
|
||||||
|
import type { CircleHazard } from "../../game/types";
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
function ParticleGeometry({ shape }: { shape: BossHazardVfxShape }) {
|
||||||
|
if (shape === "orb") return <sphereGeometry args={[0.16, 8, 6]} />;
|
||||||
|
if (shape === "spike") return <coneGeometry args={[0.16, 0.9, 5]} />;
|
||||||
|
return <octahedronGeometry args={[0.19, 0]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One instanced particle draw plus one transient accent and an optional column.
|
||||||
|
* Effects mutate preallocated transforms and never touch combat state.
|
||||||
|
*/
|
||||||
|
export function CircleHazardVfx({ hazard, active }: { hazard: CircleHazard; active: boolean }) {
|
||||||
|
const profile = bossHazardVfxProfile(hazard.kind);
|
||||||
|
const particles = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const warningRing = useRef<THREE.Mesh>(null);
|
||||||
|
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const impactRing = useRef<THREE.Mesh>(null);
|
||||||
|
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const column = useRef<THREE.Mesh>(null);
|
||||||
|
const columnMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||||
|
const seed = useMemo(() => bossHazardVfxSeed(hazard.id), [hazard.id]);
|
||||||
|
const reducedMotion = useMemo(() => (
|
||||||
|
document.documentElement.classList.contains("force-reduced-motion")
|
||||||
|
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
), []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const time = useGameStore.getState().time;
|
||||||
|
const activeAge = Math.max(0, time - hazard.activatesAt);
|
||||||
|
const warningRemaining = Math.max(0, hazard.activatesAt - time);
|
||||||
|
const effectTime = reducedMotion ? seed * 2 : clock.elapsedTime + seed * 7.3;
|
||||||
|
const activeDuration = Math.max(0.22, hazard.expiresAt - hazard.activatesAt);
|
||||||
|
const impactDuration = Math.min(0.48, activeDuration);
|
||||||
|
const impactProgress = THREE.MathUtils.clamp(activeAge / impactDuration, 0, 1);
|
||||||
|
|
||||||
|
if (particles.current) {
|
||||||
|
for (let index = 0; index < profile.particleCount; index += 1) {
|
||||||
|
const offset = (index + 0.5) / profile.particleCount;
|
||||||
|
const baseAngle = offset * TAU + seed * TAU;
|
||||||
|
const angle = baseAngle + effectTime * profile.spinSpeed;
|
||||||
|
const stagger = (offset + seed * 0.37) % 1;
|
||||||
|
let radial = hazard.radius * (0.28 + (index % 3) * 0.2);
|
||||||
|
let y = 0.12;
|
||||||
|
let width = 0.72;
|
||||||
|
let height = 0.72;
|
||||||
|
|
||||||
|
if (profile.motion === "bubble") {
|
||||||
|
const cycle = (effectTime * 0.48 + stagger) % 1;
|
||||||
|
radial = hazard.radius * (0.22 + (index % 3) * 0.22);
|
||||||
|
y = 0.08 + cycle * profile.height;
|
||||||
|
width = 0.55 + Math.sin(cycle * Math.PI) * 0.65;
|
||||||
|
height = width;
|
||||||
|
} else if (profile.motion === "fall") {
|
||||||
|
const cycle = 1 - ((effectTime * 0.72 + stagger) % 1);
|
||||||
|
radial = hazard.radius * (0.18 + (index % 3) * 0.2);
|
||||||
|
y = 0.22 + cycle * profile.height;
|
||||||
|
width = active ? 0.82 : 0.55;
|
||||||
|
height = active ? 2.9 : 2.15;
|
||||||
|
} else if (profile.motion === "flame") {
|
||||||
|
const cycle = (effectTime * 0.95 + stagger) % 1;
|
||||||
|
radial = hazard.radius * (0.2 + (index % 3) * 0.21);
|
||||||
|
y = 0.12 + cycle * profile.height * 0.62;
|
||||||
|
width = 0.5 + Math.sin(cycle * Math.PI) * 0.85;
|
||||||
|
height = 0.75 + Math.sin(cycle * Math.PI) * 1.6;
|
||||||
|
} else if (profile.motion === "orbit") {
|
||||||
|
radial = hazard.radius * (0.3 + (index % 2) * 0.24);
|
||||||
|
y = 0.28 + ((index % 4) / 3) * profile.height + Math.sin(effectTime * 2.2 + index) * 0.16;
|
||||||
|
width = 0.65 + (index % 2) * 0.35;
|
||||||
|
height = profile.shape === "crystal" ? 1.55 : width;
|
||||||
|
} else if (profile.motion === "shockwave") {
|
||||||
|
const wave = active ? impactProgress : 0.22 + (1 - Math.min(1, warningRemaining / 1.6)) * 0.18;
|
||||||
|
radial = hazard.radius * wave;
|
||||||
|
y = 0.13 + Math.sin(offset * Math.PI) * profile.height * 0.12;
|
||||||
|
width = active ? 0.72 + impactProgress * 0.75 : 0.58;
|
||||||
|
height = active ? 1.25 : 0.75;
|
||||||
|
} else if (profile.motion === "spike") {
|
||||||
|
const rise = active ? THREE.MathUtils.clamp(activeAge / 0.16, 0, 1) : 0.12;
|
||||||
|
radial = hazard.radius * (0.2 + (index % 3) * 0.23);
|
||||||
|
width = 0.75 + (index % 2) * 0.35;
|
||||||
|
height = Math.max(0.18, rise * (profile.height + (index % 3) * 0.34));
|
||||||
|
y = height * 0.45;
|
||||||
|
} else {
|
||||||
|
const cycle = (effectTime * 0.72 + stagger) % 1;
|
||||||
|
radial = hazard.radius * (0.18 + cycle * 0.68);
|
||||||
|
y = 0.12 + Math.sin(cycle * Math.PI) * profile.height;
|
||||||
|
width = 0.6 + Math.sin(cycle * Math.PI) * 0.72;
|
||||||
|
height = width * 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningScale = active ? 1 : 0.56;
|
||||||
|
transform.position.set(Math.sin(angle) * radial, y, Math.cos(angle) * radial);
|
||||||
|
transform.rotation.set(index * 0.73, -angle, profile.motion === "fall" ? 0 : effectTime * 0.45 + index);
|
||||||
|
transform.scale.set(width * warningScale, height * warningScale, width * warningScale);
|
||||||
|
transform.updateMatrix();
|
||||||
|
particles.current.setMatrixAt(index, transform.matrix);
|
||||||
|
}
|
||||||
|
particles.current.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particleMaterial.current) {
|
||||||
|
const pulse = reducedMotion ? 0.72 : 0.64 + Math.sin(effectTime * 4.6) * 0.12;
|
||||||
|
particleMaterial.current.opacity = active ? pulse + 0.16 : pulse * 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warningRing.current && warningMaterial.current) {
|
||||||
|
const countdown = THREE.MathUtils.clamp(warningRemaining / 1.8, 0, 1);
|
||||||
|
const scale = hazard.radius * (0.18 + countdown * 0.82);
|
||||||
|
warningRing.current.visible = !active;
|
||||||
|
warningRing.current.scale.setScalar(scale);
|
||||||
|
warningRing.current.rotation.z = reducedMotion ? seed * TAU : effectTime * profile.spinSpeed * 0.42;
|
||||||
|
warningMaterial.current.opacity = 0.48 + (reducedMotion ? 0 : Math.sin(effectTime * 7) * 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (impactRing.current && impactMaterial.current) {
|
||||||
|
impactRing.current.visible = active && impactProgress < 0.999;
|
||||||
|
impactRing.current.scale.setScalar(hazard.radius * (0.16 + impactProgress * 1.02));
|
||||||
|
impactMaterial.current.opacity = (1 - impactProgress) * 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column.current && columnMaterial.current) {
|
||||||
|
const columnPulse = reducedMotion ? 0.78 : 0.7 + Math.sin(effectTime * 3.8) * 0.18;
|
||||||
|
column.current.rotation.y = effectTime * profile.spinSpeed * 0.3;
|
||||||
|
column.current.scale.set(
|
||||||
|
hazard.radius * (active ? 0.9 : 0.62),
|
||||||
|
profile.height * (active ? 1 : 0.72),
|
||||||
|
hazard.radius * (active ? 0.9 : 0.62),
|
||||||
|
);
|
||||||
|
columnMaterial.current.opacity = profile.columnOpacity * columnPulse * (active ? 1 : 0.52);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const color = active ? profile.activeColor : profile.warningColor;
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<instancedMesh ref={particles} args={[undefined, undefined, profile.particleCount]} frustumCulled={false}>
|
||||||
|
<ParticleGeometry shape={profile.shape} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={particleMaterial}
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={active ? 0.8 : 0.35}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</instancedMesh>
|
||||||
|
<mesh ref={warningRing} position={[0, 0.085, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<torusGeometry args={[1, 0.026, 5, 36]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={warningMaterial}
|
||||||
|
color={profile.highlightColor}
|
||||||
|
transparent
|
||||||
|
opacity={0.58}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={impactRing} position={[0, 0.12, 0]} rotation={[-Math.PI / 2, 0, 0]} visible={false}>
|
||||||
|
<ringGeometry args={[0.84, 1, 40]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={impactMaterial}
|
||||||
|
color={profile.highlightColor}
|
||||||
|
transparent
|
||||||
|
opacity={0}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{profile.columnOpacity > 0 && (
|
||||||
|
<mesh ref={column} position={[0, profile.height * 0.5, 0]}>
|
||||||
|
<cylinderGeometry args={[0.48, 0.16, 1, 18, 1, true]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={columnMaterial}
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={profile.columnOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BOSS_BURROW_CLEARANCE, bossBurrowPositionY, bossIsBurrowing } from "./bossBurrowVisuals";
|
||||||
|
|
||||||
|
describe("boss burrow visuals", () => {
|
||||||
|
it("places the scaled top of the model below the arena floor", () => {
|
||||||
|
const modelTopY = 2.8371768724243784;
|
||||||
|
const modelScale = 0.82;
|
||||||
|
const positionY = bossBurrowPositionY(modelTopY, modelScale);
|
||||||
|
|
||||||
|
expect(positionY + modelTopY * modelScale).toBeCloseTo(-BOSS_BURROW_CLEARANCE, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts sinking during the warning and remains submerged through the charge", () => {
|
||||||
|
expect(bossIsBurrowing("burrow-rush", "telegraph")).toBe(true);
|
||||||
|
expect(bossIsBurrowing("burrow-rush", "charging")).toBe(true);
|
||||||
|
expect(bossIsBurrowing("burrow-rush", "holding")).toBe(false);
|
||||||
|
expect(bossIsBurrowing("bull-charge", "charging")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { BossMechanicId, BossMotionMode } from "../../game/types";
|
||||||
|
|
||||||
|
export const BOSS_BURROW_CLEARANCE = 0.58;
|
||||||
|
|
||||||
|
export function bossBurrowPositionY(
|
||||||
|
modelTopY: number,
|
||||||
|
modelScale: number,
|
||||||
|
clearance = BOSS_BURROW_CLEARANCE,
|
||||||
|
): number {
|
||||||
|
return -(Math.max(0, modelTopY) * Math.max(0, modelScale) + Math.max(0, clearance));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bossIsBurrowing(
|
||||||
|
activeMechanicId: BossMechanicId | null,
|
||||||
|
mode: BossMotionMode,
|
||||||
|
): boolean {
|
||||||
|
return activeMechanicId === "burrow-rush" && (mode === "telegraph" || mode === "charging");
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BOSS_HAZARD_VFX, bossHazardVfxProfile, bossHazardVfxSeed } from "./bossHazardVisuals";
|
||||||
|
import type { CircleHazardKind } from "./types";
|
||||||
|
|
||||||
|
const HAZARD_KINDS: readonly CircleHazardKind[] = [
|
||||||
|
"venom_pool",
|
||||||
|
"skyfall",
|
||||||
|
"quake",
|
||||||
|
"lava_pool",
|
||||||
|
"stinger_eruption",
|
||||||
|
"hourglass",
|
||||||
|
"tidal_burst",
|
||||||
|
"soul_rift",
|
||||||
|
"crownfall",
|
||||||
|
"royal_shockwave",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("boss hazard visuals", () => {
|
||||||
|
it("defines a bounded effect profile for every circular hazard kind", () => {
|
||||||
|
expect(Object.keys(BOSS_HAZARD_VFX).sort()).toEqual([...HAZARD_KINDS].sort());
|
||||||
|
for (const kind of HAZARD_KINDS) {
|
||||||
|
const profile = bossHazardVfxProfile(kind);
|
||||||
|
expect(profile.particleCount).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(profile.particleCount).toBeLessThanOrEqual(9);
|
||||||
|
expect(profile.height).toBeGreaterThan(0);
|
||||||
|
expect(profile.warningColor).not.toBe(profile.highlightColor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses shape and motion, not color alone, to distinguish common attacks", () => {
|
||||||
|
expect(bossHazardVfxProfile("venom_pool")).toMatchObject({ motion: "bubble", shape: "orb" });
|
||||||
|
expect(bossHazardVfxProfile("stinger_eruption")).toMatchObject({ motion: "spike", shape: "spike" });
|
||||||
|
expect(bossHazardVfxProfile("royal_shockwave")).toMatchObject({ motion: "shockwave", shape: "crystal" });
|
||||||
|
expect(bossHazardVfxProfile("soul_rift")).toMatchObject({ motion: "orbit", shape: "orb" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates stable normalized seeds without runtime randomness", () => {
|
||||||
|
expect(bossHazardVfxSeed("meteor-12")).toBe(bossHazardVfxSeed("meteor-12"));
|
||||||
|
expect(bossHazardVfxSeed("meteor-12")).not.toBe(bossHazardVfxSeed("meteor-13"));
|
||||||
|
expect(bossHazardVfxSeed("meteor-12")).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bossHazardVfxSeed("meteor-12")).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { CircleHazardKind } from "./types";
|
||||||
|
|
||||||
|
export type BossHazardVfxMotion = "bubble" | "fall" | "flame" | "orbit" | "shockwave" | "spike" | "surge";
|
||||||
|
export type BossHazardVfxShape = "crystal" | "orb" | "spike";
|
||||||
|
|
||||||
|
export interface BossHazardVfxProfile {
|
||||||
|
readonly warningColor: string;
|
||||||
|
readonly activeColor: string;
|
||||||
|
readonly highlightColor: string;
|
||||||
|
readonly motion: BossHazardVfxMotion;
|
||||||
|
readonly shape: BossHazardVfxShape;
|
||||||
|
readonly particleCount: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly spinSpeed: number;
|
||||||
|
readonly columnOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering-only identity for circular hazards. Combat rules remain in boss
|
||||||
|
* mechanics; renderers can project this profile without branching on boss id.
|
||||||
|
*/
|
||||||
|
export const BOSS_HAZARD_VFX: Record<CircleHazardKind, BossHazardVfxProfile> = {
|
||||||
|
venom_pool: {
|
||||||
|
warningColor: "#9a54d6",
|
||||||
|
activeColor: "#6fca45",
|
||||||
|
highlightColor: "#d6ff7d",
|
||||||
|
motion: "bubble",
|
||||||
|
shape: "orb",
|
||||||
|
particleCount: 7,
|
||||||
|
height: 0.8,
|
||||||
|
spinSpeed: 0.35,
|
||||||
|
columnOpacity: 0,
|
||||||
|
},
|
||||||
|
skyfall: {
|
||||||
|
warningColor: "#ff4d42",
|
||||||
|
activeColor: "#ff792e",
|
||||||
|
highlightColor: "#ffe0a1",
|
||||||
|
motion: "fall",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 6,
|
||||||
|
height: 5.2,
|
||||||
|
spinSpeed: 0.55,
|
||||||
|
columnOpacity: 0.13,
|
||||||
|
},
|
||||||
|
quake: {
|
||||||
|
warningColor: "#ff5549",
|
||||||
|
activeColor: "#d64226",
|
||||||
|
highlightColor: "#ffd08a",
|
||||||
|
motion: "shockwave",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 8,
|
||||||
|
height: 0.75,
|
||||||
|
spinSpeed: 0.18,
|
||||||
|
columnOpacity: 0,
|
||||||
|
},
|
||||||
|
lava_pool: {
|
||||||
|
warningColor: "#ff6a31",
|
||||||
|
activeColor: "#ff3b18",
|
||||||
|
highlightColor: "#ffd05a",
|
||||||
|
motion: "flame",
|
||||||
|
shape: "spike",
|
||||||
|
particleCount: 7,
|
||||||
|
height: 1.25,
|
||||||
|
spinSpeed: 0.28,
|
||||||
|
columnOpacity: 0.04,
|
||||||
|
},
|
||||||
|
stinger_eruption: {
|
||||||
|
warningColor: "#ff4b42",
|
||||||
|
activeColor: "#cf2442",
|
||||||
|
highlightColor: "#ffd2b3",
|
||||||
|
motion: "spike",
|
||||||
|
shape: "spike",
|
||||||
|
particleCount: 7,
|
||||||
|
height: 1.75,
|
||||||
|
spinSpeed: 0.12,
|
||||||
|
columnOpacity: 0,
|
||||||
|
},
|
||||||
|
hourglass: {
|
||||||
|
warningColor: "#ed8b3a",
|
||||||
|
activeColor: "#c16a28",
|
||||||
|
highlightColor: "#ffe3a3",
|
||||||
|
motion: "orbit",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 8,
|
||||||
|
height: 2.2,
|
||||||
|
spinSpeed: 1.35,
|
||||||
|
columnOpacity: 0.1,
|
||||||
|
},
|
||||||
|
tidal_burst: {
|
||||||
|
warningColor: "#36cae2",
|
||||||
|
activeColor: "#178fc6",
|
||||||
|
highlightColor: "#c9fbff",
|
||||||
|
motion: "surge",
|
||||||
|
shape: "orb",
|
||||||
|
particleCount: 8,
|
||||||
|
height: 1.65,
|
||||||
|
spinSpeed: 0.75,
|
||||||
|
columnOpacity: 0.08,
|
||||||
|
},
|
||||||
|
soul_rift: {
|
||||||
|
warningColor: "#aa78ff",
|
||||||
|
activeColor: "#6f42cf",
|
||||||
|
highlightColor: "#ebd8ff",
|
||||||
|
motion: "orbit",
|
||||||
|
shape: "orb",
|
||||||
|
particleCount: 8,
|
||||||
|
height: 2.45,
|
||||||
|
spinSpeed: 1.65,
|
||||||
|
columnOpacity: 0.14,
|
||||||
|
},
|
||||||
|
crownfall: {
|
||||||
|
warningColor: "#e7c94d",
|
||||||
|
activeColor: "#d95331",
|
||||||
|
highlightColor: "#fff1a8",
|
||||||
|
motion: "fall",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 7,
|
||||||
|
height: 5.6,
|
||||||
|
spinSpeed: -0.65,
|
||||||
|
columnOpacity: 0.15,
|
||||||
|
},
|
||||||
|
royal_shockwave: {
|
||||||
|
warningColor: "#edcb55",
|
||||||
|
activeColor: "#c94631",
|
||||||
|
highlightColor: "#fff0a6",
|
||||||
|
motion: "shockwave",
|
||||||
|
shape: "crystal",
|
||||||
|
particleCount: 9,
|
||||||
|
height: 0.85,
|
||||||
|
spinSpeed: -0.22,
|
||||||
|
columnOpacity: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bossHazardVfxProfile(kind: CircleHazardKind) {
|
||||||
|
return BOSS_HAZARD_VFX[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bossHazardVfxSeed(id: string) {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < id.length; index += 1) {
|
||||||
|
hash ^= id.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return (hash >>> 0) / 0xffffffff;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user