Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd6a1ce3c7 | |||
| f8b98e6b23 |
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 46
|
||||
versionName "1.0.28"
|
||||
versionCode 48
|
||||
versionName "1.0.30"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+524
-8
@@ -4395,6 +4395,10 @@ h2 {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.customize-tab-back {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.embedded-screen .gear-summary,
|
||||
.embedded-screen .talent-toolbar {
|
||||
margin-top: 16px;
|
||||
@@ -6851,6 +6855,109 @@ h2 {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.workshop-bottom-display {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workshop-bottom-summary {
|
||||
background: #1c1e25;
|
||||
border: 2px solid #0a0b0e;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.15;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid article {
|
||||
align-items: center;
|
||||
background: #1c1e25;
|
||||
border: 2px solid #0a0b0e;
|
||||
color: var(--ink);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
min-height: 58px;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid article > span {
|
||||
align-items: center;
|
||||
background: #15161c;
|
||||
color: var(--gold);
|
||||
display: flex;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid strong,
|
||||
.workshop-bottom-grid small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid strong {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid small,
|
||||
.workshop-bottom-grid p {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.05;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid i {
|
||||
color: var(--gold);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.equipment-action-strip,
|
||||
.crafting-action-row {
|
||||
align-items: center;
|
||||
background: #1c1e25;
|
||||
border: 2px solid #0a0b0e;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
min-height: 42px;
|
||||
outline: 2px solid #3e3d47;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.equipment-action-strip .comparison-delta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.equipment-action-strip > p {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.crafting-action-row {
|
||||
justify-content: flex-end;
|
||||
margin-top: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.game-shell.workshop-shell {
|
||||
height: 100dvh;
|
||||
@@ -6871,6 +6978,10 @@ h2 {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .customize-heading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .screen-heading {
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
@@ -6892,6 +7003,10 @@ h2 {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.workshop-shell .customize-tab-back {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.workshop-shell .customize-tabs,
|
||||
.workshop-shell .equipment-tabs,
|
||||
.workshop-shell .talent-page-tabs {
|
||||
@@ -6907,6 +7022,11 @@ h2 {
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.workshop-shell .customize-tabs {
|
||||
grid-template-columns: 70px repeat(4, minmax(0, 1fr));
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.workshop-shell .equipment-screen,
|
||||
.workshop-shell .talent-screen {
|
||||
gap: 0;
|
||||
@@ -6965,6 +7085,23 @@ h2 {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .item-comparison,
|
||||
.workshop-shell .crafting-detail-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .equipment-action-strip {
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
min-height: 36px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .equipment-action-strip .comparison-delta {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.workshop-shell .comparison-arrow {
|
||||
font-size: 11px;
|
||||
padding: 0;
|
||||
@@ -7020,7 +7157,7 @@ h2 {
|
||||
|
||||
.workshop-shell .equipment-layout {
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(178px, 0.95fr);
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(210px, 0.85fr);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@@ -7115,12 +7252,15 @@ h2 {
|
||||
|
||||
.workshop-shell .crafting-layout {
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(132px, 0.42fr) minmax(230px, 1fr) minmax(190px, 0.72fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filters {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filter-grid,
|
||||
@@ -7128,9 +7268,13 @@ h2 {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filter-grid {
|
||||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filter-grid button {
|
||||
min-height: 28px;
|
||||
padding: 4px;
|
||||
min-height: 32px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filter-grid strong {
|
||||
@@ -7313,15 +7457,249 @@ h2 {
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-list > button {
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
min-height: 34px;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-list > button i {
|
||||
.workshop-shell .crafting-action-row {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
margin-top: 5px;
|
||||
min-height: 28px;
|
||||
outline: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-action-row .primary-button {
|
||||
font-size: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.workshop-shell .customize-layout {
|
||||
gap: 8px;
|
||||
grid-template-columns: 148px minmax(0, 1fr);
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker {
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker > .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-list > button:nth-child(n+4) {
|
||||
.workshop-shell .class-picker > button {
|
||||
gap: 6px;
|
||||
margin-bottom: 5px;
|
||||
min-height: 44px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker > button > span {
|
||||
flex-basis: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker strong {
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker small {
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.workshop-shell .loadout-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .class-detail {
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-portrait {
|
||||
flex-basis: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-detail h2 {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-detail p:last-child,
|
||||
.workshop-shell .loadout-heading > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .loadout-heading,
|
||||
.workshop-shell .ability-library-heading,
|
||||
.workshop-shell .save-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots {
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots button {
|
||||
min-height: 40px;
|
||||
padding: 5px 2px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots span {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots strong {
|
||||
font-size: 9px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library {
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
margin-top: 5px;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button {
|
||||
align-content: center;
|
||||
gap: 5px;
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 58px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button > span {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library strong {
|
||||
font-size: 8px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library small,
|
||||
.workshop-shell .ability-library i {
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library i {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button:nth-child(n+6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .save-row .primary-button {
|
||||
font-size: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.workshop-shell .save-row > span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.workshop-bottom-display {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid {
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.workshop-bottom-grid article {
|
||||
min-height: 50px;
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) and (max-height: 620px) {
|
||||
.workshop-shell .equipment-layout {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(190px, 0.7fr);
|
||||
}
|
||||
|
||||
.workshop-shell .equipment-action-strip .comparison-delta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filter-grid {
|
||||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filter-grid button {
|
||||
min-height: 34px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filter-grid strong {
|
||||
font-size: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-filter-grid span {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-level-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-list > button {
|
||||
display: grid;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.workshop-shell .customize-layout {
|
||||
grid-template-columns: 118px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button:nth-child(n+6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-bottom-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.workshop-shell .crafting-list > button {
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workshop-shell .crafting-list > button i {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -7341,3 +7719,141 @@ h2 {
|
||||
grid-column: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.workshop-shell .customize-screen > .customize-layout {
|
||||
gap: 8px;
|
||||
grid-template-columns: 148px minmax(0, 1fr);
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker {
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker > .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker > button {
|
||||
gap: 6px;
|
||||
margin-bottom: 5px;
|
||||
min-height: 44px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker > button > span {
|
||||
flex-basis: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker strong {
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-picker small {
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.workshop-shell .loadout-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .class-detail {
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-detail p:last-child,
|
||||
.workshop-shell .loadout-heading > span,
|
||||
.workshop-shell .ability-library small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .class-portrait {
|
||||
flex-basis: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.workshop-shell .class-detail h2 {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.workshop-shell .loadout-heading,
|
||||
.workshop-shell .ability-library-heading,
|
||||
.workshop-shell .save-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots {
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots button {
|
||||
min-height: 40px;
|
||||
padding: 5px 2px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots span {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots strong {
|
||||
font-size: 9px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library {
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
margin-top: 5px;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button {
|
||||
align-content: center;
|
||||
gap: 5px;
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 58px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button > span {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library strong {
|
||||
font-size: 8px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library i {
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button:nth-child(n+6) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) and (max-height: 620px) {
|
||||
.workshop-shell .customize-screen > .customize-layout {
|
||||
grid-template-columns: 118px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type CharacterProfile,
|
||||
type GameClass,
|
||||
} from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
import { EquipmentScreen } from './EquipmentScreen'
|
||||
import { TalentScreen } from './TalentScreen'
|
||||
|
||||
@@ -14,7 +15,8 @@ type Props = {
|
||||
}
|
||||
|
||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
|
||||
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [classId, setClassId] = useState(profile.character.classId)
|
||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||
if (activeTab !== 'class') return null
|
||||
return {
|
||||
mode: 'class',
|
||||
title: 'Ability Library',
|
||||
subtitle: gameClass.name,
|
||||
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
|
||||
items: gameClass.spells.map((ability) => {
|
||||
const locked = ability.unlockLevel > profile.character.level
|
||||
const equipped = slots.includes(ability.id)
|
||||
return {
|
||||
glyph: locked ? 'L' : ability.glyph,
|
||||
title: ability.name,
|
||||
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
|
||||
detail: ability.description,
|
||||
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
|
||||
}
|
||||
}),
|
||||
}
|
||||
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
|
||||
|
||||
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
|
||||
|
||||
async function persistChanges() {
|
||||
saveScroll()
|
||||
setSaving(true)
|
||||
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
|
||||
return (
|
||||
<section className="content-screen customize-screen">
|
||||
<div className="screen-heading">
|
||||
<div className="screen-heading customize-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Workshop</p>
|
||||
<h1>Customize Character</h1>
|
||||
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
|
||||
{([
|
||||
{ key: 'equipment', label: 'Equipment' },
|
||||
{ key: 'crafting', label: 'Crafting' },
|
||||
{ key: 'talents', label: 'Talents' },
|
||||
{ key: 'class', label: 'Class' },
|
||||
] as const).map((tab) => (
|
||||
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
{activeTab === 'equipment' && (
|
||||
<EquipmentScreen
|
||||
embedded
|
||||
mode="equipment"
|
||||
showModeTabs={false}
|
||||
profile={profile}
|
||||
onUpdated={onSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crafting' && (
|
||||
<EquipmentScreen
|
||||
embedded
|
||||
mode="crafting"
|
||||
showModeTabs={false}
|
||||
profile={profile}
|
||||
onUpdated={onSaved}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type EquipmentSlot,
|
||||
type Item,
|
||||
} from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
|
||||
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||
weapon: 'Weapon',
|
||||
@@ -24,16 +25,28 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||
}
|
||||
|
||||
const EQUIPMENT_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_LIST_PAGE_SIZE = 6
|
||||
const CRAFTING_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
||||
.filter((slot) => slot !== 'component')
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
onBack?: () => void
|
||||
onUpdated: (profile: CharacterProfile) => void
|
||||
embedded?: boolean
|
||||
mode?: 'equipment' | 'crafting'
|
||||
showModeTabs?: boolean
|
||||
}
|
||||
|
||||
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||
export function EquipmentScreen({
|
||||
profile,
|
||||
onBack,
|
||||
onUpdated,
|
||||
embedded = false,
|
||||
mode,
|
||||
showModeTabs = true,
|
||||
}: Props) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const totalItemCount = profile.inventory.reduce(
|
||||
(total, item) => total + item.quantity,
|
||||
0,
|
||||
@@ -49,7 +62,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
const [crafting, setCrafting] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
|
||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
|
||||
const [inventoryPage, setInventoryPage] = useState(0)
|
||||
const [recipePage, setRecipePage] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
@@ -173,6 +186,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
}
|
||||
}, [equipmentTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode) setEquipmentTab(mode)
|
||||
}, [mode])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
@@ -247,6 +264,143 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
}
|
||||
}
|
||||
|
||||
function renderEquipmentActions() {
|
||||
if (!selectedItem) {
|
||||
return <p>Select an item to inspect it.</p>
|
||||
}
|
||||
if (selectedItem.slot === 'component') {
|
||||
return <p className="component-note">Used in crafting.</p>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
||||
onClick={equipSelected}
|
||||
type="button"
|
||||
>
|
||||
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
||||
</button>
|
||||
{upgradeRecipe && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
|
||||
onClick={upgradeSelected}
|
||||
type="button"
|
||||
>
|
||||
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
|
||||
</button>
|
||||
)}
|
||||
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
||||
<button
|
||||
className="breakdown-button"
|
||||
disabled={equipping || breakingDown || upgrading}
|
||||
onClick={breakdownSelected}
|
||||
type="button"
|
||||
>
|
||||
{breakingDown
|
||||
? 'Breaking Down...'
|
||||
: selectedItem.quantity > 1
|
||||
? 'Break Down Duplicate'
|
||||
: 'Break Down'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
||||
if (equipmentTab === 'crafting') {
|
||||
if (!selectedRecipe) {
|
||||
return {
|
||||
mode: 'crafting',
|
||||
title: 'Craft Output',
|
||||
subtitle: 'No recipe selected',
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'crafting',
|
||||
title: selectedRecipe.item.name,
|
||||
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
|
||||
summary: selectedRecipe.item.description,
|
||||
items: [
|
||||
{
|
||||
glyph: selectedRecipe.item.glyph,
|
||||
title: 'Craft Output',
|
||||
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
|
||||
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
|
||||
},
|
||||
...selectedRecipe.components.map((component) => ({
|
||||
glyph: component.item.glyph,
|
||||
title: component.item.name,
|
||||
meta: `Item Level ${component.item.itemLevel}`,
|
||||
status: `${component.owned}/${component.quantity}`,
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
if (!selectedItem) {
|
||||
return {
|
||||
mode: 'equipment',
|
||||
title: 'Equipment Detail',
|
||||
subtitle: 'No item selected',
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'equipment',
|
||||
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
|
||||
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
|
||||
summary: selectedItem.description,
|
||||
items: selectedItem.slot === 'component'
|
||||
? [{
|
||||
glyph: selectedItem.glyph,
|
||||
title: selectedItem.name,
|
||||
meta: `Owned: ${selectedItem.quantity}`,
|
||||
status: 'Component',
|
||||
}]
|
||||
: [
|
||||
{
|
||||
glyph: selectedItem.glyph,
|
||||
title: selectedItem.name,
|
||||
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
|
||||
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
|
||||
},
|
||||
...(comparisonItem && comparisonItem.id !== selectedItem.id
|
||||
? [{
|
||||
glyph: comparisonItem.glyph,
|
||||
title: comparisonItem.name,
|
||||
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
|
||||
status: 'Currently Equipped',
|
||||
}]
|
||||
: [{
|
||||
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
|
||||
status: 'Comparison',
|
||||
}]),
|
||||
...(upgradeRecipe
|
||||
? [
|
||||
{
|
||||
glyph: upgradeRecipe.item.glyph,
|
||||
title: `Upgrade to ${upgradeRecipe.item.name}`,
|
||||
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
|
||||
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
|
||||
},
|
||||
...upgradeRecipe.components.map((component) => ({
|
||||
glyph: component.item.glyph,
|
||||
title: component.item.name,
|
||||
meta: `Required for upgrade`,
|
||||
status: `${component.owned}/${component.quantity}`,
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
|
||||
|
||||
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
@@ -273,22 +427,24 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
||||
</div>
|
||||
|
||||
<nav className="equipment-tabs">
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('equipment')}
|
||||
type="button"
|
||||
>
|
||||
Equipment
|
||||
</button>
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('crafting')}
|
||||
type="button"
|
||||
>
|
||||
Crafting
|
||||
</button>
|
||||
</nav>
|
||||
{showModeTabs && (
|
||||
<nav className="equipment-tabs">
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('equipment')}
|
||||
type="button"
|
||||
>
|
||||
Equipment
|
||||
</button>
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('crafting')}
|
||||
type="button"
|
||||
>
|
||||
Crafting
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{equipmentTab === 'equipment' ? (
|
||||
<>
|
||||
@@ -297,9 +453,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
selectedItem.slot === 'component' ? (
|
||||
<>
|
||||
<ItemDetail title="Crafting Component" item={selectedItem} />
|
||||
<div className="equip-action">
|
||||
<p className="component-note">Used in crafting.</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -313,41 +466,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="equip-action">
|
||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
||||
onClick={equipSelected}
|
||||
type="button"
|
||||
>
|
||||
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
||||
</button>
|
||||
{upgradeRecipe && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
|
||||
onClick={upgradeSelected}
|
||||
type="button"
|
||||
>
|
||||
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
|
||||
</button>
|
||||
)}
|
||||
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
||||
<button
|
||||
className="breakdown-button"
|
||||
disabled={equipping || breakingDown || upgrading}
|
||||
onClick={breakdownSelected}
|
||||
type="button"
|
||||
>
|
||||
{breakingDown
|
||||
? 'Breaking Down...'
|
||||
: selectedItem.quantity > 1
|
||||
? 'Break Down Duplicate'
|
||||
: 'Break Down'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
@@ -355,6 +473,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="equipment-action-strip">
|
||||
{renderEquipmentActions()}
|
||||
</section>
|
||||
|
||||
<div className="equipment-layout">
|
||||
<section className="equipped-panel">
|
||||
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
||||
@@ -469,7 +591,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<strong>All</strong>
|
||||
<span>{profile.craftingRecipes.length}</span>
|
||||
</button>
|
||||
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
||||
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||
<button
|
||||
className={slotFilter === slot ? 'active' : ''}
|
||||
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
||||
@@ -480,7 +602,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<strong>{label}</strong>
|
||||
<strong>{SLOT_LABELS[slot]}</strong>
|
||||
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -557,6 +679,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
previousDisabled={recipePage <= 0}
|
||||
/>
|
||||
)}
|
||||
<div className="crafting-action-row">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
|
||||
onClick={craftSelected}
|
||||
type="button"
|
||||
>
|
||||
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="crafting-detail-panel">
|
||||
@@ -579,14 +711,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
|
||||
onClick={craftSelected}
|
||||
type="button"
|
||||
>
|
||||
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="inventory-empty">Select a recipe.</p>
|
||||
|
||||
+99
-1
@@ -56,12 +56,28 @@ export type DualScreenCombatState = {
|
||||
targetGroup: 0 | 1 | 2
|
||||
}
|
||||
|
||||
export type DualScreenWorkshopState = {
|
||||
mode: 'class' | 'equipment' | 'crafting' | 'talents'
|
||||
title: string
|
||||
subtitle: string
|
||||
summary?: string
|
||||
items: Array<{
|
||||
glyph?: string
|
||||
title: string
|
||||
meta?: string
|
||||
detail?: string
|
||||
status?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type DualScreenMessage =
|
||||
| { type: 'combat-state'; state: DualScreenCombatState }
|
||||
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
||||
| { type: 'companion-ready' }
|
||||
| { type: 'companion-heartbeat' }
|
||||
| { type: 'control-action'; action: InputAction }
|
||||
| { type: 'combat-ended' }
|
||||
| { type: 'workshop-ended' }
|
||||
|
||||
type DualScreenContextValue = {
|
||||
enabled: boolean
|
||||
@@ -280,16 +296,64 @@ export function useDualScreenPublisher(
|
||||
}, [enabled, state])
|
||||
}
|
||||
|
||||
export function useDualScreenWorkshopPublisher(
|
||||
state: DualScreenWorkshopState | null,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const stateRef = useRef(state)
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !state) return
|
||||
const channel = createChannel()
|
||||
if (!channel) return
|
||||
const publish = () => {
|
||||
if (stateRef.current) {
|
||||
channel.postMessage({
|
||||
type: 'workshop-state',
|
||||
state: stateRef.current,
|
||||
} satisfies DualScreenMessage)
|
||||
}
|
||||
}
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === 'companion-ready') publish()
|
||||
}
|
||||
publish()
|
||||
return () => {
|
||||
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
|
||||
channel.close()
|
||||
}
|
||||
}, [enabled, state])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !state) return
|
||||
const channel = createChannel()
|
||||
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
|
||||
channel?.close()
|
||||
}, [enabled, state])
|
||||
}
|
||||
|
||||
export function DualScreenBottomDisplay() {
|
||||
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
||||
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const channel = createChannel()
|
||||
if (!channel) return
|
||||
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === 'combat-state') setState(event.data.state)
|
||||
if (event.data.type === 'combat-state') {
|
||||
setState(event.data.state)
|
||||
setWorkshopState(null)
|
||||
}
|
||||
if (event.data.type === 'workshop-state') {
|
||||
setWorkshopState(event.data.state)
|
||||
setState(null)
|
||||
}
|
||||
if (event.data.type === 'combat-ended') setState(null)
|
||||
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
||||
}
|
||||
announce()
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -307,6 +371,40 @@ export function DualScreenBottomDisplay() {
|
||||
channel?.close()
|
||||
}
|
||||
|
||||
if (!state && workshopState) {
|
||||
return (
|
||||
<main className="dual-bottom-display workshop-bottom-display">
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{workshopState.mode}</p>
|
||||
<h1>{workshopState.title}</h1>
|
||||
</div>
|
||||
<div className="dual-controls-progress">
|
||||
<span>{workshopState.subtitle}</span>
|
||||
</div>
|
||||
</header>
|
||||
{workshopState.summary && (
|
||||
<section className="workshop-bottom-summary">
|
||||
{workshopState.summary}
|
||||
</section>
|
||||
)}
|
||||
<section className="workshop-bottom-grid">
|
||||
{workshopState.items.map((item, index) => (
|
||||
<article key={`${item.title}-${index}`}>
|
||||
{item.glyph && <span>{item.glyph}</span>}
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
{item.meta && <small>{item.meta}</small>}
|
||||
{item.detail && <p>{item.detail}</p>}
|
||||
</div>
|
||||
{item.status && <i>{item.status}</i>}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<main className="dual-bottom-display dual-bottom-waiting">
|
||||
|
||||
Reference in New Issue
Block a user