made some changes to the UI, removed leaderboards. updated gamesaves
This commit is contained in:
+621
-30
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,4 +12,10 @@ body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
+66
-8
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user