From 8e48bb4fb662a5a7ed894af479fb2c9ecb7376aa Mon Sep 17 00:00:00 2001 From: Warren H Date: Mon, 13 Jul 2026 21:31:36 -0400 Subject: [PATCH] Release v0.1.11 2026-07-13 --- package.json | 2 +- .../boss/BossMechanicIndicators.tsx | 20 +- src/components/boss/CircleHazardVfx.tsx | 198 ++++++++++++++++++ src/game/bossHazardVisuals.test.ts | 43 ++++ src/game/bossHazardVisuals.ts | 146 +++++++++++++ 5 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 src/components/boss/CircleHazardVfx.tsx create mode 100644 src/game/bossHazardVisuals.test.ts create mode 100644 src/game/bossHazardVisuals.ts 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; +}