Release v0.1.5 2026-07-12

This commit is contained in:
Warren H
2026-07-12 22:10:12 -04:00
parent bef71d391a
commit 35553c18dd
41 changed files with 2005 additions and 2654 deletions
+8 -3
View File
@@ -2,6 +2,7 @@ import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
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";
@@ -85,12 +86,16 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < ability.mana || noDispel || invalidTarget;
const resourceCopy = `${ability.mana ? `${ability.mana} mana` : "free"}${ability.castTime ? ` · ${ability.castTime.toFixed(1)}s` : ""}`;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
@@ -106,7 +111,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ability.gamepad}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / ability.cooldown) } as React.CSSProperties}>
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
+23 -9
View File
@@ -1,14 +1,19 @@
import { RUN_BUFFS, bossHealthMultiplier, countRunBuff } from "../game/roguelike";
import { RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
import { HEALER_CLASSES } from "../game/healers";
import { useGameStore } from "../game/store";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
const runBuffs = useGameStore((state) => state.runBuffs);
const healerClassId = useGameStore((state) => state.healerClassId);
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
const choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId);
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff);
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
const nextRound = round + 1;
const abilities = HEALER_CLASSES[healerClassId].abilities;
return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
<header>
@@ -16,10 +21,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
<h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header>
<div className="buff-choice-grid">
{choices.map((buffId) => {
<div className={`buff-choice-grid choice-count-${choices.length}`}>
{choices.length > 0 ? choices.map((buffId) => {
const buff = RUN_BUFFS[buffId];
const stacks = countRunBuff(runBuffs, buffId);
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
const nextRank = Math.min(buff.maxRank, rank + 1);
const ability = abilities[buff.abilityId];
return (
<button
key={buffId}
@@ -31,14 +38,21 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
aria-pressed={selected === buffId}
>
<i>{buff.icon}</i>
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span>
<b>{buff.summary}</b>
<span><small>{rank ? `Rank ${rank} ${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
<p>{buff.detail}</p>
</button>
);
})}
}) : (
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun}>
<i></i>
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
<b>All 18 blessings reached maximum rank.</b>
<p>Keep completed build and begin next randomized encounter.</p>
</button>
)}
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Claim</footer>
<footer>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</footer>
</div>
);
}
+115 -56
View File
@@ -5,9 +5,11 @@ import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
import { selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import { ABILITY_ORDER } from "../game/data";
import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike";
import type { AbilityId, BossId } from "../game/types";
import {
GEAR_OWNER_LABELS,
GEAR_OWNER_ORDER,
@@ -483,10 +485,14 @@ function GearScreen() {
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
const selectedPassiveAbilityId = useFrontendStore((state) => state.selectedPassiveAbilityId);
const selectedPassiveInfusionId = useFrontendStore((state) => state.selectedPassiveInfusionId);
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
const selectPassiveAbility = useFrontendStore((state) => state.selectPassiveAbility);
const selectPassiveInfusion = useFrontendStore((state) => state.selectPassiveInfusion);
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
@@ -503,6 +509,10 @@ function GearScreen() {
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
const passiveHealerClassId = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman" ? selectedOwnerId : "priest";
const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilityId === selectedPassiveAbilityId);
const selectedPassive = RUN_BUFFS[selectedPassiveInfusionId];
const healerAbilities = HEALER_CLASSES[passiveHealerClassId].abilities;
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
const actions = useMemo<MenuAction[]>(() => [
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
@@ -518,7 +528,7 @@ function GearScreen() {
id: `slot-${slotId}`,
run: () => selectSlot(slotId),
neighbors: {
up: index === 0 ? "back" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
up: index === 0 ? "workshop-upgrade" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
left: `owner-${selectedOwnerId}`,
right: previewEntryId,
@@ -531,25 +541,42 @@ function GearScreen() {
run: () => selectInfusion(infusion.id),
neighbors: {
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
right: index === infusionChoices.length - 1 ? "install-infusion" : undefined,
},
})),
...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({
...(healerOwner ? ABILITY_ORDER.map((abilityId, index) => ({
id: `passive-ability-${abilityId}`,
run: () => selectPassiveAbility(abilityId),
neighbors: {
up: index < 3 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-ability-${ABILITY_ORDER[index - 3]}`,
down: index < 3
? `passive-ability-${ABILITY_ORDER[index + 3]}`
: `passive-${passiveChoices[Math.min(index - 3, passiveChoices.length - 1)].id}`,
left: index % 3 > 0 ? `passive-ability-${ABILITY_ORDER[index - 1]}` : `slot-${selectedSlotId}`,
right: index % 3 < 2 ? `passive-ability-${ABILITY_ORDER[index + 1]}` : undefined,
},
})) : []),
...(healerOwner ? passiveChoices.map((passive, index) => ({
id: `passive-${passive.id}`,
run: () => installPassive(passive.id),
run: () => {
selectPassiveInfusion(passive.id);
installPassive(passive.id);
},
enabled: passiveUnlocked,
neighbors: {
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`,
up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`,
down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
},
})) : []),
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } },
{ id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: `infusion-${infusionChoices[infusionChoices.length - 1].id}`, down: healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : undefined } },
{ id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveChoices, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectPassiveAbility, selectPassiveInfusion, selectSlot, selectWorkshopMode, selectedOwnerId, selectedPassiveAbilityId, selectedSlotId, upgrade]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const passiveContext = workshopMode === "infusion" && healerOwner && controller.focusedId.startsWith("passive-");
if (!hunter || !slot) return null;
const currentBonus = gearBonusText(recipe.statId, slot.level);
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
@@ -585,7 +612,25 @@ function GearScreen() {
<div className="gear-infusion-options">
{infusionChoices.map((infusion) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></FocusButton>)}
</div>
{healerOwner && <div className="gear-passive-options"><span>Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>{PASSIVE_INFUSIONS.map((passive) => <FocusButton key={passive.id} id={`passive-${passive.id}`} focusedId={controller.focusedId} focus={controller.focus} disabled={!passiveUnlocked} className={hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""} onClick={() => installPassive(passive.id)}><i>{passive.icon}</i><span><strong>{passive.name}</strong><small>{passive.summary}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}</div>}
{healerOwner && <div className="gear-passive-options">
<span>Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>
<div className="gear-passive-ability-filter">
{ABILITY_ORDER.map((abilityId) => <FocusButton key={abilityId} id={`passive-ability-${abilityId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedPassiveAbilityId === abilityId ? "is-selected" : ""} onClick={() => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName}</FocusButton>)}
</div>
<div className="gear-passive-choice-list">
{passiveChoices.map((passive) => <FocusButton
key={passive.id}
id={`passive-${passive.id}`}
focusedId={controller.focusedId}
focus={controller.focus}
disabled={!passiveUnlocked}
className={`${selectedPassiveInfusionId === passive.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""}`}
onFocus={() => selectPassiveInfusion(passive.id)}
onPointerEnter={() => selectPassiveInfusion(passive.id)}
onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }}
><i>{passive.icon}</i><span><strong>{healerAbilities[passive.abilityId].shortName}: {passive.name}</strong><small>{formatRunBuffEffect(passive.id, 1)}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}
</div>
</div>}
</article>}
</div>
<ControllerLegend back />
@@ -593,15 +638,15 @@ function GearScreen() {
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilityId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{passiveContext ? <article className={passiveUnlocked ? "is-met" : "is-missing"}><i>{passiveUnlocked ? "✓" : "×"}</i><span><strong>{formatRunBuffEffect(selectedPassive.id, 1)}</strong><small>{selectedPassive.detail}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}</b></article> : (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
@@ -664,8 +709,6 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const bossPageSize = 12;
const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize)));
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
@@ -673,14 +716,11 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons";
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
const changeBossPage = (nextPage: number) => {
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
setBossPage(page);
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
const visibleBossIds = selectedBossGroup.bossIds;
const bossGridColumns = Math.min(2, visibleBossIds.length);
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
};
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
@@ -688,31 +728,41 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
setMessage("Online matchmaking connects here when game server is configured.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const column = Math.floor(index / bossGridRows);
const row = index % bossGridRows;
const neighborInColumn = (targetColumn: number) => {
const columnStart = targetColumn * bossGridRows;
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
...(isDungeon ? BOSS_GROUPS.map((group, index) => {
const groupColumns = 5;
const row = Math.floor(index / groupColumns);
const column = index % groupColumns;
const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn];
return {
id: `boss-group-${group.id}`,
run: () => selectBossGroup(group.id),
neighbors: {
up: row > 0 ? `boss-group-${groupAt(row - 1, column)?.id}` : "back",
down: groupAt(row + 1, column)
? `boss-group-${groupAt(row + 1, column)?.id}`
: group.id === selectedBossGroup.id ? `boss-${selectedBossGroup.bossIds[0]}` : undefined,
left: column > 0 ? `boss-group-${groupAt(row, column - 1)?.id}` : undefined,
right: groupAt(row, column + 1) ? `boss-group-${groupAt(row, column + 1)?.id}` : undefined,
},
};
}) : []),
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const row = Math.floor(index / bossGridColumns);
const column = index % bossGridColumns;
const bossAt = (targetRow: number, targetColumn: number) => visibleBossIds[targetRow * bossGridColumns + targetColumn];
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
? `boss-${visibleBossIds[index + 1]}`
: `difficulty-${DIFFICULTIES[0].slug}`,
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined),
up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`,
down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`,
left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined,
right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined,
},
};
}) : []),
...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
@@ -724,15 +774,15 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } },
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]);
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
], [bossGridColumns, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
? [
[selectedBoss.name, selectedBoss.summary],
[selectedBoss.mechanics[0], selectedBoss.briefing],
[selectedBoss.mechanics[1], "Controller-ready party behavior and full lower-display support."],
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
[bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."],
]
: isPve
? [
@@ -753,17 +803,26 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<div className="boss-picker-heading">
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
<div>
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}> Previous</FocusButton>
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next </FocusButton>
</div>
<div className="boss-picker-heading"><span>Choose a mechanic group</span></div>
<div className="boss-group-grid" aria-label="Choose boss group">
{BOSS_GROUPS.map((group) => (
<FocusButton
key={group.id}
id={`boss-group-${group.id}`}
focusedId={controller.focusedId}
focus={controller.focus}
className={`boss-group-choice ${group.id === selectedBossGroup.id ? "is-selected" : ""}`}
aria-pressed={group.id === selectedBossGroup.id}
onClick={() => selectBossGroup(group.id)}
>
<b>{group.letter}</b><span><strong>Group {group.letter}</strong><small>{group.name}</small></span>
</FocusButton>
))}
</div>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows, "--boss-grid-columns": bossGridColumns } as React.CSSProperties}>
<div className="boss-group-heading"><span>Group {selectedBossGroup.letter} · {selectedBossGroup.name}</span><small>{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians</small></div>
<div className="boss-choice-grid">
{visibleBossIds.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
const group = BOSS_GROUP_BY_ID[boss.groupId];
return (
<FocusButton
key={bossId}
@@ -775,7 +834,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>Group {group.letter} · {group.name} · {boss.mechanics[0]}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
+19 -89
View File
@@ -6,6 +6,7 @@ import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
import { bossAnimationCue } from "../game/bosses/mechanicPool";
import {
isActorAnimationOneShot,
shouldStartActorAnimation,
@@ -598,7 +599,9 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
function BullBoss({ bossIndex }: { bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
const motionMode = motion?.mode ?? "holding";
const animationCue = motion ? bossAnimationCue(motion) : "idle";
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
@@ -618,15 +621,13 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const clipName = phase === "victory" || defeated
? "Death"
: motionMode === "telegraph"
: animationCue === "attack"
? "Idle_Headlow"
: motionMode === "pouncing"
: animationCue === "special"
? "Gallop_Jump"
: motionMode === "charging" || motionMode === "returning"
: animationCue === "move"
? "Gallop"
: motionMode === "stacking"
? "Idle_Headlow"
: "Idle";
: "Idle";
useEffect(() => {
const next = actions[clipName];
@@ -661,9 +662,6 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
} else if (motion.mode === "returning") {
facingX = ARENA_CENTER[0] + motion.formationOffsetX - motion.position[0];
facingZ = ARENA_CENTER[1] - motion.position[1];
}
if (Math.hypot(facingX, facingZ) > 0.01) {
const targetAngle = Math.atan2(facingX, facingZ);
@@ -745,7 +743,7 @@ const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
},
"crystal-bat-matriarch": {
url: CRYSTAL_BAT_MATRIARCH_URL,
scale: 1.04,
scale: 0.828,
idle: "Idle",
move: "Swoop",
attack: "SonicPulse",
@@ -820,84 +818,17 @@ const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
},
};
const PROTOTYPE_MOVE_MODES = [
"skyfall",
"mantis_sidestep",
"ram_charging",
"cinderback_ricochet",
"charging",
"returning",
"sandglass_burrowing",
"crab_scuttling",
] as const;
const PROTOTYPE_ATTACK_MODES = [
"tethering",
"venom_cast",
"breath_telegraph",
"breath_sweeping",
"mantis_line_telegraph",
"mantis_cross_telegraph",
"ram_charge_telegraph",
"ram_quake",
"ram_shatter",
"cinderback_curl",
"cinderback_slam",
"ghost_soul_cross",
"ghost_soul_cross_followup",
"ghost_haunting",
"golem_shockwave",
"golem_crownfall",
"telegraph",
"stacking",
"pouncing",
"sandglass_burrow_telegraph",
"sandglass_eruption",
"sandglass_hourglass",
"crab_scuttle_telegraph",
"crab_tidal_burst",
] as const;
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
function alternateBossClip(kind: AlternateBossKind, motion: ReturnType<typeof useGameStore.getState>["bossMotion"]) {
const config = ALTERNATE_BOSS_CONFIG[kind];
if (config.prototype) {
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
return config.idle;
}
if (kind === "sandglass-scorpion") {
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
if (motionMode === "sandglass_eruption") return config.attack;
if (motionMode === "sandglass_hourglass") return config.special;
if (motionMode === "sandglass_recover") return "Stagger";
}
if (kind === "cragclaw-crab") {
if (motionMode === "crab_scuttling") return config.move;
if (motionMode === "crab_scuttle_telegraph") return config.attack;
if (motionMode === "crab_tidal_burst") return config.special;
}
if (kind === "mournveil-ghost") {
if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack;
if (motionMode === "ghost_haunting") return config.special;
}
if (kind === "crownshard-golem") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
}
if (kind === "crystal-bat-matriarch") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
if (motionMode === "golem_recover") return "Stagger";
}
if (BOSS_ARCHETYPE_BY_ID[kind] === "web-caster" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
return config.idle;
return config[bossAnimationCue(motion)];
}
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind];
const archetype = BOSS_ARCHETYPE_BY_ID[kind];
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
const motionMode = motion?.mode ?? "holding";
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
@@ -915,7 +846,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
});
}, [kind, model]);
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode);
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);
useEffect(() => {
const next = actions[clipName];
@@ -944,7 +875,7 @@ 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.mode === "sandglass_burrowing";
const burrowed = archetype === "burrower" && motion.activeMechanicId === "burrow-rush" && motion.mode === "charging";
const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
@@ -955,14 +886,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
);
if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
} else if (archetype === "duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
} else if (
motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
)) {
) {
const target = state.partyPositions[motion.chargeTargetId];
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
} else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) {
} else if (motion.mode === "telegraph" || motion.mode === "charging") {
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
}
const difference = Math.atan2(
+96 -21
View File
@@ -1,18 +1,17 @@
import { useFrame } from "@react-three/fiber";
import { useRef, type ComponentType } from "react";
import { Html } from "@react-three/drei";
import { useLayoutEffect, useRef, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
import { useGameStore } from "../../game/store";
import type { MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
import type { BossMotionMode, MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
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);
const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
const EMPTY_POOL_TELEGRAPHS: never[] = [];
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
const ACTIVE_LANE_MODES = new Set<BossMotionMode>(["charging"]);
const DANGER_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
@@ -290,7 +289,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
}
/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */
/** Tall gold ward plus a lightweight spectral pursuer for the shared Soul Siphon mechanic. */
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
@@ -392,12 +391,49 @@ function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySym
);
}
function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) {
const MEMORY_SYMBOL_GLYPHS: Record<MemorySymbolId, string> = {
triangle: "△",
cross: "+",
circle: "○",
square: "◇",
};
function MemoryTileIndicator({ tile, inputActive, completed }: { tile: MemoryTile; inputActive: boolean; completed: boolean }) {
const color = MEMORY_SYMBOLS[tile.symbol].color;
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
const gridOffsets = [-0.48, 0, 0.48] as const;
const group = useRef<THREE.Group>(null);
const fade = useRef(1);
const materials = useRef<{ material: THREE.MeshBasicMaterial; baseOpacity: number }[]>([]);
useLayoutEffect(() => {
const nextMaterials: { material: THREE.MeshBasicMaterial; baseOpacity: number }[] = [];
group.current?.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const childMaterials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of childMaterials) {
if (material instanceof THREE.MeshBasicMaterial) {
nextMaterials.push({ material, baseOpacity: material.opacity });
}
}
});
materials.current = nextMaterials;
}, [inputActive]);
useFrame((_, delta) => {
if (!completed && fade.current >= 0.999) return;
if (completed && fade.current <= 0.01) {
fade.current = 0;
if (group.current) group.current.visible = false;
return;
}
if (group.current) group.current.visible = true;
fade.current = THREE.MathUtils.damp(fade.current, completed ? 0 : 1, 18, delta);
for (const entry of materials.current) entry.material.opacity = entry.baseOpacity * fade.current;
});
return (
<group position={[tile.center[0], 0.075, tile.center[1]]}>
<group ref={group} position={[tile.center[0], 0.075, tile.center[1]]}>
<mesh>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
@@ -440,21 +476,60 @@ function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph:
);
const flashSymbol = telegraph.sequence[flashIndex];
const source = bossPosition ?? telegraph.center;
const completedCount = showingSequence ? 0 : telegraph.inputIndex ?? 0;
return (
<>
{telegraph.tiles.map((tile) => <MemoryTileIndicator key={tile.symbol} tile={tile} inputActive={!showingSequence} />)}
{telegraph.tiles.map((tile) => {
const sequenceIndex = telegraph.sequence!.indexOf(tile.symbol);
return (
<MemoryTileIndicator
key={tile.symbol}
tile={tile}
inputActive={!showingSequence}
completed={sequenceIndex >= 0 && sequenceIndex < completedCount}
/>
);
})}
{showingSequence && (
<group position={[source[0], 2.5, source[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.88, 32]} />
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
</mesh>
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.8, 0.9, 32]} />
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
</mesh>
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
</group>
<Html position={[source[0], 3.1, source[1] + 0.3]} center zIndexRange={[30, 20]} style={{ pointerEvents: "none" }}>
<div style={{ display: "grid", justifyItems: "center", gap: 7 }}>
<div
style={{
width: 64,
height: 64,
display: "grid",
placeItems: "center",
borderRadius: "50%",
border: `4px solid ${MEMORY_SYMBOLS[flashSymbol].color}`,
background: "rgba(17, 24, 39, 0.96)",
boxShadow: "0 0 0 4px rgba(216, 184, 92, 0.92), 0 0 18px rgba(255, 244, 199, 0.8)",
color: MEMORY_SYMBOLS[flashSymbol].color,
fontSize: 47,
fontWeight: 900,
lineHeight: 1,
textShadow: "0 0 8px currentColor",
}}
>
{MEMORY_SYMBOL_GLYPHS[flashSymbol]}
</div>
<div style={{ display: "flex", gap: 6 }}>
{telegraph.sequence.map((_, index) => (
<span
key={index}
style={{
width: index === flashIndex ? 9 : 7,
height: index === flashIndex ? 9 : 7,
borderRadius: "50%",
background: index === flashIndex
? MEMORY_SYMBOLS[flashSymbol].color
: index < flashIndex ? "#fff4c7" : "#64748b",
boxShadow: index === flashIndex ? "0 0 7px currentColor" : "none",
}}
/>
))}
</div>
</div>
</Html>
)}
</>
);