Release Healer Man 0.1.6

This commit is contained in:
phenom committed 2026-08-20 14:05:50 -04:00
1 parent 55cfd43d66
commit b12fb84859
96 files changed
+10874 -609

No files matched your search

+9 -3
View File
@@ -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>
+24 -3
View File
@@ -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
View File
@@ -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>
+2
View File
@@ -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
+51 -12
View File
@@ -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>
+157
View File
@@ -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
View File
@@ -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}
+1 -1
View File
@@ -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 {
+31
View File
@@ -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>
);
}