forked from phenom/healer-man
Release Healer Man 0.1.6
This commit is contained in:
1 parent
55cfd43d66
commit
b12fb84859
96 files changed
+10874
-609
No files matched your search
@@ -82,7 +82,11 @@ function ActionSlot({
|
||||
const cooldownLabel = (item?.cooldownRemaining ?? 0) > 0.05
|
||||
? ", cooling down"
|
||||
: "";
|
||||
const targetLabel = item?.target === "friendly" ? ", targets selected ally or self" : "";
|
||||
const targetLabel = item?.target === "friendly"
|
||||
? ", targets selected ally or self"
|
||||
: item?.target === "any"
|
||||
? ", targets selected enemy or ally"
|
||||
: "";
|
||||
const resourceLabel = item?.resourceCost
|
||||
? `, costs ${item.resourceCost} ${item.resourceLabel ?? "resource"}`
|
||||
: "";
|
||||
@@ -93,7 +97,7 @@ function ActionSlot({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
|
||||
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
|
||||
style={style}
|
||||
aria-disabled={unavailable}
|
||||
aria-label={item ? `${item.name}, ${chord}${targetLabel}${resourceLabel}${lockedLabel}${cooldownLabel}` : `Empty binding, ${chord}`}
|
||||
@@ -114,6 +118,7 @@ function ActionSlot({
|
||||
<strong>{item?.name ?? "Empty"}</strong>
|
||||
{item?.lockedUntilLevel ? <span className="action-slot__unlock">LVL {item.lockedUntilLevel}</span> : null}
|
||||
{item?.target === "friendly" ? <span className="action-slot__target">ALLY</span> : null}
|
||||
{item?.target === "any" ? <span className="action-slot__target">ANY</span> : null}
|
||||
{item?.resourceCost ? <small aria-hidden="true">{item.resourceCost}</small> : null}
|
||||
{cooldown > 0 && <i className="action-slot__cooldown" aria-hidden="true" />}
|
||||
{(item?.cooldownRemaining ?? 0) > 0.05 && (
|
||||
@@ -183,7 +188,8 @@ export function ActionBar({
|
||||
? `Unlocks at level ${inspectedItem.lockedUntilLevel}`
|
||||
: inspectedItem.resourceCost
|
||||
? `${inspectedItem.resourceCost} ${inspectedItem.resourceLabel ?? "resource"}`
|
||||
: inspectedItem.target === "friendly" ? "Friendly target" : "Ready"
|
||||
: inspectedItem.target === "friendly" ? "Friendly target"
|
||||
: inspectedItem.target === "any" ? "Enemy or friendly target" : "Ready"
|
||||
: inspectedControl ? "Empty binding" : "Focus a button for details"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -48,6 +50,10 @@ export function DungeonSelectScreen() {
|
||||
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||
const character = activeCharacter ?? rosterCharacter;
|
||||
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
|
||||
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
|
||||
@@ -73,8 +79,22 @@ export function DungeonSelectScreen() {
|
||||
setRolePickerOpen(false);
|
||||
};
|
||||
|
||||
const enterAsRole = (role: PartyRole) => {
|
||||
if (selectedDungeon?.available) enterDungeon(selectedDungeon.id, role);
|
||||
const enterAsRole = async (role: PartyRole) => {
|
||||
if (!selectedDungeon?.available || !character) return;
|
||||
if (!onlineGroup || !session || session.kind !== "account") {
|
||||
enterDungeon(selectedDungeon.id, role);
|
||||
return;
|
||||
}
|
||||
if (!await updateOnlineMember(session, character, role)) return;
|
||||
if (onlineGroup.leaderAccountId !== session.ownerId) {
|
||||
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[role]}. Waiting for the group leader to start.`);
|
||||
return;
|
||||
}
|
||||
await startOnlineActivity(session, {
|
||||
type: "dungeon",
|
||||
contentId: selectedDungeon.id,
|
||||
partySize: 5,
|
||||
});
|
||||
};
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...DUNGEON_CATEGORIES.map((category, index) => ({
|
||||
@@ -220,6 +240,7 @@ export function DungeonSelectScreen() {
|
||||
{selectedDungeon.available && !selectedPackInstalled && (
|
||||
<p className="front-notice" role="status">Install this expedition from Main menu / Downloads before playing offline.</p>
|
||||
)}
|
||||
<OnlineGroupPanel />
|
||||
{rolePickerOpen && characterClass ? (
|
||||
<section className="dungeon-role-picker" aria-labelledby="dungeon-role-title">
|
||||
<header>
|
||||
@@ -240,7 +261,7 @@ export function DungeonSelectScreen() {
|
||||
>
|
||||
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
||||
<span>
|
||||
<strong>{PARTY_ROLE_LABELS[role]}</strong>
|
||||
<strong>{onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? `Ready as ${PARTY_ROLE_LABELS[role]}` : PARTY_ROLE_LABELS[role]}</strong>
|
||||
<small>{ROLE_DETAILS[role].summary}</small>
|
||||
</span>
|
||||
</ControllerButton>
|
||||
|
||||
+70
-5
@@ -24,6 +24,9 @@ import { AuraStrip } from "./AuraStrip";
|
||||
import { ManastormStatus } from "./ManastormStatus";
|
||||
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||
import { BossLootWindow } from "./BossLootWindow";
|
||||
import { SummonFrames } from "./SummonFrames";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||
|
||||
function LoadingOverlay() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
@@ -61,12 +64,14 @@ export function DefeatedPrompt({
|
||||
gameMode,
|
||||
resurrectionCharges,
|
||||
onReturn,
|
||||
sharedOnline = false,
|
||||
}: {
|
||||
gameMode: "dungeon" | "manastorm";
|
||||
resurrectionCharges: number;
|
||||
onReturn: () => void;
|
||||
sharedOnline?: boolean;
|
||||
}) {
|
||||
const canReturn = gameMode === "dungeon" || resurrectionCharges > 0;
|
||||
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
|
||||
return (
|
||||
<section
|
||||
className="defeated-prompt"
|
||||
@@ -86,10 +91,12 @@ export function DefeatedPrompt({
|
||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||
: "Respawn at entrance"}
|
||||
</button>
|
||||
) : sharedOnline ? (
|
||||
<small>Your shared run is still active. A living player can resurrect you.</small>
|
||||
) : (
|
||||
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
|
||||
)}
|
||||
{gameMode === "dungeon" ? (
|
||||
{gameMode === "dungeon" && !sharedOnline ? (
|
||||
<small>Respawning resets the current encounter and restores the party.</small>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -122,6 +129,9 @@ export function Hud() {
|
||||
const health = useCombatStore((state) => state.health);
|
||||
const maxHealth = useCombatStore((state) => state.maxHealth);
|
||||
const shield = useCombatStore((state) => state.shield);
|
||||
const namedAbsorb = useCombatStore((state) => state.auras
|
||||
.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)
|
||||
.reduce((total, aura) => total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0), 0));
|
||||
const resource = useCombatStore((state) => state.resource);
|
||||
const maxResource = useCombatStore((state) => state.maxResource);
|
||||
const resourcePools = useCombatStore((state) => state.resourcePools);
|
||||
@@ -132,8 +142,10 @@ export function Hud() {
|
||||
const cooldowns = useCombatStore((state) => state.cooldowns);
|
||||
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
|
||||
const activeCast = useCombatStore((state) => state.activeCast);
|
||||
const pendingGroundTarget = useCombatStore((state) => state.pendingGroundTarget);
|
||||
const feedback = useCombatStore((state) => state.feedback);
|
||||
const target = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null);
|
||||
const selectedFriendlyActorId = useCombatStore((state) => state.selectedFriendlyActorId);
|
||||
const friendlyTarget = usePartyStore((state) => state.selectedMemberId
|
||||
? state.members.find((member) => member.id === state.selectedMemberId) ?? null
|
||||
: null);
|
||||
@@ -141,6 +153,7 @@ export function Hud() {
|
||||
const partyMembers = usePartyStore((state) => state.members);
|
||||
const playerRole = usePartyStore((state) => state.playerRole);
|
||||
const settings = useCombatStore((state) => state.settings);
|
||||
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
|
||||
const [clock, setClock] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,6 +161,15 @@ export function Hud() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGroundTarget) return;
|
||||
const cancel = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") useCombatStore.getState().cancelGroundTarget();
|
||||
};
|
||||
window.addEventListener("keydown", cancel);
|
||||
return () => window.removeEventListener("keydown", cancel);
|
||||
}, [pendingGroundTarget]);
|
||||
|
||||
const updateThreatMeterPosition = useCallback((threatMeterPosition: typeof settings.threatMeterPosition) => {
|
||||
useCombatStore.getState().updateSettings({ threatMeterPosition });
|
||||
}, []);
|
||||
@@ -211,11 +233,25 @@ export function Hud() {
|
||||
role: member.role,
|
||||
alive: member.health > 0,
|
||||
})),
|
||||
...(onlineSession?.players ?? [])
|
||||
.filter((player) => player.accountId !== onlineSession?.localAccountId)
|
||||
.map((player) => ({
|
||||
id: onlinePlayerActorId(player.accountId),
|
||||
name: player.character?.name ?? player.username,
|
||||
role: player.role ?? "damage",
|
||||
alive: player.health > 0,
|
||||
})),
|
||||
],
|
||||
target.targetActorId,
|
||||
target.forcedTarget,
|
||||
clock,
|
||||
) : [];
|
||||
const onlineFriendlyAccountId = selectedFriendlyActorId
|
||||
? onlineAccountIdFromActor(selectedFriendlyActorId)
|
||||
: null;
|
||||
const onlineFriendly = onlineFriendlyAccountId
|
||||
? onlineSession?.players.find((player) => player.accountId === onlineFriendlyAccountId) ?? null
|
||||
: null;
|
||||
const selectedFriendly = selfSelected
|
||||
? {
|
||||
name: character?.name ?? "Adventurer",
|
||||
@@ -223,6 +259,7 @@ export function Hud() {
|
||||
health,
|
||||
maxHealth,
|
||||
self: true,
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
}
|
||||
: friendlyTarget
|
||||
? {
|
||||
@@ -231,7 +268,17 @@ export function Hud() {
|
||||
health: friendlyTarget.health,
|
||||
maxHealth: friendlyTarget.maxHealth,
|
||||
self: false,
|
||||
targetId: friendlyTarget.id,
|
||||
}
|
||||
: onlineFriendly
|
||||
? {
|
||||
name: onlineFriendly.character?.name ?? onlineFriendly.username,
|
||||
detail: `Online player · Level ${onlineFriendly.character?.level ?? 1}`,
|
||||
health: onlineFriendly.health,
|
||||
maxHealth: onlineFriendly.maxHealth,
|
||||
self: false,
|
||||
targetId: onlinePlayerActorId(onlineFriendly.accountId),
|
||||
}
|
||||
: null;
|
||||
const friendlyTargetPercent = selectedFriendly
|
||||
? Math.max(0, Math.min(100, selectedFriendly.health / selectedFriendly.maxHealth * 100))
|
||||
@@ -275,10 +322,27 @@ export function Hud() {
|
||||
character={character ? { ...character, level } : null}
|
||||
health={{ current: health, maximum: maxHealth, label: "Health", tone: "health" }}
|
||||
resource={{ current: resource, maximum: maxResource, label: resourceName, tone: resourceTone }}
|
||||
shield={shield}
|
||||
shield={shield + namedAbsorb}
|
||||
dead={health <= 0}
|
||||
/>
|
||||
<PartyFrames />
|
||||
<SummonFrames now={clock} />
|
||||
|
||||
{pendingGroundTarget ? (
|
||||
<section className="ground-target-reticle" role="status" aria-live="polite">
|
||||
<div aria-hidden="true"><i /><b /></div>
|
||||
<strong>Place ground effect</strong>
|
||||
<small>Move with WASD / left stick. Confirm with Enter / A; Escape / B cancels.</small>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!pendingGroundTarget.valid}
|
||||
onClick={() => useCombatStore.getState().confirmGroundTarget()}
|
||||
>Confirm</button>
|
||||
<button type="button" onClick={() => useCombatStore.getState().cancelGroundTarget()}>Cancel</button>
|
||||
</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Minimap
|
||||
position={playerPosition}
|
||||
@@ -325,11 +389,11 @@ export function Hud() {
|
||||
</header>
|
||||
<div className="target-frame__track"><span style={{ width: `${friendlyTargetPercent}%` }} /></div>
|
||||
<TimedEffectStrip
|
||||
targetId={selectedFriendly.self ? null : friendlyTarget?.id ?? null}
|
||||
targetId={selectedFriendly.self ? null : selectedFriendly.targetId}
|
||||
className="timed-effect-strip--target"
|
||||
/>
|
||||
<AuraStrip
|
||||
targetId={selectedFriendly.self ? PLAYER_AGGRO_ID : friendlyTarget?.id ?? PLAYER_AGGRO_ID}
|
||||
targetId={selectedFriendly.targetId}
|
||||
className="aura-strip--target"
|
||||
/>
|
||||
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
|
||||
@@ -384,6 +448,7 @@ export function Hud() {
|
||||
gameMode={gameMode}
|
||||
resurrectionCharges={resurrectionCharges}
|
||||
onReturn={returnFromDefeat}
|
||||
sharedOnline={onlineSession !== null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
import { ContentManagerScreen } from "./ContentManagerScreen";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -156,6 +157,7 @@ export function MainMenuScreen() {
|
||||
<h2>Ways to play</h2>
|
||||
<p>Select a destination. More adventures will unlock here as they are built.</p>
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
||||
{isGm && (
|
||||
<ControllerButton
|
||||
|
||||
@@ -33,6 +33,8 @@ import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
|
||||
installManastormShellRuntime();
|
||||
|
||||
@@ -49,10 +51,19 @@ export function ManastormSelectScreen() {
|
||||
const saveSelectedManastormLoadout = useShellStore((state) => state.saveSelectedManastormLoadout);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
const fillWithAi = useOnlineGroupStore((state) => state.fillWithAi);
|
||||
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||
const adminConfig = useManastormAdminStore((state) => state.config);
|
||||
const character = activeCharacter ?? rosterCharacter;
|
||||
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
||||
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
|
||||
const groupHumanCount = onlineGroup?.members.length ?? 1;
|
||||
const effectivePartySize = onlineGroup
|
||||
? (fillWithAi ? Math.max(partySize, groupHumanCount) : groupHumanCount) as ManastormPartySize
|
||||
: partySize;
|
||||
const [resume, setResume] = useState(true);
|
||||
const progress = character?.manastormProgress ?? createEmptyManastormProgress();
|
||||
const effectiveCatalog = useMemo(
|
||||
@@ -71,7 +82,7 @@ export function ManastormSelectScreen() {
|
||||
const canEnter = modeEnabled && hasReadyBosses;
|
||||
const modeProgress = manastormModeProgress(progress, modeId);
|
||||
const modeLabel = modeId === 2 ? "End-game" : "Leveling";
|
||||
const partyProgress = manastormPartyProgress(progress, partySize, modeId);
|
||||
const partyProgress = manastormPartyProgress(progress, effectivePartySize, modeId);
|
||||
const checkpoint = Math.max(1, partyProgress.checkpointLevel);
|
||||
const startingLevel = resume && checkpoint > 1 ? checkpoint : 1;
|
||||
const cacheChance = modeId === 2
|
||||
@@ -81,7 +92,7 @@ export function ManastormSelectScreen() {
|
||||
try {
|
||||
const preview = drawManastormStage(
|
||||
startingLevel,
|
||||
partySize,
|
||||
effectivePartySize,
|
||||
[],
|
||||
null,
|
||||
() => 0.5,
|
||||
@@ -92,14 +103,14 @@ export function ManastormSelectScreen() {
|
||||
return manastormReward(
|
||||
preview.encounter,
|
||||
startingLevel,
|
||||
partySize,
|
||||
effectivePartySize,
|
||||
effectiveCatalog,
|
||||
modeId,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [effectiveCatalog, modeId, partyProgress.highestLevel, partySize, startingLevel]);
|
||||
}, [effectiveCatalog, effectivePartySize, modeId, partyProgress.highestLevel, startingLevel]);
|
||||
const spellLoadoutModel = useMemo(() => character
|
||||
? manastormActiveSpellLoadoutModel(character.classId, character.level, character.talentRanks)
|
||||
: null, [character?.classId, character?.level, character?.talentRanks]);
|
||||
@@ -132,7 +143,7 @@ export function ManastormSelectScreen() {
|
||||
character.id,
|
||||
character.level,
|
||||
role,
|
||||
partySize,
|
||||
effectivePartySize,
|
||||
character.categoryId ?? "wow",
|
||||
);
|
||||
void preloadAvatarModels([character, ...party]).catch((error) => {
|
||||
@@ -140,10 +151,30 @@ export function ManastormSelectScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
const enterAsRole = async (role: PartyRole) => {
|
||||
if (!canEnter || !character) return;
|
||||
if (!onlineGroup || !session || session.kind !== "account") {
|
||||
enterManastorm(role, partySize, startingLevel);
|
||||
return;
|
||||
}
|
||||
if (!await updateOnlineMember(session, character, role)) return;
|
||||
if (onlineGroup.leaderAccountId !== session.ownerId) {
|
||||
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[role]}. Waiting for the group leader to start.`);
|
||||
return;
|
||||
}
|
||||
await startOnlineActivity(session, {
|
||||
type: "manastorm",
|
||||
contentId: "manastorm",
|
||||
partySize: effectivePartySize,
|
||||
startingLevel,
|
||||
});
|
||||
};
|
||||
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...MANASTORM_PARTY_SIZES.map((size) => ({
|
||||
id: `manastorm-party-${size}`,
|
||||
run: () => setPartySize(size),
|
||||
enabled: !onlineGroup || (size >= groupHumanCount && (fillWithAi || size === groupHumanCount)),
|
||||
})),
|
||||
{ id: "manastorm-start-fresh", run: () => setResume(false) },
|
||||
...(checkpoint > 1 ? [{ id: "manastorm-start-resume", run: () => setResume(true) }] : []),
|
||||
@@ -153,7 +184,7 @@ export function ManastormSelectScreen() {
|
||||
})) ?? []),
|
||||
...availableRoles.map((role) => ({
|
||||
id: `manastorm-role-${role}`,
|
||||
run: () => canEnter && enterManastorm(role, partySize, startingLevel),
|
||||
run: () => { if (canEnter) void enterAsRole(role); },
|
||||
enabled: canEnter,
|
||||
})),
|
||||
{ id: "manastorm-back", run: returnToMainMenu },
|
||||
@@ -161,8 +192,10 @@ export function ManastormSelectScreen() {
|
||||
availableRoles,
|
||||
canEnter,
|
||||
checkpoint,
|
||||
enterManastorm,
|
||||
partySize,
|
||||
groupHumanCount,
|
||||
fillWithAi,
|
||||
onlineGroup,
|
||||
effectivePartySize,
|
||||
returnToMainMenu,
|
||||
selectedSpellIds,
|
||||
spellLoadoutModel,
|
||||
@@ -235,6 +268,7 @@ export function ManastormSelectScreen() {
|
||||
<h2 id="manastorm-role-title">Build your expedition</h2>
|
||||
<small>Progress and first clears are tracked separately for every party size.</small>
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
|
||||
<div className="manastorm-setup-block">
|
||||
<strong>Party size</strong>
|
||||
@@ -245,8 +279,9 @@ export function ManastormSelectScreen() {
|
||||
controlId={`manastorm-party-${size}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={`manastorm-size-option ${partySize === size ? "is-active" : ""}`}
|
||||
className={`manastorm-size-option ${effectivePartySize === size ? "is-active" : ""}`}
|
||||
type="button"
|
||||
disabled={Boolean(onlineGroup && (size < groupHumanCount || (!fillWithAi && size !== groupHumanCount)))}
|
||||
onClick={() => setPartySize(size)}
|
||||
>
|
||||
{size}
|
||||
@@ -254,7 +289,11 @@ export function ManastormSelectScreen() {
|
||||
))}
|
||||
</div>
|
||||
<small>
|
||||
{partySize === 1 ? "Solo" : `You + ${partySize - 1} companion${partySize === 2 ? "" : "s"}`}
|
||||
{onlineGroup
|
||||
? fillWithAi
|
||||
? `${groupHumanCount} player${groupHumanCount === 1 ? "" : "s"} + ${Math.max(0, effectivePartySize - groupHumanCount)} AI teammate${effectivePartySize - groupHumanCount === 1 ? "" : "s"}`
|
||||
: `${groupHumanCount} grouped player${groupHumanCount === 1 ? "" : "s"} · AI fill off`
|
||||
: partySize === 1 ? "Solo" : `You + ${partySize - 1} companion${partySize === 2 ? "" : "s"}`}
|
||||
{" · "}Highest clear {partyProgress.highestLevel}
|
||||
</small>
|
||||
</div>
|
||||
@@ -345,11 +384,11 @@ export function ManastormSelectScreen() {
|
||||
disabled={!canEnter}
|
||||
onFocus={() => preloadRole(role)}
|
||||
onPointerEnter={() => preloadRole(role)}
|
||||
onClick={() => canEnter && enterManastorm(role, partySize, startingLevel)}
|
||||
onClick={() => { if (canEnter) void enterAsRole(role); }}
|
||||
>
|
||||
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
||||
<span>
|
||||
<strong>Enter as {PARTY_ROLE_LABELS[role]}</strong>
|
||||
<strong>{onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? "Ready" : "Enter"} as {PARTY_ROLE_LABELS[role]}</strong>
|
||||
<small>{ROLE_DETAILS[role].summary}</small>
|
||||
</span>
|
||||
</ControllerButton>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import {
|
||||
classCanFillPartyRole,
|
||||
partyRolesForClass,
|
||||
PARTY_ROLE_LABELS,
|
||||
type PartyRole,
|
||||
} from "../game/partyRoles";
|
||||
|
||||
export function OnlineGroupPanel() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const snapshot = useOnlineGroupStore((state) => state.snapshot);
|
||||
const busy = useOnlineGroupStore((state) => state.busy);
|
||||
const error = useOnlineGroupStore((state) => state.error);
|
||||
const fillWithAi = useOnlineGroupStore((state) => state.fillWithAi);
|
||||
const invite = useOnlineGroupStore((state) => state.invite);
|
||||
const respond = useOnlineGroupStore((state) => state.respond);
|
||||
const leave = useOnlineGroupStore((state) => state.leave);
|
||||
const remove = useOnlineGroupStore((state) => state.remove);
|
||||
const updateMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
const clearActivity = useOnlineGroupStore((state) => state.clearActivity);
|
||||
const setFillWithAi = useOnlineGroupStore((state) => state.setFillWithAi);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot.invitations.length > 0) setPanelOpen(true);
|
||||
}, [snapshot.invitations.length]);
|
||||
|
||||
if (!session || session.kind !== "account" || !session.accessToken) return null;
|
||||
const group = snapshot.group;
|
||||
const currentMember = group?.members.find((member) => member.accountId === session.ownerId) ?? null;
|
||||
const leader = group?.leaderAccountId === session.ownerId;
|
||||
const roles = character ? partyRolesForClass(character.classId) : [];
|
||||
const readyCount = group?.members.filter((member) => member.character && member.role).length ?? 0;
|
||||
|
||||
const submitInvite = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
if (await invite(session, username.trim())) setUsername("");
|
||||
};
|
||||
const chooseRole = (role: PartyRole) => {
|
||||
if (character && classCanFillPartyRole(character.classId, role)) void updateMember(session, character, role);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className="online-group-panel" open={panelOpen} onToggle={(event) => setPanelOpen(event.currentTarget.open)}>
|
||||
<summary>
|
||||
<span aria-hidden="true">◎</span>
|
||||
<strong>Online group</strong>
|
||||
<small>{group ? `${group.members.length} / 5 players · ${readyCount} ready` : "Optional co-op"}</small>
|
||||
</summary>
|
||||
<div className="online-group-panel__body">
|
||||
{snapshot.invitations.length > 0 && (
|
||||
<section className="online-group-invites" aria-label="Group invitations">
|
||||
<strong>Invitations</strong>
|
||||
{snapshot.invitations.map((invitation) => (
|
||||
<div key={invitation.id}>
|
||||
<span><b>{invitation.inviterUsername}</b><small>wants you in their group</small></span>
|
||||
<button type="button" disabled={busy} onClick={() => void respond(session, invitation.id, true)}>Accept</button>
|
||||
<button type="button" disabled={busy} onClick={() => void respond(session, invitation.id, false)}>Decline</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{group && (
|
||||
<section className="online-group-roster" aria-label="Online group roster">
|
||||
<header><strong>Party roster</strong><small>{leader ? "You are the leader" : "Waiting for the leader"}</small></header>
|
||||
{group.members.map((member) => {
|
||||
const memberClass = member.character ? classById(member.character.classId as Parameters<typeof classById>[0]) : null;
|
||||
return (
|
||||
<div className="online-group-member" key={member.accountId}>
|
||||
<span className={member.character && member.role ? "is-ready" : ""} aria-hidden="true" />
|
||||
<p>
|
||||
<strong>{member.username}{group.leaderAccountId === member.accountId ? " ★" : ""}</strong>
|
||||
<small>{member.character
|
||||
? `${member.character.name} · Level ${member.character.level} ${memberClass?.name ?? member.character.classId}`
|
||||
: "Choosing a character"}</small>
|
||||
</p>
|
||||
<b>{member.role ? PARTY_ROLE_LABELS[member.role] : "Not ready"}</b>
|
||||
{leader && member.accountId !== session.ownerId && (
|
||||
<button type="button" disabled={busy} onClick={() => void remove(session, member.accountId)}>Remove</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{group && character && currentMember && (
|
||||
<section className="online-group-role" aria-label="Your group role">
|
||||
<strong>Your role</strong>
|
||||
<div>
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
type="button"
|
||||
key={role}
|
||||
className={currentMember.role === role ? "is-active" : ""}
|
||||
disabled={busy || Boolean(group.activity)}
|
||||
aria-pressed={currentMember.role === role}
|
||||
onClick={() => chooseRole(role)}
|
||||
>
|
||||
{PARTY_ROLE_LABELS[role]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{group?.activity && (
|
||||
<div className="online-group-activity" role="status">
|
||||
<span>
|
||||
<strong>Launching {group.activity.type === "dungeon" ? "dungeon" : "Manastorm"}</strong>
|
||||
<small>{group.activity.fillWithAi ? `Party filled to ${group.activity.partySize} with AI` : `${group.activity.partySize} players only`}</small>
|
||||
</span>
|
||||
{leader && <button type="button" disabled={busy} onClick={() => void clearActivity(session)}>Reset</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leader && group && !group.activity && (
|
||||
<label className="online-group-ai-toggle">
|
||||
<input type="checkbox" checked={fillWithAi} onChange={(event) => setFillWithAi(event.target.checked)} />
|
||||
<span><strong>Fill open slots with AI</strong><small>Turn off to enter with only grouped players.</small></span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{(!group || leader) && (
|
||||
<form className="online-group-invite-form" onSubmit={submitInvite}>
|
||||
<label htmlFor="online-group-username">Invite account name</label>
|
||||
<div>
|
||||
<input
|
||||
id="online-group-username"
|
||||
value={username}
|
||||
maxLength={20}
|
||||
autoComplete="off"
|
||||
placeholder="PlayerName"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={busy || !username.trim() || (group?.members.length ?? 0) >= 5}>Invite</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{group && (
|
||||
<button className="online-group-leave" type="button" disabled={busy} onClick={() => void leave(session)}>
|
||||
{leader && group.members.length > 1 ? "Leave and transfer leadership" : "Leave group"}
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="online-group-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
+117
-3
@@ -11,6 +11,11 @@ import {
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
import { AuraStrip } from "./AuraStrip";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import type { OnlineGroupMember } from "../app/onlineGroupTypes";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||
|
||||
const PARTY_COMMANDS: readonly {
|
||||
command: PartyCommand;
|
||||
@@ -107,6 +112,22 @@ function MemberFrame({ member, selected, onSelect }: {
|
||||
const characterClass = classById(member.classId);
|
||||
const race = raceById(member.raceId);
|
||||
const percent = healthPercent(member.health, member.maxHealth);
|
||||
const absorb = useCombatStore((state) => state.auras.reduce((total, aura) => (
|
||||
aura.targetId === member.id
|
||||
? total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0)
|
||||
: total
|
||||
), 0));
|
||||
const absorbPercent = Math.min(100 - percent, healthPercent(absorb, member.maxHealth));
|
||||
const dispellableCount = useCombatStore((state) => state.auras.reduce((total, aura) => (
|
||||
aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory
|
||||
? total + 1
|
||||
: total
|
||||
), 0));
|
||||
const dispellableNames = useCombatStore((state) => state.auras
|
||||
.filter((aura) => aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory)
|
||||
.map((aura) => aura.definition.name)
|
||||
.join(", "));
|
||||
const resourcePercent = healthPercent(member.resource, member.maxResource);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -118,10 +139,15 @@ function MemberFrame({ member, selected, onSelect }: {
|
||||
<span className="party-frame__sigil" style={{ "--party-class-color": characterClass.color } as CSSProperties}>{characterClass.sigil}</span>
|
||||
<span className="party-frame__body">
|
||||
<span className="party-frame__identity"><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {race.name} · {member.specialization}</small></span>
|
||||
{dispellableCount ? <span className="party-frame__dispel" title={dispellableNames}>DISPEL {dispellableCount}</span> : null}
|
||||
<span className="party-frame__health" role="progressbar" aria-label={`${member.name} health`} aria-valuemin={0} aria-valuemax={member.maxHealth} aria-valuenow={Math.max(0, member.health)}>
|
||||
<i style={{ width: `${percent}%` }} />
|
||||
{absorb > 0 ? <em style={{ left: `${percent}%`, width: `${absorbPercent}%` }} /> : null}
|
||||
<b>{Math.ceil(member.health)} / {member.maxHealth}</b>
|
||||
</span>
|
||||
<span className={`party-frame__resource party-frame__resource--${member.resourceType}`} title={`${Math.floor(member.resource)} / ${member.maxResource} ${member.resourceName}`}>
|
||||
<i style={{ width: `${resourcePercent}%` }} />
|
||||
</span>
|
||||
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
|
||||
<AuraStrip targetId={member.id} className="aura-strip--party" />
|
||||
</span>
|
||||
@@ -129,19 +155,85 @@ function MemberFrame({ member, selected, onSelect }: {
|
||||
);
|
||||
}
|
||||
|
||||
function OnlineMemberFrame({
|
||||
member,
|
||||
player,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
member: OnlineGroupMember;
|
||||
player: OnlineSessionPlayer | null;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const character = member.character;
|
||||
if (!character) return null;
|
||||
const characterClass = classById(character.classId as Parameters<typeof classById>[0]);
|
||||
const percent = player ? healthPercent(player.health, player.maxHealth) : 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`party-frame party-frame--online ${selected ? "is-selected" : ""} ${player && player.health <= 0 ? "is-down" : ""}`}
|
||||
aria-label={`Target ${character.name}, online player`}
|
||||
aria-pressed={selected}
|
||||
disabled={!player}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="party-frame__sigil" style={{ "--party-class-color": characterClass.color } as CSSProperties}>{characterClass.sigil}</span>
|
||||
<span className="party-frame__body">
|
||||
<span className="party-frame__identity"><strong>{character.name}</strong><small>{member.role ? PARTY_ROLE_LABELS[member.role] : "Player"} · {member.username}</small></span>
|
||||
{player ? (
|
||||
<>
|
||||
<span className="party-frame__health" role="progressbar" aria-label={`${character.name} health`} aria-valuemin={0} aria-valuemax={player.maxHealth} aria-valuenow={Math.max(0, player.health)}>
|
||||
<i style={{ width: `${percent}%` }} />
|
||||
<b>{Math.ceil(player.health)} / {player.maxHealth}</b>
|
||||
</span>
|
||||
<span className="party-frame__resource" title={`${Math.floor(player.resource)} / ${player.maxResource} ${player.resourceName}`}>
|
||||
<i style={{ width: `${healthPercent(player.resource, player.maxResource)}%` }} />
|
||||
</span>
|
||||
<span className="party-frame__online-status"><i /> Live</span>
|
||||
</>
|
||||
) : <span className="party-frame__online-status">Connecting…</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact roster for the player plus the selected number of targetable AI allies. */
|
||||
export function PartyFrames() {
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
||||
const playerHealth = useCombatStore((state) => state.health);
|
||||
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
|
||||
const playerResource = useCombatStore((state) => state.resource);
|
||||
const playerMaxResource = useCombatStore((state) => state.maxResource);
|
||||
const playerResourceName = useCombatStore((state) => state.resourceName);
|
||||
const playerAuraAbsorb = useCombatStore((state) => state.auras.reduce((total, aura) => (
|
||||
aura.targetId === PLAYER_AGGRO_ID
|
||||
? total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0)
|
||||
: total
|
||||
), 0));
|
||||
const playerShield = useCombatStore((state) => state.shield);
|
||||
const members = usePartyStore((state) => state.members);
|
||||
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
|
||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||
const routeBlocked = usePartyStore((state) => state.routeBlocked);
|
||||
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
|
||||
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
|
||||
const selectedOnlineActorId = useCombatStore((state) => state.selectedFriendlyActorId);
|
||||
const playerPercent = healthPercent(playerHealth, playerMaxHealth);
|
||||
if (!members.length) return null;
|
||||
const groupSize = members.length + 1;
|
||||
const playerAbsorb = playerShield + playerAuraAbsorb;
|
||||
const playerAbsorbPercent = Math.min(100 - playerPercent, healthPercent(playerAbsorb, playerMaxHealth));
|
||||
const onlineMembers = onlineActivity && onlineGroup
|
||||
? onlineGroup.members.filter((member) => member.accountId !== sessionOwnerId && member.character)
|
||||
: [];
|
||||
const onlinePlayersByAccount = new Map(
|
||||
(onlineSession?.players ?? []).map((player) => [player.accountId, player]),
|
||||
);
|
||||
if (!members.length && !onlineMembers.length) return null;
|
||||
const groupSize = members.length + onlineMembers.length + 1;
|
||||
|
||||
const selectMember = (id: string) => {
|
||||
useCombatStore.getState().clearTarget();
|
||||
@@ -153,10 +245,16 @@ export function PartyFrames() {
|
||||
usePartyStore.getState().selectSelf();
|
||||
};
|
||||
|
||||
const selectOnlineMember = (accountId: string) => {
|
||||
useCombatStore.getState().clearTarget();
|
||||
usePartyStore.getState().clearSelection();
|
||||
useCombatStore.getState().selectFriendlyActor(onlinePlayerActorId(accountId));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="party-frames" aria-label={`${groupSize} player party`}>
|
||||
<header><span>Party</span><b className={routeBlocked ? "is-warning" : ""}>{routeBlocked ? "Route blocked" : `${groupSize} / ${groupSize}`}</b></header>
|
||||
<PartyCommandBar />
|
||||
{members.length ? <PartyCommandBar /> : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`party-frame party-frame--player ${selfSelected ? "is-selected" : ""} ${playerHealth <= 0 ? "is-down" : ""}`}
|
||||
@@ -169,12 +267,28 @@ export function PartyFrames() {
|
||||
<span className="party-frame__identity"><strong>{character?.name ?? "Adventurer"}</strong><small>You · {PARTY_ROLE_LABELS[playerRole]}</small></span>
|
||||
<span className="party-frame__health" role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={playerMaxHealth} aria-valuenow={Math.max(0, playerHealth)}>
|
||||
<i style={{ width: `${playerPercent}%` }} />
|
||||
{playerAbsorb > 0 ? <em style={{ left: `${playerPercent}%`, width: `${playerAbsorbPercent}%` }} /> : null}
|
||||
<b>{Math.ceil(playerHealth)} / {playerMaxHealth}</b>
|
||||
</span>
|
||||
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}>
|
||||
<i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} />
|
||||
</span>
|
||||
<TimedEffectStrip targetId={null} className="timed-effect-strip--party" />
|
||||
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" />
|
||||
</span>
|
||||
</button>
|
||||
{onlineMembers.map((member) => {
|
||||
const actorId = onlinePlayerActorId(member.accountId);
|
||||
return (
|
||||
<OnlineMemberFrame
|
||||
key={member.accountId}
|
||||
member={member}
|
||||
player={onlinePlayersByAccount.get(member.accountId) ?? null}
|
||||
selected={selectedOnlineActorId === actorId}
|
||||
onSelect={() => selectOnlineMember(member.accountId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{members.map((member) => (
|
||||
<MemberFrame
|
||||
key={member.id}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface PlayerFrameMeter {
|
||||
current: number;
|
||||
maximum: number;
|
||||
label: string;
|
||||
tone: "health" | "mana" | "rage" | "energy" | "runic-power" | "focus" | "nature-power" | "psi";
|
||||
tone: string;
|
||||
}
|
||||
|
||||
export interface PlayerFrameProps {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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 selectFriendlyActor = useCombatStore((state) => state.selectFriendlyActor);
|
||||
if (!summons.length) return null;
|
||||
return (
|
||||
<section className="summon-frames" aria-label="Pets, totems, and summons">
|
||||
{summons.map((summon) => {
|
||||
const healthPercent = summon.maxHealth > 0 ? Math.max(0, Math.min(100, summon.health / summon.maxHealth * 100)) : 0;
|
||||
const remaining = summon.expiresAt >= Number.MAX_SAFE_INTEGER
|
||||
? "∞"
|
||||
: `${Math.max(0, Math.ceil((summon.expiresAt - now) / 1_000))}s`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user