Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7b59ce77 | ||
|
|
4b31480bec | ||
|
|
ed34c2503c | ||
|
|
8e48bb4fb6 | ||
|
|
fb41147197 |
+3
-3
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "i-want-to-heal",
|
||||
"private": true,
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "pnpm assets:sync-basis-transcoder",
|
||||
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
||||
"db:backup": "node scripts/backup-db.mjs",
|
||||
"db:init": "node scripts/init-db.mjs",
|
||||
"prebuild": "pnpm assets:sync-basis-transcoder",
|
||||
"prebuild": "node scripts/sync_basis_transcoder.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"android:sync": "pnpm run build && cap sync android",
|
||||
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
|
||||
|
||||
+39
-20
@@ -8,7 +8,6 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -339,17 +338,6 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
|
||||
return result
|
||||
|
||||
|
||||
def multipart_asset(path: Path, media_type: str) -> tuple[bytes, str]:
|
||||
boundary = f"----iwanttoheal{secrets.token_hex(12)}"
|
||||
prefix = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
|
||||
f"Content-Type: {media_type}\r\n\r\n"
|
||||
).encode()
|
||||
body = prefix + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
||||
return body, f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def upload_release_asset(
|
||||
release_id: int,
|
||||
path: Path,
|
||||
@@ -357,15 +345,46 @@ def upload_release_asset(
|
||||
media_type: str,
|
||||
token: str,
|
||||
) -> None:
|
||||
body, content_type = multipart_asset(path, media_type)
|
||||
gitea_request(
|
||||
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
||||
f"?name={urllib.parse.quote(path.name)}",
|
||||
token=token,
|
||||
method="POST",
|
||||
body=body,
|
||||
content_type=content_type,
|
||||
curl = shutil.which("curl")
|
||||
if not curl:
|
||||
raise SystemExit("curl is required to upload Gitea release assets")
|
||||
|
||||
url = (
|
||||
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
||||
f"?name={urllib.parse.quote(path.name)}"
|
||||
)
|
||||
headers = (
|
||||
"Accept: application/json\n"
|
||||
f"Authorization: token {token}\n"
|
||||
"Expect: 100-continue\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
curl,
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--fail-with-body",
|
||||
"--connect-timeout",
|
||||
"30",
|
||||
"--max-time",
|
||||
"300",
|
||||
"--header",
|
||||
"@-",
|
||||
"--form",
|
||||
f"attachment=@{path};type={media_type}",
|
||||
url,
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
input=headers,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
details = result.stdout.strip() or result.stderr.strip()
|
||||
raise SystemExit(
|
||||
f"Gitea release asset upload failed (curl {result.returncode}): {details}"
|
||||
)
|
||||
print(f"Release asset uploaded: {path.name}")
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEnc
|
||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||
import type { BottomTab, PartyMember } from "../game/types";
|
||||
import { useFrontendStore } from "../frontend/store";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||
|
||||
function RewardSummary() {
|
||||
@@ -192,6 +192,7 @@ function BriefingPanel() {
|
||||
}
|
||||
|
||||
function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
const hunter = useActiveHunter();
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
const round = useGameStore((state) => state.round);
|
||||
@@ -208,6 +209,8 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
|
||||
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
||||
return (
|
||||
<div className={`end-panel end-${phase}`}>
|
||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
||||
@@ -225,7 +228,8 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
onFocus={() => setEndlessChoiceSelection("continue")}
|
||||
onPointerEnter={() => setEndlessChoiceSelection("continue")}
|
||||
onClick={startRogueTrialsEndless}
|
||||
>Continue Endless</button>
|
||||
aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
|
||||
><span>Endless Mode</span><small>High score · {endlessHighScoreLabel}</small></button>
|
||||
<button
|
||||
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
|
||||
onFocus={() => setEndlessChoiceSelection("quit")}
|
||||
|
||||
+296
-50
@@ -25,10 +25,13 @@ import {
|
||||
type ActorAnimationState,
|
||||
} from "../game/actorAnimation";
|
||||
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
|
||||
import type { PartyAbilityId } from "../game/partyCombat";
|
||||
import { partyAttackVfxProfile } from "../game/partyAttackVisuals";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { BossId, MemberId, PulseKind } from "../game/types";
|
||||
import { BossRoom } from "./BossRoom";
|
||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
import { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals";
|
||||
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
|
||||
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
@@ -130,8 +133,11 @@ function createBossRenderModel(source: THREE.Object3D) {
|
||||
? object.material.map(cloneMaterial)
|
||||
: cloneMaterial(object.material);
|
||||
});
|
||||
model.updateMatrixWorld(true);
|
||||
const modelTopY = new THREE.Box3().setFromObject(model).max.y;
|
||||
return {
|
||||
model,
|
||||
modelTopY,
|
||||
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
|
||||
material,
|
||||
baseOpacity: material.opacity,
|
||||
@@ -834,9 +840,10 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const light = useRef<THREE.PointLight>(null);
|
||||
const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
|
||||
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 targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
|
||||
@@ -873,9 +880,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
if (!current) return;
|
||||
const motion = current.motion;
|
||||
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;
|
||||
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));
|
||||
if (!bossCanTrackTarget(current.boss.hp)) return;
|
||||
|
||||
@@ -994,7 +1001,15 @@ function TankAuraField() {
|
||||
}
|
||||
|
||||
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const projectile = useRef<THREE.Group>(null);
|
||||
const impact = useRef<THREE.Group>(null);
|
||||
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const accentMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const trail = useRef<THREE.Mesh>(null);
|
||||
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const impactCoreMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const lastAbilityId = useRef<PartyAbilityId | null>(null);
|
||||
const start = useMemo(() => new THREE.Vector3(), []);
|
||||
const end = useMemo(() => new THREE.Vector3(), []);
|
||||
const current = useMemo(() => new THREE.Vector3(), []);
|
||||
@@ -1002,7 +1017,7 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
||||
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!group.current) return;
|
||||
if (!projectile.current || !impact.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const action = state.partyCombat.combatants[memberId].visualAction;
|
||||
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||
@@ -1013,8 +1028,19 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
||||
&& action.abilityId !== "overcharge"
|
||||
&& state.time >= action.startedAt
|
||||
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
|
||||
group.current.visible = active;
|
||||
if (!active || !action) return;
|
||||
projectile.current.visible = active;
|
||||
impact.current.visible = false;
|
||||
if (!action || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return;
|
||||
|
||||
const profile = partyAttackVfxProfile(action.abilityId);
|
||||
if (lastAbilityId.current !== action.abilityId) {
|
||||
lastAbilityId.current = action.abilityId;
|
||||
coreMaterial.current?.color.set(profile.primary);
|
||||
accentMaterial.current?.color.set(profile.accent);
|
||||
trailMaterial.current?.color.set(profile.primary);
|
||||
impactMaterial.current?.color.set(profile.accent);
|
||||
impactCoreMaterial.current?.color.set(profile.primary);
|
||||
}
|
||||
|
||||
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
|
||||
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
|
||||
@@ -1025,44 +1051,81 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
||||
const target = targetMotion.position;
|
||||
start.set(source[0], 1.18, source[1]);
|
||||
end.set(target[0], 1.12, target[1]);
|
||||
current.copy(start).lerp(end, progress);
|
||||
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
|
||||
group.current.position.copy(current);
|
||||
direction.subVectors(end, start).normalize();
|
||||
group.current.quaternion.setFromUnitVectors(up, direction);
|
||||
if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12);
|
||||
if (active) {
|
||||
current.copy(start).lerp(end, progress);
|
||||
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
|
||||
projectile.current.position.copy(current);
|
||||
direction.subVectors(end, start).normalize();
|
||||
projectile.current.quaternion.setFromUnitVectors(up, direction);
|
||||
const pulseScale = memberId === "orin" ? 1 + Math.sin(clock.elapsedTime * 14) * 0.12 : 1;
|
||||
projectile.current.scale.setScalar(profile.scale * pulseScale);
|
||||
trail.current?.scale.set(1, profile.trail, 1);
|
||||
if (trailMaterial.current) trailMaterial.current.opacity = 0.34 + Math.sin(clock.elapsedTime * 10) * 0.08;
|
||||
}
|
||||
|
||||
const impactProgress = rapid
|
||||
? progress > 0.72 ? (progress - 0.72) / 0.28 : -1
|
||||
: (state.time - action.impactAt) / 0.3;
|
||||
const impactVisible = impactProgress >= 0 && impactProgress <= 1 && state.time <= action.endsAt + 0.3;
|
||||
impact.current.visible = impactVisible;
|
||||
if (impactVisible) {
|
||||
impact.current.position.copy(end);
|
||||
impact.current.scale.setScalar(profile.scale * (0.45 + impactProgress * 2.15));
|
||||
if (impactMaterial.current) impactMaterial.current.opacity = (1 - impactProgress) * 0.9;
|
||||
if (impactCoreMaterial.current) impactCoreMaterial.current.opacity = (1 - impactProgress) * 0.72;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group} visible={false}>
|
||||
{memberId === "nia" ? (
|
||||
<>
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
|
||||
<meshBasicMaterial color="#d7b477" />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.5, 0]}>
|
||||
<coneGeometry args={[0.085, 0.2, 6]} />
|
||||
<meshBasicMaterial color="#f1ddaa" />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.4, 0]}>
|
||||
<coneGeometry args={[0.1, 0.18, 4]} />
|
||||
<meshBasicMaterial color="#70cf8e" />
|
||||
</mesh>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.19, 12, 10]} />
|
||||
<meshBasicMaterial color="#bc8cff" />
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.25, 0.025, 6, 20]} />
|
||||
<meshBasicMaterial color="#ead8ff" transparent opacity={0.8} />
|
||||
</mesh>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
<>
|
||||
<group ref={projectile} visible={false}>
|
||||
{memberId === "nia" ? (
|
||||
<>
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
|
||||
<meshBasicMaterial ref={coreMaterial} color="#77d596" />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.5, 0]}>
|
||||
<coneGeometry args={[0.085, 0.2, 6]} />
|
||||
<meshBasicMaterial ref={accentMaterial} color="#e5ffb8" />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.4, 0]}>
|
||||
<coneGeometry args={[0.1, 0.18, 4]} />
|
||||
<meshBasicMaterial color="#d7b477" />
|
||||
</mesh>
|
||||
<mesh ref={trail} position={[0, -0.62, 0]}>
|
||||
<coneGeometry args={[0.12, 0.72, 6, 1, true]} />
|
||||
<meshBasicMaterial ref={trailMaterial} color="#77d596" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
|
||||
</mesh>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.19, 12, 10]} />
|
||||
<meshBasicMaterial ref={coreMaterial} color="#a87cff" toneMapped={false} />
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.25, 0.025, 6, 20]} />
|
||||
<meshBasicMaterial ref={accentMaterial} color="#ead9ff" transparent opacity={0.8} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh ref={trail} position={[0, -0.48, 0]}>
|
||||
<coneGeometry args={[0.18, 0.95, 8, 1, true]} />
|
||||
<meshBasicMaterial ref={trailMaterial} color="#a87cff" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
|
||||
</mesh>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
<group ref={impact} visible={false}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.28, 0.45, 24]} />
|
||||
<meshBasicMaterial ref={impactMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<octahedronGeometry args={[0.24, 0]} />
|
||||
<meshBasicMaterial ref={impactCoreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1075,6 +1138,144 @@ function RangedProjectiles() {
|
||||
);
|
||||
}
|
||||
|
||||
function CloseAttackVfx({ memberId }: { memberId: "brann" | "orin" | "vale" }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const firstArc = useRef<THREE.Mesh>(null);
|
||||
const secondArc = useRef<THREE.Mesh>(null);
|
||||
const groundRing = useRef<THREE.Mesh>(null);
|
||||
const core = useRef<THREE.Mesh>(null);
|
||||
const primaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const secondaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const ringMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const lastAbilityId = useRef<PartyAbilityId | null>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!group.current || !firstArc.current || !secondArc.current || !groundRing.current || !core.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const actor = state.partyCombat.combatants[memberId];
|
||||
const action = actor.visualAction;
|
||||
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||
const visible = action !== null
|
||||
&& state.phase === "combat"
|
||||
&& member.hp > 0
|
||||
&& state.time >= action.startedAt
|
||||
&& state.time <= action.endsAt + 0.18
|
||||
&& partyAttackVfxProfile(action.abilityId).style !== "projectile";
|
||||
group.current.visible = visible;
|
||||
if (!visible || !action) return;
|
||||
|
||||
const profile = partyAttackVfxProfile(action.abilityId);
|
||||
if (lastAbilityId.current !== action.abilityId) {
|
||||
lastAbilityId.current = action.abilityId;
|
||||
primaryMaterial.current?.color.set(profile.primary);
|
||||
secondaryMaterial.current?.color.set(profile.accent);
|
||||
ringMaterial.current?.color.set(profile.primary);
|
||||
coreMaterial.current?.color.set(profile.accent);
|
||||
}
|
||||
|
||||
const duration = Math.max(0.2, action.endsAt - action.startedAt);
|
||||
const progress = THREE.MathUtils.clamp((state.time - action.startedAt) / duration, 0, 1);
|
||||
const impactProgress = THREE.MathUtils.clamp((state.time - action.impactAt + 0.08) / 0.3, 0, 1);
|
||||
const source = state.partyPositions[memberId];
|
||||
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
|
||||
const target = targetMotion.position;
|
||||
const sourceStyle = profile.style === "buff" || profile.style === "spin";
|
||||
const effectHeight = sourceStyle ? 0.22 : profile.style === "slam" ? 0.18 : 1.05;
|
||||
group.current.position.set(sourceStyle ? source[0] : target[0], effectHeight, sourceStyle ? source[1] : target[1]);
|
||||
group.current.rotation.y = sourceStyle
|
||||
? clock.elapsedTime * 0.8
|
||||
: Math.atan2(target[0] - source[0], target[1] - source[1]);
|
||||
|
||||
const slashStyle = profile.style === "slash" || profile.style === "double-slash";
|
||||
firstArc.current.visible = slashStyle;
|
||||
secondArc.current.visible = profile.style === "double-slash";
|
||||
groundRing.current.visible = profile.style === "slam" || profile.style === "spin" || profile.style === "buff";
|
||||
core.current.visible = profile.style === "slam" || profile.style === "buff";
|
||||
const actionScale = profile.scale * (0.65 + Math.sin(progress * Math.PI) * 0.75);
|
||||
firstArc.current.scale.setScalar(actionScale);
|
||||
firstArc.current.rotation.z = -Math.PI * (0.82 - progress * 0.34);
|
||||
secondArc.current.scale.setScalar(actionScale * 0.92);
|
||||
secondArc.current.rotation.z = -Math.PI * (0.15 + progress * 0.36);
|
||||
const burstScale = profile.scale * (0.5 + impactProgress * 2.2);
|
||||
groundRing.current.scale.setScalar(burstScale);
|
||||
groundRing.current.rotation.z = clock.elapsedTime * (memberId === "vale" ? -1.6 : 0.85);
|
||||
core.current.scale.setScalar(profile.scale * (0.6 + Math.sin(progress * Math.PI) * 1.1));
|
||||
core.current.rotation.set(clock.elapsedTime, clock.elapsedTime * 1.4, 0);
|
||||
if (primaryMaterial.current) primaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.9;
|
||||
if (secondaryMaterial.current) secondaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.84;
|
||||
if (ringMaterial.current) ringMaterial.current.opacity = (1 - impactProgress * 0.7) * 0.76;
|
||||
if (coreMaterial.current) coreMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.72;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group} visible={false}>
|
||||
<mesh ref={firstArc} rotation={[0, 0, -Math.PI * 0.72]}>
|
||||
<torusGeometry args={[0.72, 0.055, 6, 28, Math.PI * 1.28]} />
|
||||
<meshBasicMaterial ref={primaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
<mesh ref={secondArc} position={[0.16, 0.02, 0.05]} rotation={[0, 0, -Math.PI * 0.2]}>
|
||||
<torusGeometry args={[0.64, 0.045, 6, 26, Math.PI * 1.18]} />
|
||||
<meshBasicMaterial ref={secondaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
<mesh ref={groundRing} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.38, 0.54, 28]} />
|
||||
<meshBasicMaterial ref={ringMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
<mesh ref={core} position={[0, 0.3, 0]}>
|
||||
<octahedronGeometry args={[0.26, 0]} />
|
||||
<meshBasicMaterial ref={coreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function PartyPowerAuraVfx({ memberId }: { memberId: "orin" | "vale" }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
useFrame(({ clock }) => {
|
||||
if (!group.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const actor = state.partyCombat.combatants[memberId];
|
||||
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||
const active = state.phase === "combat" && member.hp > 0 && (memberId === "orin" ? actor.overchargeStacks > 0 : actor.bladeFlurryUntil > state.time);
|
||||
group.current.visible = active;
|
||||
if (!active) return;
|
||||
const position = state.partyPositions[memberId];
|
||||
group.current.position.set(position[0], 0.16, position[1]);
|
||||
group.current.rotation.y = clock.elapsedTime * (memberId === "orin" ? 1.4 : -1.8);
|
||||
const pulse = 0.92 + Math.sin(clock.elapsedTime * 5.5) * 0.12;
|
||||
group.current.scale.setScalar(pulse);
|
||||
if (material.current) material.current.opacity = 0.38 + Math.sin(clock.elapsedTime * 4.2) * 0.1;
|
||||
});
|
||||
const color = memberId === "orin" ? "#bc72ff" : "#9a86ff";
|
||||
return (
|
||||
<group ref={group} visible={false}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.7, 0.035, 6, 28]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.4} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.72, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.46, 0.025, 6, 24]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function PartyCombatVfx() {
|
||||
return (
|
||||
<>
|
||||
<RangedProjectiles />
|
||||
<CloseAttackVfx memberId="brann" />
|
||||
<CloseAttackVfx memberId="orin" />
|
||||
<CloseAttackVfx memberId="vale" />
|
||||
<PartyPowerAuraVfx memberId="orin" />
|
||||
<PartyPowerAuraVfx memberId="vale" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BossActor() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const primaryBossId = useGameStore((state) => state.boss.id);
|
||||
@@ -1180,27 +1381,72 @@ function PerformanceProbe() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const FX_BURST_PARTICLE_COUNT = 8;
|
||||
|
||||
function scenePulseColor(kind: PulseKind) {
|
||||
if (kind === "shield") return "#62bdff";
|
||||
if (kind === "purify") return "#c39bff";
|
||||
if (kind === "renew") return "#72e0a1";
|
||||
if (kind === "breath") return "#66dcff";
|
||||
if (kind === "venom") return "#8fdb4f";
|
||||
if (kind === "tether") return "#d482ff";
|
||||
if (kind === "skyfall") return "#ffd36b";
|
||||
if (kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash") return "#ff643c";
|
||||
return "#ffe087";
|
||||
}
|
||||
|
||||
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const ring = useRef<THREE.Mesh>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const particles = useRef<THREE.InstancedMesh>(null);
|
||||
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const core = useRef<THREE.Mesh>(null);
|
||||
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||
const age = useRef(0);
|
||||
const isBossFx = kind === "boss";
|
||||
const state = useGameStore.getState();
|
||||
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
|
||||
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
|
||||
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
|
||||
useFrame((_, delta) => {
|
||||
const color = scenePulseColor(kind);
|
||||
useFrame(({ clock }, delta) => {
|
||||
age.current += delta;
|
||||
if (!ring.current || !material.current) return;
|
||||
if (!group.current || !ring.current || !material.current || !particles.current || !core.current) return;
|
||||
const progress = Math.min(1, age.current / 0.7);
|
||||
ring.current.scale.setScalar(0.5 + progress * 3.6);
|
||||
material.current.opacity = (1 - progress) * 0.85;
|
||||
core.current.scale.setScalar(0.45 + Math.sin(progress * Math.PI) * 1.8);
|
||||
core.current.rotation.set(clock.elapsedTime * 1.5, clock.elapsedTime * 2, 0);
|
||||
if (coreMaterial.current) coreMaterial.current.opacity = (1 - progress) * 0.72;
|
||||
for (let index = 0; index < FX_BURST_PARTICLE_COUNT; index += 1) {
|
||||
const angle = index / FX_BURST_PARTICLE_COUNT * Math.PI * 2 + clock.elapsedTime * 0.6;
|
||||
const radial = progress * (kind === "boss" ? 3.2 : 1.65);
|
||||
const scale = (1 - progress) * (kind === "boss" ? 1.4 : 0.9);
|
||||
transform.position.set(Math.sin(angle) * radial, 0.18 + Math.sin(progress * Math.PI) * (0.7 + (index % 2) * 0.45), Math.cos(angle) * radial);
|
||||
transform.rotation.set(angle, progress * Math.PI * 2 + index, clock.elapsedTime);
|
||||
transform.scale.setScalar(scale);
|
||||
transform.updateMatrix();
|
||||
particles.current.setMatrixAt(index, transform.matrix);
|
||||
}
|
||||
particles.current.instanceMatrix.needsUpdate = true;
|
||||
if (particleMaterial.current) particleMaterial.current.opacity = (1 - progress) * 0.82;
|
||||
});
|
||||
return (
|
||||
<mesh ref={ring} position={position} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.38, 0.5, 32]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
<group ref={group} position={position}>
|
||||
<mesh ref={ring} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.38, 0.5, 32]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
<mesh ref={core} position={[0, 0.42, 0]}>
|
||||
<octahedronGeometry args={[0.24, 0]} />
|
||||
<meshBasicMaterial ref={coreMaterial} color={color} transparent opacity={0.72} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</mesh>
|
||||
<instancedMesh ref={particles} args={[undefined, undefined, FX_BURST_PARTICLE_COUNT]} frustumCulled={false}>
|
||||
<tetrahedronGeometry args={[0.18, 0]} />
|
||||
<meshBasicMaterial ref={particleMaterial} color={color} transparent opacity={0.82} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1232,7 +1478,7 @@ export function GameScene() {
|
||||
<TankAuraField />
|
||||
<Party />
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<PartyCombatVfx />
|
||||
<CombatFx />
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||
</GameAssetProvider>
|
||||
|
||||
@@ -147,7 +147,7 @@ function PhaseOverlay() {
|
||||
<span>{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{copy}</p>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : "Restart from lower display"}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { PoolTelegraphKind, WorldPosition } from "../../game/types";
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const LANE_PARTICLE_COUNT = 7;
|
||||
const AREA_PARTICLE_COUNT = 8;
|
||||
const BREATH_PARTICLE_COUNT = 12;
|
||||
|
||||
function useReducedMotion() {
|
||||
return useMemo(() => (
|
||||
document.documentElement.classList.contains("force-reduced-motion")
|
||||
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
), []);
|
||||
}
|
||||
|
||||
export type LaneVfxVariant = "beam" | "charge" | "slash" | "web";
|
||||
|
||||
function LaneParticleGeometry({ variant }: { variant: LaneVfxVariant }) {
|
||||
if (variant === "beam" || variant === "web") return <sphereGeometry args={[0.11, 7, 5]} />;
|
||||
if (variant === "slash") return <octahedronGeometry args={[0.15, 0]} />;
|
||||
return <tetrahedronGeometry args={[0.13, 0]} />;
|
||||
}
|
||||
|
||||
/** One instanced draw for motion/readability layered over an existing lane telegraph. */
|
||||
export function LaneEnergyVfx({
|
||||
start,
|
||||
end,
|
||||
width,
|
||||
color,
|
||||
active,
|
||||
variant,
|
||||
height = 0.13,
|
||||
}: {
|
||||
start: WorldPosition;
|
||||
end: WorldPosition;
|
||||
width: number;
|
||||
color: string;
|
||||
active: boolean;
|
||||
variant: LaneVfxVariant;
|
||||
height?: number;
|
||||
}) {
|
||||
const particles = useRef<THREE.InstancedMesh>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const dx = end[0] - start[0];
|
||||
const dz = end[1] - start[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const angle = Math.atan2(dx, dz);
|
||||
const midpoint: [number, number, number] = [(start[0] + end[0]) * 0.5, height, (start[1] + end[1]) * 0.5];
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!particles.current) return;
|
||||
const time = reducedMotion ? 0.35 : clock.elapsedTime;
|
||||
const speed = active ? 2.6 : variant === "web" ? 0.5 : 0.85;
|
||||
for (let index = 0; index < LANE_PARTICLE_COUNT; index += 1) {
|
||||
const offset = index / LANE_PARTICLE_COUNT;
|
||||
const cycle = (time * speed + offset) % 1;
|
||||
const cross = Math.sin(time * 2.4 + index * 1.7) * width * (variant === "web" ? 0.08 : 0.28);
|
||||
const rise = variant === "slash"
|
||||
? Math.sin(cycle * Math.PI) * (active ? 0.65 : 0.28)
|
||||
: variant === "web" ? Math.sin(time * 3 + index) * 0.07 : Math.sin(cycle * Math.PI) * 0.18;
|
||||
transform.position.set(cross, rise, -length * 0.5 + cycle * length);
|
||||
transform.rotation.set(time * 0.8 + index, index * 0.63, active ? time * 1.5 : 0);
|
||||
const scale = (active ? 1.25 : 0.78) * (0.75 + Math.sin(cycle * Math.PI) * 0.4);
|
||||
transform.scale.set(
|
||||
variant === "slash" ? scale * 0.7 : scale,
|
||||
variant === "charge" ? scale * 0.55 : scale,
|
||||
variant === "charge" ? scale * 2.1 : scale,
|
||||
);
|
||||
transform.updateMatrix();
|
||||
particles.current.setMatrixAt(index, transform.matrix);
|
||||
}
|
||||
particles.current.instanceMatrix.needsUpdate = true;
|
||||
if (material.current) {
|
||||
const pulse = reducedMotion ? 0.65 : 0.58 + Math.sin(time * 6.2) * 0.14;
|
||||
material.current.opacity = active ? pulse + 0.22 : pulse * 0.62;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||
<instancedMesh ref={particles} args={[undefined, undefined, LANE_PARTICLE_COUNT]} frustumCulled={false}>
|
||||
<LaneParticleGeometry variant={variant} />
|
||||
<meshBasicMaterial
|
||||
ref={material}
|
||||
color={color}
|
||||
transparent
|
||||
opacity={active ? 0.82 : 0.38}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreathParticleVfx({
|
||||
position,
|
||||
angle,
|
||||
range,
|
||||
halfAngle,
|
||||
active,
|
||||
}: {
|
||||
position: WorldPosition;
|
||||
angle: number;
|
||||
range: number;
|
||||
halfAngle: number;
|
||||
active: boolean;
|
||||
}) {
|
||||
const particles = useRef<THREE.InstancedMesh>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!particles.current) return;
|
||||
const time = reducedMotion ? 0.4 : clock.elapsedTime;
|
||||
for (let index = 0; index < BREATH_PARTICLE_COUNT; index += 1) {
|
||||
const row = index % 4;
|
||||
const lane = Math.floor(index / 4);
|
||||
const offsetAngle = -halfAngle + (lane / 2) * halfAngle * 2;
|
||||
const cycle = (time * (active ? 1.7 : 0.52) + row * 0.24 + lane * 0.08) % 1;
|
||||
const distance = range * (0.08 + cycle * 0.9);
|
||||
const scale = (active ? 1 : 0.55) * (1.15 - cycle * 0.55);
|
||||
transform.position.set(
|
||||
Math.sin(offsetAngle) * distance,
|
||||
0.18 + Math.sin(cycle * Math.PI) * (active ? 0.72 : 0.32),
|
||||
Math.cos(offsetAngle) * distance,
|
||||
);
|
||||
transform.rotation.set(time * 1.2 + index, -offsetAngle, cycle * TAU);
|
||||
transform.scale.set(scale * 0.72, scale * 1.45, scale * 0.72);
|
||||
transform.updateMatrix();
|
||||
particles.current.setMatrixAt(index, transform.matrix);
|
||||
}
|
||||
particles.current.instanceMatrix.needsUpdate = true;
|
||||
if (material.current) material.current.opacity = active ? 0.74 : 0.26;
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={[position[0], 0.1, position[1]]} rotation={[0, angle, 0]}>
|
||||
<instancedMesh ref={particles} args={[undefined, undefined, BREATH_PARTICLE_COUNT]} frustumCulled={false}>
|
||||
<tetrahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial
|
||||
ref={material}
|
||||
color={active ? "#7ceaff" : "#b8f4ff"}
|
||||
transparent
|
||||
opacity={active ? 0.74 : 0.26}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function CircleMechanicVfx({
|
||||
radius,
|
||||
innerRadius = 0,
|
||||
kind,
|
||||
color,
|
||||
active,
|
||||
}: {
|
||||
radius: number;
|
||||
innerRadius?: number;
|
||||
kind: Extract<PoolTelegraphKind, "donut" | "soak" | "spread"> | "pounce";
|
||||
color: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const particles = useRef<THREE.InstancedMesh>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!particles.current) return;
|
||||
const time = reducedMotion ? 0.3 : clock.elapsedTime;
|
||||
for (let index = 0; index < AREA_PARTICLE_COUNT; index += 1) {
|
||||
const offset = index / AREA_PARTICLE_COUNT;
|
||||
const cycle = (time * (active ? 1.2 : 0.55) + offset) % 1;
|
||||
const orbit = offset * TAU + time * (kind === "soak" ? -0.55 : 0.7);
|
||||
let radial = radius * 0.82;
|
||||
if (kind === "spread") radial = radius * (0.18 + cycle * 0.75);
|
||||
if (kind === "soak" || kind === "pounce") radial = radius * (0.92 - cycle * 0.68);
|
||||
if (kind === "donut") radial = index % 2 === 0 ? Math.max(0.2, innerRadius + 0.18) : radius - 0.18;
|
||||
const scale = (active ? 1 : 0.64) * (0.72 + Math.sin(cycle * Math.PI) * 0.48);
|
||||
transform.position.set(Math.sin(orbit) * radial, 0.12 + Math.sin(cycle * Math.PI) * 0.52, Math.cos(orbit) * radial);
|
||||
transform.rotation.set(time + index, orbit, cycle * TAU);
|
||||
transform.scale.setScalar(scale);
|
||||
transform.updateMatrix();
|
||||
particles.current.setMatrixAt(index, transform.matrix);
|
||||
}
|
||||
particles.current.instanceMatrix.needsUpdate = true;
|
||||
if (material.current) material.current.opacity = active ? 0.84 : 0.42;
|
||||
});
|
||||
|
||||
return (
|
||||
<instancedMesh ref={particles} args={[undefined, undefined, AREA_PARTICLE_COUNT]} frustumCulled={false}>
|
||||
<octahedronGeometry args={[0.16, 0]} />
|
||||
<meshBasicMaterial
|
||||
ref={material}
|
||||
color={color}
|
||||
transparent
|
||||
opacity={active ? 0.84 : 0.42}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,13 @@ 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 { BreathParticleVfx, CircleMechanicVfx, LaneEnergyVfx } from "./BossAttackVfx";
|
||||
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);
|
||||
@@ -41,6 +44,10 @@ function bossHpAt(state: GameStoreState, bossIndex: number) {
|
||||
return bossIndex === 0 ? state.boss.hp : state.additionalBosses[bossIndex - 1]?.boss.hp ?? 0;
|
||||
}
|
||||
|
||||
function bossAt(state: GameStoreState, bossIndex: number) {
|
||||
return bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss;
|
||||
}
|
||||
|
||||
function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) {
|
||||
if (!motion) return current;
|
||||
let earliest = current;
|
||||
@@ -192,26 +199,37 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
|
||||
(lane.start[1] + lane.end[1]) * 0.5,
|
||||
];
|
||||
const active = ACTIVE_LANE_MODES.has(motion.mode);
|
||||
const chargeEffect = motion.mode === "telegraph" || motion.mode === "charging";
|
||||
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||
return (
|
||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[lane.width, length]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
|
||||
</mesh>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
|
||||
<boxGeometry args={[0.07, 0.03, length]} />
|
||||
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
|
||||
<>
|
||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[lane.width, length]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{active && (
|
||||
<mesh position={[0, 0.055, 0]}>
|
||||
<boxGeometry args={[0.16, 0.08, length]} />
|
||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
|
||||
<boxGeometry args={[0.07, 0.03, length]} />
|
||||
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{active && (
|
||||
<mesh position={[0, 0.055, 0]}>
|
||||
<boxGeometry args={[0.16, 0.08, length]} />
|
||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
<LaneEnergyVfx
|
||||
start={lane.start}
|
||||
end={lane.end}
|
||||
width={lane.width}
|
||||
color={active ? DANGER_HIGHLIGHT_COLOR : color}
|
||||
active={active}
|
||||
variant={chargeEffect ? "charge" : "slash"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,9 +250,10 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
|
||||
if (!warningMaterial.current) return;
|
||||
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
|
||||
});
|
||||
if (phase !== "combat" || motionMode !== "stacking") return null;
|
||||
if (phase !== "combat" || (motionMode !== "stacking" && motionMode !== "pouncing")) return null;
|
||||
|
||||
const radius = BULL_POUNCE.stackRadius;
|
||||
const active = motionMode === "pouncing";
|
||||
return (
|
||||
<group ref={group}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
@@ -261,6 +280,7 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<CircleMechanicVfx radius={radius} kind="pounce" color={active ? "#ffe0a8" : "#ff7278"} active={active} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -291,6 +311,7 @@ export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
<LaneEnergyVfx start={first} end={second} width={0.34} color={color} active={stretched} variant="web" height={0.2} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -305,16 +326,25 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
|
||||
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||
return (
|
||||
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
|
||||
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
<>
|
||||
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
|
||||
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
<BreathParticleVfx
|
||||
position={motion.position}
|
||||
angle={motion.breathAngle}
|
||||
range={SKY_SWEEPER_BREATH.range}
|
||||
halfAngle={SKY_SWEEPER_BREATH.halfAngle}
|
||||
active={motion.mode === "breath_sweeping"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,19 +358,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 (
|
||||
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
@@ -358,9 +377,10 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
|
||||
{!active && (
|
||||
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.22, 0.34, 24]} />
|
||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
|
||||
<meshBasicMaterial color={visual.highlightColor} transparent opacity={0.95} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
<CircleHazardVfx hazard={hazard} active={active} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -639,30 +659,41 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
|
||||
: telegraph.kind === "spread" ? "#e869ff"
|
||||
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||
|
||||
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
|
||||
if (telegraph.kind === "beam") {
|
||||
if (!telegraph.start || !telegraph.end) return null;
|
||||
const dx = telegraph.end[0] - telegraph.start[0];
|
||||
const dz = telegraph.end[1] - telegraph.start[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const angle = Math.atan2(dx, dz);
|
||||
return (
|
||||
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
||||
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
|
||||
<boxGeometry args={[0.06, 0.035, length]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
|
||||
<>
|
||||
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
||||
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{CHARGE_MARKERS.map((index) => (
|
||||
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
|
||||
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
|
||||
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
|
||||
<boxGeometry args={[0.06, 0.035, length]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{CHARGE_MARKERS.map((index) => (
|
||||
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
|
||||
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
|
||||
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
<LaneEnergyVfx
|
||||
start={telegraph.start}
|
||||
end={telegraph.end}
|
||||
width={telegraph.width ?? 1}
|
||||
color={active ? "#ffe1b3" : color}
|
||||
active={active}
|
||||
variant="beam"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -705,6 +736,13 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<CircleMechanicVfx
|
||||
radius={telegraph.radius}
|
||||
innerRadius={innerRadius}
|
||||
kind={telegraph.kind}
|
||||
color={color}
|
||||
active={active}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -714,7 +752,60 @@ export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number
|
||||
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
|
||||
}
|
||||
|
||||
export function BasicMeleeVfx({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const slashMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const initialBoss = bossAt(useGameStore.getState(), bossIndex);
|
||||
const previousNextMeleeAt = useRef(initialBoss?.nextMeleeAt ?? 0);
|
||||
const age = useRef(Number.POSITIVE_INFINITY);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const state = useGameStore.getState();
|
||||
const boss = bossAt(state, bossIndex);
|
||||
const motion = motionAt(state, bossIndex);
|
||||
if (!group.current || !boss || !motion) return;
|
||||
if (boss.nextMeleeAt !== previousNextMeleeAt.current) {
|
||||
if (state.phase === "combat" && motion.mode === "holding") age.current = 0;
|
||||
previousNextMeleeAt.current = boss.nextMeleeAt;
|
||||
}
|
||||
age.current += delta;
|
||||
const visible = state.phase === "combat" && age.current < 0.42 && state.party[1].hp > 0;
|
||||
group.current.visible = visible;
|
||||
if (!visible) return;
|
||||
const target = state.partyPositions.brann;
|
||||
const progress = THREE.MathUtils.clamp(age.current / 0.42, 0, 1);
|
||||
group.current.position.set(target[0], 1.02, target[1]);
|
||||
group.current.rotation.y = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
|
||||
group.current.scale.setScalar(0.68 + progress * 0.72);
|
||||
if (slashMaterial.current) slashMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.92;
|
||||
if (impactMaterial.current) impactMaterial.current.opacity = (1 - progress) * 0.68;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group} visible={false}>
|
||||
<mesh rotation={[0, 0, -Math.PI * 0.72]}>
|
||||
<torusGeometry args={[0.7, 0.055, 6, 28, Math.PI * 1.35]} />
|
||||
<meshBasicMaterial
|
||||
ref={slashMaterial}
|
||||
color="#ffd58a"
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, -0.94, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.32, 0.5, 24]} />
|
||||
<meshBasicMaterial ref={impactMaterial} color="#ff8d5c" transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
||||
BasicMeleeVfx,
|
||||
ChargeLaneIndicator,
|
||||
SlashLaneIndicators,
|
||||
PounceStackIndicator,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export const BULL_CHARGE = {
|
||||
|
||||
export const BULL_POUNCE = {
|
||||
stackDuration: 5,
|
||||
stackRadius: 2.2,
|
||||
stackRadius: 3.2,
|
||||
sharedDamage: 200,
|
||||
leapDuration: 0.55,
|
||||
cooldown: 4,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PARTY_ABILITY_LOADOUTS, PARTY_ABILITY_NAMES } from "./partyCombat";
|
||||
import { PARTY_ATTACK_VFX, partyAttackVfxProfile } from "./partyAttackVisuals";
|
||||
|
||||
describe("party attack visuals", () => {
|
||||
it("covers every party combat ability", () => {
|
||||
expect(Object.keys(PARTY_ATTACK_VFX).sort()).toEqual(Object.keys(PARTY_ABILITY_NAMES).sort());
|
||||
});
|
||||
|
||||
it("keeps every profile attached to its loadout owner", () => {
|
||||
for (const [ownerId, abilityIds] of Object.entries(PARTY_ABILITY_LOADOUTS)) {
|
||||
for (const abilityId of abilityIds) {
|
||||
expect(partyAttackVfxProfile(abilityId).ownerId).toBe(ownerId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every ranged attack a visible trail and every melee loadout a non-projectile style", () => {
|
||||
for (const abilityId of [...PARTY_ABILITY_LOADOUTS.nia, ...PARTY_ABILITY_LOADOUTS.orin]) {
|
||||
const profile = partyAttackVfxProfile(abilityId);
|
||||
if (profile.style === "buff") continue;
|
||||
expect(profile.style).toBe("projectile");
|
||||
expect(profile.trail).toBeGreaterThan(0);
|
||||
}
|
||||
for (const abilityId of [...PARTY_ABILITY_LOADOUTS.brann, ...PARTY_ABILITY_LOADOUTS.vale]) {
|
||||
expect(partyAttackVfxProfile(abilityId).style).not.toBe("projectile");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AiCombatantId, PartyAbilityId } from "./partyCombat";
|
||||
|
||||
export type PartyAttackVfxStyle = "buff" | "double-slash" | "projectile" | "slam" | "slash" | "spin";
|
||||
|
||||
export interface PartyAttackVfxProfile {
|
||||
readonly ownerId: AiCombatantId;
|
||||
readonly style: PartyAttackVfxStyle;
|
||||
readonly primary: string;
|
||||
readonly accent: string;
|
||||
readonly scale: number;
|
||||
readonly trail: number;
|
||||
}
|
||||
|
||||
/** Visual identity only. Damage, timing, targeting, and resources stay in partyCombat. */
|
||||
export const PARTY_ATTACK_VFX: Record<PartyAbilityId, PartyAttackVfxProfile> = {
|
||||
sword_slash: { ownerId: "brann", style: "slash", primary: "#f0c56b", accent: "#fff0b0", scale: 0.9, trail: 0 },
|
||||
shield_slam: { ownerId: "brann", style: "slam", primary: "#62b9ff", accent: "#d9f2ff", scale: 1.05, trail: 0 },
|
||||
revenge: { ownerId: "brann", style: "double-slash", primary: "#ef6c4d", accent: "#ffd0a5", scale: 1.05, trail: 0 },
|
||||
sweeping_guard: { ownerId: "brann", style: "spin", primary: "#77d7ff", accent: "#e5f8ff", scale: 1.15, trail: 0 },
|
||||
bulwark_march: { ownerId: "brann", style: "buff", primary: "#4da8ff", accent: "#d5efff", scale: 1.25, trail: 0 },
|
||||
|
||||
quick_shot: { ownerId: "nia", style: "projectile", primary: "#77d596", accent: "#e5ffb8", scale: 0.78, trail: 0.45 },
|
||||
aimed_shot: { ownerId: "nia", style: "projectile", primary: "#f0c961", accent: "#fff4be", scale: 1, trail: 0.7 },
|
||||
rapid_fire: { ownerId: "nia", style: "projectile", primary: "#65dcba", accent: "#d8fff1", scale: 0.72, trail: 0.38 },
|
||||
kill_shot: { ownerId: "nia", style: "projectile", primary: "#f05d57", accent: "#ffd3a3", scale: 1.18, trail: 0.9 },
|
||||
deadeye: { ownerId: "nia", style: "projectile", primary: "#fff0a8", accent: "#ffffff", scale: 1.35, trail: 1.15 },
|
||||
|
||||
arcane_bolt: { ownerId: "orin", style: "projectile", primary: "#a87cff", accent: "#ead9ff", scale: 0.9, trail: 0.75 },
|
||||
ember_lance: { ownerId: "orin", style: "projectile", primary: "#ff713d", accent: "#ffd080", scale: 0.92, trail: 0.9 },
|
||||
arcane_burst: { ownerId: "orin", style: "projectile", primary: "#5d9fff", accent: "#dcf3ff", scale: 1.25, trail: 1.05 },
|
||||
comet: { ownerId: "orin", style: "projectile", primary: "#7ce5ff", accent: "#f0fdff", scale: 1.55, trail: 1.35 },
|
||||
overcharge: { ownerId: "orin", style: "buff", primary: "#b15cff", accent: "#f0d6ff", scale: 1.15, trail: 0 },
|
||||
|
||||
quick_cut: { ownerId: "vale", style: "slash", primary: "#c68aff", accent: "#f1ddff", scale: 0.78, trail: 0 },
|
||||
twin_fang: { ownerId: "vale", style: "double-slash", primary: "#e36cff", accent: "#ffd5ff", scale: 0.92, trail: 0 },
|
||||
backstab: { ownerId: "vale", style: "slash", primary: "#f15b7a", accent: "#ffd4df", scale: 1.12, trail: 0 },
|
||||
fan_of_blades: { ownerId: "vale", style: "spin", primary: "#c9d5df", accent: "#ffffff", scale: 1.2, trail: 0 },
|
||||
blade_flurry: { ownerId: "vale", style: "buff", primary: "#8a7cff", accent: "#e8e3ff", scale: 1.15, trail: 0 },
|
||||
};
|
||||
|
||||
export function partyAttackVfxProfile(abilityId: PartyAbilityId) {
|
||||
return PARTY_ATTACK_VFX[abilityId];
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ARENA_CENTER } from "./arena";
|
||||
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
|
||||
import { BULL_POUNCE } from "./bosses/mechanicPool";
|
||||
import { createBaseMotion, createCircleHazard, returnBossToArenaCenter } from "./bosses/shared";
|
||||
import { freshParty } from "./data";
|
||||
import { distance, pointToSegmentDistance } from "./geometry";
|
||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||
import type { MemberId, WorldPosition } from "./types";
|
||||
|
||||
@@ -36,4 +38,54 @@ describe("party boss positioning", () => {
|
||||
expect(next.brann[1]).toBeGreaterThan(sharedStart[1]);
|
||||
expect(next.vale[1]).toBeLessThan(sharedStart[1]);
|
||||
});
|
||||
|
||||
it("keeps AI allies inside the enlarged pounce stack while avoiding another boss's danger", () => {
|
||||
expect(BULL_POUNCE.stackRadius).toBe(3.2);
|
||||
const initialStackCenter: WorldPosition = [0, 0];
|
||||
let positions: Record<MemberId, WorldPosition> = {
|
||||
aelia: [...initialStackCenter],
|
||||
brann: [...initialStackCenter],
|
||||
nia: [...initialStackCenter],
|
||||
orin: [...initialStackCenter],
|
||||
vale: [...initialStackCenter],
|
||||
};
|
||||
const dangerMotion = {
|
||||
...createBaseMotion("crownshard-golem"),
|
||||
mode: "mantis_line_telegraph" as const,
|
||||
hazards: [createCircleHazard({
|
||||
id: "overlapping-impact",
|
||||
kind: "crownfall",
|
||||
center: initialStackCenter,
|
||||
radius: 1.2,
|
||||
activatesAt: 2,
|
||||
duration: 4,
|
||||
damage: 30,
|
||||
})],
|
||||
slashLanes: [{
|
||||
id: "overlapping-lane",
|
||||
start: [0, -5] as WorldPosition,
|
||||
end: [0, 5] as WorldPosition,
|
||||
width: 1.2,
|
||||
damage: 25,
|
||||
}],
|
||||
};
|
||||
const pounceMotion = {
|
||||
...createBaseMotion("bulldrome"),
|
||||
mode: "stacking" as const,
|
||||
pounceTargetId: "brann" as const,
|
||||
pounceCenter: initialStackCenter,
|
||||
};
|
||||
|
||||
for (let step = 0; step < 20; step += 1) {
|
||||
positions = updatePartyPositions(positions, [dangerMotion, pounceMotion], freshParty(), 1 + step * 0.1, 0.1);
|
||||
pounceMotion.pounceCenter = [...positions.brann];
|
||||
}
|
||||
|
||||
for (const memberId of ["brann", "nia", "orin", "vale"] as const) {
|
||||
expect(distance(positions[memberId], pounceMotion.pounceCenter)).toBeLessThan(BULL_POUNCE.stackRadius);
|
||||
expect(distance(positions[memberId], initialStackCenter)).toBeGreaterThanOrEqual(1.2 + 0.55);
|
||||
expect(pointToSegmentDistance(positions[memberId], dangerMotion.slashLanes[0].start, dangerMotion.slashLanes[0].end))
|
||||
.toBeGreaterThanOrEqual(1.2 * 0.5 + 0.7);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+132
-8
@@ -1,6 +1,6 @@
|
||||
import { clampToArena } from "./arena";
|
||||
import { BULL_CHARGE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
|
||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "./arena";
|
||||
import { BULL_CHARGE, BULL_POUNCE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
|
||||
import { angularDistance, moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
export type AiMemberId = Exclude<MemberId, "aelia">;
|
||||
@@ -10,6 +10,7 @@ export interface PartyBehaviorContext {
|
||||
current: WorldPosition;
|
||||
formationTarget: WorldPosition;
|
||||
bossMotion: BossMotionState;
|
||||
bossMotions: readonly BossMotionState[];
|
||||
partyPositions: Record<MemberId, WorldPosition>;
|
||||
time: number;
|
||||
}
|
||||
@@ -27,6 +28,13 @@ export interface PartyBehavior {
|
||||
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
|
||||
const MOVE_SPEEDS: Record<AiMemberId, number> = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
|
||||
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
|
||||
const HAZARD_LOOKAHEAD = 2.2;
|
||||
const CIRCLE_HAZARD_CLEARANCE = 0.55;
|
||||
const LANE_HAZARD_CLEARANCE = 0.7;
|
||||
const STACK_EDGE_PADDING = 0.35;
|
||||
const STACK_SAMPLE_RINGS = 3;
|
||||
const STACK_SAMPLES_PER_RING = 24;
|
||||
const STACK_CENTER_OFFSET: WorldPosition = [0, 0];
|
||||
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
||||
brann: [-0.55, 0],
|
||||
nia: [0.45, 0.4],
|
||||
@@ -59,15 +67,130 @@ export function combatFormation(boss: WorldPosition): Record<AiMemberId, WorldPo
|
||||
};
|
||||
}
|
||||
|
||||
function circleDanger(
|
||||
x: number,
|
||||
z: number,
|
||||
center: WorldPosition,
|
||||
radius: number,
|
||||
innerRadius = 0,
|
||||
) {
|
||||
const distance = Math.hypot(x - center[0], z - center[1]);
|
||||
const unsafeInnerRadius = Math.max(0, innerRadius - CIRCLE_HAZARD_CLEARANCE);
|
||||
const unsafeOuterRadius = radius + CIRCLE_HAZARD_CLEARANCE;
|
||||
if (distance >= unsafeOuterRadius || (unsafeInnerRadius > 0 && distance <= unsafeInnerRadius)) return 0;
|
||||
const penetration = unsafeInnerRadius > 0
|
||||
? Math.min(distance - unsafeInnerRadius, unsafeOuterRadius - distance)
|
||||
: unsafeOuterRadius - distance;
|
||||
return 1 + Math.max(0, penetration);
|
||||
}
|
||||
|
||||
function laneDanger(x: number, z: number, start: WorldPosition, end: WorldPosition, width: number) {
|
||||
const clearance = width * 0.5 + LANE_HAZARD_CLEARANCE;
|
||||
const laneX = end[0] - start[0];
|
||||
const laneZ = end[1] - start[1];
|
||||
const lengthSquared = laneX * laneX + laneZ * laneZ;
|
||||
const projection = lengthSquared < 0.0001
|
||||
? 0
|
||||
: Math.max(0, Math.min(1, ((x - start[0]) * laneX + (z - start[1]) * laneZ) / lengthSquared));
|
||||
const distance = Math.hypot(x - (start[0] + projection * laneX), z - (start[1] + projection * laneZ));
|
||||
return distance < clearance ? 1 + clearance - distance : 0;
|
||||
}
|
||||
|
||||
function bossDangerAt(x: number, z: number, bossMotions: readonly BossMotionState[], time: number) {
|
||||
let danger = 0;
|
||||
for (const motion of bossMotions) {
|
||||
for (const hazard of motion.hazards) {
|
||||
if (hazard.resolved || hazard.expiresAt <= time || hazard.activatesAt - time > HAZARD_LOOKAHEAD) continue;
|
||||
danger += circleDanger(x, z, hazard.center, hazard.radius, hazard.innerRadius);
|
||||
}
|
||||
|
||||
for (const telegraph of motion.poolTelegraphs) {
|
||||
if (telegraph.resolved || telegraph.expiresAt <= time || telegraph.activatesAt - time > HAZARD_LOOKAHEAD) continue;
|
||||
if (telegraph.kind === "soak" || telegraph.kind === "memory" || telegraph.kind === "soul-siphon") continue;
|
||||
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
|
||||
danger += laneDanger(x, z, telegraph.start, telegraph.end, telegraph.width ?? 0);
|
||||
} else {
|
||||
danger += circleDanger(x, z, telegraph.center, telegraph.radius, telegraph.innerRadius);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lane of motion.slashLanes) {
|
||||
danger += laneDanger(x, z, lane.start, lane.end, lane.width);
|
||||
}
|
||||
|
||||
if (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping") {
|
||||
const dx = x - motion.position[0];
|
||||
const dz = z - motion.position[1];
|
||||
const distance = Math.hypot(dx, dz);
|
||||
const angle = Math.atan2(dx, dz);
|
||||
const breathAngle = motion.mode === "breath_telegraph" ? motion.breathStartAngle : motion.breathAngle;
|
||||
const radialPenetration = SKY_SWEEPER_BREATH.range + CIRCLE_HAZARD_CLEARANCE - distance;
|
||||
const angularPenetration = SKY_SWEEPER_BREATH.halfAngle + 0.08 - angularDistance(angle, breathAngle);
|
||||
if (radialPenetration > 0 && angularPenetration > 0) {
|
||||
danger += 1 + Math.min(radialPenetration, angularPenetration * Math.max(1, distance));
|
||||
}
|
||||
}
|
||||
}
|
||||
return danger;
|
||||
}
|
||||
|
||||
function safeStackTarget(
|
||||
memberId: AiMemberId,
|
||||
current: WorldPosition,
|
||||
center: WorldPosition,
|
||||
preferredOffset: WorldPosition,
|
||||
stackRadius: number,
|
||||
bossMotions: readonly BossMotionState[],
|
||||
time: number,
|
||||
) {
|
||||
const preferred = clampToArena([center[0] + preferredOffset[0], center[1] + preferredOffset[1]]);
|
||||
const preferredDanger = bossDangerAt(preferred[0], preferred[1], bossMotions, time);
|
||||
if (preferredDanger === 0) return preferred;
|
||||
|
||||
const insideRadius = Math.max(0, stackRadius - STACK_EDGE_PADDING);
|
||||
let bestX = preferred[0];
|
||||
let bestZ = preferred[1];
|
||||
let bestScore = preferredDanger * 1_000
|
||||
+ Math.hypot(bestX - current[0], bestZ - current[1]) * 0.05;
|
||||
const angleStep = Math.PI * 2 / STACK_SAMPLES_PER_RING;
|
||||
const memberPhase = AI_MEMBER_IDS.indexOf(memberId) * angleStep * 0.5;
|
||||
|
||||
for (let ring = 1; ring <= STACK_SAMPLE_RINGS; ring += 1) {
|
||||
const sampleRadius = insideRadius * ring / STACK_SAMPLE_RINGS;
|
||||
for (let sample = 0; sample < STACK_SAMPLES_PER_RING; sample += 1) {
|
||||
const angle = memberPhase + sample * angleStep;
|
||||
let candidateX = center[0] + Math.sin(angle) * sampleRadius;
|
||||
let candidateZ = center[1] + Math.cos(angle) * sampleRadius;
|
||||
const arenaX = candidateX - ARENA_CENTER[0];
|
||||
const arenaZ = candidateZ - ARENA_CENTER[1];
|
||||
const arenaDistance = Math.hypot(arenaX, arenaZ);
|
||||
if (arenaDistance > ARENA_RADIUS) {
|
||||
const scale = ARENA_RADIUS / arenaDistance;
|
||||
candidateX = ARENA_CENTER[0] + arenaX * scale;
|
||||
candidateZ = ARENA_CENTER[1] + arenaZ * scale;
|
||||
}
|
||||
|
||||
const danger = bossDangerAt(candidateX, candidateZ, bossMotions, time);
|
||||
const preferredDistance = Math.hypot(candidateX - preferred[0], candidateZ - preferred[1]);
|
||||
const travelDistance = Math.hypot(candidateX - current[0], candidateZ - current[1]);
|
||||
const score = danger * 1_000 + preferredDistance + travelDistance * 0.05;
|
||||
if (score >= bestScore) continue;
|
||||
bestX = candidateX;
|
||||
bestZ = candidateZ;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return [bestX, bestZ] as WorldPosition;
|
||||
}
|
||||
|
||||
export const stackForPounceBehavior: PartyBehavior = {
|
||||
id: "stack-for-pounce",
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
decide: ({ memberId, current, bossMotion, bossMotions, time }) => {
|
||||
if (bossMotion.mode !== "stacking") return null;
|
||||
const offset = STACK_OFFSETS[memberId];
|
||||
const offset = memberId === bossMotion.pounceTargetId ? STACK_CENTER_OFFSET : STACK_OFFSETS[memberId];
|
||||
return {
|
||||
target: memberId === bossMotion.pounceTargetId
|
||||
? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]]
|
||||
: [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]],
|
||||
target: safeStackTarget(memberId, current, bossMotion.pounceCenter, offset, BULL_POUNCE.stackRadius, bossMotions, time),
|
||||
speed: 3.4,
|
||||
};
|
||||
},
|
||||
@@ -311,6 +434,7 @@ export function updatePartyPositions(
|
||||
current: next[memberId],
|
||||
formationTarget: formation[memberId],
|
||||
bossMotion,
|
||||
bossMotions: activeMotions,
|
||||
partyPositions: next,
|
||||
time,
|
||||
});
|
||||
|
||||
@@ -850,10 +850,29 @@ describe("Rogue Trials", () => {
|
||||
expect(replaced.phase).toBe("combat");
|
||||
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
|
||||
expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
|
||||
expect(replaced.boss.nextMeleeAt).toBeGreaterThan(replaced.time);
|
||||
expect(replaced.bossMotion.nextMechanicAt).toBeGreaterThan(replaced.time);
|
||||
expect(replaced.endlessBossKills).toBe(1);
|
||||
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("schedules endless attacks from the transition time instead of replaying overdue attacks", () => {
|
||||
useGameStore.setState({ round: 5, phase: "victory", time: 180 });
|
||||
|
||||
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
|
||||
const started = useGameStore.getState();
|
||||
const bosses = [
|
||||
{ boss: started.boss, motion: started.bossMotion },
|
||||
...started.additionalBosses,
|
||||
];
|
||||
expect(bosses.every((entry) => entry.boss.nextMeleeAt > started.time)).toBe(true);
|
||||
expect(bosses.every((entry) => entry.motion.nextMechanicAt > started.time)).toBe(true);
|
||||
|
||||
const startingTankHp = started.party.find((member) => member.id === "brann")!.hp;
|
||||
useGameStore.getState().tick(0.05);
|
||||
expect(useGameStore.getState().party.find((member) => member.id === "brann")!.hp).toBe(startingTankHp);
|
||||
});
|
||||
|
||||
it("keeps endless mode running until the whole party falls", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
round: 5,
|
||||
|
||||
+7
-6
@@ -156,7 +156,7 @@ const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): Bo
|
||||
return normalizeEncounterBossIds(requested);
|
||||
};
|
||||
|
||||
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
|
||||
function createEncounterMotion(bossId: BossId, index: number, count: number, startsAt = 0): BossMotionState {
|
||||
const motion = cloneMotion(createBossMotionState(bossId));
|
||||
const offset = count === 3 ? [-3.7, 0, 3.7][index] : count === 2 ? [-2.65, 2.65][index] : 0;
|
||||
motion.formationOffsetX = offset;
|
||||
@@ -165,17 +165,17 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo
|
||||
motion.chargeEnd[0] += offset;
|
||||
motion.pounceCenter[0] += offset;
|
||||
const stagger = index * 2.4;
|
||||
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
|
||||
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += startsAt + stagger;
|
||||
return constrainBossMotion(motion);
|
||||
}
|
||||
|
||||
function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number): AdditionalBossState {
|
||||
function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number, startsAt = 0): 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;
|
||||
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
|
||||
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += startsAt + stagger;
|
||||
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count, startsAt) };
|
||||
}
|
||||
|
||||
const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
|
||||
@@ -466,7 +466,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
|
||||
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
|
||||
const encounterBosses = bossIds.map((bossId, index) => {
|
||||
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier);
|
||||
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier, state.time);
|
||||
return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
|
||||
});
|
||||
const primary = encounterBosses[0];
|
||||
@@ -698,6 +698,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
index,
|
||||
slots.length,
|
||||
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
|
||||
time,
|
||||
);
|
||||
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
|
||||
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
|
||||
|
||||
@@ -1077,6 +1077,8 @@ 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; }
|
||||
.end-actions button.is-controller-focused { outline: 2px solid #fff1b6; outline-offset: 2px; }
|
||||
.endless-choice-actions button:first-child { display: grid; gap: 3px; min-width: 155px; }
|
||||
.endless-choice-actions button:first-child small { font-size: 7px; font-weight: 600; letter-spacing: 0.06em; opacity: 0.72; }
|
||||
|
||||
.buff-draft {
|
||||
height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user