diff --git a/package.json b/package.json
index 9e60071..650e726 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
- "version": "0.1.13",
+ "version": "0.1.14",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx
index b29936c..d8281d0 100644
--- a/src/components/BottomScreen.tsx
+++ b/src/components/BottomScreen.tsx
@@ -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 (
{phase === "victory" ? "✦" : "×"}
@@ -225,7 +228,8 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
onFocus={() => setEndlessChoiceSelection("continue")}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
- >Continue Endless
+ aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
+ >
Endless ModeHigh score · {endlessHighScoreLabel}
);
}
diff --git a/src/game/bosses/mechanicPool.ts b/src/game/bosses/mechanicPool.ts
index a4e35d5..3e4ef3d 100644
--- a/src/game/bosses/mechanicPool.ts
+++ b/src/game/bosses/mechanicPool.ts
@@ -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,
diff --git a/src/game/partyBehaviors.test.ts b/src/game/partyBehaviors.test.ts
index c023f44..e6b8cfc 100644
--- a/src/game/partyBehaviors.test.ts
+++ b/src/game/partyBehaviors.test.ts
@@ -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 = {
+ 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);
+ }
+ });
});
diff --git a/src/game/partyBehaviors.ts b/src/game/partyBehaviors.ts
index b0c6741..48f6297 100644
--- a/src/game/partyBehaviors.ts
+++ b/src/game/partyBehaviors.ts
@@ -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;
@@ -10,6 +10,7 @@ export interface PartyBehaviorContext {
current: WorldPosition;
formationTarget: WorldPosition;
bossMotion: BossMotionState;
+ bossMotions: readonly BossMotionState[];
partyPositions: Record;
time: number;
}
@@ -27,6 +28,13 @@ export interface PartyBehavior {
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
const MOVE_SPEEDS: Record = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
const EVADE_SIDES: Record = { 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 = {
brann: [-0.55, 0],
nia: [0.45, 0.4],
@@ -59,15 +67,130 @@ export function combatFormation(boss: WorldPosition): Record= 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,
});
diff --git a/src/game/store.test.ts b/src/game/store.test.ts
index 06044cf..fc47a48 100644
--- a/src/game/store.test.ts
+++ b/src/game/store.test.ts
@@ -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,
diff --git a/src/game/store.ts b/src/game/store.ts
index 7279f1e..5e2097d 100644
--- a/src/game/store.ts
+++ b/src/game/store.ts
@@ -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 => {
@@ -466,7 +466,7 @@ export const useGameStore = create((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((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");
diff --git a/src/styles.css b/src/styles.css
index cfda40a..0617216 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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%;