made some changes to the UI, removed leaderboards. updated gamesaves

This commit is contained in:
Warren H
2026-06-18 13:00:29 -04:00
parent 3c90998a61
commit a604569a2f
44 changed files with 2301 additions and 435 deletions
+621 -30
View File
@@ -310,6 +310,7 @@ textarea:focus-visible,
}
.binding-capture,
.dual-startup-prompt,
.controller-keyboard-backdrop {
align-items: center;
background: rgba(5, 6, 9, 0.88);
@@ -320,7 +321,8 @@ textarea:focus-visible,
z-index: 100;
}
.binding-capture > div {
.binding-capture > div,
.dual-startup-prompt > section {
background: var(--panel);
border: 3px solid #090a0d;
box-shadow: 8px 8px 0 #050609;
@@ -337,7 +339,29 @@ textarea:focus-visible,
margin: 18px 0;
}
.dual-startup-prompt p:not(.eyebrow) {
color: var(--muted);
font-size: 20px;
line-height: 1.2;
margin-top: 12px;
}
.dual-startup-prompt small {
color: var(--muted);
display: block;
font-size: 16px;
margin-top: 12px;
}
.dual-startup-prompt div {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
margin-top: 22px;
}
.binding-capture button,
.dual-startup-prompt button,
.controller-keyboard button {
background: #242630;
border: 2px solid #090a0d;
@@ -351,6 +375,17 @@ textarea:focus-visible,
padding: 8px 24px;
}
.dual-startup-prompt button {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
min-height: 48px;
padding: 10px 14px;
}
.dual-startup-prompt button:first-child {
outline-color: var(--green);
}
.controller-keyboard {
background: var(--panel);
border: 3px solid #090a0d;
@@ -572,11 +607,12 @@ textarea:focus-visible,
.dual-top-party-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dual-top-party-grid.raid {
grid-template-rows: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-rows: repeat(3, minmax(0, 1fr));
}
.dual-top-member {
@@ -638,8 +674,34 @@ textarea:focus-visible,
vertical-align: middle;
}
.dual-top-party-grid.raid .dual-top-member {
min-height: 72px;
padding: 8px;
}
.dual-top-party-grid.raid .member-header {
gap: 5px;
}
.dual-top-party-grid.raid .member-header strong {
font-size: 16px;
}
.dual-top-party-grid.raid .member-header small {
display: none;
}
.dual-top-party-grid.raid .dual-top-member .bar {
height: 18px;
margin-top: 7px;
}
.dual-top-party-grid.raid .member-effects {
margin-top: 5px;
}
.dual-top-log {
display: flex;
display: none;
gap: 14px;
min-height: 36px;
overflow: hidden;
@@ -711,7 +773,7 @@ textarea:focus-visible,
display: grid;
gap: 10px;
height: calc(100dvh - 20px);
grid-template-rows: auto 1fr auto;
grid-template-rows: auto 1fr;
min-height: 0;
}
@@ -727,7 +789,7 @@ textarea:focus-visible,
}
.dual-top-main .dual-top-party-grid.raid {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.dual-top-main .dual-top-member {
@@ -980,8 +1042,13 @@ textarea:focus-visible,
}
.game-shell {
display: flex;
flex-direction: column;
height: 100dvh;
width: min(1180px, calc(100% - 28px));
margin: 22px auto;
margin: 0 auto;
overflow: hidden;
padding: 12px 0;
position: relative;
}
@@ -1289,11 +1356,27 @@ h2 {
.menu-screen,
.content-screen,
.message-panel {
margin-top: 18px;
flex: 1;
margin-top: 12px;
min-height: 0;
overflow: hidden;
padding: 28px;
}
.content-screen {
display: flex;
flex-direction: column;
}
.menu-screen {
align-items: center;
display: flex;
}
.content-screen > .screen-heading {
flex: 0 0 auto;
}
.message-panel {
align-items: center;
display: flex;
@@ -1411,6 +1494,30 @@ h2 {
transform: translateY(-2px);
}
.cloud-sync-card {
cursor: default;
justify-content: space-between;
}
.cloud-sync-card:hover {
outline-color: #42414c;
transform: none;
}
.cloud-sync-card > div {
display: grid;
flex: 1;
gap: 6px;
}
.cloud-sync-card .text-button:disabled {
opacity: 0.7;
}
.cloud-sync-message {
color: var(--gold);
}
.menu-card > span,
.class-portrait {
align-items: center;
@@ -1466,6 +1573,130 @@ h2 {
outline-color: var(--gold);
}
.settings-tabs,
.talent-page-tabs {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 14px;
}
.settings-tabs button,
.talent-page-tabs button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--muted);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
min-height: 42px;
outline: 2px solid #41404a;
padding: 8px 10px;
text-transform: uppercase;
}
.settings-tabs button.selected,
.settings-tabs button:hover,
.talent-page-tabs button.active,
.talent-page-tabs button:hover {
color: var(--gold);
outline-color: var(--gold);
}
.settings-screen,
.equipment-screen,
.talent-screen,
.customize-screen {
height: calc(100dvh - 92px);
}
.settings-tab-panel {
flex: 1;
min-height: 0;
}
.settings-bindings-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.settings-bindings-panel .settings-heading,
.settings-bindings-panel .binding-tabs,
.settings-bindings-panel .settings-footer {
flex: 0 0 auto;
}
.settings-bindings-panel .binding-list {
flex: 1;
min-height: 0;
}
.equipment-screen .gear-summary,
.equipment-screen .equipment-tabs,
.equipment-screen .item-comparison,
.equipment-screen .equipment-footer,
.talent-screen .talent-toolbar,
.talent-screen .talent-page-tabs,
.talent-screen .talent-footer {
flex: 0 0 auto;
}
.equipment-screen .equipment-layout,
.equipment-screen .crafting-panel,
.talent-screen .talent-tree {
flex: 1;
min-height: 0;
}
.talent-screen .talent-tree {
display: grid;
gap: 12px;
grid-template-rows: repeat(2, minmax(0, 1fr));
overflow: hidden;
}
.embedded-screen {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.customize-screen > .customize-tabs {
flex: 0 0 auto;
}
.customize-screen > .customize-layout,
.customize-screen > .embedded-screen {
flex: 1;
min-height: 0;
overflow: hidden;
}
.customize-screen .loadout-editor {
display: flex;
flex-direction: column;
min-height: 0;
}
.customize-screen .ability-library {
flex: 1;
min-height: 0;
}
.customize-screen .class-picker {
min-height: 0;
overflow: hidden;
}
.loot-preview-grid,
.leaderboard-table {
max-height: 360px;
overflow-y: auto;
}
.combat-header-actions {
align-items: center;
display: flex;
@@ -2341,6 +2572,13 @@ h2 {
padding: 15px;
}
.equipped-panel,
.inventory-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.equipment-tabs {
display: flex;
gap: 8px;
@@ -2382,6 +2620,8 @@ h2 {
display: grid;
gap: 8px;
margin-top: 13px;
min-height: 0;
overflow: hidden;
}
.equipment-slots > button {
@@ -2437,10 +2677,12 @@ h2 {
.inventory-list {
display: grid;
flex: 1;
gap: 8px;
margin-top: 13px;
max-height: 442px;
overflow-y: auto;
min-height: 0;
overflow: hidden;
padding: 2px;
}
@@ -2482,10 +2724,43 @@ h2 {
display: grid;
gap: 8px;
max-height: 360px;
overflow-y: auto;
overflow: hidden;
padding: 2px;
}
.list-pager {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: auto 1fr auto;
margin-top: 4px;
}
.list-pager button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--gold);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
min-height: 34px;
outline: 2px solid #41404a;
padding: 6px 10px;
}
.list-pager button:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.55;
}
.list-pager span {
color: var(--muted);
font-family: 'Press Start 2P', monospace;
font-size: 7px;
text-align: center;
}
.crafting-list > button {
align-items: center;
background: var(--panel-light);
@@ -3226,7 +3501,7 @@ h2 {
.party-grid {
display: grid;
gap: 11px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 17px;
}
@@ -3243,11 +3518,12 @@ h2 {
}
.party-member:first-child {
grid-column: 1 / -1;
grid-column: auto;
}
.raid-party-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.raid-party-grid .party-member:first-child {
@@ -3379,6 +3655,39 @@ h2 {
top: 0;
}
.member-health .health-text {
color: var(--ink);
font-family: 'Press Start 2P', monospace;
font-size: 7px;
font-style: normal;
left: 50%;
line-height: 1;
pointer-events: none;
position: absolute;
text-shadow: 1px 1px #08090c;
top: 50%;
transform: translate(-50%, -50%);
white-space: nowrap;
z-index: 2;
}
.raid-party-grid .party-member {
min-height: 66px;
padding: 7px;
}
.raid-party-grid .member-header {
gap: 5px;
}
.raid-party-grid .member-header strong {
font-size: 18px;
}
.raid-party-grid .member-header small {
display: none;
}
.member-effects {
display: flex;
flex-wrap: wrap;
@@ -3724,6 +4033,8 @@ h2 {
display: flex;
inset: 0;
justify-content: center;
overflow-y: auto;
padding: 16px;
position: fixed;
z-index: 10;
}
@@ -3733,8 +4044,10 @@ h2 {
background: var(--panel);
border: 3px solid #0b0c0f;
box-shadow: 8px 8px 0 #050507;
max-height: calc(100dvh - 32px);
max-width: 520px;
outline: 2px solid var(--gold);
overflow-y: auto;
padding: 32px;
text-align: center;
}
@@ -3915,18 +4228,25 @@ h2 {
}
.pvp-match-screen {
gap: 16px;
gap: 0;
height: calc(100dvh - 24px);
margin-top: 0;
overflow: hidden;
padding: 8px;
}
.pvp-board {
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr) minmax(0, 1fr);
gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr);
min-height: 0;
}
.pvp-side,
.pvp-middle-panel {
gap: 12px;
gap: 8px;
min-height: 0;
padding: 8px;
}
.pvp-vertical-spell-bar,
@@ -3935,7 +4255,8 @@ h2 {
}
.pvp-vertical-spell-bar .spell {
min-height: 86px;
min-height: 58px;
padding: 6px;
}
.pvp-screen-tools {
@@ -3950,9 +4271,9 @@ h2 {
.pvp-resource-wrap {
color: #82bfff;
min-width: 220px;
min-width: 150px;
text-align: right;
width: min(240px, 100%);
width: min(170px, 100%);
}
.pvp-resource-wrap > span {
@@ -3966,16 +4287,93 @@ h2 {
min-width: 0;
}
.pvp-side .party-grid {
gap: 6px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 8px;
}
.pvp-side .pvp-party-grid.raid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.pvp-side .pvp-party-grid.raid .party-member {
min-height: 62px;
padding: 6px;
}
.pvp-side .pvp-party-grid.raid .member-header strong {
font-size: 16px;
}
.pvp-side .pvp-party-grid.raid .member-header small {
display: none;
}
.pvp-side .party-member {
min-height: 76px;
padding: 8px;
}
.pvp-side .party-member:first-child {
grid-column: auto;
}
.pvp-side .member-header {
gap: 5px;
}
.pvp-side .member-header strong {
font-size: 19px;
}
.pvp-side .member-header small {
font-size: 14px;
}
.pvp-side .bar {
height: 14px;
}
.pvp-side .member-effects {
margin-top: 4px;
}
.pvp-side .member-effects span {
font-size: 11px;
padding: 2px 4px;
}
.pvp-side .encounter-header .eyebrow {
display: none;
}
.pvp-enemy-race {
display: grid;
gap: 12px;
gap: 8px;
}
.pvp-middle-panel .encounter-header h2 {
font-size: 20px;
}
.pvp-middle-panel .encounter-header small,
.pvp-enemy-race small {
font-size: 14px;
}
.pvp-middle-panel .roguelike-upgrade-list,
.pvp-side .roguelike-upgrade-list {
font-size: 12px;
line-height: 1.1;
margin-top: 4px;
}
.pvp-choice-columns {
display: grid;
gap: 16px;
gap: 10px;
grid-template-columns: 1fr;
margin-top: 16px;
margin-top: 0;
}
.pvp-choice-columns > div > strong {
@@ -3989,8 +4387,8 @@ h2 {
.pvp-choice-columns .upgrade-choice-grid button {
background: #252833;
min-height: 120px;
padding: 14px;
min-height: 70px;
padding: 8px;
}
.pvp-leaderboard-row {
@@ -3999,20 +4397,31 @@ h2 {
.pvp-upgrade-dialog {
max-width: 1120px !important;
padding: 12px !important;
text-align: left !important;
width: min(1120px, calc(100vw - 32px));
}
.pvp-upgrade-dialog > p:not(.eyebrow) {
font-size: 18px !important;
margin-top: 8px !important;
}
.pvp-upgrade-dialog .pvp-choice-columns {
gap: 10px;
margin-top: 0;
}
.pvp-upgrade-dialog .upgrade-choice-grid strong {
color: #ffe8a5;
font-size: 11px;
line-height: 1.6;
font-size: 9px;
line-height: 1.25;
}
.pvp-upgrade-dialog .upgrade-choice-grid small {
color: #d3d9e6;
font-size: 16px;
line-height: 1.35;
font-size: 12px;
line-height: 1.15;
}
.pvp-upgrade-dialog .upgrade-choice-grid button.selected-upgrade {
@@ -4057,9 +4466,191 @@ h2 {
z-index: 1;
}
@media (min-width: 1000px) and (min-height: 900px) {
.game-shell {
width: min(1220px, calc(100% - 20px));
}
}
@media (min-width: 1000px) and (max-height: 1120px) {
.settings-screen,
.equipment-screen,
.talent-screen,
.customize-screen {
padding: 16px;
}
.settings-heading {
padding: 8px 0 12px;
}
.settings-heading > p,
.controller-preferences p:not(.eyebrow),
.dual-screen-settings p:not(.eyebrow) {
font-size: 16px;
}
.binding-tabs {
margin: 12px 0;
}
.binding-tabs button {
min-height: 38px;
}
.binding-list {
gap: 7px;
}
.binding-list button {
min-height: 39px;
padding: 6px 9px;
}
.binding-list button > span {
font-size: 16px;
}
.settings-footer {
margin-top: 12px;
padding-top: 10px;
}
.controller-preferences,
.dual-screen-settings {
margin-top: 14px;
padding: 14px;
}
.controller-icon-options {
grid-template-columns: minmax(120px, 1fr) repeat(3, minmax(118px, auto));
}
.gear-summary,
.talent-toolbar {
margin-top: 12px;
padding: 10px 12px;
}
.equipment-tabs,
.talent-page-tabs {
margin-top: 10px;
}
.equipment-tab {
min-height: 38px;
}
.item-comparison {
grid-template-columns: 1fr auto 1fr minmax(132px, 0.45fr);
margin-top: 10px;
min-height: 122px;
padding: 9px;
}
.item-detail {
padding: 9px;
}
.item-detail > p:not(.eyebrow),
.item-detail ul {
margin-top: 6px;
}
.equipment-layout {
gap: 12px;
margin-top: 10px;
}
.equipped-panel,
.inventory-panel,
.crafting-panel,
.set-bonus-panel {
padding: 10px;
}
.equipment-slots,
.inventory-list,
.crafting-list {
gap: 6px;
margin-top: 9px;
}
.equipment-slots > button,
.inventory-list > button,
.crafting-list > button {
min-height: 46px;
padding: 5px 7px;
}
.equipment-slots > button > span,
.inventory-list > button > span,
.crafting-list > button > span,
.item-title > span {
height: 31px;
}
.inventory-list,
.crafting-list {
max-height: none;
}
.crafting-panel {
display: flex;
flex-direction: column;
}
.crafting-filter-bar {
flex: 0 0 auto;
}
.crafting-layout {
flex: 1;
min-height: 0;
}
.crafting-list {
min-height: 0;
overflow: hidden;
}
.crafting-detail {
align-content: start;
overflow: hidden;
}
.talent-tree {
margin-top: 10px;
}
.talent-tier {
gap: 10px;
padding: 8px 0;
}
.talent-node {
min-height: 0;
padding: 8px;
}
.talent-node > p {
font-size: 14px;
line-height: 1;
margin-top: 6px;
}
.rank-pips {
margin: 6px 0;
}
.talent-footer {
padding-top: 10px;
}
}
@media (max-height: 720px) {
.game-shell {
margin: 6px auto;
padding: 6px 0;
width: min(1180px, calc(100% - 20px));
}
+72 -5
View File
@@ -19,7 +19,12 @@ import {
type AuthSession,
type CharacterProfile,
} from './profile'
import { getGameMode, type GameMode } from './gameRepository'
import {
getCloudSyncStatus,
getGameMode,
syncCloudSave,
type GameMode,
} from './gameRepository'
import { focusFirstControl } from './input.tsx'
type Screen =
@@ -40,8 +45,8 @@ const MENU_ITEMS: Array<{
glyph: string
description: string
}> = [
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a five-player party through dangerous encounters.' },
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide a ten-player party through three-phase challenges.' },
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' },
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
@@ -49,6 +54,7 @@ const MENU_ITEMS: Array<{
]
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false
function activityInitials(name: string) {
return name
@@ -88,6 +94,8 @@ function App() {
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
const [showLeaderboard, setShowLeaderboard] = useState(false)
const [error, setError] = useState('')
const [syncingCloud, setSyncingCloud] = useState(false)
const [syncMessage, setSyncMessage] = useState('')
useEffect(() => {
loadAuthSession()
@@ -105,6 +113,17 @@ function App() {
.finally(() => setAuthChecked(true))
}, [])
useEffect(() => {
const handleModeChange = (event: Event) => {
const nextMode = (event as CustomEvent<GameMode>).detail
setGameMode(nextMode)
}
window.addEventListener('chronicle:mode-changed', handleModeChange as EventListener)
return () => {
window.removeEventListener('chronicle:mode-changed', handleModeChange as EventListener)
}
}, [])
useEffect(() => {
if (screen === 'combat') return
window.requestAnimationFrame(() => {
@@ -138,11 +157,27 @@ function App() {
setProfile(null)
setGameMode(getGameMode())
setScreen('menu')
setSyncMessage('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
}
}
async function syncSaveNow() {
setSyncingCloud(true)
setSyncMessage('')
try {
const updated = await syncCloudSave()
setProfile(updated)
setGameMode(getGameMode())
setSyncMessage('Cloud save updated.')
} catch (reason) {
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
} finally {
setSyncingCloud(false)
}
}
if (error) {
return (
<main className="game-shell">
@@ -253,6 +288,8 @@ function App() {
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
]
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available
const lootPreviewEncounters = [...activity.encounters]
.filter((encounter) => encounter.isBoss)
.sort((a, b) => lootSort === 'boss'
@@ -285,6 +322,28 @@ function App() {
{screen === 'menu' && (
<section className="menu-screen">
<div className="main-menu-grid">
{canShowCloudSync && (
<div className="menu-card cloud-sync-card">
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
<div>
<strong>Cloud Save</strong>
<small>
{cloudSync.dirty
? 'Local progress waiting. Upload when you want to refresh the server copy.'
: 'Server copy matches this device.'}
</small>
{syncMessage && <small className="cloud-sync-message">{syncMessage}</small>}
</div>
<button
className="text-button"
disabled={syncingCloud || !cloudSync.dirty}
onClick={syncSaveNow}
type="button"
>
{syncingCloud ? 'Syncing...' : cloudSync.dirty ? 'Sync Save To Server' : 'Already Synced'}
</button>
</div>
)}
{MENU_ITEMS.map((item) => (
<button
className="menu-card"
@@ -457,6 +516,7 @@ function App() {
Start Match
</button>
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
@@ -488,6 +548,7 @@ function App() {
)}
</div>
</div>
)}
</>
)}
</section>
@@ -663,6 +724,7 @@ function App() {
</>
)}
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
@@ -682,7 +744,9 @@ function App() {
<p className="section-note">
{gameMode === 'offline'
? 'Offline runs are not submitted'
: 'Lowest resource spent ranks first'}
: canShowCloudSync
? 'Manual save sync updates your cloud profile.'
: 'Lowest resource spent ranks first'}
</p>
<div className="leaderboard-tabs">
{([
@@ -730,13 +794,16 @@ function App() {
<div className="leaderboard-empty">
{gameMode === 'offline'
? 'Connect with an online character to compete in rankings.'
: 'Complete this difficulty to claim the first ranking.'}
: canShowCloudSync
? 'No leaderboard entries yet.'
: 'Complete this difficulty to claim the first ranking.'}
</div>
)}
</div>
</>
)}
</div>
)}
</section>
)}
+1 -1
View File
@@ -156,7 +156,7 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
<h2>Play Offline</h2>
<p>
No account or connection required. Offline progress stays on
this device and is excluded from online leaderboards.
this device.
</p>
</div>
{offlineCharacterExists && (
+27 -19
View File
@@ -241,7 +241,7 @@ function makeRoguelikeSegment(
encounter.maxHealth
+ encounter.damage * 18
+ encounter.tankDamage * 10
+ encounter.partyDamage * 12
+ encounter.partyDamage * 18
)
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
@@ -331,7 +331,7 @@ export function CombatScreen({
)
const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
const partyTemplate = useMemo(
() => (dungeon.partySize === 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
() => (dungeon.partySize >= 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
...member,
name: member.id === 'mira' ? profile.character.name : member.name,
})),
@@ -346,10 +346,10 @@ export function CombatScreen({
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [enemyHealth, setEnemyHealth] = useState(encounters[initialEncounterIndex].maxHealth)
const [cooldowns, setCooldowns] = useState<Record<string, number>>({})
const [elapsedTicks, setElapsedTicks] = useState(0)
const [, setElapsedTicks] = useState(0)
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1>(0)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const [log, setLog] = useState<CombatLogEntry[]>([
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
])
@@ -373,6 +373,7 @@ export function CombatScreen({
const nextFloatingTextId = useRef(1)
const partyRef = useRef(partyTemplate)
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
const elapsedTicksRef = useRef(0)
const encounter = encounters[encounterIndex]
const currentPart = getCurrentPart(encounterIndex)
const firstEncounterIndex = (startPart - 1) * 3
@@ -471,6 +472,7 @@ export function CombatScreen({
setEncounterIndex(initialEncounterIndex)
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
setCooldowns({})
elapsedTicksRef.current = 0
setElapsedTicks(0)
setStatus('playing')
setPaused(false)
@@ -670,7 +672,7 @@ export function CombatScreen({
}, [selectedId])
const selectDirectionalTarget = useCallback((action: InputAction) => {
const columns = dungeon.partySize === 10 ? 5 : 3
const columns = dungeon.partySize >= 10 ? 6 : 3
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedId)
if (currentIndex < 0) {
setSelectedId(partyRef.current[0].id)
@@ -711,7 +713,7 @@ export function CombatScreen({
}, [dungeon.partySize, selectedId])
const selectDirectTarget = useCallback((slot: number) => {
const index = slot + (dungeon.partySize === 10 ? targetGroup * 5 : 0)
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
const member = partyRef.current[index]
if (member) setSelectedId(member.id)
}, [dungeon.partySize, targetGroup])
@@ -748,6 +750,7 @@ export function CombatScreen({
setParty(recoveredParty)
setEncounterIndex((current) => current + 1)
setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0)
setCooldowns({})
setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource))
@@ -771,11 +774,12 @@ export function CombatScreen({
return
}
if (action === 'toggleTargetGroup') {
if (dungeon.partySize !== 10) return
if (dungeon.partySize <= 6) return
setTargetGroup((current) => {
const next = current === 0 ? 1 : 0
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedId)
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 5) + next * 5]
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember) setSelectedId(nextMember.id)
return next
})
@@ -798,7 +802,9 @@ export function CombatScreen({
useEffect(() => {
if (status !== 'playing' || paused) return
const timer = window.setInterval(() => {
setElapsedTicks((value) => value + 1)
const nextElapsedTicks = elapsedTicksRef.current + 1
elapsedTicksRef.current = nextElapsedTicks
setElapsedTicks(nextElapsedTicks)
setResource((value) => clamp(value + 2.4, 0, maxResource))
setCooldowns((current) =>
Object.fromEntries(
@@ -820,19 +826,19 @@ export function CombatScreen({
const primaryTarget = living[Math.floor(Math.random() * living.length)]
const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
const useDefaultBossMechanics = encounter.isBoss && mechanics.length === 0
const bossPulse = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 7 === 0
const bossPulse = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 7 === 0
&& (useDefaultBossMechanics || mechanics.includes('party-pulse'))
const appliesDebuff = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 11 === 0
const appliesDebuff = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 11 === 0
&& (useDefaultBossMechanics || mechanics.includes('searing-mark'))
const appliesMaxHealthCut = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0
const appliesMaxHealthCut = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 13 === 0
&& mechanics.includes('max-health-cut')
const appliesHealingReduction = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0
const appliesHealingReduction = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 9 === 0
&& mechanics.includes('healing-reduction')
const tankBuster = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 8 === 0
const tankBuster = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 8 === 0
&& mechanics.includes('tank-buster')
const resourceDrain = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 10 === 0
const resourceDrain = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 10 === 0
&& mechanics.includes('resource-drain')
const appliesPoison = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0
const appliesPoison = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 12 === 0
&& mechanics.includes('ramping-poison')
if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
@@ -957,6 +963,7 @@ export function CombatScreen({
setParty(recoveredParty)
setEncounterIndex((value) => value + 1)
setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0)
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, TICK_MS)
@@ -965,7 +972,6 @@ export function CombatScreen({
addLog,
addFloatingHeal,
difficulty.damageMultiplier,
elapsedTicks,
encounter,
encounterIndex,
encounters,
@@ -1123,7 +1129,7 @@ export function CombatScreen({
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
</div>
</div>
<div className={`party-grid ${dungeon.partySize === 10 ? 'raid-party-grid' : ''}`}>
<div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
{party.map((member) => (
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
@@ -1146,6 +1152,7 @@ export function CombatScreen({
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
@@ -1395,6 +1402,7 @@ export function CombatScreen({
setParty(recoveredParty)
setEncounterIndex(nextIndex)
setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0)
setStatus('playing')
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
+84 -5
View File
@@ -22,6 +22,9 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
component: 'Component',
}
const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 6
type Props = {
profile: CharacterProfile
onBack?: () => void
@@ -45,6 +48,8 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
@@ -75,6 +80,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
(total, item) => total + item.quantity,
0,
)
const inventoryPageCount = Math.max(
1,
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
)
const inventoryPageItems = visibleInventory.slice(
inventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
(inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
)
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null)
@@ -92,11 +105,27 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
},
[profile.craftingRecipes, slotFilter, levelFilter],
)
const recipePageCount = Math.max(
1,
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
)
const recipePageItems = filteredRecipes.slice(
recipePage * CRAFTING_LIST_PAGE_SIZE,
(recipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
useEffect(() => {
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
}, [inventoryPageCount])
useEffect(() => {
setRecipePage((current) => Math.min(current, recipePageCount - 1))
}, [recipePageCount])
useEffect(() => {
if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
@@ -270,6 +299,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
key={slot}
onClick={() => {
setSelectedSlot(slot)
setInventoryPage(0)
const firstSlotItem = profile.inventory.find(
(candidate) => candidate.slot === slot,
)
@@ -302,14 +332,17 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{selectedSlot && (
<button
className="inventory-filter-clear"
onClick={() => setSelectedSlot(null)}
onClick={() => {
setSelectedSlot(null)
setInventoryPage(0)
}}
type="button"
>
Show All Items
</button>
)}
<div className="inventory-list">
{visibleInventory.map((item) => (
{inventoryPageItems.map((item) => (
<button
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
key={item.id}
@@ -333,6 +366,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</p>
)}
</div>
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
<ListPager
label={`Page ${inventoryPage + 1} / ${inventoryPageCount}`}
onNext={() => setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))}
onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))}
nextDisabled={inventoryPage >= inventoryPageCount - 1}
previousDisabled={inventoryPage <= 0}
/>
)}
</section>
</div>
</>
@@ -347,7 +389,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<select
className="filter-select"
value={slotFilter}
onChange={(e) => setSlotFilter(e.target.value as EquipmentSlot | 'all')}
onChange={(e) => {
setSlotFilter(e.target.value as EquipmentSlot | 'all')
setRecipePage(0)
}}
>
<option value="all">All Slots</option>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
@@ -357,7 +402,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<select
className="filter-select"
value={levelFilter ?? ''}
onChange={(e) => setLevelFilter(e.target.value === '' ? null : Number(e.target.value))}
onChange={(e) => {
setLevelFilter(e.target.value === '' ? null : Number(e.target.value))
setRecipePage(0)
}}
>
<option value="">All Levels</option>
{availableLevels.map((level) => (
@@ -371,7 +419,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{filteredRecipes.length > 0 && (
<div className="crafting-layout">
<div className="crafting-list">
{filteredRecipes.map((recipe) => (
{recipePageItems.map((recipe) => (
<button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
key={recipe.id}
@@ -389,6 +437,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
</button>
))}
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
<ListPager
label={`Page ${recipePage + 1} / ${recipePageCount}`}
onNext={() => setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))}
onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))}
nextDisabled={recipePage >= recipePageCount - 1}
previousDisabled={recipePage <= 0}
/>
)}
</div>
{selectedRecipe && (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
@@ -466,6 +523,28 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
)
}
function ListPager({
label,
nextDisabled,
previousDisabled,
onNext,
onPrevious,
}: {
label: string
nextDisabled: boolean
previousDisabled: boolean
onNext: () => void
onPrevious: () => void
}) {
return (
<div className="list-pager">
<button disabled={previousDisabled} onClick={onPrevious} type="button">Prev</button>
<span>{label}</span>
<button disabled={nextDisabled} onClick={onNext} type="button">Next</button>
</div>
)
}
function GearStat({ value, label }: { value: string; label: string }) {
return (
<div className="gear-stat">
+201 -60
View File
@@ -4,7 +4,13 @@ import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { ControllerBindingLabel } from './ControllerIcons'
import { useGameAction, useInput, type InputAction } from '../input'
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
import {
DualScreenTopCombat,
useDualScreen,
useDualScreenPublisher,
type DualScreenCombatState,
} from '../dualScreen'
import {
randomCpuDifficulty,
recordCpuPvpLeaderboard,
@@ -238,7 +244,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
encounter.maxHealth
+ encounter.damage * 18
+ encounter.tankDamage * 10
+ encounter.partyDamage * 12
+ encounter.partyDamage * 18
)
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
@@ -366,7 +372,7 @@ export function PvPRoguelikeScreen({
.filter((spell) => spell.unlockLevel === 1)
.slice(0, 5)
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
const [abilityLabelMode, setAbilityLabelMode] = useState<AbilityLabelMode>('ability')
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
const selfBuffChoicesCatalog = useMemo(
() => buildSelfBuffChoices(starterSpells, abilityLabelMode),
[abilityLabelMode, starterSpells],
@@ -410,10 +416,14 @@ export function PvPRoguelikeScreen({
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const nextLogId = useRef(2)
const nextFloatingTextId = useRef(1)
const recordedRunRef = useRef(false)
const rewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>())
const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1)
const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide)
@@ -431,11 +441,16 @@ export function PvPRoguelikeScreen({
const cpuDone = cpuSide.enemyHealth <= 0
const playerAlive = playerSide.party.some((member) => member.health > 0)
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
const partyColumns = contentType === 'raid' ? 6 : 3
const {
bindings,
controllerIconStyle,
directPartyTargeting,
lastDevice,
} = useInput()
const {
enabled: dualScreenEnabled,
} = useDualScreen()
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
}, [])
@@ -449,18 +464,17 @@ export function PvPRoguelikeScreen({
}, 900)
}, [])
const finishRoguelikeRun = useCallback((cleared: number) => {
if (rewardClaimedRef.current) return
rewardClaimedRef.current = true
const bossesCleared = Math.floor(cleared / 3)
const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue)
completeRoguelike(
rewardDungeon.id,
rewardDifficulty.id,
cleared,
0,
0,
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
{
bossesCleared,
bossesCleared: 1,
experienceMode: 'pvp-boss-quarter-level',
},
)
@@ -475,6 +489,11 @@ export function PvPRoguelikeScreen({
})
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
const finishRoguelikeRun = useCallback(() => {
if (rewardClaimedRef.current) return
rewardClaimedRef.current = true
}, [])
useEffect(() => {
setPlayerBuffChoices((current) => current
.map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
@@ -501,6 +520,7 @@ export function PvPRoguelikeScreen({
cpuRef.current = baseCpu
nextLogId.current = 2
playerClearedEncounterRef.current = -1
bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment)
setEncounterIndex(0)
setStage(1)
@@ -514,6 +534,8 @@ export function PvPRoguelikeScreen({
setSelectedBuff(null)
setSelectedDebuff(null)
setEncountersCleared(0)
setPaused(false)
setTargetGroup(0)
setReward(null)
setRewardError('')
setShowEndLog(false)
@@ -521,6 +543,7 @@ export function PvPRoguelikeScreen({
setCpuDifficulty(null)
recordedRunRef.current = false
rewardClaimedRef.current = false
cpuDefeatedRef.current = false
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`)
@@ -659,10 +682,45 @@ export function PvPRoguelikeScreen({
setSelectedId(living[nextIndex].id)
}, [selectedId])
const selectDirectionalTarget = useCallback((action: InputAction) => {
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
if (currentIndex < 0) {
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
if (firstLiving) setSelectedId(firstLiving.id)
return
}
const currentRow = Math.floor(currentIndex / partyColumns)
const currentColumn = currentIndex % partyColumns
const candidates = playerRef.current.party
.map((member, index) => ({
member,
index,
row: Math.floor(index / partyColumns),
column: index % partyColumns,
}))
.filter(({ member, index, row, column }) => {
if (member.health <= 0 || index === currentIndex) return false
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
if (action === 'navigateRight') return row === currentRow && column > currentColumn
if (action === 'navigateUp') return row < currentRow
return row > currentRow
})
.sort((a, b) => {
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow)
const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow)
const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn)
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
return aPrimary - bPrimary || aSecondary - bSecondary
})
if (candidates[0]) setSelectedId(candidates[0].member.id)
}, [partyColumns, selectedId])
const selectDirectTarget = useCallback((slot: number) => {
const member = playerRef.current.party[slot]
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
const member = playerRef.current.party[index]
if (member?.health > 0) setSelectedId(member.id)
}, [])
}, [contentType, targetGroup])
const cpuTakeTurn = useCallback(() => {
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
@@ -774,7 +832,7 @@ export function PvPRoguelikeScreen({
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => {
if (status !== 'playing' || !encounter) return
if (status !== 'playing' || paused || !encounter) return
const timer = window.setInterval(() => {
setElapsedTicks((value) => value + 1)
cpuTakeTurn()
@@ -783,6 +841,7 @@ export function PvPRoguelikeScreen({
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1)
if (encounter.isBoss) awardBossReward(encounterIndex)
}
playerRef.current = nextPlayer
cpuRef.current = nextCpu
@@ -791,28 +850,23 @@ export function PvPRoguelikeScreen({
const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0)
const nextCpuAlive = nextCpu.party.some((member) => member.health > 0)
const clearedCount = nextPlayer.enemyHealth <= 0
? Math.max(encountersCleared, encounterIndex + 1)
: encountersCleared
if (!nextPlayerAlive) {
finishRoguelikeRun(clearedCount)
finishRoguelikeRun()
setStatus('lost')
addLog('Your party fell first.', 'danger')
return
}
if (!nextCpuAlive) {
finishRoguelikeRun(clearedCount)
setStatus('won')
addLog(`CPU ${cpuDifficulty ?? 1} fell first.`, 'loot')
return
if (!nextCpuAlive && !cpuDefeatedRef.current) {
cpuDefeatedRef.current = true
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
}
if (nextPlayer.enemyHealth <= 0 && nextCpu.enemyHealth <= 0) {
addLog(`${encounter.enemyName} cleared by both teams. Choose your next edge.`, 'loot')
if (nextPlayer.enemyHealth <= 0) {
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
beginUpgradePhase()
}
}, TICK_MS)
return () => window.clearInterval(timer)
}, [addLog, advanceSide, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, status])
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, status])
useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
@@ -828,6 +882,16 @@ export function PvPRoguelikeScreen({
})
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
useEffect(() => {
if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl())
}, [status])
useEffect(() => {
if (!paused) return
window.requestAnimationFrame(() => focusFirstControl())
}, [paused])
const confirmUpgradeChoices = useCallback(() => {
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
@@ -912,7 +976,15 @@ export function PvPRoguelikeScreen({
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useGameAction((action) => {
if (status !== 'playing') return
if (action === 'pause' || action === 'back') {
if (status === 'playing') setPaused((value) => !value)
return
}
if (paused || status !== 'playing') return
if (action.startsWith('navigate')) {
selectDirectionalTarget(action)
return
}
if (action === 'previousTarget') {
selectRelativeTarget(-1)
return
@@ -925,41 +997,93 @@ export function PvPRoguelikeScreen({
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
return
}
if (action === 'toggleTargetGroup') {
if (contentType !== 'raid') return
setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember?.health > 0) setSelectedId(nextMember.id)
return next
})
return
}
if (action.startsWith('ability')) {
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
if (spell) castPlayerSpell(spell)
}
})
return (
<main className="game-shell" data-combat-active={status === 'playing' ? 'true' : 'false'}>
<section className="content-screen pvp-match-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">PvP Roguelike</p>
<h1>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h1>
</div>
<div className="pvp-screen-tools">
<div className="roguelike-timing-row">
<button
className={`text-button ${abilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${abilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
<button className="back-button" onClick={onExit} type="button">Leave</button>
</div>
</div>
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
difficultyName: `Stage ${stage}`,
dungeonName: encounter.enemyName,
contentName: 'PvP Roguelike',
encounterName: encounter.enemyName,
encounterDescription: encounter.description,
encounterHealth: playerSide.enemyHealth,
encounterMaxHealth: encounter.maxHealth,
encounterIsBoss: encounter.isBoss,
encounterIndex,
encounterCount: encounters.length,
party: playerSide.party,
partySize: playerSide.party.length,
selectedId,
log,
status: status === 'queueing' ? 'playing' : status,
resource: playerSide.resource,
maxResource,
resourceName: gameClass.resourceName,
playerIsAlive: playerAlive,
spells: starterSpells.map((spell, slotIndex) => ({
...spell,
cost: spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady),
slotIndex,
remaining: playerSide.cooldowns[spell.id] ?? 0,
})),
activeDevice: lastDevice,
bindings: bindings[lastDevice],
controllerIconStyle,
directPartyTargeting,
paused,
targetGroup,
}), [
bindings,
controllerIconStyle,
directPartyTargeting,
encounter.description,
encounter.enemyName,
encounter.isBoss,
encounter.maxHealth,
encounterIndex,
encounters.length,
gameClass.resourceName,
lastDevice,
log,
maxResource,
paused,
playerAlive,
playerSide.buffs,
playerSide.cooldowns,
playerSide.debuffs,
playerSide.enemyHealth,
playerSide.freeCastReady,
playerSide.party,
playerSide.resource,
selectedId,
stage,
starterSpells,
status,
targetGroup,
])
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return (
<main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
>
<section className="content-screen pvp-match-screen">
{status === 'queueing' && (
<div className="placeholder-panel">
<div className="placeholder-runes">P V P</div>
@@ -967,7 +1091,14 @@ export function PvPRoguelikeScreen({
</div>
)}
{status !== 'queueing' && (
{dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat
state={dualScreenState}
onSelectTarget={setSelectedId}
/>
)}
{!dualScreenEnabled && status !== 'queueing' && (
<div className="pvp-board">
<section className="combat-panel pvp-side">
<div className="encounter-header">
@@ -982,7 +1113,7 @@ export function PvPRoguelikeScreen({
</div>
</div>
</div>
<div className="party-grid">
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
{playerSide.party.map((member) => (
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
@@ -998,6 +1129,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
@@ -1087,7 +1219,7 @@ export function PvPRoguelikeScreen({
</div>
</div>
</div>
<div className="party-grid">
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
{cpuSide.party.map((member) => (
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
<div className="member-header">
@@ -1098,6 +1230,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
@@ -1125,9 +1258,6 @@ export function PvPRoguelikeScreen({
{status === 'upgrade-choice' && (
<div className="result-screen">
<div className="pvp-upgrade-dialog">
<p className="eyebrow">Round Cleared</p>
<h2>Choose Your Edge</h2>
<p>Take 1 buff for yourself and 1 debuff for the CPU.</p>
<div className="pvp-choice-columns">
<div>
<strong>Self Buff</strong>
@@ -1169,6 +1299,17 @@ export function PvPRoguelikeScreen({
</div>
)}
{paused && (
<div className="pause-screen">
<div>
<p className="eyebrow">Paused</p>
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
</div>
</div>
)}
{(status === 'won' || status === 'lost') && (
<div className="result-screen">
<div>
@@ -1176,7 +1317,7 @@ export function PvPRoguelikeScreen({
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
<p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary">
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
<>
+155 -126
View File
@@ -24,6 +24,7 @@ import {
export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller')
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
const [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const {
@@ -50,6 +51,7 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
const visibleActions = INPUT_ACTIONS.filter((action) => (
@@ -95,138 +97,165 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<section className="dual-screen-settings">
<div>
<p className="eyebrow">Display</p>
<h2>AYN Thor Dual-Screen Mode</h2>
<p>
The upper display shows enemy and party health. The lower display
keeps targeting, resources, skills, and cooldowns.
</p>
</div>
<div className="dual-screen-actions">
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{([
{ key: 'display', label: 'Display' },
{ key: 'input', label: 'Input' },
{ key: 'bindings', label: 'Bindings' },
] as const).map((tab) => (
<button
className={dualScreenEnabled ? 'selected' : ''}
onClick={() => {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
}}
aria-selected={settingsTab === tab.key}
className={settingsTab === tab.key ? 'selected' : ''}
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
role="tab"
type="button"
>
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button>
<button onClick={launchTopDisplay} type="button">
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
<small>
{displayMessage || (
topDisplayConnected
? 'The companion display is connected and receiving live combat data.'
: 'Open the companion display before starting combat.'
)}
</small>
{nativeDualScreen && androidDisplays.length > 0 && (
<div className="android-display-list">
{androidDisplays.map((display) => (
<span key={display.id}>
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
{display.width}×{display.height} at {Math.round(display.refreshRate)} Hz
{display.isPresentation ? ' - Presentation' : ''}
</span>
))}
</div>
)}
</section>
<div className="settings-heading">
<div>
<p className="eyebrow">Input</p>
<h2>Keybindings</h2>
</div>
<p>Select an action, then press the new key or controller control.</p>
</div>
<section className="controller-preferences">
<div>
<p className="eyebrow">Targeting</p>
<h3>Direct Party Keybinds</h3>
<p>
Assign party slots directly. In raids, use the group-switch binding
to alternate between members 1-5 and 6-10.
</p>
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<div className="controller-icon-options">
<span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
key={style}
onClick={() => setControllerIconStyle(style)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
</button>
))}
</div>
</section>
<div className="binding-tabs">
<button
className={device === 'controller' ? 'selected' : ''}
onClick={() => setDevice('controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
onClick={() => setDevice('pc')}
type="button"
>
PC
</button>
</div>
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
key={action}
onClick={() => beginCapture(device, action)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
<kbd>
{capture?.device === device && capture.action === action
? 'Press a control...'
: (
<ControllerBindingLabel
binding={bindings[device][action]}
iconStyle={controllerIconStyle}
/>
)}
</kbd>
{tab.label}
</button>
))}
</div>
</nav>
<footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span>
<button className="text-button" onClick={() => resetBindings(device)} type="button">
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
{settingsTab === 'display' && (
<section className="dual-screen-settings settings-tab-panel">
<div>
<p className="eyebrow">Display</p>
<h2>AYN Thor Dual-Screen Mode</h2>
<p>
The upper display shows enemy and party health. The lower display
keeps targeting, resources, skills, and cooldowns.
</p>
</div>
<div className="dual-screen-actions">
<button
className={dualScreenEnabled ? 'selected' : ''}
onClick={() => {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
}}
type="button"
>
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button>
<button onClick={launchTopDisplay} type="button">
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
<small>
{displayMessage || (
topDisplayConnected
? 'The companion display is connected and receiving live combat data.'
: 'Open the companion display before starting combat.'
)}
</small>
{nativeDualScreen && androidDisplays.length > 0 && (
<div className="android-display-list">
{androidDisplays.map((display) => (
<span key={display.id}>
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
{display.width}x{display.height} at {Math.round(display.refreshRate)} Hz
{display.isPresentation ? ' - Presentation' : ''}
</span>
))}
</div>
)}
</section>
)}
{settingsTab === 'input' && (
<section className="controller-preferences settings-tab-panel">
<div>
<p className="eyebrow">Targeting</p>
<h3>Direct Party Keybinds</h3>
<p>
Assign party slots directly. In raids, use the group-switch binding
to alternate between members 1-6, 7-12, and 13-18.
</p>
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<div className="controller-icon-options">
<span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
key={style}
onClick={() => setControllerIconStyle(style)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
</button>
))}
</div>
</section>
)}
{settingsTab === 'bindings' && (
<section className="settings-bindings-panel settings-tab-panel">
<div className="settings-heading">
<div>
<p className="eyebrow">Input</p>
<h2>Keybindings</h2>
</div>
<p>Select an action, then press the new key or controller control.</p>
</div>
<div className="binding-tabs">
<button
className={device === 'controller' ? 'selected' : ''}
onClick={() => setDevice('controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
onClick={() => setDevice('pc')}
type="button"
>
PC
</button>
</div>
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
key={action}
onClick={() => beginCapture(device, action)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
<kbd>
{capture?.device === device && capture.action === action
? 'Press a control...'
: (
<ControllerBindingLabel
binding={bindings[device][action]}
iconStyle={controllerIconStyle}
/>
)}
</kbd>
</button>
))}
</div>
<footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span>
<button className="text-button" onClick={() => resetBindings(device)} type="button">
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
</section>
)}
{capture && (
<div className="binding-capture" role="dialog" aria-modal="true">
+22 -1
View File
@@ -15,6 +15,7 @@ type Props = {
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [talentPage, setTalentPage] = useState(0)
const [resetting, setResetting] = useState(false)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
@@ -28,6 +29,11 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
const tiers = Array.from(
new Set(gameClass.talents.map((talent) => talent.tier)),
).sort((a, b) => a - b)
const tierPages = Array.from(
{ length: Math.ceil(tiers.length / 2) },
(_, index) => tiers.slice(index * 2, index * 2 + 2),
)
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
useEffect(() => {
window.scrollTo(0, scrollRef.current)
@@ -123,8 +129,23 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
</div>
</div>
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
{tierPages.map((pageTiers, index) => (
<button
aria-selected={talentPage === index}
className={talentPage === index ? 'active' : ''}
key={pageTiers.join('-')}
onClick={() => setTalentPage(index)}
role="tab"
type="button"
>
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
</button>
))}
</nav>
<div className="talent-tree">
{tiers.map((tier) => {
{visibleTiers.map((tier) => {
const requiredPoints = (tier - 1) * 5
return (
<section className="talent-tier" key={tier}>
+80 -13
View File
@@ -11,6 +11,7 @@ import {
} from 'react'
import type { CombatLogEntry, PartyMember, Spell } from './game'
import {
getNativeDisplays,
hasNativeDualScreenBridge,
openNativeTopDisplay,
} from './nativeDualScreen'
@@ -23,6 +24,7 @@ import { ControllerBindingLabel } from './components/ControllerIcons'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot'
const STARTUP_CHOICE_KEY = 'ashen-halls-dual-screen-startup-choice'
const CHANNEL_NAME = 'ashen-halls-dual-screen'
export type DualScreenCombatState = {
@@ -51,7 +53,7 @@ export type DualScreenCombatState = {
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
paused: boolean
targetGroup: 0 | 1
targetGroup: 0 | 1 | 2
}
type DualScreenMessage =
@@ -172,6 +174,73 @@ export function useDualScreen() {
return context
}
export function DualScreenStartupPrompt() {
const { openTopDisplay, setEnabled } = useDualScreen()
const [visible, setVisible] = useState(false)
const [displayCount, setDisplayCount] = useState<number | null>(null)
const [message, setMessage] = useState('')
const autoOpenedRef = useRef(false)
useEffect(() => {
if (!hasNativeDualScreenBridge()) return
if (new URLSearchParams(window.location.search).has('display')) return
const choice = localStorage.getItem(STARTUP_CHOICE_KEY)
if (choice === 'yes') {
if (autoOpenedRef.current) return
autoOpenedRef.current = true
openTopDisplay().catch(() => {
// Settings can still launch the display manually if Android rejects startup launch.
})
return
}
if (choice === 'no') return
getNativeDisplays()
.then((result) => setDisplayCount(result.displays.length))
.catch(() => setDisplayCount(null))
.finally(() => setVisible(true))
}, [openTopDisplay])
async function enableDualScreen() {
localStorage.setItem(STARTUP_CHOICE_KEY, 'yes')
setMessage('Opening second display...')
const opened = await openTopDisplay()
if (opened) {
setVisible(false)
return
}
setMessage('No second display found. Check Thor display mode, then try again.')
}
function skipDualScreen() {
localStorage.setItem(STARTUP_CHOICE_KEY, 'no')
setEnabled(false)
setVisible(false)
}
if (!visible) return null
return (
<div className="dual-startup-prompt" role="dialog" aria-modal="true">
<section>
<p className="eyebrow">Display Setup</p>
<h2>Use Dual-Screen Mode?</h2>
<p>
Choose yes on AYN Thor. The game opens the combat view on the upper
display and keeps controls on the lower display.
</p>
{displayCount !== null && (
<small>{displayCount} Android display{displayCount === 1 ? '' : 's'} detected.</small>
)}
{message && <small>{message}</small>}
<div>
<button onClick={enableDualScreen} type="button">Yes, Enable</button>
<button onClick={skipDualScreen} type="button">No</button>
</div>
</section>
</div>
)
}
export function useDualScreenPublisher(
state: DualScreenCombatState,
enabled: boolean,
@@ -280,9 +349,9 @@ export function DualScreenBottomDisplay() {
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
{state.directPartyTargeting ? (
<>
{([1, 2, 3, 4, 5] as const).map((slot) => {
{([1, 2, 3, 4, 5, 6] as const).map((slot) => {
const action = `targetParty${slot}` as InputAction
const memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 0)
const memberIndex = slot - 1 + (state.partySize > 6 ? state.targetGroup * 6 : 0)
return (
<button onClick={() => sendAction(action)} type="button" key={action}>
<ControllerBindingLabel
@@ -293,13 +362,13 @@ export function DualScreenBottomDisplay() {
</button>
)
})}
{state.partySize === 10 && (
{state.partySize > 6 && (
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
<ControllerBindingLabel
binding={state.bindings.toggleTargetGroup}
iconStyle={state.controllerIconStyle}
/>{' '}
Party Group {state.targetGroup + 1}
Party Group {state.targetGroup + 1}/{Math.ceil(state.partySize / 6)}
</button>
)}
</>
@@ -396,11 +465,13 @@ export function DualScreenTopCombat({
</section>
<section className="dual-top-party">
<div className={`dual-top-party-grid ${state.partySize === 10 ? 'raid' : ''}`}>
<div className={`dual-top-party-grid ${state.partySize > 6 ? 'raid' : ''}`}>
{state.party.map((member, index) => {
const partySlot = index + 1 + (state.directPartyTargeting && state.partySize === 10 ? state.targetGroup * 5 : 0)
const partySlot = (index % 6) + 1
const targetAction = `targetParty${partySlot}` as InputAction
const targetBinding = state.directPartyTargeting ? state.bindings[targetAction] : null
const groupStart = state.partySize > 6 ? state.targetGroup * 6 : 0
const inCurrentTargetGroup = index >= groupStart && index < groupStart + 6
const targetBinding = state.directPartyTargeting && inCurrentTargetGroup ? state.bindings[targetAction] : null
return (
<button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
@@ -418,6 +489,7 @@ export function DualScreenTopCombat({
{member.shield > 0 && (
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
)}
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
</div>
{state.directPartyTargeting && targetBinding && (
<div className="member-target-key">
@@ -437,11 +509,6 @@ export function DualScreenTopCombat({
</div>
</section>
<footer className="dual-top-log">
{state.log.slice(0, 3).map((entry) => (
<span className={entry.tone} key={entry.id}>{entry.text}</span>
))}
</footer>
</div>
)
}
+9
View File
@@ -50,6 +50,7 @@ export const INITIAL_PARTY: PartyMember[] = [
{ id: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
]
export const RAID_PARTY: PartyMember[] = [
@@ -63,6 +64,14 @@ export const RAID_PARTY: PartyMember[] = [
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
{ id: 'dax', name: 'Dax', role: 'Damage', health: 108, maxHealth: 108, shield: 0, hotTicks: 0 },
{ id: 'nyx', name: 'Nyx', role: 'Damage', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
{ id: 'ren', name: 'Ren', role: 'Damage', health: 104, maxHealth: 104, shield: 0, hotTicks: 0 },
{ id: 'iona', name: 'Iona', role: 'Damage', health: 96, maxHealth: 96, shield: 0, hotTicks: 0 },
{ id: 'sol', name: 'Sol', role: 'Damage', health: 106, maxHealth: 106, shield: 0, hotTicks: 0 },
{ id: 'nari', name: 'Nari', role: 'Damage', health: 99, maxHealth: 99, shield: 0, hotTicks: 0 },
{ id: 'joss', name: 'Joss', role: 'Damage', health: 103, maxHealth: 103, shield: 0, hotTicks: 0 },
{ id: 'eira', name: 'Eira', role: 'Damage', health: 97, maxHealth: 97, shield: 0, hotTicks: 0 },
{ id: 'cato', name: 'Cato', role: 'Damage', health: 107, maxHealth: 107, shield: 0, hotTicks: 0 },
{ id: 'rhea', name: 'Rhea', role: 'Damage', health: 101, maxHealth: 101, shield: 0, hotTicks: 0 },
]
export const SPELLS: Spell[] = [
+508 -135
View File
@@ -1,5 +1,6 @@
import starterProfile from './offline-starter-profile.json'
import type {
Account,
AuthSession,
CharacterProfile,
DungeonReward,
@@ -69,37 +70,65 @@ type OfflineSave = {
lootRolls: Record<string, LootRoll>
}
const modeKey = 'chronicle.gameMode'
type OnlineCache = {
version: 1
account: Account
save: OfflineSave
dirty: boolean
}
export type CloudSyncStatus = {
available: boolean
dirty: boolean
}
type RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
type NetworkError = Error & {
network?: boolean
}
type LocalSaveStore = {
readSave: () => OfflineSave | null
writeSave: (save: OfflineSave) => void
readAccount: () => Account | null
}
const modeKey = 'chronicle.repositoryMode'
const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1'
const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T {
return structuredClone(value)
}
function readMode(): GameMode {
return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online'
function toGameMode(mode: RepositoryMode): GameMode {
return mode === 'online' ? 'online' : 'offline'
}
function writeMode(mode: GameMode) {
localStorage.setItem(modeKey, mode)
function dispatchModeChange(mode: RepositoryMode) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent<GameMode>('chronicle:mode-changed', {
detail: toGameMode(mode),
}))
}
function readOfflineSave(): OfflineSave | null {
const serialized = localStorage.getItem(offlineSaveKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized)
if (raw.version === 3) return raw as OfflineSave
if (raw.version === 2) return migrateV2ToV3(raw)
if (raw.version === 1) return migrateV1ToV2(raw)
return null
} catch {
return null
function readMode(): RepositoryMode {
const stored = localStorage.getItem(modeKey)
if (stored === 'offline-cached' || stored === 'offline-local' || stored === 'online') {
return stored
}
if (stored === 'offline') return 'offline-local'
return 'online'
}
function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
function writeMode(mode: RepositoryMode) {
localStorage.setItem(modeKey, mode)
dispatchModeChange(mode)
}
function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
const p = v1.profile
const classes = [1, 2, 3]
const characters: Record<number, CharacterData> = {}
@@ -118,7 +147,7 @@ function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string
inventory: cid === p.character.classId ? clone(p.inventory) : [],
}
}
const v2: OfflineSave = {
return {
version: 3,
characterName: p.character.name,
activeClassId: p.character.classId,
@@ -127,28 +156,98 @@ function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string
characters,
lootRolls: v1.lootRolls ?? {},
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v2))
return v2
}
function migrateV2ToV3(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
const v3: OfflineSave = {
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
return {
...v2,
version: 3,
completedRaidPhases: 0,
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v3))
return v3
}
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
if (!raw || typeof raw !== 'object') return null
const candidate = raw as {
version?: number
profile?: CharacterProfile
lootRolls?: Record<string, LootRoll>
}
if (candidate.version === 3) return candidate as OfflineSave
if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
}
if (candidate.version === 1 && candidate.profile) {
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })
}
return null
}
function readSaveKey(key: string): OfflineSave | null {
const serialized = localStorage.getItem(key)
if (!serialized) return null
try {
const raw = JSON.parse(serialized)
const save = normalizeOfflineSave(raw)
if (!save) return null
if (raw.version !== 3) {
localStorage.setItem(key, JSON.stringify(save))
}
return save
} catch {
return null
}
}
function readOfflineSave(): OfflineSave | null {
return readSaveKey(offlineSaveKey)
}
function writeOfflineSave(save: OfflineSave) {
localStorage.setItem(offlineSaveKey, JSON.stringify(save))
}
function requireOfflineSave(): OfflineSave {
const save = readOfflineSave()
if (!save) throw new Error('No offline character exists yet.')
return save
function readOnlineCache(): OnlineCache | null {
const serialized = localStorage.getItem(onlineCacheKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized) as {
version?: number
account?: Account
save?: unknown
dirty?: boolean
}
if (
raw.version !== 1
|| !raw.account
|| typeof raw.account.id !== 'number'
|| typeof raw.account.username !== 'string'
) {
return null
}
const save = normalizeOfflineSave(raw.save)
if (!save) return null
const cache: OnlineCache = {
version: 1,
account: raw.account,
save,
dirty: Boolean(raw.dirty),
}
if ((raw.save as { version?: number } | undefined)?.version !== 3) {
writeOnlineCache(cache)
}
return cache
} catch {
return null
}
}
function writeOnlineCache(cache: OnlineCache) {
localStorage.setItem(onlineCacheKey, JSON.stringify(cache))
}
function clearOnlineCache() {
localStorage.removeItem(onlineCacheKey)
}
function buildProfile(save: OfflineSave): CharacterProfile {
@@ -309,6 +408,40 @@ function componentDropQuantity(itemLevel: number) {
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
}
function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave {
const characters = clone(existingSave?.characters ?? {})
for (const gameClass of profile.classes) {
if (!characters[gameClass.id]) {
characters[gameClass.id] = emptyCharacterData(gameClass.id)
}
}
const activeClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)
if (!activeClass) {
throw new Error('The active class does not exist in the cached profile.')
}
const talentRanks: Record<string, number> = {}
for (const talent of activeClass.talents) {
talentRanks[String(talent.id)] = talent.rank
}
characters[profile.character.classId] = {
level: profile.character.level,
experience: profile.character.experience,
talentPoints: profile.character.talentPoints,
abilitySlots: [...profile.abilitySlots],
talentRanks,
inventory: clone(profile.inventory),
}
return {
version: 3,
characterName: profile.character.name,
activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts,
completedRaidPhases: profile.completedRaidPhases,
characters,
lootRolls: clone(existingSave?.lootRolls ?? {}),
}
}
function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]): T {
const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0)
let weightedRoll = Math.random() * totalWeight
@@ -335,66 +468,197 @@ function getApiBaseUrl(): string {
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl()
const url = baseUrl ? `${baseUrl}${path}` : path
const response = await fetch(url, init)
let response: Response
try {
response = await fetch(url, init)
} catch (reason) {
const networkError = new Error('Unable to reach the game server.') as NetworkError
networkError.network = true
networkError.cause = reason
throw networkError
}
const body = await response.json()
if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
return body
}
function isNetworkError(reason: unknown): reason is NetworkError {
return reason instanceof Error && Boolean((reason as NetworkError).network)
}
function cachedOnlineSession(): AuthSession | null {
const cache = readOnlineCache()
if (!cache) return null
return {
account: cache.account,
profile: buildProfile(cache.save),
}
}
function resumeCachedOnlineSession(): AuthSession | null {
const session = cachedOnlineSession()
if (!session) return null
writeMode('offline-cached')
return session
}
function cacheActiveOnlineProfile(profile: CharacterProfile, dirty = false) {
const cache = readOnlineCache()
if (!cache) return
writeOnlineCache({
...cache,
save: mergeProfileIntoSave(profile, cache.save),
dirty,
})
}
async function loadServerSyncSave(): Promise<OfflineSave> {
return requestJson('/api/profile/sync-save')
}
async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: CharacterProfile; save: OfflineSave }> {
return requestJson('/api/profile/sync-save', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ save }),
})
}
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
if (!session.account || !session.profile) return session
const cache = readOnlineCache()
if (cache?.account.id === session.account.id && cache.dirty) {
writeOnlineCache({
...cache,
account: session.account,
})
return {
account: session.account,
profile: buildProfile(cache.save),
}
}
try {
const save = await loadServerSyncSave()
writeOnlineCache({
version: 1,
account: session.account,
save,
dirty: false,
})
} catch {
writeOnlineCache({
version: 1,
account: session.account,
save: mergeProfileIntoSave(session.profile, cache?.account.id === session.account.id ? cache.save : undefined),
dirty: false,
})
}
return session
}
async function fallbackToCachedOnline<T>(reason: unknown, localAction: () => Promise<T>): Promise<T> {
if (!isNetworkError(reason) || !readOnlineCache()) throw reason
writeMode('offline-cached')
return localAction()
}
async function withOnlineFallback<T>(
onlineAction: () => Promise<T>,
localAction: () => Promise<T>,
onSuccess?: (result: T) => void | Promise<void>,
): Promise<T> {
try {
const result = await onlineAction()
await onSuccess?.(result)
return result
} catch (reason) {
return fallbackToCachedOnline(reason, localAction)
}
}
async function loadOnlineSessionFromServer(): Promise<AuthSession> {
const session = await requestJson<AuthSession>('/api/auth/session')
return finalizeOnlineSession(session)
}
const serverRepository: GameRepository = {
loadSession: () => requestJson('/api/auth/session'),
register: (username, password, characterName) =>
requestJson('/api/auth/register', {
loadSession: async () => {
try {
return await loadOnlineSessionFromServer()
} catch (reason) {
if (isNetworkError(reason)) {
const fallback = resumeCachedOnlineSession()
if (fallback) return fallback
}
throw reason
}
},
register: async (username, password, characterName) =>
finalizeOnlineSession(await requestJson('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, characterName }),
}),
login: (username, password) =>
requestJson('/api/auth/login', {
})),
login: async (username, password) =>
finalizeOnlineSession(await requestJson('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
})),
logout: async () => {
await requestJson('/api/auth/logout', { method: 'POST' })
try {
await requestJson('/api/auth/logout', { method: 'POST' })
} catch (reason) {
if (!isNetworkError(reason)) throw reason
}
clearOnlineCache()
writeMode('online')
},
loadProfile: () => requestJson('/api/profile'),
loadProfile: () =>
readOnlineCache()
? cachedOnlineLocalRepository.loadProfile()
: withOnlineFallback(
() => requestJson('/api/profile'),
() => cachedOnlineLocalRepository.loadProfile(),
(profile) => {
cacheActiveOnlineProfile(profile)
},
),
saveProfile: (classId, abilitySlots) =>
requestJson('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classId, abilitySlots }),
}),
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
requestJson(`/api/dungeons/${dungeonId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }),
}),
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
resourceSpent,
durationSeconds,
completedPart,
startPart,
partDurationSeconds,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
requestJson('/api/roguelike/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }),
}),
cachedOnlineLocalRepository.completeRoguelike(
dungeonId,
difficultyId,
encountersCleared,
resourceSpent,
durationSeconds,
options,
),
allocateTalent: (talentId) =>
requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }),
cachedOnlineLocalRepository.allocateTalent(talentId),
resetTalents: () =>
requestJson('/api/talents/reset', { method: 'POST' }),
cachedOnlineLocalRepository.resetTalents(),
equipItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }),
cachedOnlineLocalRepository.equipItem(itemId),
discardExtraItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }),
cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }),
cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) =>
requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }),
cachedOnlineLocalRepository.craftItem(recipeId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
requestJson(`/api/encounters/${encounterId}/loot-roll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, runToken }),
}),
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
function emptyCharacterData(classId: number): CharacterData {
@@ -419,28 +683,35 @@ function emptyCharacterData(classId: number): CharacterData {
}
}
const offlineRepository: GameRepository = {
async loadSession() {
const save = readOfflineSave()
return {
account: save ? offlineAccount : null,
profile: save ? buildProfile(save) : null,
}
},
async register() {
throw new Error('Account registration requires online mode.')
},
async login() {
throw new Error('Account login requires online mode.')
},
async logout() {
writeMode('online')
},
async loadProfile() {
return buildProfile(requireOfflineSave())
},
async saveProfile(classId, abilitySlots) {
const save = requireOfflineSave()
function requireStoredSave(store: LocalSaveStore): OfflineSave {
const save = store.readSave()
if (!save) throw new Error('No local character exists yet.')
return save
}
function createLocalRepository(store: LocalSaveStore): GameRepository {
return {
async loadSession() {
const save = store.readSave()
return {
account: save ? (store.readAccount() ?? offlineAccount) : null,
profile: save ? buildProfile(save) : null,
}
},
async register() {
throw new Error('Account registration requires online mode.')
},
async login() {
throw new Error('Account login requires online mode.')
},
async logout() {
writeMode('online')
},
async loadProfile() {
return buildProfile(requireStoredSave(store))
},
async saveProfile(classId, abilitySlots) {
const save = requireStoredSave(store)
const static_ = clone(starterProfile) as CharacterProfile
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.')
@@ -466,10 +737,10 @@ const offlineRepository: GameRepository = {
}
save.characters[classId].abilitySlots = slots
save.activeClassId = classId
writeOfflineSave(save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
store.writeSave(save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
void startPart
void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
@@ -478,7 +749,7 @@ const offlineRepository: GameRepository = {
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
@@ -560,7 +831,7 @@ const offlineRepository: GameRepository = {
}
}
writeOfflineSave(save)
store.writeSave(save)
const updatedProfile = buildProfile(save)
return {
@@ -579,8 +850,8 @@ const offlineRepository: GameRepository = {
bonusItem,
profile: updatedProfile,
}
},
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
},
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
if (!Number.isInteger(encountersCleared) || encountersCleared < 0) {
throw new Error('The roguelike progress total is invalid.')
}
@@ -590,7 +861,7 @@ const offlineRepository: GameRepository = {
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
@@ -644,7 +915,7 @@ const offlineRepository: GameRepository = {
cd.talentPoints + levelsGained,
)
writeOfflineSave(save)
store.writeSave(save)
const updatedProfile = buildProfile(save)
return {
@@ -663,9 +934,9 @@ const offlineRepository: GameRepository = {
bonusItem: null,
profile: updatedProfile,
}
},
async allocateTalent(talentId) {
const save = requireOfflineSave()
},
async allocateTalent(talentId) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
@@ -698,11 +969,11 @@ const offlineRepository: GameRepository = {
}
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1
writeOfflineSave(save)
return buildProfile(save)
},
async resetTalents() {
const save = requireOfflineSave()
store.writeSave(save)
return buildProfile(save)
},
async resetTalents() {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
@@ -719,11 +990,11 @@ const offlineRepository: GameRepository = {
profile.maxTalentPoints,
cd.talentPoints + refunded,
)
writeOfflineSave(save)
return buildProfile(save)
},
async equipItem(itemId) {
const save = requireOfflineSave()
store.writeSave(save)
return buildProfile(save)
},
async equipItem(itemId) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
@@ -731,22 +1002,22 @@ const offlineRepository: GameRepository = {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
const save = requireOfflineSave()
store.writeSave(save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async breakdownItem(itemId) {
const save = requireOfflineSave()
store.writeSave(save)
return buildProfile(save)
},
async breakdownItem(itemId) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
@@ -785,11 +1056,11 @@ const offlineRepository: GameRepository = {
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async craftItem(recipeId) {
const save = requireOfflineSave()
store.writeSave(save)
return buildProfile(save)
},
async craftItem(recipeId) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
@@ -809,14 +1080,14 @@ const offlineRepository: GameRepository = {
addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
store.writeSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.')
}
const save = requireOfflineSave()
const save = requireStoredSave(store)
const rollKey = `${runToken}:${encounterId}:${difficultyId}`
if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])
@@ -894,13 +1165,112 @@ const offlineRepository: GameRepository = {
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return clone(result)
store.writeSave(save)
return clone(result)
},
}
}
const offlineRepository = createLocalRepository({
readSave: readOfflineSave,
writeSave: writeOfflineSave,
readAccount: () => offlineAccount,
})
const cachedOnlineLocalRepository = createLocalRepository({
readSave: () => readOnlineCache()?.save ?? null,
writeSave: (save) => {
const cache = readOnlineCache()
if (!cache) throw new Error('No cached account save exists yet.')
writeOnlineCache({
...cache,
save,
dirty: true,
})
},
readAccount: () => readOnlineCache()?.account ?? null,
})
const cachedOnlineRepository: GameRepository = {
async loadSession() {
try {
const session = await loadOnlineSessionFromServer()
if (session.account && session.profile) {
writeMode('online')
return session
}
} catch {
// Fall through to local cached mirror.
}
return cachedOnlineLocalRepository.loadSession()
},
async register() {
throw new Error('Account registration requires online mode.')
},
async login() {
throw new Error('Account login requires online mode.')
},
async logout() {
clearOnlineCache()
writeMode('online')
},
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
resourceSpent,
durationSeconds,
completedPart,
startPart,
partDurationSeconds,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike(
dungeonId,
difficultyId,
encountersCleared,
resourceSpent,
durationSeconds,
options,
),
allocateTalent: (talentId) => cachedOnlineLocalRepository.allocateTalent(talentId),
resetTalents: () => cachedOnlineLocalRepository.resetTalents(),
equipItem: (itemId) => cachedOnlineLocalRepository.equipItem(itemId),
discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
export function getGameMode(): GameMode {
return readMode()
return toGameMode(readMode())
}
export function getCloudSyncStatus(): CloudSyncStatus {
const cache = readOnlineCache()
return {
available: Boolean(cache),
dirty: Boolean(cache?.dirty),
}
}
export async function syncCloudSave(): Promise<CharacterProfile> {
const cache = readOnlineCache()
if (!cache) {
throw new Error('No signed-in save is available for cloud sync.')
}
const synced = await pushServerSyncSave(cache.save)
writeOnlineCache({
version: 1,
account: cache.account,
save: synced.save,
dirty: false,
})
writeMode('online')
return synced.profile
}
export function selectOnlineMode() {
@@ -926,14 +1296,14 @@ export function createOfflineCharacter(characterName: string): AuthSession {
lootRolls: {},
}
writeOfflineSave(save)
writeMode('offline')
writeMode('offline-local')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function resumeOfflineCharacter(): AuthSession | null {
const save = readOfflineSave()
if (!save) return null
writeMode('offline')
writeMode('offline-local')
return { account: offlineAccount, profile: buildProfile(save) }
}
@@ -942,5 +1312,8 @@ export function hasOfflineCharacter(): boolean {
}
export function activeGameRepository(): GameRepository {
return readMode() === 'offline' ? offlineRepository : serverRepository
const mode = readMode()
if (mode === 'offline-local') return offlineRepository
if (mode === 'offline-cached') return cachedOnlineRepository
return serverRepository
}
+6
View File
@@ -12,4 +12,10 @@ body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
#root {
height: 100dvh;
overflow: hidden;
}
+66 -8
View File
@@ -33,6 +33,7 @@ export const INPUT_ACTIONS = [
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
'pause',
] as const
@@ -60,6 +61,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty3: 'Target Party Member 3',
targetParty4: 'Target Party Member 4',
targetParty5: 'Target Party Member 5',
targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group',
pause: 'Pause Menu',
}
@@ -85,6 +87,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'F3',
targetParty4: 'F4',
targetParty5: 'F5',
targetParty6: 'F6',
toggleTargetGroup: 'Tab',
pause: 'Escape',
},
@@ -108,6 +111,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15',
targetParty4: 'Button13',
targetParty5: 'Button4',
targetParty6: 'Button11',
toggleTargetGroup: 'Button6',
pause: 'Button9',
},
@@ -202,13 +206,19 @@ function bindingGroup(action: InputAction) {
}
function isVisible(element: HTMLElement) {
if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false
return element.getClientRects().length > 0
}
function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
const scope: ParentNode = keyboard ?? pauseMenu ?? document
const dialog = Array.from(
document.querySelectorAll<HTMLElement>(
'.result-screen, .binding-capture, .dual-startup-prompt',
),
).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
return Array.from(
scope.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
@@ -256,7 +266,22 @@ function moveFocus(action: InputAction) {
const next = ranked[0]?.candidate
if (!next) return
next.focus({ preventScroll: true })
next.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
function hasUiOverlay() {
return Array.from(
document.querySelectorAll<HTMLElement>(
'.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard',
),
).some(isVisible)
}
function isCombatTargetAction(action: InputAction) {
return action.startsWith('navigate')
|| action.startsWith('targetParty')
|| action === 'previousTarget'
|| action === 'nextTarget'
|| action === 'toggleTargetGroup'
}
const BUTTON_LABELS: Record<number, string> = {
@@ -372,6 +397,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({})
const lastCombatNavigationRef = useRef(0)
useEffect(() => {
bindingsRef.current = bindings
@@ -416,18 +442,29 @@ export function InputProvider({ children }: { children: ReactNode }) {
}, [])
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay()
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
const now = performance.now()
if (now - lastCombatNavigationRef.current < 125) return
lastCombatNavigationRef.current = now
}
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (action.startsWith('navigate')) {
if (!combatActive) moveFocus(action)
if (uiOverlay || !combatActive) moveFocus(action)
} else if (action === 'confirm') {
const active = document.activeElement
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLElement && active.matches('button, [role="button"]')) {
} else if (
active instanceof HTMLElement
&& active.matches('button:not(:disabled), [role="button"]')
&& isVisible(active)
) {
active.click()
} else {
focusFirstControl()
@@ -435,7 +472,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
} else if (action === 'back') {
if (keyboardInputRef.current) {
closeKeyboard()
} else if (!combatActive) {
} else if (uiOverlay || !combatActive) {
const backButton = Array.from(
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
).find(isVisible)
@@ -458,18 +495,28 @@ export function InputProvider({ children }: { children: ReactNode }) {
const combatActive = Boolean(
document.querySelector('[data-combat-active="true"]'),
)
const uiOverlay = hasUiOverlay()
const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
const uiPriority = [
'navigateUp',
'navigateDown',
'navigateLeft',
'navigateRight',
'confirm',
'back',
] satisfies InputAction[]
const directTargetActions = [
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
] satisfies InputAction[]
const combatPriority = [
@@ -487,7 +534,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = combatActive && preferencesRef.current.directPartyTargeting
const action = menuDpadActions[token] && (!combatActive || uiOverlay)
? menuDpadActions[token]
: uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
: combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
@@ -541,8 +592,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
const ensureFocus = () => {
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
const candidates = focusableElements()
const active = document.activeElement
const activeIsUsable = active instanceof HTMLElement
&& candidates.includes(active)
&& isVisible(active)
if (
document.activeElement === document.body
(!activeIsUsable || document.activeElement === document.body)
&& !keyboardInputRef.current
&& !captureRef.current
) {
@@ -553,6 +609,8 @@ export function InputProvider({ children }: { children: ReactNode }) {
window.requestAnimationFrame(ensureFocus)
})
observer.observe(document.getElementById('root') ?? document.body, {
attributes: true,
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
childList: true,
subtree: true,
})
+16 -2
View File
@@ -1,9 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Capacitor } from '@capacitor/core'
import './index.css'
import App from './App.tsx'
import { InputProvider } from './input.tsx'
import { DualScreenBottomDisplay, DualScreenProvider } from './dualScreen.tsx'
import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
@@ -11,6 +12,7 @@ createRoot(document.getElementById('root')!).render(
<DualScreenBottomDisplay />
) : (
<DualScreenProvider>
<DualScreenStartupPrompt />
<InputProvider>
<App />
</InputProvider>
@@ -19,7 +21,19 @@ createRoot(document.getElementById('root')!).render(
</StrictMode>,
)
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
const isNativeApp = Capacitor.isNativePlatform()
if (import.meta.env.PROD && isNativeApp && 'serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations()
.then((registrations) => Promise.all(registrations.map((registration) => registration.unregister())))
.then(() => caches.keys())
.then((keys) => Promise.all(keys.filter((key) => key.startsWith('chronicle-')).map((key) => caches.delete(key))))
.catch(() => {
// Native app assets should come directly from the APK when cache cleanup is unavailable.
})
}
if (import.meta.env.PROD && !isNativeApp && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js').catch(() => {
// Offline launch remains optional when registration is unavailable.
+9 -9
View File
@@ -4918,7 +4918,7 @@
"name": "Bulldrome Hunting Ground",
"recommendedLevel": 1,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 125,
"description": "A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.",
@@ -6289,7 +6289,7 @@
"name": "Tigrex Raid",
"recommendedLevel": 5,
"contentType": "raid",
"partySize": 10,
"partySize": 18,
"completionItemLevel": null,
"experienceReward": 275,
"description": "A raid-scale hunt against Tigrex, Rathalos, and Gypceros.",
@@ -6700,7 +6700,7 @@
"name": "Tigrex Hunting Ground",
"recommendedLevel": 5,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 205,
"description": "A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.",
@@ -7111,7 +7111,7 @@
"name": "Nargacuga Hunting Ground",
"recommendedLevel": 10,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 245,
"description": "A three-boss hunt featuring Nargacuga, Azuros, and Diablos.",
@@ -7522,7 +7522,7 @@
"name": "Nargacuga Raid",
"recommendedLevel": 10,
"contentType": "raid",
"partySize": 10,
"partySize": 18,
"completionItemLevel": null,
"experienceReward": 325,
"description": "A raid-scale hunt against Nargacuga, Azuros, and Diablos.",
@@ -7933,7 +7933,7 @@
"name": "Barroth Hunting Ground",
"recommendedLevel": 15,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 285,
"description": "A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.",
@@ -8344,7 +8344,7 @@
"name": "Barroth Raid",
"recommendedLevel": 15,
"contentType": "raid",
"partySize": 10,
"partySize": 18,
"completionItemLevel": null,
"experienceReward": 375,
"description": "A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.",
@@ -8755,7 +8755,7 @@
"name": "Anjanath Hunting Ground",
"recommendedLevel": 20,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 325,
"description": "A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.",
@@ -9166,7 +9166,7 @@
"name": "Anjanath Raid",
"recommendedLevel": 20,
"contentType": "raid",
"partySize": 10,
"partySize": 18,
"completionItemLevel": null,
"experienceReward": 425,
"description": "A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.",