From de7b59ce776a46d820160629620ca5f27a27b6f1 Mon Sep 17 00:00:00 2001 From: Warren H Date: Mon, 13 Jul 2026 23:19:40 -0400 Subject: [PATCH] Release v0.1.14 2026-07-13 --- package.json | 2 +- src/components/BottomScreen.tsx | 8 +- src/components/TopScreen.tsx | 2 +- src/game/bosses/mechanicPool.ts | 2 +- src/game/partyBehaviors.test.ts | 54 +++++++++++- src/game/partyBehaviors.ts | 140 ++++++++++++++++++++++++++++++-- src/game/store.test.ts | 19 +++++ src/game/store.ts | 13 +-- src/styles.css | 2 + 9 files changed, 222 insertions(+), 20 deletions(-) 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%;