forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
4c768fbec6
commit
b16821e92e
330 files changed
+35406
-1760
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
EMPTY_DAMAGE_TEXT_POPUPS,
|
||||
formatDamageText,
|
||||
useDamageTextStore,
|
||||
type DamageTextPopup,
|
||||
} from "../game/floatingCombatText";
|
||||
import { DungeonOccludedHtml } from "../scene/DungeonOccludedHtml";
|
||||
|
||||
export function damageTextClassName(popup: DamageTextPopup): string {
|
||||
return `damage-text__number damage-text__number--${popup.category}${popup.critical ? " is-critical" : ""}`;
|
||||
}
|
||||
|
||||
function DamageTextNumbers({ popups }: { readonly popups: readonly DamageTextPopup[] }) {
|
||||
return (
|
||||
<span className="damage-text" aria-hidden="true">
|
||||
{popups.map((popup) => (
|
||||
<span
|
||||
key={popup.key}
|
||||
className={damageTextClassName(popup)}
|
||||
style={{
|
||||
"--damage-x": `${[-40, 40, -40, 40][popup.slot]}px`,
|
||||
"--damage-y": `${[-4, -24, -44, -64][popup.slot]}px`,
|
||||
} as CSSProperties}
|
||||
>{formatDamageText(popup.amount)}</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorldDamageText({
|
||||
targetActorId,
|
||||
position,
|
||||
offsetX = 0,
|
||||
}: {
|
||||
readonly targetActorId: string;
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly offsetX?: number;
|
||||
}) {
|
||||
const popups = useDamageTextStore((state) => state.byTarget[targetActorId] ?? EMPTY_DAMAGE_TEXT_POPUPS);
|
||||
if (popups.length === 0) return null;
|
||||
return (
|
||||
<DungeonOccludedHtml center position={position} zIndexRange={[80, 0]} style={{ pointerEvents: "none" }}>
|
||||
<span className="damage-text__world-anchor" style={{ left: offsetX }}>
|
||||
<DamageTextNumbers popups={popups} />
|
||||
</span>
|
||||
</DungeonOccludedHtml>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrameDamageText({ targetActorId }: { readonly targetActorId: string }) {
|
||||
const popups = useDamageTextStore((state) => state.byTarget[targetActorId] ?? EMPTY_DAMAGE_TEXT_POPUPS);
|
||||
if (popups.length === 0) return null;
|
||||
return <span className="party-frame__damage-popups"><DamageTextNumbers popups={popups} /></span>;
|
||||
}
|
||||
@@ -65,4 +65,22 @@ describe("combat visual mode option", () => {
|
||||
expect(markup).toContain("Stored on this device. Enable the pack");
|
||||
expect(markup).toContain(">Enable</button>");
|
||||
});
|
||||
|
||||
it("offers independent damage and healing number switches", () => {
|
||||
const markup = renderOptions(DEFAULT_COMBAT_PRESENTATION_SETTINGS.vfxMode);
|
||||
expect(markup).toContain("Your damage");
|
||||
expect(markup).toContain("Ally damage");
|
||||
expect(markup).toContain("Incoming damage");
|
||||
expect(markup).toContain("Party healing numbers");
|
||||
});
|
||||
|
||||
it("shows dungeon graphics controls with the saved defaults", () => {
|
||||
const markup = renderOptions(DEFAULT_COMBAT_PRESENTATION_SETTINGS.vfxMode);
|
||||
expect(markup).toContain("Render resolution");
|
||||
expect(markup).toContain("Texture filtering");
|
||||
expect(markup).toContain("Shadows");
|
||||
expect(markup).toContain("Ambient fill");
|
||||
expect(markup).toContain(">Adaptive</button>");
|
||||
expect(markup).toContain(">Standard</button>");
|
||||
});
|
||||
});
|
||||
+45
-2
@@ -20,19 +20,30 @@ import {
|
||||
type GameplayFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "../game/frameRateSettings";
|
||||
import {
|
||||
AMBIENT_FILL_LEVELS,
|
||||
RENDER_SCALES,
|
||||
SHADOW_QUALITIES,
|
||||
TEXTURE_FILTERING_LEVELS,
|
||||
nextGraphicsChoice,
|
||||
type GraphicsSettings,
|
||||
} from "../game/graphicsSettings";
|
||||
import {
|
||||
combatMediaPackReady,
|
||||
downloadCombatMediaPack,
|
||||
removeCombatMediaPack,
|
||||
} from "../game/combatMediaPack";
|
||||
|
||||
export interface OptionsPanelSettings {
|
||||
export interface OptionsPanelSettings extends GraphicsSettings {
|
||||
showMobHealthBars: boolean;
|
||||
showMobAggroRanges: boolean;
|
||||
showTargetFrame: boolean;
|
||||
showThreatMeter: boolean;
|
||||
threatMeterPosition: NormalizedHudPosition;
|
||||
showFloatingCombatText: boolean;
|
||||
showOwnDamageText: boolean;
|
||||
showAllyDamageText: boolean;
|
||||
showIncomingDamageText: boolean;
|
||||
autoTargetNearest: boolean;
|
||||
enableScreenShake: boolean;
|
||||
masterVolume: number;
|
||||
@@ -55,7 +66,7 @@ export interface OptionsPanelProps {
|
||||
|
||||
interface ToggleOption {
|
||||
id: keyof Pick<OptionsPanelSettings,
|
||||
"showMobHealthBars" | "showMobAggroRanges" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "autoTargetNearest" | "enableScreenShake">;
|
||||
"showMobHealthBars" | "showMobAggroRanges" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "showOwnDamageText" | "showAllyDamageText" | "showIncomingDamageText" | "autoTargetNearest" | "enableScreenShake">;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
@@ -66,6 +77,9 @@ const TOGGLES: readonly ToggleOption[] = [
|
||||
{ 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: "Party healing numbers", description: "Float effective healing amounts over party frames during combat." },
|
||||
{ id: "showOwnDamageText", label: "Your damage", description: "Float your damage over the enemy you hit." },
|
||||
{ id: "showAllyDamageText", label: "Ally damage", description: "Float companion and online ally damage over enemies." },
|
||||
{ id: "showIncomingDamageText", label: "Incoming damage", description: "Float damage over party characters and your party frame." },
|
||||
{ id: "autoTargetNearest", label: "Automatic targeting", description: "Pick the nearest valid enemy when an offensive ability needs one." },
|
||||
{ id: "enableScreenShake", label: "Screen shake", description: "Use restrained camera impact for major hits." },
|
||||
] as const;
|
||||
@@ -167,6 +181,10 @@ function OptionsDialog({
|
||||
{ id: "option-keybinds", run: () => useGameStore.getState().openOverlay("keybinds") },
|
||||
{ id: "option-gameplay-frame-rate", run: cycleGameplayFrameRate },
|
||||
{ id: "option-preview-frame-rate", run: cyclePreviewFrameRate },
|
||||
{ id: "option-render-scale", run: () => onChange({ renderScale: nextGraphicsChoice(settings.renderScale, RENDER_SCALES) }) },
|
||||
{ id: "option-texture-filtering", run: () => onChange({ textureFiltering: nextGraphicsChoice(settings.textureFiltering, TEXTURE_FILTERING_LEVELS) }) },
|
||||
{ id: "option-shadow-quality", run: () => onChange({ shadowQuality: nextGraphicsChoice(settings.shadowQuality, SHADOW_QUALITIES) }) },
|
||||
{ id: "option-ambient-fill", run: () => onChange({ ambientFill: nextGraphicsChoice(settings.ambientFill, AMBIENT_FILL_LEVELS) }) },
|
||||
...TOGGLES.map((option) => ({
|
||||
id: `option-${option.id}`,
|
||||
run: () => onChange({ [option.id]: !settings[option.id] }),
|
||||
@@ -242,6 +260,31 @@ function OptionsDialog({
|
||||
cyclePreviewFrameRate,
|
||||
)}
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Dungeon graphics</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>Render resolution</strong><small>Adaptive adjusts sharpness for frame rate. Fixed 100–200% keeps your chosen pixel density.</small></span>
|
||||
{optionButton("option-render-scale", settings.renderScale === "adaptive" ? "Adaptive" : `${Math.round(settings.renderScale * 100)}%`, () => onChange({
|
||||
renderScale: nextGraphicsChoice(settings.renderScale, RENDER_SCALES),
|
||||
}))}
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<span><strong>Texture filtering</strong><small>Sharpen floor and wall textures viewed at an angle. Source texture resolution stays the same.</small></span>
|
||||
{optionButton("option-texture-filtering", `${settings.textureFiltering}×`, () => onChange({
|
||||
textureFiltering: nextGraphicsChoice(settings.textureFiltering, TEXTURE_FILTERING_LEVELS),
|
||||
}))}
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<span><strong>Shadows</strong><small>Standard preserves the current lightweight shadows. High adds dungeon and creature shadows and uses a sharper map.</small></span>
|
||||
{optionButton("option-shadow-quality", settings.shadowQuality[0].toUpperCase() + settings.shadowQuality.slice(1), () => onChange({
|
||||
shadowQuality: nextGraphicsChoice(settings.shadowQuality, SHADOW_QUALITIES),
|
||||
}))}
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<span><strong>Ambient fill</strong><small>Change indirect brightness while preserving each dungeon's authored light colors and local lights.</small></span>
|
||||
{optionButton("option-ambient-fill", settings.ambientFill[0].toUpperCase() + settings.ambientFill.slice(1), () => onChange({
|
||||
ambientFill: nextGraphicsChoice(settings.ambientFill, AMBIENT_FILL_LEVELS),
|
||||
}))}
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Gameplay and interface</h3>
|
||||
{TOGGLES.map((option) => (
|
||||
<div key={option.id} className="option-row">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../ga
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useHudSurface } from "./HudSurface";
|
||||
import { FrameDamageText } from "./FloatingDamageText";
|
||||
import {
|
||||
PARTY_HEAL_POPUP_LIFETIME_MS,
|
||||
addedPartyHealPopups,
|
||||
@@ -400,6 +401,7 @@ export function PartyFrames() {
|
||||
/>
|
||||
</span>
|
||||
<HealPopups popups={healPopups} targetId={PLAYER_AGGRO_ID} />
|
||||
<FrameDamageText targetActorId={PLAYER_AGGRO_ID} />
|
||||
</button>
|
||||
{onlineMembers.map((member) => {
|
||||
const actorId = onlinePlayerActorId(member.accountId);
|
||||
|
||||
+55
-8
@@ -1,9 +1,14 @@
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
|
||||
export function SummonFrames({ now }: { readonly now: number }) {
|
||||
const summons = useCombatStore((state) => state.summons);
|
||||
const selectedFriendlyActorId = useCombatStore((state) => state.selectedFriendlyActorId);
|
||||
const selectedTargetId = useCombatStore((state) => state.selectedTargetId);
|
||||
const selectFriendlyActor = useCombatStore((state) => state.selectFriendlyActor);
|
||||
const commandHunterPet = useCombatStore((state) => state.commandHunterPet);
|
||||
const setHunterPetReactMode = useCombatStore((state) => state.setHunterPetReactMode);
|
||||
const setHunterPetGrowlAutocast = useCombatStore((state) => state.setHunterPetGrowlAutocast);
|
||||
if (!summons.length) return null;
|
||||
return (
|
||||
<section className="summon-frames" aria-label="Pets, totems, and summons">
|
||||
@@ -12,18 +17,60 @@ export function SummonFrames({ now }: { readonly now: number }) {
|
||||
const remaining = summon.expiresAt >= Number.MAX_SAFE_INTEGER
|
||||
? "∞"
|
||||
: `${Math.max(0, Math.ceil((summon.expiresAt - now) / 1_000))}s`;
|
||||
const playerHunterPet = Boolean(summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID);
|
||||
const command = summon.hunterPetCommand ?? "follow";
|
||||
const reactMode = summon.hunterPetReactMode ?? "defensive";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className={`summon-frame summon-frame--${summon.kind}${selectedFriendlyActorId === summon.id ? " is-selected" : ""}`}
|
||||
key={summon.id}
|
||||
aria-pressed={selectedFriendlyActorId === summon.id}
|
||||
onClick={() => selectFriendlyActor(selectedFriendlyActorId === summon.id ? null : summon.id)}
|
||||
>
|
||||
<header><strong>{summon.name}</strong><span>{summon.kind} · {remaining}</span></header>
|
||||
<div><i style={{ width: `${healthPercent}%` }} /></div>
|
||||
<small>{Math.ceil(summon.health)} / {summon.maxHealth}</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="summon-frame__select"
|
||||
aria-pressed={selectedFriendlyActorId === summon.id}
|
||||
onClick={() => selectFriendlyActor(selectedFriendlyActorId === summon.id ? null : summon.id)}
|
||||
>
|
||||
<header><strong>{summon.name}</strong><span>{summon.kind} · {remaining}</span></header>
|
||||
<div><i style={{ width: `${healthPercent}%` }} /></div>
|
||||
<small>{Math.ceil(summon.health)} / {summon.maxHealth}</small>
|
||||
</button>
|
||||
{playerHunterPet && (
|
||||
<div className="hunter-pet-controls" aria-label={`${summon.name} controls`}>
|
||||
<div className="hunter-pet-controls__commands" role="group" aria-label="Pet command">
|
||||
{(["attack", "follow", "stay"] as const).map((petCommand) => (
|
||||
<button
|
||||
key={petCommand}
|
||||
type="button"
|
||||
aria-pressed={command === petCommand}
|
||||
disabled={petCommand === "attack" && !selectedTargetId}
|
||||
onClick={() => commandHunterPet(summon.id, petCommand)}
|
||||
>{petCommand}</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="hunter-pet-controls__react">
|
||||
<span>React</span>
|
||||
<select
|
||||
aria-label="Pet reaction"
|
||||
value={reactMode}
|
||||
onChange={(event) => setHunterPetReactMode(summon.id, event.currentTarget.value as "defensive" | "aggressive" | "passive")}
|
||||
>
|
||||
<option value="defensive">Defensive</option>
|
||||
<option value="aggressive">Aggressive</option>
|
||||
<option value="passive">Passive</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="hunter-pet-controls__growl">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(summon.hunterPetGrowlAutocastEnabled)}
|
||||
onChange={(event) => setHunterPetGrowlAutocast(summon.id, event.currentTarget.checked)}
|
||||
/>
|
||||
Growl auto
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user