forked from phenom/healer-man
Release Healer Man 0.1.4
This commit is contained in:
1 parent
5bb170039a
commit
f4c9cf356c
63 files changed
+2694
-81
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { lazy, Suspense, useEffect, useMemo } from "react";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import {
|
||||
abilityAtLevel,
|
||||
@@ -13,7 +14,41 @@ import { EquipmentPanel } from "./EquipmentPanel";
|
||||
import { RomSkillsPanel } from "./RomSkillsPanel";
|
||||
|
||||
const PartyManagementPanel = lazy(() => import("./PartyManagementPanel").then((module) => ({ default: module.PartyManagementPanel })));
|
||||
const TalentGameplayPanel = lazy(() => import("./TalentGameplayPanel"));
|
||||
let talentGameplayPanelModule: Promise<typeof import("./TalentGameplayPanel")> | null = null;
|
||||
|
||||
function loadTalentGameplayPanel() {
|
||||
if (!talentGameplayPanelModule) {
|
||||
talentGameplayPanelModule = import("./TalentGameplayPanel").catch((error: unknown) => {
|
||||
talentGameplayPanelModule = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return talentGameplayPanelModule;
|
||||
}
|
||||
|
||||
function preloadTalentGameplayPanel() {
|
||||
void loadTalentGameplayPanel().catch(() => undefined);
|
||||
}
|
||||
|
||||
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
|
||||
|
||||
function TalentsPanelLoading() {
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
return (
|
||||
<div className="modal-backdrop game-panel-backdrop" role="presentation">
|
||||
<section className="game-panel talents-panel talents-panel--loading" role="dialog" aria-modal="true" aria-labelledby="talents-loading-title">
|
||||
<header className="game-panel__header">
|
||||
<div>
|
||||
<p className="eyebrow">Specialization</p>
|
||||
<h2 id="talents-loading-title">Talents</h2>
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={() => openOverlay("pause")} aria-label="Return to pause menu">×</button>
|
||||
</header>
|
||||
<p className="game-panel__empty" role="status">Preparing talent trees…</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Maps the data-driven combat catalogs into the full-screen game panels. */
|
||||
export function GameplayPanels() {
|
||||
@@ -22,9 +57,14 @@ export function GameplayPanels() {
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
const settings = useCombatStore((state) => state.settings);
|
||||
const presentationSettings = useShellStore((state) => state.combatPresentationSettings);
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const classDefinition = classById(classId);
|
||||
|
||||
useEffect(() => {
|
||||
if (overlay === "pause" && !classId.startsWith("rom-")) preloadTalentGameplayPanel();
|
||||
}, [classId, overlay]);
|
||||
|
||||
const spellbookAbilities = useMemo(() => [...abilities]
|
||||
.sort((left, right) => left.unlockLevel - right.unlockLevel)
|
||||
.map((catalogAbility) => {
|
||||
@@ -76,12 +116,15 @@ export function GameplayPanels() {
|
||||
{overlay === "talents"
|
||||
? classId.startsWith("rom-")
|
||||
? <RomSkillsPanel />
|
||||
: <Suspense fallback={null}><TalentGameplayPanel /></Suspense>
|
||||
: <Suspense fallback={<TalentsPanelLoading />}><TalentGameplayPanel /></Suspense>
|
||||
: null}
|
||||
<OptionsPanel
|
||||
settings={settings}
|
||||
onChange={(patch) => useCombatStore.getState().updateSettings(patch)}
|
||||
onReset={() => useCombatStore.getState().resetSettings()}
|
||||
presentationSettings={presentationSettings}
|
||||
onPresentationChange={(patch) => useShellStore.getState().updateCombatPresentationSettings(patch)}
|
||||
onPresentationReset={() => useShellStore.getState().resetCombatPresentationSettings()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+101
-5
@@ -6,9 +6,15 @@ import {
|
||||
} from "../game/hudLayout";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import {
|
||||
type CombatActorGroup,
|
||||
type CombatPresentationSettings,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
|
||||
export interface OptionsPanelSettings {
|
||||
showMobHealthBars: boolean;
|
||||
showMobAggroRanges: boolean;
|
||||
showTargetFrame: boolean;
|
||||
showThreatMeter: boolean;
|
||||
threatMeterPosition: NormalizedHudPosition;
|
||||
@@ -24,19 +30,23 @@ export interface OptionsPanelProps {
|
||||
settings: OptionsPanelSettings;
|
||||
onChange: (patch: Partial<OptionsPanelSettings>) => void;
|
||||
onReset: () => void;
|
||||
presentationSettings: CombatPresentationSettings;
|
||||
onPresentationChange: (patch: Partial<CombatPresentationSettings>) => void;
|
||||
onPresentationReset: () => void;
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface ToggleOption {
|
||||
id: keyof Pick<OptionsPanelSettings,
|
||||
"showMobHealthBars" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "autoTargetNearest" | "enableScreenShake">;
|
||||
"showMobHealthBars" | "showMobAggroRanges" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "autoTargetNearest" | "enableScreenShake">;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const TOGGLES: readonly ToggleOption[] = [
|
||||
{ id: "showMobHealthBars", label: "Mob health bars", description: "Show health above nearby enemies." },
|
||||
{ id: "showMobAggroRanges", label: "Mob aggro ranges", description: "Show the boundary where idle dungeon enemies automatically attack your party." },
|
||||
{ id: "showTargetFrame", label: "Target frame", description: "Show detailed status for your current target." },
|
||||
{ id: "showThreatMeter", label: "Threat meter", description: "Show the compact movable threat table for your selected enemy." },
|
||||
{ id: "showFloatingCombatText", label: "Combat numbers", description: "Show damage, healing, and absorb feedback." },
|
||||
@@ -44,12 +54,39 @@ const TOGGLES: readonly ToggleOption[] = [
|
||||
{ id: "enableScreenShake", label: "Screen shake", description: "Use restrained camera impact for major hits." },
|
||||
] as const;
|
||||
|
||||
const COMBAT_ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
||||
const VFX_QUALITY_ORDER: readonly CombatVfxQuality[] = ["off", "low", "high"];
|
||||
|
||||
function actorGroupLabel(group: CombatActorGroup): string {
|
||||
return group === "enemy" ? "Enemies" : `${group[0].toUpperCase()}${group.slice(1)}`;
|
||||
}
|
||||
|
||||
function quantize(value: number, step: number, minimum: number, maximum: number): number {
|
||||
const next = Math.round(value / step) * step;
|
||||
return Math.max(minimum, Math.min(maximum, Number(next.toFixed(2))));
|
||||
}
|
||||
|
||||
function OptionsDialog({ settings, onChange, onReset, onClose }: Required<Omit<OptionsPanelProps, "open">>) {
|
||||
function OptionsDialog({
|
||||
settings,
|
||||
onChange,
|
||||
onReset,
|
||||
presentationSettings,
|
||||
onPresentationChange,
|
||||
onPresentationReset,
|
||||
onClose,
|
||||
}: Required<Omit<OptionsPanelProps, "open">>) {
|
||||
const patchGroupBoolean = (
|
||||
field: "vfxEnabled" | "audioEnabled",
|
||||
group: CombatActorGroup,
|
||||
value: boolean,
|
||||
) => onPresentationChange({ [field]: { ...presentationSettings[field], [group]: value } });
|
||||
const patchGroupVolume = (group: CombatActorGroup, value: number) => onPresentationChange({
|
||||
audioVolume: { ...presentationSettings.audioVolume, [group]: quantize(value, 0.1, 0, 1) },
|
||||
});
|
||||
const cycleVfxQuality = () => {
|
||||
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
|
||||
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] });
|
||||
};
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...TOGGLES.map((option) => ({
|
||||
id: `option-${option.id}`,
|
||||
@@ -61,8 +98,27 @@ function OptionsDialog({ settings, onChange, onReset, onClose }: Required<Omit<O
|
||||
{ id: "option-scale-up", run: () => onChange({ uiScale: quantize(settings.uiScale + 0.05, 0.05, 0.75, 1.25) }) },
|
||||
{ id: "option-minimap", run: () => onChange({ minimapRotation: settings.minimapRotation === "north-up" ? "player-up" : "north-up" }) },
|
||||
{ id: "option-threat-position", run: () => onChange({ threatMeterPosition: DEFAULT_THREAT_METER_POSITION }) },
|
||||
{ id: "option-reset", run: onReset },
|
||||
], [onChange, onReset, settings]);
|
||||
{ id: "option-vfx-quality", run: cycleVfxQuality },
|
||||
...COMBAT_ACTOR_GROUPS.flatMap((group) => [
|
||||
{
|
||||
id: `option-vfx-${group}`,
|
||||
run: () => patchGroupBoolean("vfxEnabled", group, !presentationSettings.vfxEnabled[group]),
|
||||
},
|
||||
{
|
||||
id: `option-audio-${group}`,
|
||||
run: () => patchGroupBoolean("audioEnabled", group, !presentationSettings.audioEnabled[group]),
|
||||
},
|
||||
{
|
||||
id: `option-audio-${group}-down`,
|
||||
run: () => patchGroupVolume(group, presentationSettings.audioVolume[group] - 0.1),
|
||||
},
|
||||
{
|
||||
id: `option-audio-${group}-up`,
|
||||
run: () => patchGroupVolume(group, presentationSettings.audioVolume[group] + 0.1),
|
||||
},
|
||||
]),
|
||||
{ id: "option-reset", run: () => { onReset(); onPresentationReset(); } },
|
||||
], [onChange, onPresentationChange, onPresentationReset, onReset, presentationSettings, settings]);
|
||||
const controller = useMenuController(actions, { columns: 1, onBack: onClose });
|
||||
|
||||
const optionButton = (id: string, label: string, run: () => void, disabled = false) => (
|
||||
@@ -127,10 +183,50 @@ function OptionsDialog({ settings, onChange, onReset, onClose }: Required<Omit<O
|
||||
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
|
||||
}))}
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Combat presentation</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>VFX quality</strong><small>High uses the full particle budget; Low reduces particles and lights; Off skips combat VFX.</small></span>
|
||||
{optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
|
||||
</div>
|
||||
{COMBAT_ACTOR_GROUPS.map((group) => (
|
||||
<div className="options-panel__combat-group" key={group}>
|
||||
<div className="option-row">
|
||||
<span><strong>{actorGroupLabel(group)} VFX</strong><small>Show attacks, spells, trails, impacts, and auras from this group.</small></span>
|
||||
<ControllerButton
|
||||
controlId={`option-vfx-${group}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
type="button"
|
||||
className={presentationSettings.vfxEnabled[group] ? "is-on" : ""}
|
||||
aria-pressed={presentationSettings.vfxEnabled[group]}
|
||||
onClick={() => patchGroupBoolean("vfxEnabled", group, !presentationSettings.vfxEnabled[group])}
|
||||
>{presentationSettings.vfxEnabled[group] ? "On" : "Off"}</ControllerButton>
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<span><strong>{actorGroupLabel(group)} combat audio</strong><small>Mute or mix ability and weapon sounds from this group.</small></span>
|
||||
<div className="option-audio-controls">
|
||||
<ControllerButton
|
||||
controlId={`option-audio-${group}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
type="button"
|
||||
className={presentationSettings.audioEnabled[group] ? "is-on" : ""}
|
||||
aria-pressed={presentationSettings.audioEnabled[group]}
|
||||
onClick={() => patchGroupBoolean("audioEnabled", group, !presentationSettings.audioEnabled[group])}
|
||||
>{presentationSettings.audioEnabled[group] ? "On" : "Muted"}</ControllerButton>
|
||||
<div className="option-stepper">
|
||||
{optionButton(`option-audio-${group}-down`, "−", () => patchGroupVolume(group, presentationSettings.audioVolume[group] - 0.1), presentationSettings.audioVolume[group] <= 0)}
|
||||
<output>{Math.round(presentationSettings.audioVolume[group] * 100)}%</output>
|
||||
{optionButton(`option-audio-${group}-up`, "+", () => patchGroupVolume(group, presentationSettings.audioVolume[group] + 0.1), presentationSettings.audioVolume[group] >= 1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="options-panel__actions">
|
||||
<ControllerButton controlId="option-reset" selectedId={controller.selectedId} select={controller.select} type="button" className="button" onClick={onReset}>Restore defaults</ControllerButton>
|
||||
<ControllerButton controlId="option-reset" selectedId={controller.selectedId} select={controller.select} type="button" className="button" onClick={() => { onReset(); onPresentationReset(); }}>Restore defaults</ControllerButton>
|
||||
<button type="button" className="button button--primary" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
<footer className="game-panel__footer"><span><kbd>A</kbd> Change</span><span><kbd>B</kbd> Pause menu</span></footer>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const gameplayPanelsSource = readFileSync(
|
||||
new URL("./GameplayPanels.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("gameplay panel loading integration", () => {
|
||||
it("starts loading talents from the pause menu and keeps a visible first-open surface", () => {
|
||||
expect(gameplayPanelsSource).toContain('overlay === "pause"');
|
||||
expect(gameplayPanelsSource).toContain("preloadTalentGameplayPanel()");
|
||||
expect(gameplayPanelsSource).toContain("fallback={<TalentsPanelLoading />}");
|
||||
expect(gameplayPanelsSource).not.toContain("<Suspense fallback={null}><TalentGameplayPanel />");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user