Compare commits

...

1 Commits

Author SHA1 Message Date
Warren H 88874933c3 Android build v1.0.26 2026-06-19 20:55:23 -04:00
7 changed files with 724 additions and 219 deletions
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 43 versionCode 44
versionName "1.0.27" versionName "1.0.26"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+432 -8
View File
@@ -1052,6 +1052,10 @@ textarea:focus-visible,
position: relative; position: relative;
} }
.game-shell.dungeon-shell {
width: min(1400px, calc(100% - 28px));
}
.auth-shell { .auth-shell {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -1368,6 +1372,10 @@ h2 {
flex-direction: column; flex-direction: column;
} }
.dungeon-run-screen {
gap: 14px;
}
.menu-screen { .menu-screen {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -1377,6 +1385,31 @@ h2 {
flex: 0 0 auto; flex: 0 0 auto;
} }
.dungeon-run-board {
display: grid;
flex: 1;
gap: 14px;
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.48fr);
min-height: 0;
overflow: hidden;
}
.dungeon-run-main,
.dungeon-setup-rail {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dungeon-run-main {
gap: 14px;
}
.dungeon-setup-rail {
gap: 10px;
}
.message-panel { .message-panel {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -1857,6 +1890,17 @@ h2 {
grid-template-columns: 92px minmax(0, 1fr) minmax(190px, 260px); grid-template-columns: 92px minmax(0, 1fr) minmax(190px, 260px);
} }
.dungeon-focus-card {
flex: 0 0 auto;
grid-template-columns: 108px minmax(0, 1fr);
margin-top: 0;
min-height: 178px;
}
.dungeon-focus-card > .dungeon-art {
height: 108px;
}
.run-summary-copy p:not(.eyebrow) { .run-summary-copy p:not(.eyebrow) {
color: var(--muted); color: var(--muted);
font-size: 18px; font-size: 18px;
@@ -1868,6 +1912,240 @@ h2 {
gap: 8px; gap: 8px;
} }
.part-setup-panel .part-picker {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.part-setup-panel .primary-button {
min-height: 54px;
}
.dungeon-choice-panel {
display: flex;
flex: 1;
flex-direction: column;
margin-top: 0;
min-height: 0;
}
.dungeon-choice-grid {
align-content: start;
flex: 1;
grid-auto-rows: minmax(86px, max-content);
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: 0;
overflow: hidden;
}
.dungeon-choice-grid .activity-card {
min-height: 86px;
}
.tier-setup-panel,
.part-setup-panel,
.dungeon-setup-rail .difficulty-section,
.dungeon-setup-rail .loot-preview-section,
.dungeon-setup-rail .leaderboard-section {
margin-top: 0;
}
.tier-setup-panel {
flex: 0 0 auto;
}
.tier-setup-panel .tier-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tier-setup-panel .tier-grid button {
min-height: 56px;
padding: 9px;
}
.dungeon-setup-rail .run-setup-heading {
gap: 10px;
}
.dungeon-setup-rail .run-setup-heading small {
font-size: 14px;
line-height: 1.05;
}
.dungeon-setup-rail .difficulty-summary {
grid-template-columns: 1fr;
}
.dungeon-setup-rail .difficulty-summary small {
line-height: 1.05;
}
.dungeon-setup-rail .difficulty-summary dl {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.dungeon-setup-rail .loot-preview-section {
min-height: 0;
}
.dungeon-setup-rail .loot-preview-grid {
grid-template-columns: 1fr;
max-height: 260px;
overflow-y: auto;
}
@media (max-height: 1100px) {
.game-shell.dungeon-shell {
padding: 8px 0;
}
.dungeon-shell .app-header {
min-height: 58px;
padding: 9px 14px;
}
.dungeon-run-screen {
gap: 10px;
margin-top: 10px;
padding: 16px;
}
.dungeon-run-screen .screen-heading {
padding-bottom: 10px;
}
.dungeon-run-screen .screen-heading h1 {
font-size: 18px;
}
.dungeon-run-board,
.dungeon-run-main {
gap: 10px;
}
.dungeon-setup-rail {
gap: 8px;
}
.dungeon-run-screen .run-setup-panel,
.dungeon-run-screen .run-summary-card,
.dungeon-run-screen .difficulty-section,
.dungeon-run-screen .loot-preview-section,
.dungeon-run-screen .leaderboard-section {
padding: 10px;
}
.dungeon-focus-card {
grid-template-columns: 84px minmax(0, 1fr);
min-height: 132px;
}
.dungeon-focus-card > .dungeon-art {
height: 84px;
}
.dungeon-focus-card .run-summary-copy p:not(.eyebrow) {
font-size: 16px;
line-height: 1.05;
}
.dungeon-run-screen .run-setup-heading h2,
.dungeon-focus-card .run-summary-copy h2 {
font-size: 12px;
}
.dungeon-run-screen .eyebrow {
font-size: 7px;
margin-bottom: 5px;
}
.dungeon-choice-grid,
.dungeon-run-screen .tier-grid {
gap: 8px;
margin-top: 9px;
}
.dungeon-choice-grid .activity-card {
gap: 5px 9px;
grid-template-columns: 54px minmax(0, 1fr);
min-height: 82px;
padding: 8px;
}
.dungeon-choice-grid .activity-card .dungeon-art {
height: 50px;
width: 50px;
}
.dungeon-choice-grid .activity-card strong,
.dungeon-run-screen .tier-grid strong {
font-size: 7px;
}
.dungeon-choice-grid .activity-card small,
.dungeon-choice-grid .activity-card i,
.dungeon-run-screen .tier-grid span {
font-size: 13px;
line-height: 1;
}
.tier-setup-panel .tier-grid button {
min-height: 48px;
padding: 7px;
}
.part-setup-panel .part-picker {
gap: 6px;
}
.part-setup-panel .primary-button {
font-size: 8px;
min-height: 44px;
padding: 7px 8px;
}
.dungeon-setup-rail .difficulty-summary {
gap: 8px;
padding: 8px;
}
.dungeon-setup-rail .difficulty-summary small {
font-size: 13px;
}
.dungeon-setup-rail .difficulty-summary dl {
gap: 6px;
}
.dungeon-setup-rail .difficulty-summary dl > div {
padding: 5px 6px;
}
.dungeon-setup-rail .difficulty-summary dt,
.dungeon-setup-rail .difficulty-summary dd {
font-size: 12px;
}
.dungeon-setup-rail .equipment-heading h2 {
font-size: 11px;
}
.dungeon-setup-rail .loot-preview-grid {
max-height: 210px;
}
}
@media (max-width: 900px) {
.dungeon-run-board {
grid-template-columns: 1fr;
overflow-y: auto;
}
.dungeon-run-main,
.dungeon-setup-rail {
overflow: visible;
}
}
.part-picker .primary-button.selected-part { .part-picker .primary-button.selected-part {
background: #f0cb79; background: #f0cb79;
outline-color: #fff; outline-color: #fff;
@@ -2730,6 +3008,29 @@ h2 {
margin-top: 18px; margin-top: 18px;
} }
.equipment-screen.crafting-active .gear-summary {
gap: 10px;
margin-top: 12px;
padding: 10px 13px;
}
.equipment-screen.crafting-active .gear-character > span {
flex-basis: 40px;
height: 40px;
}
.equipment-screen.crafting-active .gear-stat strong {
font-size: 13px;
}
.equipment-screen.crafting-active .gear-stat span {
font-size: 13px;
}
.equipment-screen.crafting-active .equipment-tabs {
margin-top: 12px;
}
.equipment-tab { .equipment-tab {
background: var(--panel-light); background: var(--panel-light);
border: 2px solid #090a0d; border: 2px solid #090a0d;
@@ -2836,6 +3137,15 @@ h2 {
margin-top: 18px; margin-top: 18px;
} }
.equipment-screen.crafting-active .crafting-panel {
display: flex;
flex: 1;
flex-direction: column;
margin-top: 12px;
min-height: 0;
overflow: hidden;
}
.crafting-filter-bar { .crafting-filter-bar {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -2861,14 +3171,100 @@ h2 {
.crafting-layout { .crafting-layout {
display: grid; display: grid;
gap: 12px; gap: 12px;
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.75fr); flex: 1;
grid-template-columns: minmax(210px, 0.55fr) minmax(360px, 1fr) minmax(320px, 0.85fr);
margin-top: 13px; margin-top: 13px;
min-height: 0;
overflow: hidden;
}
.crafting-filters,
.crafting-list-panel,
.crafting-detail-panel {
background: var(--panel-light);
border: 2px solid #090a0d;
display: flex;
flex-direction: column;
min-height: 0;
outline: 2px solid #41404a;
overflow: hidden;
padding: 10px;
}
.crafting-filters {
gap: 14px;
}
.crafting-filter-grid {
display: grid;
gap: 7px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.crafting-filter-grid button,
.crafting-level-row button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
outline: 2px solid #41404a;
}
.crafting-filter-grid button {
display: grid;
gap: 4px;
min-height: 48px;
padding: 7px;
text-align: left;
}
.crafting-filter-grid button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.crafting-filter-grid button.active,
.crafting-level-row button.active {
background: #29291f;
outline-color: var(--gold);
}
.crafting-filter-grid strong,
.crafting-filter-grid span {
display: block;
}
.crafting-filter-grid strong {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
line-height: 1.35;
}
.crafting-filter-grid span {
color: var(--gold);
font-size: 14px;
}
.crafting-level-row {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.crafting-level-row button {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
min-height: 34px;
min-width: 48px;
padding: 6px 9px;
} }
.crafting-list { .crafting-list {
display: grid; display: grid;
flex: 1;
gap: 8px; gap: 8px;
max-height: 360px; margin-top: 10px;
min-height: 0;
overflow: hidden; overflow: hidden;
padding: 2px; padding: 2px;
} }
@@ -2953,23 +3349,47 @@ h2 {
text-align: right; text-align: right;
} }
.crafting-list i.ready {
color: var(--green);
}
.crafting-list i.missing {
color: #e36c79;
}
.crafting-detail { .crafting-detail {
background: var(--panel-light); background: transparent;
border: 2px solid #090a0d; border: 0;
border-top-color: var(--rarity-color, #a8a3ad); border-top-color: var(--rarity-color, #a8a3ad);
display: grid; display: grid;
gap: 10px; gap: 10px;
padding: 10px; min-height: 0;
overflow: hidden;
padding: 0;
} }
.crafting-detail .item-detail { .crafting-detail .item-detail {
padding: 0; flex: 0 0 auto;
border: 0; }
.crafting-detail-heading {
align-items: center;
display: flex;
justify-content: space-between;
}
.crafting-detail-heading span {
color: var(--muted);
font-family: 'Press Start 2P', monospace;
font-size: 7px;
} }
.crafting-components { .crafting-components {
display: grid; display: grid;
flex: 1;
gap: 6px; gap: 6px;
min-height: 0;
overflow-y: auto;
} }
.crafting-components > div { .crafting-components > div {
@@ -3709,7 +4129,7 @@ h2 {
background: var(--gold); background: var(--gold);
border: 2px solid #0a0b0e; border: 2px solid #0a0b0e;
color: #21180a; color: #21180a;
display: flex; display: none;
font-family: 'Press Start 2P', monospace; font-family: 'Press Start 2P', monospace;
font-size: 7px; font-size: 7px;
gap: 5px; gap: 5px;
@@ -3721,6 +4141,10 @@ h2 {
z-index: 2; z-index: 2;
} }
.party-member.selected .target-marker {
display: flex;
}
.target-marker i { .target-marker i {
border-bottom: 4px solid transparent; border-bottom: 4px solid transparent;
border-left: 6px solid #21180a; border-left: 6px solid #21180a;
+79 -61
View File
@@ -328,7 +328,7 @@ function App() {
: a.sequence - b.sequence) : a.sequence - b.sequence)
return ( return (
<main className="game-shell"> <main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''}`}>
<header className="topbar app-header"> <header className="topbar app-header">
<button <button
className="brand-button" className="brand-button"
@@ -586,19 +586,82 @@ function App() {
)} )}
{(screen === 'dungeons' || screen === 'raids') && ( {(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen"> <section className="content-screen dungeon-run-screen">
<ScreenHeading <ScreenHeading
eyebrow="Adventure" eyebrow="Adventure"
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'} title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
onBack={() => setScreen('menu')} onBack={() => setScreen('menu')}
/> />
<section className="run-setup-panel"> <div className="dungeon-run-board">
<div className="dungeon-run-main">
<article className="run-summary-card dungeon-focus-card">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(activity.name)}
</div>
<div className="run-summary-copy">
<p className="eyebrow">Selected Run</p>
<h2>{activity.name}</h2>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div>
</div>
</article>
<section className="run-setup-panel dungeon-choice-panel">
<div className="run-setup-heading"> <div className="run-setup-heading">
<div> <div>
<p className="eyebrow">Step 1</p> <p className="eyebrow">Pick Run</p>
<h2>Item Level</h2> <h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div> </div>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by character level.</small> <small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
</div>
<div className="activity-card-grid dungeon-choice-grid">
{tierActivityOptions.map((candidate) => {
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
const locked = profile.character.level < difficulty.unlockLevel
const selected = candidate.id === activity.id
return (
<button
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={candidate.id}
onClick={() => {
if (screen === 'raids') setSelectedRaidId(candidate.id)
else setSelectedDungeonId(candidate.id)
setSelectedDifficultyId(difficulty.id)
}}
type="button"
>
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(candidate.name)}
</span>
<strong>{candidate.name}</strong>
<small>{candidate.locationName}</small>
<i>
Level {candidate.recommendedLevel} | {candidate.partySize} Players
</i>
</button>
)
})}
</div>
</section>
</div>
<aside className="dungeon-setup-rail">
<section className="run-setup-panel tier-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
</div> </div>
<div className="tier-grid"> <div className="tier-grid">
{tierOptions.map((difficulty) => { {tierOptions.map((difficulty) => {
@@ -636,62 +699,13 @@ function App() {
</div> </div>
</section> </section>
<section className="run-setup-panel"> <section className="run-setup-panel part-setup-panel">
<div className="run-setup-heading"> <div className="run-setup-heading">
<div> <div>
<p className="eyebrow">Step 2</p> <p className="eyebrow">Start</p>
<h2>{screen === 'raids' ? 'Pick Raid' : 'Pick Dungeon'}</h2> <h2>{sectionName}</h2>
</div>
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
</div>
<div className="activity-card-grid">
{tierActivityOptions.map((candidate) => {
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
const locked = profile.character.level < difficulty.unlockLevel
const selected = candidate.id === activity.id
return (
<button
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={candidate.id}
onClick={() => {
if (screen === 'raids') setSelectedRaidId(candidate.id)
else setSelectedDungeonId(candidate.id)
setSelectedDifficultyId(difficulty.id)
}}
type="button"
>
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(candidate.name)}
</span>
<strong>{candidate.name}</strong>
<small>{candidate.locationName}</small>
<i>
Level {candidate.recommendedLevel} | {candidate.partySize} Players
</i>
</button>
)
})}
</div>
</section>
<article className="run-summary-card">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(activity.name)}
</div>
<div className="run-summary-copy">
<p className="eyebrow">Step 3</p>
<h2>{activity.name}</h2>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div> </div>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : 'Choose a section to launch.'}</small>
</div> </div>
<div className="part-picker"> <div className="part-picker">
{parts.map((p) => ( {parts.map((p) => (
@@ -711,7 +725,8 @@ function App() {
</button> </button>
))} ))}
</div> </div>
</article> </section>
<div className="difficulty-section compact-difficulty-section"> <div className="difficulty-section compact-difficulty-section">
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}> <div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div> <div>
@@ -722,10 +737,11 @@ function App() {
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div> <div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div> <div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div> <div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div> <div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
<div className="loot-preview-section"> <div className="loot-preview-section">
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
@@ -874,6 +890,8 @@ function App() {
)} )}
</div> </div>
)} )}
</aside>
</div>
</section> </section>
)} )}
+15 -24
View File
@@ -35,7 +35,6 @@ import {
} from '../dualScreen' } from '../dualScreen'
const TICK_MS = 700 const TICK_MS = 700
const TARGET_RENDER_THROTTLE_MS = 180
type RoguelikeMode = 'dungeon' | 'raid' type RoguelikeMode = 'dungeon' | 'raid'
type RoguelikeUpgradeTiming = 'boss' | 'encounter' type RoguelikeUpgradeTiming = 'boss' | 'encounter'
@@ -387,8 +386,6 @@ export function CombatScreen({
const nextFloatingTextId = useRef(1) const nextFloatingTextId = useRef(1)
const combatRef = useRef(initialCombatState) const combatRef = useRef(initialCombatState)
const selectedIdRef = useRef(partyTemplate[0].id) const selectedIdRef = useRef(partyTemplate[0].id)
const selectedRenderTimeoutRef = useRef<number | null>(null)
const lastSelectedRenderAtRef = useRef(0)
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
const encounter = encounters[encounterIndex] const encounter = encounters[encounterIndex]
const currentPart = getCurrentPart(encounterIndex) const currentPart = getCurrentPart(encounterIndex)
@@ -438,32 +435,27 @@ export function CombatScreen({
? nextState(combatRef.current) ? nextState(combatRef.current)
: nextState : nextState
combatRef.current = next combatRef.current = next
setSelectedId(selectedIdRef.current)
setCombatState(next) setCombatState(next)
}, []) }, [])
const syncSelectedTargetDom = useCallback((id: string) => {
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
const selected = button.dataset.partyMemberId === id
button.classList.toggle('selected', selected)
button.setAttribute('aria-pressed', String(selected))
})
}, [])
const setSelectedTargetId = useCallback((id: string) => { const setSelectedTargetId = useCallback((id: string) => {
if (selectedIdRef.current === id) return if (selectedIdRef.current === id) return
selectedIdRef.current = id selectedIdRef.current = id
const now = performance.now() syncSelectedTargetDom(id)
const elapsed = now - lastSelectedRenderAtRef.current }, [syncSelectedTargetDom])
if (elapsed >= TARGET_RENDER_THROTTLE_MS) {
lastSelectedRenderAtRef.current = now
setSelectedId(id)
return
}
if (selectedRenderTimeoutRef.current !== null) return
selectedRenderTimeoutRef.current = window.setTimeout(() => {
selectedRenderTimeoutRef.current = null
lastSelectedRenderAtRef.current = performance.now()
setSelectedId(selectedIdRef.current)
}, TARGET_RENDER_THROTTLE_MS - elapsed)
}, [])
useEffect(() => () => { useEffect(() => {
if (selectedRenderTimeoutRef.current !== null) { syncSelectedTargetDom(selectedIdRef.current)
window.clearTimeout(selectedRenderTimeoutRef.current) }, [combatState, syncSelectedTargetDom])
}
}, [])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
const entry = { id: nextLogId.current++, text, tone } const entry = { id: nextLogId.current++, text, tone }
@@ -1218,17 +1210,16 @@ export function CombatScreen({
{party.map((member) => ( {party.map((member) => (
<button <button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`} className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id} key={member.id}
onClick={() => setSelectedTargetId(member.id)} onClick={() => setSelectedTargetId(member.id)}
aria-pressed={selectedId === member.id} aria-pressed={selectedId === member.id}
type="button" type="button"
> >
{selectedId === member.id && (
<span className="target-marker" aria-hidden="true"> <span className="target-marker" aria-hidden="true">
<i /> <i />
Target Target
</span> </span>
)}
<div className="member-header"> <div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span> <span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong> <strong>{member.name}</strong>
+112 -41
View File
@@ -126,6 +126,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}, },
[profile.craftingRecipes, slotFilter, levelFilter], [profile.craftingRecipes, slotFilter, levelFilter],
) )
const readyRecipeCount = filteredRecipes.filter((recipe) => recipe.canCraft).length
const slotRecipeCounts = useMemo(
() => new Map(
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
slot,
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
]),
),
[profile.craftingRecipes],
)
const recipePageCount = Math.max( const recipePageCount = Math.max(
1, 1,
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE), Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
@@ -147,6 +157,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
setRecipePage((current) => Math.min(current, recipePageCount - 1)) setRecipePage((current) => Math.min(current, recipePageCount - 1))
}, [recipePageCount]) }, [recipePageCount])
useEffect(() => {
if (filteredRecipes.length === 0) {
setSelectedRecipeId(null)
return
}
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
setSelectedRecipeId(filteredRecipes[0].id)
}
}, [filteredRecipes, selectedRecipeId])
useEffect(() => { useEffect(() => {
if (equipmentTab === 'crafting') { if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {}) loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
@@ -430,42 +450,81 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<section className="crafting-panel"> <section className="crafting-panel">
<EquipmentHeading <EquipmentHeading
eyebrow="Crafting" eyebrow="Crafting"
title="Recipes" title="Workbench"
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`} detail={`${readyRecipeCount} ready / ${filteredRecipes.length} shown`}
/> />
<div className="crafting-filter-bar">
<select
className="filter-select"
value={slotFilter}
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]) => (
<option key={slot} value={slot}>{label}</option>
))}
</select>
<select
className="filter-select"
value={levelFilter ?? ''}
onChange={(e) => {
setLevelFilter(e.target.value === '' ? null : Number(e.target.value))
setRecipePage(0)
}}
>
<option value="">All Levels</option>
{availableLevels.map((level) => (
<option key={level} value={level}>Item Level {level}</option>
))}
</select>
</div>
{filteredRecipes.length === 0 && (
<p className="inventory-empty">No crafting recipes match filters.</p>
)}
{filteredRecipes.length > 0 && (
<div className="crafting-layout"> <div className="crafting-layout">
<aside className="crafting-filters">
<div>
<p className="eyebrow">Slot</p>
<div className="crafting-filter-grid">
<button
className={slotFilter === 'all' ? 'active' : ''}
onClick={() => {
setSlotFilter('all')
setRecipePage(0)
}}
type="button"
>
<strong>All</strong>
<span>{profile.craftingRecipes.length}</span>
</button>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
<button
className={slotFilter === slot ? 'active' : ''}
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
key={slot}
onClick={() => {
setSlotFilter(slot)
setRecipePage(0)
}}
type="button"
>
<strong>{label}</strong>
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
</button>
))}
</div>
</div>
<div>
<p className="eyebrow">Item Level</p>
<div className="crafting-level-row">
<button
className={levelFilter === null ? 'active' : ''}
onClick={() => {
setLevelFilter(null)
setRecipePage(0)
}}
type="button"
>
All
</button>
{availableLevels.map((level) => (
<button
className={levelFilter === level ? 'active' : ''}
key={level}
onClick={() => {
setLevelFilter(level)
setRecipePage(0)
}}
type="button"
>
{level}
</button>
))}
</div>
</div>
</aside>
<section className="crafting-list-panel">
<EquipmentHeading
eyebrow="Recipes"
title={slotFilter === 'all' ? 'Available' : SLOT_LABELS[slotFilter]}
detail={`Page ${recipePage + 1}/${recipePageCount}`}
/>
{filteredRecipes.length === 0 ? (
<p className="inventory-empty">No recipes match filters.</p>
) : (
<div className="crafting-list"> <div className="crafting-list">
{recipePageItems.map((recipe) => ( {recipePageItems.map((recipe) => (
<button <button
@@ -482,9 +541,13 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''} {recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
</small> </small>
</div> </div>
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i> <i className={recipe.canCraft ? 'ready' : 'missing'}>
{recipe.canCraft ? 'Ready' : 'Needs materials'}
</i>
</button> </button>
))} ))}
</div>
)}
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && ( {filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
<ListPager <ListPager
label={`Page ${recipePage + 1} / ${recipePageCount}`} label={`Page ${recipePage + 1} / ${recipePageCount}`}
@@ -494,10 +557,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
previousDisabled={recipePage <= 0} previousDisabled={recipePage <= 0}
/> />
)} )}
</div> </section>
{selectedRecipe && (
<section className="crafting-detail-panel">
{selectedRecipe ? (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}> <div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} /> <ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
<div className="crafting-detail-heading">
<p className="eyebrow">Materials</p>
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
</div>
<div className="crafting-components"> <div className="crafting-components">
{selectedRecipe.components.map((component) => ( {selectedRecipe.components.map((component) => (
<div <div
@@ -519,13 +588,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'} {crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button> </button>
</div> </div>
) : (
<p className="inventory-empty">Select a recipe.</p>
)} )}
</section>
</div> </div>
)}
</section> </section>
)} )}
{profile.setBonuses.length > 0 && ( {equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
<section className="set-bonus-panel"> <section className="set-bonus-panel">
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
@@ -561,11 +632,11 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
) )
if (embedded) { if (embedded) {
return <div className="equipment-screen embedded-screen">{content}</div> return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
} }
return ( return (
<section className="content-screen equipment-screen"> <section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
{content} {content}
</section> </section>
) )
+1
View File
@@ -475,6 +475,7 @@ export function DualScreenTopCombat({
return ( return (
<button <button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`} className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id} key={member.id}
onClick={() => onSelectTarget(member.id)} onClick={() => onSelectTarget(member.id)}
type="button" type="button"