Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { HEALER_CLASSES } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { GameScene } from "./GameScene";
|
||||
import { tankAuraProtects } from "../game/partyCombat";
|
||||
|
||||
function CompactParty() {
|
||||
const party = useGameStore((state) => state.party);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const selected = useGameStore((state) => state.selectedMemberId);
|
||||
const barrier = useGameStore((state) => state.barrier);
|
||||
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
|
||||
const partyPositions = useGameStore((state) => state.partyPositions);
|
||||
const selectMember = useGameStore((state) => state.selectMember);
|
||||
const mana = useGameStore((state) => state.mana);
|
||||
const maxMana = useGameStore((state) => state.maxMana);
|
||||
return (
|
||||
<div className="top-party" aria-label="Party health">
|
||||
{party.map((member) => {
|
||||
const health = (member.hp / member.maxHp) * 100;
|
||||
const shield = (member.absorb / member.maxHp) * 100;
|
||||
return (
|
||||
<button
|
||||
className={`top-party-member ${selected === member.id ? "is-selected" : ""}`}
|
||||
key={member.id}
|
||||
onClick={() => selectMember(member.id)}
|
||||
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health`}
|
||||
>
|
||||
<span className="portrait-dot" style={{ background: member.color }}>{member.name[0]}</span>
|
||||
<span className="top-party-copy"><strong>{member.name}</strong><small>{member.role}</small></span>
|
||||
<span className={`microbar ${member.id === "aelia" ? "player-health-bar" : ""}`}>
|
||||
<i style={{ width: `${health}%` }} />
|
||||
{shield > 0 && <b style={{ width: `${Math.max(8, shield)}%` }} />}
|
||||
</span>
|
||||
{member.id === "aelia" && (
|
||||
<span className="player-mana-bar" aria-label={`${Math.ceil(mana)} of ${maxMana} mana`}>
|
||||
<i style={{ width: `${(mana / maxMana) * 100}%` }} />
|
||||
</span>
|
||||
)}
|
||||
<span className="top-effects">
|
||||
{member.renewExpiresAt > 0 && <em className="renew-pip">R</em>}
|
||||
{member.debuffs.length > 0 && <em className="debuff-pip">!</em>}
|
||||
{barrierProtects(partyPositions[member.id], barrier, time) && <em className="barrier-pip">B</em>}
|
||||
{tankAuraProtects(partyPositions[member.id], partyPositions.brann, tankAura, time) && <em className="tank-aura-pip">T</em>}
|
||||
{member.knockedUntil > time && <em className="knockdown-pip">KD</em>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CastingBar() {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const abilityName = HEALER_CLASSES[healerClassId].abilities.mend.name;
|
||||
const activeCast = useGameStore((state) => state.activeCast);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const party = useGameStore((state) => state.party);
|
||||
if (!activeCast) return null;
|
||||
const duration = activeCast.completesAt - activeCast.startedAt;
|
||||
const progress = Math.min(1, Math.max(0, (time - activeCast.startedAt) / duration));
|
||||
const target = party.find((member) => member.id === activeCast.targetId);
|
||||
return (
|
||||
<div className="casting-bar" aria-label={`Casting ${abilityName} on ${target?.name ?? "ally"}`}>
|
||||
<span><strong>{abilityName}</strong><small>{target?.name}</small></span>
|
||||
<b>{Math.max(0, activeCast.completesAt - time).toFixed(1)}s</b>
|
||||
<i><em style={{ width: `${progress * 100}%` }} /></i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BossBar() {
|
||||
const boss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
if (phase === "briefing") return null;
|
||||
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
return (
|
||||
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-dual" : ""}`}>
|
||||
{bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}>
|
||||
<div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
|
||||
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
|
||||
</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EncounterCallout() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const boss = useGameStore((state) => state.boss);
|
||||
const bossMotion = useGameStore((state) => state.bossMotion);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
if (phase !== "combat") return null;
|
||||
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
|
||||
return (
|
||||
<div className={`encounter-callout ${mechanic.urgent ? "is-urgent" : ""}`}>
|
||||
<span>{mechanic.name}</span>
|
||||
<strong>{mechanic.remaining.toFixed(1)}s</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseOverlay() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const primaryBoss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
||||
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
||||
if (phase === "combat") return null;
|
||||
const title = phase === "briefing" ? definitions.map((boss) => boss.title).join(" & ") : phase === "victory" ? `${bossNames} Broken` : "Party Broken";
|
||||
const eyebrow = phase === "briefing" ? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial) : phase === "victory" ? "Encounter Complete" : "Encounter Failed";
|
||||
const copy = phase === "briefing"
|
||||
? definitions.map((boss) => boss.briefing).join(" ")
|
||||
: phase === "victory"
|
||||
? "Five entered. Five endured."
|
||||
: definitions.map((boss) => boss.failure).join(" ");
|
||||
return (
|
||||
<div className={`phase-overlay phase-${phase}`}>
|
||||
<div className="phase-sigil">✦</div>
|
||||
<span>{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{copy}</p>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : "Restart from lower display"}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PauseOverlay({ onExit }: { onExit?: () => void }) {
|
||||
const paused = useGameStore((state) => state.paused);
|
||||
const selection = useGameStore((state) => state.pauseSelection);
|
||||
const setPaused = useGameStore((state) => state.setPaused);
|
||||
const setPauseSelection = useGameStore((state) => state.setPauseSelection);
|
||||
if (!paused) return null;
|
||||
const exit = () => {
|
||||
setPaused(false);
|
||||
onExit?.();
|
||||
};
|
||||
return (
|
||||
<div className="pause-overlay" role="dialog" aria-modal="true" aria-label="Game paused">
|
||||
<div className="pause-panel">
|
||||
<span>Encounter suspended</span>
|
||||
<h1>Paused</h1>
|
||||
<p>Simulation, damage, and movement are stopped.</p>
|
||||
<div className="pause-actions">
|
||||
<button
|
||||
className={selection === "resume" ? "is-controller-focused" : ""}
|
||||
onFocus={() => setPauseSelection("resume")}
|
||||
onPointerEnter={() => setPauseSelection("resume")}
|
||||
onClick={() => setPaused(false)}
|
||||
>Resume <small>START / ESC</small></button>
|
||||
<button
|
||||
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
|
||||
onFocus={() => setPauseSelection("exit")}
|
||||
onPointerEnter={() => setPauseSelection("exit")}
|
||||
onClick={exit}
|
||||
>Return to main menu <small>A</small></button>
|
||||
</div>
|
||||
<footer><b>↑ / ↓</b> Choose <i /> <b>A / ENTER</b> Confirm</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopScreen({ onExit }: { onExit?: () => void }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
||||
const setPaused = useGameStore((state) => state.setPaused);
|
||||
return (
|
||||
<section className="display top-display" aria-label="Main game viewport">
|
||||
<GameScene />
|
||||
<div className="top-vignette" />
|
||||
<div className="top-hud">
|
||||
<CompactParty />
|
||||
<BossBar />
|
||||
<div className="objective-chip"><span>Objective</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
|
||||
<EncounterCallout />
|
||||
<CastingBar />
|
||||
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
||||
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b>☰</b> Menu <small>START / ESC</small></button>}
|
||||
</div>
|
||||
<PhaseOverlay />
|
||||
<PauseOverlay onExit={onExit} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user