Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8a1fbc5e2 | |||
| bab2dce6c3 | |||
| cb38042eca | |||
| 753bba581a |
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.
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 54
|
||||
versionName "1.0.35"
|
||||
versionCode 58
|
||||
versionName "1.0.38"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+23
-4
@@ -1866,6 +1866,13 @@ function talentEffectCapacity(level) {
|
||||
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) {
|
||||
const character = database.prepare(`
|
||||
SELECT class_id AS classId, level, talent_points AS talentPoints
|
||||
@@ -1880,7 +1887,8 @@ function allocateTalent(database, characterId, talentId) {
|
||||
max_rank AS maxRank,
|
||||
tier,
|
||||
prerequisite_talent_id AS prerequisiteTalentId,
|
||||
prerequisite_rank AS prerequisiteRank
|
||||
prerequisite_rank AS prerequisiteRank,
|
||||
effect_type AS effectType
|
||||
FROM talents
|
||||
WHERE id = ?
|
||||
`).get(talentId)
|
||||
@@ -1905,14 +1913,25 @@ function allocateTalent(database, characterId, talentId) {
|
||||
} else {
|
||||
const capacity = talentEffectCapacity(character.level)
|
||||
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
|
||||
const activeCount = database.prepare(`
|
||||
SELECT COUNT(*) AS count
|
||||
const activeTalents = database.prepare(`
|
||||
SELECT
|
||||
talents.id,
|
||||
talents.name,
|
||||
talents.effect_type AS effectType
|
||||
FROM character_talents
|
||||
JOIN talents ON talents.id = character_talents.talent_id
|
||||
WHERE character_talents.character_id = ?
|
||||
AND talents.class_id = ?
|
||||
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) {
|
||||
throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
|
||||
}
|
||||
|
||||
+230
-24
@@ -1683,7 +1683,8 @@ h2 {
|
||||
|
||||
.equipment-screen .equipment-layout,
|
||||
.equipment-screen .crafting-panel,
|
||||
.talent-screen .talent-tree {
|
||||
.talent-screen .talent-tree,
|
||||
.talent-screen .spell-effect-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -3465,16 +3466,17 @@ h2 {
|
||||
.spell-effect-layout {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 260px;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
margin-top: 17px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.effect-slots-panel,
|
||||
.effect-pool-panel,
|
||||
.effect-detail-panel {
|
||||
.effect-pool-panel {
|
||||
background: #191b25;
|
||||
border: 2px solid #090a0d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 12px;
|
||||
@@ -3544,25 +3546,68 @@ h2 {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.effect-pool {
|
||||
.selected-effect-strip {
|
||||
align-items: center;
|
||||
background: #20222d;
|
||||
border: 2px solid #090a0d;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
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 {
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
background: #20222d;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
min-height: 72px;
|
||||
gap: 6px;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 9px;
|
||||
overflow: hidden;
|
||||
padding: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -3587,7 +3632,7 @@ h2 {
|
||||
color: var(--gold);
|
||||
display: flex;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
height: 34px;
|
||||
height: 26px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -3598,28 +3643,70 @@ h2 {
|
||||
|
||||
.effect-pool strong {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
font-size: 7px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.effect-pool small {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.effect-detail-panel h2 {
|
||||
color: var(--gold);
|
||||
font-size: 22px;
|
||||
margin-top: 10px;
|
||||
.effect-pool > button i {
|
||||
grid-column: 1 / -1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.effect-detail-panel p {
|
||||
color: var(--muted);
|
||||
font-size: 17px;
|
||||
line-height: 1.1;
|
||||
margin: 12px 0;
|
||||
.effect-pager {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
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 {
|
||||
@@ -7535,6 +7622,125 @@ h2 {
|
||||
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 {
|
||||
gap: 6px;
|
||||
grid-template-columns: 66px minmax(0, 1fr);
|
||||
|
||||
@@ -16,6 +16,19 @@ type Props = {
|
||||
|
||||
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
|
||||
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) {
|
||||
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 [resetting, setResetting] = useState(false)
|
||||
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
|
||||
const [effectPage, setEffectPage] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const gameClass = profile.classes.find(
|
||||
@@ -42,6 +56,11 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
?? selectedEffects[0]
|
||||
?? gameClass.talents[0]
|
||||
?? 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(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
@@ -52,6 +71,10 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
setSelectedTalentId(selectedTalent?.id ?? null)
|
||||
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
|
||||
|
||||
useEffect(() => {
|
||||
setEffectPage((page) => Math.min(page, effectPageCount - 1))
|
||||
}, [effectPageCount])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
@@ -59,6 +82,9 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
function lockReason(talent: Talent) {
|
||||
if (!isEffectClass) return 'Coming soon'
|
||||
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 (selectedEffects.length >= capacity) {
|
||||
return `Active slots full (${capacity}/${capacity})`
|
||||
@@ -182,8 +208,35 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
</div>
|
||||
<span>{selectedEffects.length}/{capacity} active</span>
|
||||
</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">
|
||||
{gameClass.talents.map((talent) => {
|
||||
{visibleTalents.map((talent) => {
|
||||
const reason = lockReason(talent)
|
||||
const active = talent.rank > 0
|
||||
const selected = selectedTalent?.id === talent.id
|
||||
@@ -202,38 +255,33 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
<span>{talent.glyph}</span>
|
||||
<div>
|
||||
<strong>{talent.name}</strong>
|
||||
<small>{talent.description}</small>
|
||||
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
|
||||
</div>
|
||||
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="effect-detail-panel">
|
||||
<p className="eyebrow">Selected Effect</p>
|
||||
{selectedTalent ? (
|
||||
<>
|
||||
<h2>{selectedTalent.name}</h2>
|
||||
<p>{selectedTalent.description}</p>
|
||||
{effectPageCount > 1 && (
|
||||
<div className="effect-pager">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
|
||||
onClick={() => toggleEffect(selectedTalent)}
|
||||
disabled={effectPage === 0}
|
||||
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
|
||||
type="button"
|
||||
>
|
||||
{busyTalentId === selectedTalent.id
|
||||
? 'Saving...'
|
||||
: selectedTalent.rank > 0
|
||||
? 'Remove Effect'
|
||||
: 'Activate Effect'}
|
||||
Prev
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p>No effect selected.</p>
|
||||
<span>{effectPage + 1}/{effectPageCount}</span>
|
||||
<button
|
||||
disabled={effectPage >= effectPageCount - 1}
|
||||
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -432,6 +432,13 @@ function talentEffectCapacity(level: number) {
|
||||
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 }
|
||||
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||
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 {
|
||||
const capacity = talentEffectCapacity(cd.level)
|
||||
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(
|
||||
(total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
|
||||
0,
|
||||
|
||||
Reference in New Issue
Block a user