Compare commits

...

4 Commits

Author SHA1 Message Date
Warren H f8a1fbc5e2 Android build v1.0.38 2026-06-20 18:06:39 -04:00
Warren H bab2dce6c3 Android build v1.0.37 2026-06-20 17:52:12 -04:00
Warren H cb38042eca Android build v1.0.37 2026-06-20 17:50:57 -04:00
Warren H 753bba581a Android build v1.0.36 2026-06-20 16:57:03 -04:00
17 changed files with 342 additions and 52 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 54 versionCode 58
versionName "1.0.35" versionName "1.0.38"
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.
+23 -4
View File
@@ -1866,6 +1866,13 @@ function talentEffectCapacity(level) {
return Math.min(4, Math.max(0, Math.floor(level / 5))) return Math.min(4, Math.max(0, Math.floor(level / 5)))
} }
function talentEffectSource(effectType) {
if (effectType.startsWith('mend_')) return 'Mend'
if (effectType.startsWith('radiance_')) return 'Radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
return effectType
}
function allocateTalent(database, characterId, talentId) { function allocateTalent(database, characterId, talentId) {
const character = database.prepare(` const character = database.prepare(`
SELECT class_id AS classId, level, talent_points AS talentPoints SELECT class_id AS classId, level, talent_points AS talentPoints
@@ -1880,7 +1887,8 @@ function allocateTalent(database, characterId, talentId) {
max_rank AS maxRank, max_rank AS maxRank,
tier, tier,
prerequisite_talent_id AS prerequisiteTalentId, prerequisite_talent_id AS prerequisiteTalentId,
prerequisite_rank AS prerequisiteRank prerequisite_rank AS prerequisiteRank,
effect_type AS effectType
FROM talents FROM talents
WHERE id = ? WHERE id = ?
`).get(talentId) `).get(talentId)
@@ -1905,14 +1913,25 @@ function allocateTalent(database, characterId, talentId) {
} else { } else {
const capacity = talentEffectCapacity(character.level) const capacity = talentEffectCapacity(character.level)
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.') if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
const activeCount = database.prepare(` const activeTalents = database.prepare(`
SELECT COUNT(*) AS count SELECT
talents.id,
talents.name,
talents.effect_type AS effectType
FROM character_talents FROM character_talents
JOIN talents ON talents.id = character_talents.talent_id JOIN talents ON talents.id = character_talents.talent_id
WHERE character_talents.character_id = ? WHERE character_talents.character_id = ?
AND talents.class_id = ? AND talents.class_id = ?
AND character_talents.rank > 0 AND character_talents.rank > 0
`).get(characterId, character.classId).count `).all(characterId, character.classId)
const source = talentEffectSource(talent.effectType)
const sourceConflict = activeTalents.find(
(candidate) => candidate.id !== talentId && talentEffectSource(candidate.effectType) === source,
)
if (sourceConflict) {
throw new Error(`Only one ${source} spell effect can be active.`)
}
const activeCount = activeTalents.length
if (activeCount >= capacity) { if (activeCount >= capacity) {
throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`) throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
} }
+230 -24
View File
@@ -1683,7 +1683,8 @@ h2 {
.equipment-screen .equipment-layout, .equipment-screen .equipment-layout,
.equipment-screen .crafting-panel, .equipment-screen .crafting-panel,
.talent-screen .talent-tree { .talent-screen .talent-tree,
.talent-screen .spell-effect-layout {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
@@ -3465,16 +3466,17 @@ h2 {
.spell-effect-layout { .spell-effect-layout {
display: grid; display: grid;
gap: 14px; gap: 14px;
grid-template-columns: 260px minmax(0, 1fr) 260px; grid-template-columns: 220px minmax(0, 1fr);
margin-top: 17px; margin-top: 17px;
min-height: 0; min-height: 0;
} }
.effect-slots-panel, .effect-slots-panel,
.effect-pool-panel, .effect-pool-panel {
.effect-detail-panel {
background: #191b25; background: #191b25;
border: 2px solid #090a0d; border: 2px solid #090a0d;
display: flex;
flex-direction: column;
min-height: 0; min-height: 0;
outline: 2px solid #3a3944; outline: 2px solid #3a3944;
padding: 12px; padding: 12px;
@@ -3544,25 +3546,68 @@ h2 {
font-size: 9px; font-size: 9px;
} }
.effect-pool { .selected-effect-strip {
align-items: center;
background: #20222d;
border: 2px solid #090a0d;
display: grid; display: grid;
gap: 10px; gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr) auto;
margin-top: 12px; margin-top: 12px;
outline: 2px solid #3a3944;
padding: 10px;
}
.selected-effect-strip strong,
.selected-effect-strip small {
display: block;
}
.selected-effect-strip strong {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 9px;
line-height: 1.35;
margin-top: 5px;
}
.selected-effect-strip small {
color: var(--muted);
font-size: 15px;
line-height: 1;
margin-top: 5px;
}
.selected-effect-strip .primary-button {
min-width: 120px;
padding: 9px 12px;
}
.effect-pool {
align-content: start;
display: grid;
flex: 1;
gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(2, 62px);
margin-top: 12px;
min-height: 0;
overflow: hidden;
} }
.effect-pool > button { .effect-pool > button {
align-items: center; align-content: center;
background: #20222d; background: #20222d;
border: 2px solid #090a0d; border: 2px solid #090a0d;
color: var(--ink); color: var(--ink);
cursor: pointer; cursor: pointer;
display: grid; display: grid;
gap: 10px; gap: 6px;
grid-template-columns: 34px minmax(0, 1fr) auto; grid-template-columns: 26px minmax(0, 1fr);
min-height: 72px; min-height: 0;
outline: 2px solid #3a3944; outline: 2px solid #3a3944;
padding: 9px; overflow: hidden;
padding: 7px;
text-align: left; text-align: left;
} }
@@ -3587,7 +3632,7 @@ h2 {
color: var(--gold); color: var(--gold);
display: flex; display: flex;
font-family: 'Press Start 2P', monospace; font-family: 'Press Start 2P', monospace;
height: 34px; height: 26px;
justify-content: center; justify-content: center;
} }
@@ -3598,28 +3643,70 @@ h2 {
.effect-pool strong { .effect-pool strong {
font-family: 'Press Start 2P', monospace; font-family: 'Press Start 2P', monospace;
font-size: 8px; font-size: 7px;
line-height: 1.35; line-height: 1.35;
} }
.effect-pool small { .effect-pool small {
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 11px;
line-height: 1; line-height: 1;
margin-top: 5px; margin-top: 5px;
} }
.effect-detail-panel h2 { .effect-pool > button i {
color: var(--gold); grid-column: 1 / -1;
font-size: 22px; line-height: 1;
margin-top: 10px;
} }
.effect-detail-panel p { .effect-pager {
color: var(--muted); align-items: center;
font-size: 17px; display: flex;
line-height: 1.1; gap: 8px;
margin: 12px 0; justify-content: flex-end;
margin-top: 8px;
}
.effect-pager button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
min-height: 28px;
outline: 2px solid #41404a;
padding: 4px 8px;
text-transform: uppercase;
}
.effect-pager button:disabled {
color: #676773;
cursor: not-allowed;
}
.effect-pager span {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 8px;
}
@media (max-width: 800px) {
.spell-effect-layout {
grid-template-columns: 1fr;
}
.effect-slots-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.effect-pool {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.selected-effect-strip {
grid-template-columns: 1fr;
}
} }
.talent-tier { .talent-tier {
@@ -7535,6 +7622,125 @@ h2 {
margin-top: 4px; margin-top: 4px;
} }
.workshop-shell .spell-effect-layout {
gap: 6px;
grid-template-columns: 172px minmax(0, 1fr);
margin-top: 5px;
overflow: hidden;
}
.workshop-shell .effect-slots-panel,
.workshop-shell .effect-pool-panel {
padding: 5px;
}
.workshop-shell .effect-slots-panel {
gap: 5px;
grid-auto-rows: minmax(43px, 1fr);
}
.workshop-shell .effect-slot {
min-height: 0;
overflow: hidden;
padding: 5px;
}
.workshop-shell .effect-slot span,
.workshop-shell .effect-pool > button i,
.workshop-shell .effect-panel-heading > span,
.workshop-shell .effect-pager span {
font-size: 6px;
}
.workshop-shell .effect-slot strong {
font-size: 6px;
line-height: 1.15;
margin-top: 3px;
}
.workshop-shell .effect-slot small {
font-size: 9px;
line-height: 1;
margin-top: 2px;
max-height: 18px;
overflow: hidden;
}
.workshop-shell .effect-panel-heading h2 {
font-size: 9px;
line-height: 1.1;
}
.workshop-shell .selected-effect-strip {
gap: 6px;
grid-template-columns: minmax(0, 1fr) auto;
margin-top: 5px;
padding: 5px;
}
.workshop-shell .selected-effect-strip .eyebrow {
display: none;
}
.workshop-shell .selected-effect-strip strong {
font-size: 7px;
line-height: 1.1;
margin-top: 0;
}
.workshop-shell .selected-effect-strip small {
font-size: 10px;
line-height: 1;
margin-top: 3px;
max-height: 20px;
overflow: hidden;
}
.workshop-shell .selected-effect-strip .primary-button {
font-size: 7px;
min-height: 25px;
min-width: 72px;
padding: 3px 6px;
}
.workshop-shell .effect-pool {
gap: 5px;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(2, 52px);
margin-top: 5px;
}
.workshop-shell .effect-pool > button {
gap: 4px;
grid-template-columns: 22px minmax(0, 1fr);
padding: 4px;
}
.workshop-shell .effect-pool > button > span {
height: 22px;
}
.workshop-shell .effect-pool strong {
font-size: 6px;
line-height: 1.15;
}
.workshop-shell .effect-pool small {
font-size: 9px;
margin-top: 2px;
}
.workshop-shell .effect-pager {
gap: 5px;
margin-top: 4px;
}
.workshop-shell .effect-pager button {
font-size: 6px;
min-height: 22px;
padding: 2px 5px;
}
.workshop-shell .talent-tier { .workshop-shell .talent-tier {
gap: 6px; gap: 6px;
grid-template-columns: 66px minmax(0, 1fr); grid-template-columns: 66px minmax(0, 1fr);
+70 -22
View File
@@ -16,6 +16,19 @@ type Props = {
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
const EFFECT_CLASS_ID = 1 const EFFECT_CLASS_ID = 1
const EFFECTS_PER_PAGE = 8
const EFFECT_SOURCE_LABELS: Record<string, string> = {
mend: 'Mend',
radiance: 'Radiance',
shield: 'Shield',
}
function effectSource(effectType: string) {
if (effectType.startsWith('mend_')) return 'mend'
if (effectType.startsWith('radiance_')) return 'radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
return effectType
}
function effectCapacity(level: number) { function effectCapacity(level: number) {
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
@@ -30,6 +43,7 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
const [busyTalentId, setBusyTalentId] = useState<number | null>(null) const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [resetting, setResetting] = useState(false) const [resetting, setResetting] = useState(false)
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null) const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
const [effectPage, setEffectPage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find( const gameClass = profile.classes.find(
@@ -42,6 +56,11 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
?? selectedEffects[0] ?? selectedEffects[0]
?? gameClass.talents[0] ?? gameClass.talents[0]
?? null ?? null
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
const visibleTalents = gameClass.talents.slice(
effectPage * EFFECTS_PER_PAGE,
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
)
useEffect(() => { useEffect(() => {
window.scrollTo(0, scrollRef.current) window.scrollTo(0, scrollRef.current)
@@ -52,6 +71,10 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
setSelectedTalentId(selectedTalent?.id ?? null) setSelectedTalentId(selectedTalent?.id ?? null)
}, [gameClass.talents, selectedTalent?.id, selectedTalentId]) }, [gameClass.talents, selectedTalent?.id, selectedTalentId])
useEffect(() => {
setEffectPage((page) => Math.min(page, effectPageCount - 1))
}, [effectPageCount])
function saveScroll() { function saveScroll() {
scrollRef.current = window.scrollY scrollRef.current = window.scrollY
} }
@@ -59,6 +82,9 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
function lockReason(talent: Talent) { function lockReason(talent: Talent) {
if (!isEffectClass) return 'Coming soon' if (!isEffectClass) return 'Coming soon'
if (talent.rank > 0) return '' if (talent.rank > 0) return ''
const source = effectSource(talent.effectType)
const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
if (capacity <= 0) return 'Unlocks at level 5' if (capacity <= 0) return 'Unlocks at level 5'
if (selectedEffects.length >= capacity) { if (selectedEffects.length >= capacity) {
return `Active slots full (${capacity}/${capacity})` return `Active slots full (${capacity}/${capacity})`
@@ -182,8 +208,35 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
</div> </div>
<span>{selectedEffects.length}/{capacity} active</span> <span>{selectedEffects.length}/{capacity} active</span>
</div> </div>
<div className="selected-effect-strip">
<div>
<p className="eyebrow">Selected Effect</p>
{selectedTalent ? (
<>
<strong>{selectedTalent.name}</strong>
<small>{selectedTalent.description}</small>
</>
) : (
<small>No effect selected.</small>
)}
</div>
{selectedTalent && (
<button
className="primary-button"
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
onClick={() => toggleEffect(selectedTalent)}
type="button"
>
{busyTalentId === selectedTalent.id
? 'Saving...'
: selectedTalent.rank > 0
? 'Remove'
: 'Activate'}
</button>
)}
</div>
<div className="effect-pool"> <div className="effect-pool">
{gameClass.talents.map((talent) => { {visibleTalents.map((talent) => {
const reason = lockReason(talent) const reason = lockReason(talent)
const active = talent.rank > 0 const active = talent.rank > 0
const selected = selectedTalent?.id === talent.id const selected = selectedTalent?.id === talent.id
@@ -202,38 +255,33 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
<span>{talent.glyph}</span> <span>{talent.glyph}</span>
<div> <div>
<strong>{talent.name}</strong> <strong>{talent.name}</strong>
<small>{talent.description}</small> <small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
</div> </div>
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i> <i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
</button> </button>
) )
})} })}
</div> </div>
</section> {effectPageCount > 1 && (
<div className="effect-pager">
<aside className="effect-detail-panel">
<p className="eyebrow">Selected Effect</p>
{selectedTalent ? (
<>
<h2>{selectedTalent.name}</h2>
<p>{selectedTalent.description}</p>
<button <button
className="primary-button" disabled={effectPage === 0}
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id} onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
onClick={() => toggleEffect(selectedTalent)}
type="button" type="button"
> >
{busyTalentId === selectedTalent.id Prev
? 'Saving...'
: selectedTalent.rank > 0
? 'Remove Effect'
: 'Activate Effect'}
</button> </button>
</> <span>{effectPage + 1}/{effectPageCount}</span>
) : ( <button
<p>No effect selected.</p> disabled={effectPage >= effectPageCount - 1}
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
type="button"
>
Next
</button>
</div>
)} )}
</aside> </section>
</div> </div>
)} )}
+17
View File
@@ -432,6 +432,13 @@ function talentEffectCapacity(level: number) {
return Math.min(4, Math.max(0, Math.floor(level / 5))) return Math.min(4, Math.max(0, Math.floor(level / 5)))
} }
function talentEffectSource(effectType: string) {
if (effectType.startsWith('mend_')) return 'Mend'
if (effectType.startsWith('radiance_')) return 'Radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
return effectType
}
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string } type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = { const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' }, 1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
@@ -1112,6 +1119,16 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
} else { } else {
const capacity = talentEffectCapacity(cd.level) const capacity = talentEffectCapacity(cd.level)
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.') if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
const source = talentEffectSource(talent.effectType)
const sourceConflict = gameClass.talents.find(
(candidate) =>
candidate.id !== talentId
&& (cd.talentRanks[String(candidate.id)] ?? 0) > 0
&& talentEffectSource(candidate.effectType) === source,
)
if (sourceConflict) {
throw new Error(`Only one ${source} spell effect can be active.`)
}
const activeCount = gameClass.talents.reduce( const activeCount = gameClass.talents.reduce(
(total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0), (total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
0, 0,