Release v0.1.18 2026-07-19
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
|
||||
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
|
||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, useGameStore } from "../game/store";
|
||||
import type { AbilitySlotId } from "../game/types";
|
||||
|
||||
export function AbilityButton({ abilityId, compact = false }: { abilityId: AbilitySlotId; compact?: boolean }) {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
|
||||
const time = useGameStore((state) => state.time);
|
||||
const cooldowns = useGameStore((state) => state.cooldowns);
|
||||
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
||||
const mana = useGameStore((state) => state.mana);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
|
||||
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
||||
const activeCast = useGameStore((state) => state.activeCast);
|
||||
const castAbility = useGameStore((state) => state.castAbility);
|
||||
const runModifiers = useGameStore((state) => state.runModifiers);
|
||||
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||||
const classes = `ability ability-${abilityId} ${compact ? "is-compact" : ""}`;
|
||||
|
||||
if (!ability) {
|
||||
return (
|
||||
<button className={`${classes} is-empty`} disabled aria-label={`Empty ${abilityId}`}>
|
||||
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
|
||||
<span className="ability-icon">—</span>
|
||||
<span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span>
|
||||
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const remaining = abilityRemaining(abilityId, time, cooldowns);
|
||||
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
|
||||
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
|
||||
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
|
||||
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
|
||||
const globalRemaining = Math.max(0, globalCooldownUntil - time);
|
||||
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
|
||||
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
||||
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
|
||||
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
|
||||
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${classes} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
|
||||
style={{ "--ability-color": ability.color } as CSSProperties}
|
||||
onClick={() => castAbility(abilityId)}
|
||||
disabled={disabled}
|
||||
title={ability.description}
|
||||
aria-label={`${ability.name}. ${ability.description}`}
|
||||
>
|
||||
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
|
||||
<span className="ability-icon">{ability.icon}</span>
|
||||
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
|
||||
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
|
||||
{remaining > 0 && (
|
||||
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as CSSProperties}>
|
||||
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
|
||||
</span>
|
||||
)}
|
||||
{remaining <= 0 && globalRemaining > 0 && (
|
||||
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as CSSProperties}>
|
||||
<b>{globalRemaining.toFixed(1)}</b>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { ABILITY_ORDER } from "../game/data";
|
||||
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
|
||||
import { HEALER_CLASSES } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||
import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||
import type { BottomTab, PartyMember } from "../game/types";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
HOCKEY_PVP_GOAL_Z,
|
||||
HOCKEY_PVP_SIDE_OFFSET_Z,
|
||||
} from "../game/hockeyHealingPvp";
|
||||
import { bottomTabsFor } from "../game/bottomTabs";
|
||||
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
||||
import {
|
||||
BLOCKBREAKER_BREACH_DAMAGE,
|
||||
BLOCKBREAKER_BRICK_COLORS,
|
||||
@@ -31,9 +31,82 @@ import {
|
||||
blockbreakerTimeMultiplier,
|
||||
} from "../game/blockbreaker";
|
||||
import { aetherShipColor } from "./aetherAssaultVisuals";
|
||||
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
|
||||
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
|
||||
import { isBeaconOfLightTarget } from "../game/healerMechanics";
|
||||
import { AbilityButton } from "./AbilityButton";
|
||||
import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting";
|
||||
import { isSingleScreenLayout } from "../platform/displayLayout";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
|
||||
function moveTacticalSelection(direction: 1 | -1) {
|
||||
const store = useGameStore.getState();
|
||||
if (store.activeTab === "combat") {
|
||||
store.cycleMember(direction);
|
||||
return;
|
||||
}
|
||||
if (store.activeTab !== "pack" || store.inventory.length === 0) return;
|
||||
const currentIndex = Math.max(0, store.inventory.findIndex((item) => item.id === store.selectedItemId));
|
||||
const nextIndex = (currentIndex + direction + store.inventory.length) % store.inventory.length;
|
||||
store.selectItem(store.inventory[nextIndex].id);
|
||||
}
|
||||
|
||||
function useSingleScreenTacticalInput() {
|
||||
useEffect(() => {
|
||||
if (!isSingleScreenLayout()) return;
|
||||
let active = getDisplaySurface() === "bottom";
|
||||
const unsubscribeSurface = subscribeDisplaySurface((surface) => { active = surface === "bottom"; });
|
||||
const cycleTab = (direction: 1 | -1) => {
|
||||
const store = useGameStore.getState();
|
||||
if (direction === 1) store.setActiveTab(cycleBottomTab(store.activeTab, store.runMode));
|
||||
else {
|
||||
const tabs = bottomTabsFor(store.runMode);
|
||||
const currentIndex = tabs.indexOf(store.activeTab);
|
||||
store.setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
|
||||
}
|
||||
};
|
||||
const activatePhaseAction = () => {
|
||||
const store = useGameStore.getState();
|
||||
if (store.phase === "briefing") store.startEncounter();
|
||||
else if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!active || event.repeat) return;
|
||||
const store = useGameStore.getState();
|
||||
if (store.paused
|
||||
|| store.runMode === "rpg-roguelike"
|
||||
|| store.phase === "intermission"
|
||||
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "arrowleft") cycleTab(-1);
|
||||
else if (key === "arrowright") cycleTab(1);
|
||||
else if (key === "arrowup") moveTacticalSelection(-1);
|
||||
else if (key === "arrowdown") moveTacticalSelection(1);
|
||||
else if (key === "enter") activatePhaseAction();
|
||||
else return;
|
||||
event.preventDefault();
|
||||
};
|
||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||
if (!active || repeat && !["Button12", "Button13", "Button14", "Button15"].includes(token)) return;
|
||||
const store = useGameStore.getState();
|
||||
if (store.paused
|
||||
|| store.runMode === "rpg-roguelike"
|
||||
|| store.phase === "intermission"
|
||||
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
||||
if (token === "Button14") cycleTab(-1);
|
||||
else if (token === "Button15") cycleTab(1);
|
||||
else if (token === "Button12") moveTacticalSelection(-1);
|
||||
else if (token === "Button13") moveTacticalSelection(1);
|
||||
else if (!repeat && token === "Button0") activatePhaseAction();
|
||||
else if (!repeat && token === "Button1") requestDisplaySurface("top");
|
||||
});
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
unsubscribeController();
|
||||
unsubscribeSurface();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function RewardSummary() {
|
||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||
@@ -113,62 +186,6 @@ function PartyList() {
|
||||
);
|
||||
}
|
||||
|
||||
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
|
||||
const time = useGameStore((state) => state.time);
|
||||
const cooldowns = useGameStore((state) => state.cooldowns);
|
||||
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
||||
const mana = useGameStore((state) => state.mana);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
|
||||
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
||||
const activeCast = useGameStore((state) => state.activeCast);
|
||||
const castAbility = useGameStore((state) => state.castAbility);
|
||||
const runModifiers = useGameStore((state) => state.runModifiers);
|
||||
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||||
if (!ability) {
|
||||
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon">—</span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
|
||||
}
|
||||
const remaining = abilityRemaining(abilityId, time, cooldowns);
|
||||
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
|
||||
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
|
||||
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
|
||||
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
|
||||
const globalRemaining = Math.max(0, globalCooldownUntil - time);
|
||||
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
|
||||
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
||||
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
|
||||
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
|
||||
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`ability ability-${abilityId} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
|
||||
style={{ "--ability-color": ability.color } as React.CSSProperties}
|
||||
onClick={() => castAbility(abilityId)}
|
||||
disabled={disabled}
|
||||
title={ability.description}
|
||||
aria-label={`${ability.name}. ${ability.description}`}
|
||||
>
|
||||
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
|
||||
<span className="ability-icon">{ability.icon}</span>
|
||||
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
|
||||
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
|
||||
{remaining > 0 && (
|
||||
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
|
||||
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
|
||||
</span>
|
||||
)}
|
||||
{remaining <= 0 && globalRemaining > 0 && (
|
||||
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
|
||||
<b>{globalRemaining.toFixed(1)}</b>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AbilityTray() {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const healer = HEALER_CLASSES[healerClassId];
|
||||
@@ -706,6 +723,7 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
|
||||
}
|
||||
|
||||
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
||||
useSingleScreenTacticalInput();
|
||||
const activeTab = useGameStore((state) => state.activeTab);
|
||||
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
|
||||
@@ -1,39 +1,80 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
import { resolveDisplayLayout } from "../platform/displayLayout";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||
|
||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
||||
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
||||
export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const dedicatedSurface = params.get("display");
|
||||
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
|
||||
const activeSurfaceRef = useRef(activeSurface);
|
||||
activeSurfaceRef.current = activeSurface;
|
||||
const showSurface = useCallback((surface: DisplaySurface) => {
|
||||
activeSurfaceRef.current = surface;
|
||||
setActiveSurface(surface);
|
||||
}, []);
|
||||
const toggleSurface = useCallback(() => {
|
||||
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
|
||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
||||
if (layout === "thor-preview") return;
|
||||
requestDisplaySurface("top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
|
||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||
if (token === "Button8" && !repeat) toggle();
|
||||
if (token === "Button8" && !repeat) toggleSurface();
|
||||
});
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab" || event.repeat) return;
|
||||
if (event.repeat) return;
|
||||
if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
|
||||
event.preventDefault();
|
||||
requestDisplaySurface("top");
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
toggleSurface();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
unsubscribeSurface();
|
||||
unsubscribeController();
|
||||
requestDisplaySurface("top");
|
||||
};
|
||||
}, [dedicatedSurface]);
|
||||
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
|
||||
|
||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
|
||||
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
|
||||
}
|
||||
|
||||
if (layout === "single") {
|
||||
const contextOpen = activeSurface === "bottom";
|
||||
return (
|
||||
<div className={`single-display-frame ${contextOpen ? "context-open" : ""}`}>
|
||||
<div className="single-primary-surface">{top}</div>
|
||||
{contextOpen && (
|
||||
<div className="single-context-layer" role="dialog" aria-modal="true" aria-label={`${contextLabel} interface`}>
|
||||
<button className="single-context-backdrop" onClick={() => requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
|
||||
<div className="single-context-surface">{bottom}</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="single-context-toggle"
|
||||
onClick={toggleSurface}
|
||||
aria-expanded={contextOpen}
|
||||
aria-label={contextOpen ? "Return to main view" : `Open ${contextLabel.toLowerCase()} interface`}
|
||||
>
|
||||
<b>{contextOpen ? "Return" : contextLabel}</b>
|
||||
<small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`device-frame active-${activeSurface}`}>
|
||||
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
|
||||
@@ -43,7 +84,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
|
||||
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
|
||||
<button
|
||||
className="native-display-switch"
|
||||
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
||||
onClick={toggleSurface}
|
||||
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
||||
>
|
||||
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||
|
||||
@@ -11,6 +11,9 @@ import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
|
||||
import { isSingleScreenLayout } from "../platform/displayLayout";
|
||||
import { ABILITY_ORDER } from "../game/data";
|
||||
import { AbilityButton } from "./AbilityButton";
|
||||
|
||||
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
|
||||
GameScene.displayName = "MemoizedGameScene";
|
||||
@@ -165,6 +168,7 @@ function PhaseOverlay() {
|
||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||
const singleScreen = isSingleScreenLayout();
|
||||
if (runMode === "rpg-roguelike") return null;
|
||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
@@ -225,7 +229,20 @@ function PhaseOverlay() {
|
||||
<span>{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{copy}</p>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
|
||||
<small>{singleScreen
|
||||
? phase === "briefing" ? "Press Start / Enter to begin" : showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
|
||||
: phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleScreenAbilityBar() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
if (!isSingleScreenLayout() || phase !== "combat" || runMode === "rpg-roguelike") return null;
|
||||
return (
|
||||
<div className="single-ability-bar" aria-label="Equipped abilities">
|
||||
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} compact />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -402,6 +419,7 @@ export function TopScreen({
|
||||
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <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>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
||||
</div>
|
||||
<SingleScreenAbilityBar />
|
||||
<GoalPopup />
|
||||
<BlockbreakerScorePopup />
|
||||
<AetherScorePopup />
|
||||
|
||||
Reference in New Issue
Block a user