diff --git a/package.json b/package.json
index 9c4cddf..cadcc34 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
- "version": "0.1.10",
+ "version": "0.1.11",
"type": "module",
"scripts": {
"predev": "pnpm assets:sync-basis-transcoder",
diff --git a/src/components/boss/BossMechanicIndicators.tsx b/src/components/boss/BossMechanicIndicators.tsx
index d2b4c59..2cb4198 100644
--- a/src/components/boss/BossMechanicIndicators.tsx
+++ b/src/components/boss/BossMechanicIndicators.tsx
@@ -3,10 +3,12 @@ import { Html } from "@react-three/drei";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
import * as THREE from "three";
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 { useGameStore } from "../../game/store";
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
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 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;
const active = time >= hazard.activatesAt;
- 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];
+ const visual = bossHazardVfxProfile(hazard.kind);
+ const color = active ? visual.activeColor : visual.warningColor;
return (
@@ -358,9 +349,10 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
{!active && (
-
+
)}
+
);
}
diff --git a/src/components/boss/CircleHazardVfx.tsx b/src/components/boss/CircleHazardVfx.tsx
new file mode 100644
index 0000000..fcf2ca5
--- /dev/null
+++ b/src/components/boss/CircleHazardVfx.tsx
@@ -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 ;
+ if (shape === "spike") return ;
+ return ;
+}
+
+/**
+ * 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(null);
+ const particleMaterial = useRef(null);
+ const warningRing = useRef(null);
+ const warningMaterial = useRef(null);
+ const impactRing = useRef(null);
+ const impactMaterial = useRef(null);
+ const column = useRef(null);
+ const columnMaterial = useRef(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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {profile.columnOpacity > 0 && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/game/bossHazardVisuals.test.ts b/src/game/bossHazardVisuals.test.ts
new file mode 100644
index 0000000..a4751fd
--- /dev/null
+++ b/src/game/bossHazardVisuals.test.ts
@@ -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);
+ });
+});
diff --git a/src/game/bossHazardVisuals.ts b/src/game/bossHazardVisuals.ts
new file mode 100644
index 0000000..6f12001
--- /dev/null
+++ b/src/game/bossHazardVisuals.ts
@@ -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 = {
+ 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;
+}