Release v0.1.5 2026-07-12
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user