diff --git a/IWantToHeal-Thor-v1.0.35.apk b/IWantToHeal-Thor-v1.0.35.apk new file mode 100644 index 0000000..39e08b5 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.35.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 9bfc59b..069197a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 53 - versionName "1.0.34" + versionCode 54 + versionName "1.0.35" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/db/seed.sql b/db/seed.sql index 76042f7..24ef400 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -678,18 +678,22 @@ JOIN coin_sources ON coin_sources.encounter_id = crafting_recipes.source_encounter_id AND coin_sources.difficulty_id = crafting_recipes.difficulty_id; +DELETE FROM character_talents +WHERE talent_id IN (SELECT id FROM talents WHERE class_id = 1); + +DELETE FROM talents WHERE class_id = 1; + INSERT OR IGNORE INTO talents (id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description) VALUES - (1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'), - (2, 1, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'), - (10, 1, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'), - (11, 1, 'overflowing-light', 'Overflowing Light', 5, 2, 1, 1, 3, 'resource_regen_percent', 2, 'O', 'Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.'), - (12, 1, 'lingering-rays', 'Lingering Rays', 5, 2, 2, 2, 3, 'hot_duration_percent', 4, 'L', 'Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.'), - (13, 1, 'radiant-precision', 'Radiant Precision', 5, 2, 3, 10, 3, 'critical_heal_percent', 1, '!', 'Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.'), - (14, 1, 'sunlit-aegis', 'Sunlit Aegis', 3, 3, 1, 11, 5, 'absorb_power_percent', 5, 'A', 'Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.'), - (15, 1, 'shared-dawn', 'Shared Dawn', 3, 3, 2, 12, 5, 'party_heal_percent', 5, '*', 'Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.'), - (16, 1, 'miracle-worker', 'Miracle Worker', 1, 4, 2, 15, 3, 'cooldown_reduction_percent', 10, 'S', 'Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.'), + (1, 1, 'shield-applies-renew', 'Shield applies Renew', 1, 1, 1, NULL, 0, 'shield_applies_renew', 0, '~', 'Sun Ward also applies Renew to the target.'), + (2, 1, 'mend-applies-renew', 'Mend applies Renew', 1, 1, 2, NULL, 0, 'mend_applies_renew', 0, '~', 'Mend also applies Renew to the target.'), + (10, 1, 'mend-adds-shield', 'Mend adds Shield', 1, 1, 3, NULL, 0, 'mend_applies_shield', 0, 'O', 'Mend also applies a shield at 50% strength to the target.'), + (11, 1, 'radiance-adds-shield', 'Radiance adds Shield', 1, 1, 4, NULL, 0, 'radiance_applies_shield', 0, 'O', 'Radiance applies a shield at 30% strength to affected party members.'), + (12, 1, 'radiance-applies-renew', 'Radiance applies Renew', 1, 1, 5, NULL, 0, 'radiance_applies_renew', 0, '~', 'Radiance applies Renew at 50% duration to affected party members.'), + (13, 1, 'shielded-damage-reduction', 'Shielded takes less', 1, 1, 6, NULL, 0, 'shielded_damage_reduction', 0, 'D', 'While shielded, the target receives 20% less damage.'), + (14, 1, 'shielded-healing-bonus', 'Shielded healing boost', 1, 1, 7, NULL, 0, 'shielded_healing_bonus', 0, '+', 'While shielded, the target receives 20% more healing.'), + (15, 1, 'mend-reduces-radiance', 'Mend lowers Radiance', 1, 1, 8, NULL, 0, 'mend_reduces_radiance_cooldown', 0, '*', 'Casting Mend reduces the cooldown of Radiance by 2 seconds.'), (3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'), (20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'), diff --git a/docs/ui-mockups/talent-effects-thor-bottom.svg b/docs/ui-mockups/talent-effects-thor-bottom.svg new file mode 100644 index 0000000..2d3e7aa --- /dev/null +++ b/docs/ui-mockups/talent-effects-thor-bottom.svg @@ -0,0 +1,67 @@ + + + + SPELL EFFECTS + Quick Swap + 4/4 ACTIVE + + + + ACTIVE SLOTS + + + LV 5 + Mend Renew + + + + LV 10 + Rad Shield + + + + LV 15 + Shield DR + + + + LV 20 + Mend CD + + + + + + POOL + + + Mend applies Renew + + + + Shield applies Renew + + + + Mend adds Shield + + + + Radiance Renew + + + + + + DETAIL + Mend applies Renew + Mend also applies Renew to + the target. + Rule: same HoT refreshes. + Different HoTs coexist. + + Equip + + Clear + + diff --git a/docs/ui-mockups/talent-effects-thor-main.svg b/docs/ui-mockups/talent-effects-thor-main.svg new file mode 100644 index 0000000..fb7ddf4 --- /dev/null +++ b/docs/ui-mockups/talent-effects-thor-main.svg @@ -0,0 +1,90 @@ + + + + CHARACTER WORKSHOP + Spell Effects + + Save Loadout + + + + UNLOCKED SLOTS + 4 active effects + + + + + 5 + Mend applies Renew + Selected + + + + + 10 + Radiance adds shield + 30 percent strength + + + + + 15 + Shielded takes less + 20 percent damage cut + + + + + 20 + Mend lowers Radiance + -2 sec cooldown + + + + + + EFFECT POOL + Pick effects, swap anytime + + + + Mend applies Renew + Direct heal also applies Renew. + ON + + + + Shield applies Renew + Sun Ward adds a Renew effect. + + + + Mend adds Shield + 50 percent shield strength. + + + + Radiance adds Shield + 30 percent to affected allies. + + + + Radiance applies Renew + 50 percent duration. + + + + Shielded gets +healing + 20 percent more healing. + + + + + + SELECTED EFFECT + Mend applies Renew + Casting Mend also applies Renew to the same target. Renew refreshes itself; different HoTs coexist. + + Equip + + diff --git a/server/game-api.mjs b/server/game-api.mjs index 522aa26..256d563 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -1862,9 +1862,13 @@ function upgradeItem(database, characterId, itemId) { return getProfile(database, characterId) } +function talentEffectCapacity(level) { + return Math.min(4, Math.max(0, Math.floor(level / 5))) +} + function allocateTalent(database, characterId, talentId) { const character = database.prepare(` - SELECT class_id AS classId, talent_points AS talentPoints + SELECT class_id AS classId, level, talent_points AS talentPoints FROM characters WHERE id = ? `).get(characterId) @@ -1884,6 +1888,49 @@ function allocateTalent(database, characterId, talentId) { if (!talent || talent.classId !== character.classId) { throw new Error('That talent does not belong to the active class.') } + + if (character.classId === 1) { + const currentRank = database.prepare(` + SELECT rank + FROM character_talents + WHERE character_id = ? AND talent_id = ? + `).get(characterId, talentId)?.rank ?? 0 + database.exec('BEGIN') + try { + if (currentRank > 0) { + database.prepare(` + DELETE FROM character_talents + WHERE character_id = ? AND talent_id = ? + `).run(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 + 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 + if (activeCount >= capacity) { + throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`) + } + database.prepare(` + INSERT INTO character_talents (character_id, talent_id, rank) + VALUES (?, ?, 1) + ON CONFLICT(character_id, talent_id) + DO UPDATE SET rank = 1 + `).run(characterId, talentId) + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + return getProfile(database, characterId) + } + if (character.talentPoints <= 0) { throw new Error('No talent points are available.') } @@ -1962,11 +2009,13 @@ function resetTalents(database, characterId) { WHERE character_id = ? AND talent_id IN (SELECT id FROM talents WHERE class_id = ?) `).run(characterId, character.classId) - database.prepare(` - UPDATE characters - SET talent_points = MIN(level, talent_points + ?) - WHERE id = ? - `).run(refunded, characterId) + if (character.classId !== 1) { + database.prepare(` + UPDATE characters + SET talent_points = MIN(level, talent_points + ?) + WHERE id = ? + `).run(refunded, characterId) + } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') diff --git a/src/App.css b/src/App.css index e6bf9ec..afca1e5 100644 --- a/src/App.css +++ b/src/App.css @@ -3454,6 +3454,174 @@ h2 { margin-top: 17px; } +.talent-empty-state { + background: var(--panel-light); + border: 2px solid #090a0d; + margin-top: 17px; + outline: 2px solid #41404a; + padding: 18px; +} + +.spell-effect-layout { + display: grid; + gap: 14px; + grid-template-columns: 260px minmax(0, 1fr) 260px; + margin-top: 17px; + min-height: 0; +} + +.effect-slots-panel, +.effect-pool-panel, +.effect-detail-panel { + background: #191b25; + border: 2px solid #090a0d; + min-height: 0; + outline: 2px solid #3a3944; + padding: 12px; +} + +.effect-slots-panel { + display: grid; + gap: 10px; + grid-auto-rows: minmax(76px, auto); +} + +.effect-slot { + background: #20222d; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + outline: 2px solid #3a3944; + padding: 10px; + text-align: left; +} + +.effect-slot.filled { + background: #29291f; + outline-color: var(--gold); +} + +.effect-slot.locked { + opacity: 0.58; +} + +.effect-slot span, +.effect-pool > button i { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + text-transform: uppercase; +} + +.effect-slot strong, +.effect-slot small { + display: block; +} + +.effect-slot strong { + font-family: 'Press Start 2P', monospace; + font-size: 8px; + line-height: 1.35; + margin-top: 8px; +} + +.effect-slot small { + color: var(--muted); + font-size: 14px; + line-height: 1; + margin-top: 6px; +} + +.effect-panel-heading { + align-items: center; + display: flex; + justify-content: space-between; +} + +.effect-panel-heading > span { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 9px; +} + +.effect-pool { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 12px; +} + +.effect-pool > button { + align-items: 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; + outline: 2px solid #3a3944; + padding: 9px; + text-align: left; +} + +.effect-pool > button.active { + background: #29291f; + outline-color: var(--gold); +} + +.effect-pool > button.selected { + border-color: var(--gold); +} + +.effect-pool > button:disabled:not(.active) { + cursor: not-allowed; + opacity: 0.55; +} + +.effect-pool > button > span { + align-items: center; + background: #15161c; + border: 1px solid #55515f; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + height: 34px; + justify-content: center; +} + +.effect-pool strong, +.effect-pool small { + display: block; +} + +.effect-pool strong { + font-family: 'Press Start 2P', monospace; + font-size: 8px; + line-height: 1.35; +} + +.effect-pool small { + color: var(--muted); + font-size: 14px; + line-height: 1; + margin-top: 5px; +} + +.effect-detail-panel h2 { + color: var(--gold); + font-size: 22px; + margin-top: 10px; +} + +.effect-detail-panel p { + color: var(--muted); + font-size: 17px; + line-height: 1.1; + margin: 12px 0; +} + .talent-tier { align-items: stretch; border-bottom: 1px solid #393943; @@ -5109,6 +5277,19 @@ h2 { margin-bottom: 4px; } +.speed-badge { + background: var(--gold); + border: 2px solid #0a0b0e; + color: #21180a; + display: inline-block; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + line-height: 1; + margin: 0 0 5px; + padding: 5px 7px; + text-transform: uppercase; +} + .action-panel .resource-row { justify-content: flex-start; } diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index d809ac3..147c098 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -105,18 +105,18 @@ function effectiveMaxHealth(member: PartyMember) { return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1))) } -function healAmount(member: PartyMember, amount: number) { - return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1)) +function healAmount(member: PartyMember, amount: number, multiplier = 1) { + return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier) } -function healMember(member: PartyMember, amount: number) { - return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member)) +function healMember(member: PartyMember, amount: number, multiplier = 1) { + return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member)) } function memberHotEffects(member: PartyMember) { if (member.hotEffects?.length) return member.hotEffects return member.hotTicks > 0 - ? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }] + ? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }] : [] } @@ -125,15 +125,15 @@ function effectId(prefix: string) { } function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) { - return [ - ...memberHotEffects(member), - { - id: effectId(spell.id), - label: spell.name, - ticks, - power: Math.max(1, Math.round(spell.power / 2)), - }, - ] + const nextEffect = { + id: effectId(spell.id), + spellId: spell.id, + label: spell.name, + ticks, + power: Math.max(1, Math.round(spell.power / 2)), + } + const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id) + return [...currentEffects, nextEffect] } function addBounceHeal(member: PartyMember, spell: Spell) { @@ -418,6 +418,7 @@ export function CombatScreen({ const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex) const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing') const [paused, setPaused] = useState(false) + const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1) const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0) const [log, setLog] = useState([ { id: 1, text: `${dungeon.name} begins.`, tone: 'system' }, @@ -445,6 +446,7 @@ export function CombatScreen({ const lastCombatTickAtRef = useRef(performance.now()) const statusRef = useRef(status) const pausedRef = useRef(paused) + const speedMultiplierRef = useRef<1 | 2>(speedMultiplier) const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState const encounter = encounters[encounterIndex] const encounterMaxHealth = encounter.maxHealth * enemyCount @@ -463,11 +465,21 @@ export function CombatScreen({ const playerHealer = party.find((member) => member.id === 'mira') const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0) const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter' - const activeSetEffects = useMemo( - () => isRoguelike - ? new Set() - : new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)), - [isRoguelike, profile.setBonuses], + const activeEffects = useMemo( + () => { + const effects = new Set( + gameClass.talents + .filter((talent) => talent.rank > 0) + .map((talent) => talent.effectType), + ) + if (!isRoguelike) { + profile.setBonuses + .filter((bonus) => bonus.active) + .forEach((bonus) => effects.add(bonus.effectType)) + } + return effects + }, + [gameClass.talents, isRoguelike, profile.setBonuses], ) const { bindings, @@ -481,6 +493,7 @@ export function CombatScreen({ statusRef.current = status pausedRef.current = paused + speedMultiplierRef.current = speedMultiplier useEffect(() => { const now = Date.now() @@ -619,6 +632,14 @@ export function CombatScreen({ const extraTarget = (blockedIds: string[]) => current.party .filter((member) => member.health > 0 && !blockedIds.includes(member.id)) .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] + const effectSpell = (name: string) => { + const ability = gameClass.spells.find((candidate) => candidate.name === name) + return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null + } + const renewEffect = effectSpell('Renew') + const shieldEffect = effectSpell('Sun Ward') + const healingMultiplier = (member: PartyMember) => + activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1 const directTargets = new Set([targetId]) const hotTargets = new Set() const shieldTargets = new Set() @@ -630,15 +651,15 @@ export function CombatScreen({ ) if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId) if (spell.kind === 'shield') shieldTargets.add(targetId) - if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) { + if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) { const extra = extraTarget([targetId]) if (extra) directTargets.add(extra.id) } - if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) { + if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) { const extra = extraTarget([targetId]) if (extra) hotTargets.add(extra.id) } - if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) { + if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) { hotTargets.add(targetId) } for (let index = 0; index < extraTargets; index += 1) { @@ -673,9 +694,20 @@ export function CombatScreen({ } } const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost'))) - const nextHealth = healMember(member, power) + const nextHealth = healMember(member, power, healingMultiplier(member)) addFloatingHeal(member.id, Math.max(0, nextHealth - member.health)) - return { ...member, health: nextHealth } + const nextShield = spell.name === 'Radiance' && activeEffects.has('radiance_applies_shield') + ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3)) + : member.shield + return { + ...member, + health: nextHealth, + shield: nextShield, + hotTicks: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') ? 0 : member.hotTicks, + hotEffects: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') && renewEffect + ? addHotEffect(member, renewEffect, 3) + : member.hotEffects, + } } if ( !directTargets.has(member.id) @@ -685,7 +717,14 @@ export function CombatScreen({ ) return member if (spell.kind === 'shield') { const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost'))) - return { ...member, shield: Math.max(member.shield, power) } + return { + ...member, + hotTicks: activeEffects.has('shield_applies_renew') && renewEffect ? 0 : member.hotTicks, + hotEffects: activeEffects.has('shield_applies_renew') && renewEffect + ? addHotEffect(member, renewEffect) + : member.hotEffects, + shield: Math.max(member.shield, power), + } } if (spell.kind === 'damage_reduction') { return { ...member, damageReductionTicks: 12 } @@ -696,7 +735,7 @@ export function CombatScreen({ if (spell.kind === 'cleanse') { return { ...member, - health: healMember(member, spell.power), + health: healMember(member, spell.power, healingMultiplier(member)), debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, @@ -705,14 +744,23 @@ export function CombatScreen({ } } const nextHealth = directTargets.has(member.id) - ? healMember(member, spell.power) + ? healMember(member, spell.power, healingMultiplier(member)) : member.health if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health) + const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield') + ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5)) + : member.shield + const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect + ? renewEffect + : spell return { ...member, health: nextHealth, + shield: nextShield, hotTicks: 0, - hotEffects: hotTargets.has(member.id) ? addHotEffect(member, spell) : member.hotEffects, + hotEffects: hotTargets.has(member.id) + ? addHotEffect(member, appliedHotSpell) + : member.hotEffects, } }) const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') @@ -730,20 +778,26 @@ export function CombatScreen({ && !current.freeCastReady && current.castsTowardFree + 1 >= 5 resourceSpentRef.current += effectiveCost + const nextCooldowns = { + ...current.cooldowns, + } + if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) { + const radiance = spells.find((candidate) => candidate.name === 'Radiance') + if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2) + } + nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades) + setCombat({ ...current, party: nextParty, resource: current.resource - effectiveCost, - cooldowns: { - ...current.cooldowns, - [spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades), - }, + cooldowns: nextCooldowns, castsTowardFree: nextCastsTowardFree, freeCastReady: gainedFreeCast || nextFreeCastReady, }) addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal') }, - [activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status], + [activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status], ) const finishRun = useCallback( @@ -909,6 +963,10 @@ export function CombatScreen({ }, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat]) useGameAction((action, device) => { + if (action === 'toggleSpeed') { + if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1)) + return + } if (action === 'pause' || (action === 'back' && device === 'pc')) { if (status === 'playing') setPaused((value) => !value) return @@ -1021,13 +1079,17 @@ export function CombatScreen({ if ((member.damageReductionTicks ?? 0) > 0) { damage = Math.round(damage * 0.5) } + if (member.shield > 0 && activeEffects.has('shielded_damage_reduction')) { + damage = Math.round(damage * 0.8) + } const absorbed = Math.min(member.shield, damage) const hotEffects = memberHotEffects(member) - let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power), 0) + const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1 + let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0) let nextBounceHeals = [...(member.bounceHeals ?? [])] if (damage > 0 && nextBounceHeals.length > 0) { nextBounceHeals = nextBounceHeals.flatMap((effect) => { - healing += healAmount(member, effect.power) + healing += healAmount(member, effect.power, healingMultiplier) const nextCharges = effect.charges - 1 if (nextCharges <= 0) return [] const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id) @@ -1192,6 +1254,7 @@ export function CombatScreen({ }) addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system') }, [ + activeEffects, addLog, addFloatingHeal, difficulty.damageMultiplier, @@ -1239,9 +1302,10 @@ export function CombatScreen({ || pausedRef.current ) return const now = performance.now() - const dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS)) + const tickMs = TICK_MS / speedMultiplierRef.current + const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs)) if (dueTicks <= 0) return - lastCombatTickAtRef.current += dueTicks * TICK_MS + lastCombatTickAtRef.current += dueTicks * tickMs for (let index = 0; index < dueTicks; index += 1) { if (statusRef.current !== 'playing' || pausedRef.current) return runCombatTickRef.current() @@ -1310,6 +1374,7 @@ export function CombatScreen({ directPartyTargeting, paused, targetGroup, + speedMultiplier, }), [ bindings, controllerIconStyle, @@ -1340,6 +1405,7 @@ export function CombatScreen({ spells, freeCastReady, roguelikeUpgrades, + speedMultiplier, status, targetGroup, ]) @@ -1403,6 +1469,7 @@ export function CombatScreen({ ? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}` : `${profile.character.name} is defeated`} + {speedMultiplier === 2 && 2x speed}
diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index c0dd7ad..9bb19c0 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -251,13 +251,13 @@ function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) { return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced') } -function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) { +function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) { const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1 - return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs)) + return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier) } -function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) { - return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member)) +function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) { + return clamp(member.health + healAmount(member, amount, debuffs, multiplier), 0, effectiveMaxHealth(member)) } function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) { @@ -446,6 +446,7 @@ export function PvPRoguelikeScreen({ const [cpuSide, setCpuSide] = useState(() => starterSide(cpuPartyTemplate, maxResource)) const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const selectedIdRef = useRef(partyTemplate[0].id) + const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1) const [elapsedTicks, setElapsedTicks] = useState(0) const [cpuDifficulty, setCpuDifficulty] = useState(null) const [queueMessage, setQueueMessage] = useState('') @@ -483,6 +484,14 @@ export function PvPRoguelikeScreen({ ? Math.max(encountersCleared, encounterIndex + 1) : encountersCleared const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1] + const activeSpellEffects = useMemo( + () => new Set( + gameClass.talents + .filter((talent) => talent.rank > 0) + .map((talent) => talent.effectType), + ), + [gameClass.talents], + ) const playerDone = playerSide.enemyHealth <= 0 const cpuDone = cpuSide.enemyHealth <= 0 const playerAlive = playerSide.party.some((member) => member.health > 0) @@ -677,6 +686,12 @@ export function PvPRoguelikeScreen({ const extraTarget = (blockedIds: string[]) => livingTargets .filter((member) => !blockedIds.includes(member.id)) .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] + const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType) + const renewEffect = starterSpells.find((candidate) => candidate.kind === 'hot') + const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield') + const radianceEffect = starterSpells.find((candidate) => candidate.kind === 'group') + const healingMultiplier = (member: PartyMember) => + hasSpellEffect('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1 const directTargets = new Set([targetId]) const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : []) const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) @@ -701,22 +716,45 @@ export function PvPRoguelikeScreen({ const extra = extraTarget([...directTargets]) if (extra) directTargets.add(extra.id) } + if (spell.kind === 'direct' && hasSpellEffect('mend_applies_renew') && renewEffect) { + directTargets.forEach((id) => hotTargets.add(id)) + } + if (spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') && shieldEffect) { + directTargets.forEach((id) => shieldTargets.add(id)) + } + if (spell.kind === 'shield' && hasSpellEffect('shield_applies_renew') && renewEffect) { + shieldTargets.forEach((id) => hotTargets.add(id)) + } const nextParty = current.party.map((member) => { if (member.health <= 0) return member if (spell.kind === 'group') { if (!groupTargets.has(member.id)) return member const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost'))) - const nextHealth = healMember(member, groupPower, debuffs) + const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member)) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) - return { ...member, health: nextHealth } + const nextShield = hasSpellEffect('radiance_applies_shield') + ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3)) + : member.shield + return { + ...member, + health: nextHealth, + shield: nextShield, + hotTicks: hasSpellEffect('radiance_applies_renew') && renewEffect + ? Math.max(member.hotTicks, 3) + : member.hotTicks, + } } if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member if (spell.kind === 'shield') { const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost'))) - return { ...member, shield: Math.max(member.shield, shieldPower) } + return { + ...member, + shield: Math.max(member.shield, shieldPower), + hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, + } } if (spell.kind === 'cleanse') { - const nextHealth = healMember(member, spell.power, debuffs) + const nextHealth = healMember(member, spell.power, debuffs, healingMultiplier(member)) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) return { ...member, @@ -728,11 +766,17 @@ export function PvPRoguelikeScreen({ healingReductionTicks: undefined, } } - const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power, debuffs) : member.health + const nextHealth = directTargets.has(member.id) + ? healMember(member, spell.power, debuffs, healingMultiplier(member)) + : member.health if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) + const nextShield = shieldTargets.has(member.id) && spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') + ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5)) + : member.shield return { ...member, health: nextHealth, + shield: nextShield, hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, } }) @@ -746,20 +790,24 @@ export function PvPRoguelikeScreen({ : current.castsTowardFree + 1 : current.castsTowardFree const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5 + const nextCooldowns = { + ...current.cooldowns, + } + if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) { + nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2) + } + nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, buffs, debuffs) const nextState: SideState = { ...current, party: nextParty, resource: current.resource - effectiveCost, - cooldowns: { - ...current.cooldowns, - [spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs), - }, + cooldowns: nextCooldowns, castsTowardFree: nextCastsTowardFree, freeCastReady: gainedFreeCast || nextFreeCastReady, } setCurrent(nextState) return true - }, [addFloatingHeal]) + }, [activeSpellEffects, addFloatingHeal, starterSpells]) const castPlayerSpell = useCallback((spell: Spell) => { if (status !== 'playing' || playerDone || !playerAlive) return @@ -867,6 +915,7 @@ export function PvPRoguelikeScreen({ const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction') const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison') const damageMultiplier = incomingDamageMultiplier(side.debuffs) + const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType) const tankPressure = tankPressureTargets(side.party) const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id)) const nextParty = side.party.map((member) => { @@ -882,8 +931,12 @@ export function PvPRoguelikeScreen({ : member.poisonStacks ?? 0 if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3 damage = Math.round(damage * damageMultiplier) + if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) { + damage = Math.round(damage * 0.8) + } const absorbed = Math.min(member.shield, damage) - const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs) : 0 + const healingMultiplier = member.shield > 0 && hasSpellEffect('shielded_healing_bonus') ? 1.2 : 1 + const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs, healingMultiplier) : 0 if (healing > 0) addFloatingHeal(sideName, member.id, healing) const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id ? 14 @@ -925,7 +978,7 @@ export function PvPRoguelikeScreen({ ), enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)), } - }, [addFloatingHeal, elapsedTicks, maxResource]) + }, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource]) const beginUpgradePhase = useCallback(() => { setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3)) @@ -985,9 +1038,9 @@ export function PvPRoguelikeScreen({ addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot') beginUpgradePhase() } - }, TICK_MS) + }, TICK_MS / speedMultiplier) return () => window.clearInterval(timer) - }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status]) + }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status]) useEffect(() => { if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return @@ -1104,6 +1157,10 @@ export function PvPRoguelikeScreen({ }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) useGameAction((action) => { + if (action === 'toggleSpeed') { + if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1)) + return + } if (action === 'pause' || action === 'back') { if (status === 'playing') setPaused((value) => !value) return @@ -1175,6 +1232,7 @@ export function PvPRoguelikeScreen({ directPartyTargeting, paused, targetGroup, + speedMultiplier, }), [ bindings, controllerIconStyle, @@ -1199,6 +1257,7 @@ export function PvPRoguelikeScreen({ playerSide.party, playerSide.resource, selectedId, + speedMultiplier, stage, starterSpells, status, @@ -1237,6 +1296,7 @@ export function PvPRoguelikeScreen({
{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource} + {speedMultiplier === 2 && 2x speed}
diff --git a/src/components/TalentScreen.tsx b/src/components/TalentScreen.tsx index 220a16a..8b85bde 100644 --- a/src/components/TalentScreen.tsx +++ b/src/components/TalentScreen.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { allocateTalent, resetTalents, type CharacterProfile, type Talent, } from '../profile' +import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen' type Props = { profile: CharacterProfile @@ -13,199 +14,238 @@ type Props = { embedded?: boolean } +const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const +const EFFECT_CLASS_ID = 1 + +function effectCapacity(level: number) { + return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length +} + +function activeEffects(talents: Talent[]) { + return talents.filter((talent) => talent.rank > 0) +} + export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) { + const { enabled: dualScreenEnabled } = useDualScreen() const [busyTalentId, setBusyTalentId] = useState(null) - const [talentPage, setTalentPage] = useState(0) const [resetting, setResetting] = useState(false) + const [selectedTalentId, setSelectedTalentId] = useState(null) const [message, setMessage] = useState('') const scrollRef = useRef(0) const gameClass = profile.classes.find( (candidate) => candidate.id === profile.character.classId, )! - const classPointsSpent = gameClass.talents.reduce( - (total, talent) => total + talent.rank, - 0, - ) - const tiers = Array.from( - new Set(gameClass.talents.map((talent) => talent.tier)), - ).sort((a, b) => a - b) - const tierPages = Array.from( - { length: Math.ceil(tiers.length / 2) }, - (_, index) => tiers.slice(index * 2, index * 2 + 2), - ) - const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? [] + const isEffectClass = gameClass.id === EFFECT_CLASS_ID + const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0 + const selectedEffects = activeEffects(gameClass.talents) + const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId) + ?? selectedEffects[0] + ?? gameClass.talents[0] + ?? null useEffect(() => { window.scrollTo(0, scrollRef.current) }, [profile]) + useEffect(() => { + if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return + setSelectedTalentId(selectedTalent?.id ?? null) + }, [gameClass.talents, selectedTalent?.id, selectedTalentId]) + function saveScroll() { scrollRef.current = window.scrollY } - function lowerTierPoints(talent: Talent) { - return gameClass.talents - .filter((candidate) => candidate.tier < talent.tier) - .reduce((total, candidate) => total + candidate.rank, 0) - } - function lockReason(talent: Talent) { - if (talent.rank >= talent.maxRank) return 'Maximum rank' - - const requiredTierPoints = (talent.tier - 1) * 5 - if (lowerTierPoints(talent) < requiredTierPoints) { - return `Requires ${requiredTierPoints} earlier-tier points` + if (!isEffectClass) return 'Coming soon' + if (talent.rank > 0) return '' + if (capacity <= 0) return 'Unlocks at level 5' + if (selectedEffects.length >= capacity) { + return `Active slots full (${capacity}/${capacity})` } - - if (talent.prerequisiteTalentId) { - const prerequisite = gameClass.talents.find( - (candidate) => candidate.id === talent.prerequisiteTalentId, - ) - if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) { - return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}` - } - } - - if (profile.character.talentPoints <= 0) return 'No points available' - return '' } - async function purchaseRank(talent: Talent) { + async function toggleEffect(talent: Talent) { saveScroll() setBusyTalentId(talent.id) setMessage('') try { const updated = await allocateTalent(talent.id) onUpdated(updated) - setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`) + setSelectedTalentId(talent.id) + setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`) } catch (reason) { - setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.') + setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.') } finally { setBusyTalentId(null) } } - async function refundTree() { + async function clearEffects() { saveScroll() setResetting(true) setMessage('') try { const updated = await resetTalents() onUpdated(updated) - setMessage('All points in this talent tree were refunded.') + setMessage('Spell effects cleared.') } catch (reason) { - setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.') + setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.') } finally { setResetting(false) } } + const workshopState = useMemo(() => { + if (!isEffectClass) return null + return { + mode: 'talents', + title: 'Spell Effects', + subtitle: `${selectedEffects.length}/${capacity} active`, + summary: selectedTalent + ? `${selectedTalent.name}: ${selectedTalent.description}` + : 'Choose effects to modify your spells.', + items: gameClass.talents.map((talent) => ({ + glyph: talent.glyph, + title: talent.name, + meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available', + detail: talent.description, + status: talent.rank > 0 ? 'Selected' : '', + })), + } + }, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent]) + + useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled) + const content = ( <> {!embedded && (

Character Growth

-

Talents

+

Spell Effects

)} -
+
{gameClass.name[0]}
-

{gameClass.name} Tree

-

Shape Your Healing Style

+

{gameClass.name} Effects

+

Modify Your Spells

- {profile.character.talentPoints} - Available - {classPointsSpent} spent in this tree + {selectedEffects.length}/{capacity} + Active + Slots unlock at levels 5, 10, 15, 20
- + {!isEffectClass ? ( +
+

Spell effects coming soon for {gameClass.name}.

+

This replacement system starts with the first class.

+
+ ) : ( +
+
+

Active Slots

+ {EFFECT_SLOT_LEVELS.map((level, index) => { + const effect = selectedEffects[index] + const unlocked = profile.character.level >= level + return ( + + ) + })} +
-
- {visibleTiers.map((tier) => { - const requiredPoints = (tier - 1) * 5 - return ( -
-
- Tier {tier} - - {tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`} - +
+
+
+

Effect Pool

+

Choose and Swap

-
- {gameClass.talents - .filter((talent) => talent.tier === tier) - .sort((a, b) => a.branch - b.branch) - .map((talent) => { - const reason = lockReason(talent) - const isBusy = busyTalentId === talent.id - return ( -
0 ? 'invested' : ''}`} - key={talent.id} - style={{ gridColumn: talent.branch }} - > -
- {talent.glyph} -
- {talent.name} - Rank {talent.rank}/{talent.maxRank} -
-
-

{talent.description}

-
- {Array.from({ length: talent.maxRank }, (_, index) => ( - - ))} -
- -
- ) - })} -
-
- ) - })} -
+ {selectedEffects.length}/{capacity} active +
+
+ {gameClass.talents.map((talent) => { + const reason = lockReason(talent) + const active = talent.rank > 0 + const selected = selectedTalent?.id === talent.id + const isBusy = busyTalentId === talent.id + return ( + + ) + })} +
+ + + +
+ )}
- {message || 'Talent changes are saved immediately.'} + {message || 'Spell effect changes are saved immediately.'}
diff --git a/src/dualScreen.tsx b/src/dualScreen.tsx index 10d1ce6..928cbaf 100644 --- a/src/dualScreen.tsx +++ b/src/dualScreen.tsx @@ -54,6 +54,7 @@ export type DualScreenCombatState = { directPartyTargeting: boolean paused: boolean targetGroup: 0 | 1 | 2 + speedMultiplier: 1 | 2 } export type DualScreenWorkshopState = { @@ -121,7 +122,7 @@ function loadRecentSnapshot() { function memberHotEffects(member: PartyMember) { if (member.hotEffects?.length) return member.hotEffects return member.hotTicks > 0 - ? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks }] + ? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }] : [] } @@ -445,6 +446,7 @@ export function DualScreenBottomDisplay() {
{state.resourceName} {Math.floor(state.resource)} / {state.maxResource} + {state.speedMultiplier === 2 && 2x speed}
diff --git a/src/game.ts b/src/game.ts index 88a1e47..fce0782 100644 --- a/src/game.ts +++ b/src/game.ts @@ -10,6 +10,7 @@ export type PartyMember = { hotTicks: number hotEffects?: Array<{ id: string + spellId: string label: string ticks: number power: number diff --git a/src/gameRepository.ts b/src/gameRepository.ts index 431f212..cb7bc94 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -428,6 +428,10 @@ function scaledPvpBossExperience( return { experience, level } } +function talentEffectCapacity(level: number) { + return Math.min(4, Math.max(0, Math.floor(level / 5))) +} + type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string } const COMPONENT_ITEMS: Record = { 1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' }, @@ -1102,6 +1106,24 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { )! const talent = gameClass.talents.find((candidate) => candidate.id === talentId) if (!talent) throw new Error('That talent does not belong to the active class.') + if (save.activeClassId === 1) { + if ((cd.talentRanks[String(talentId)] ?? 0) > 0) { + cd.talentRanks[String(talentId)] = 0 + } else { + const capacity = talentEffectCapacity(cd.level) + if (capacity <= 0) throw new Error('Spell effects unlock at level 5.') + const activeCount = gameClass.talents.reduce( + (total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0), + 0, + ) + if (activeCount >= capacity) { + throw new Error(`Level ${cd.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`) + } + cd.talentRanks[String(talentId)] = 1 + } + store.writeSave(save) + return buildProfile(save) + } if (cd.talentPoints <= 0) { throw new Error('No talent points are available.') } @@ -1144,10 +1166,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { for (const talent of gameClass.talents) { cd.talentRanks[String(talent.id)] = 0 } - cd.talentPoints = Math.min( - profile.maxTalentPoints, - cd.talentPoints + refunded, - ) + if (save.activeClassId !== 1) { + cd.talentPoints = Math.min( + profile.maxTalentPoints, + cd.talentPoints + refunded, + ) + } store.writeSave(save) return buildProfile(save) }, diff --git a/src/input.tsx b/src/input.tsx index e4da847..bee8e94 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [ 'targetParty5', 'targetParty6', 'toggleTargetGroup', + 'toggleSpeed', 'pause', ] as const @@ -63,6 +64,7 @@ export const ACTION_LABELS: Record = { targetParty5: 'Target Party Member 5', targetParty6: 'Target Party Member 6', toggleTargetGroup: 'Switch Raid Target Group', + toggleSpeed: 'Toggle 2x Speed', pause: 'Pause Menu', } @@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record = { targetParty5: 'F5', targetParty6: 'F6', toggleTargetGroup: 'Tab', + toggleSpeed: 'Backquote', pause: 'Escape', }, controller: { @@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record = { targetParty3: 'Button15', targetParty4: 'Button13', targetParty5: 'Button4', - targetParty6: 'Button11', + targetParty6: 'Button10', toggleTargetGroup: 'Button6', + toggleSpeed: 'Button11', pause: 'Button9', }, } @@ -145,7 +149,8 @@ const InputContext = createContext(null) function loadBindings(): Record { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial>> - const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller } + const savedController = saved.controller + const controller = { ...DEFAULT_BINDINGS.controller, ...savedController } const usesLegacyAbilityDefaults = [ 'Button2', 'Button3', @@ -166,6 +171,15 @@ function loadBindings(): Record { ability6: DEFAULT_BINDINGS.controller.ability6, }) } + if (savedController?.toggleSpeed === 'Button7') { + controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed + } + if (savedController?.ability6 === 'Button10') { + controller.ability6 = DEFAULT_BINDINGS.controller.ability6 + } + if (savedController?.targetParty6 === 'Button11') { + controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6 + } return { pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc }, controller, @@ -504,9 +518,11 @@ export function InputProvider({ children }: { children: ReactNode }) { 'targetParty5', 'targetParty6', 'toggleTargetGroup', + 'toggleSpeed', ] satisfies InputAction[] const combatPriority = [ 'pause', + 'toggleSpeed', 'ability1', 'ability2', 'ability3', diff --git a/src/offline-starter-profile.json b/src/offline-starter-profile.json index 8b72c34..305480c 100644 --- a/src/offline-starter-profile.json +++ b/src/offline-starter-profile.json @@ -147,154 +147,137 @@ { "id": 1, "classId": 1, - "slug": "bright-reserves", - "name": "Bright Reserves", - "maxRank": 5, + "slug": "shield-applies-renew", + "name": "Shield applies Renew", + "maxRank": 1, "tier": 1, "branch": 1, "prerequisiteTalentId": null, "prerequisiteRank": 0, "prerequisiteName": null, - "effectType": "max_resource", - "effectValuePerRank": 2, - "glyph": "M", - "description": "Increases maximum Mana by 2 per rank.", + "effectType": "shield_applies_renew", + "effectValuePerRank": 0, + "glyph": "~", + "description": "Sun Ward also applies Renew to the target.", "rank": 0 }, { "id": 2, "classId": 1, - "slug": "gentle-dawn", - "name": "Gentle Dawn", - "maxRank": 5, + "slug": "mend-applies-renew", + "name": "Mend applies Renew", + "maxRank": 1, "tier": 1, "branch": 2, "prerequisiteTalentId": null, "prerequisiteRank": 0, "prerequisiteName": null, - "effectType": "hot_power_percent", - "effectValuePerRank": 2, + "effectType": "mend_applies_renew", + "effectValuePerRank": 0, "glyph": "~", - "description": "Increases healing-over-time power by 2% per rank.", + "description": "Mend also applies Renew to the target.", "rank": 0 }, { "id": 10, "classId": 1, - "slug": "steady-hands", - "name": "Steady Hands", - "maxRank": 5, + "slug": "mend-adds-shield", + "name": "Mend adds Shield", + "maxRank": 1, "tier": 1, "branch": 3, "prerequisiteTalentId": null, "prerequisiteRank": 0, "prerequisiteName": null, - "effectType": "direct_heal_percent", - "effectValuePerRank": 2, - "glyph": "+", - "description": "Increases direct healing by 2% per rank.", + "effectType": "mend_applies_shield", + "effectValuePerRank": 0, + "glyph": "O", + "description": "Mend also applies a shield at 50% strength to the target.", "rank": 0 }, { "id": 11, "classId": 1, - "slug": "overflowing-light", - "name": "Overflowing Light", - "maxRank": 5, - "tier": 2, - "branch": 1, - "prerequisiteTalentId": 1, - "prerequisiteRank": 3, - "prerequisiteName": "Bright Reserves", - "effectType": "resource_regen_percent", - "effectValuePerRank": 2, + "slug": "radiance-adds-shield", + "name": "Radiance adds Shield", + "maxRank": 1, + "tier": 1, + "branch": 4, + "prerequisiteTalentId": null, + "prerequisiteRank": 0, + "prerequisiteName": null, + "effectType": "radiance_applies_shield", + "effectValuePerRank": 0, "glyph": "O", - "description": "Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.", + "description": "Radiance applies a shield at 30% strength to affected party members.", "rank": 0 }, { "id": 12, "classId": 1, - "slug": "lingering-rays", - "name": "Lingering Rays", - "maxRank": 5, - "tier": 2, - "branch": 2, - "prerequisiteTalentId": 2, - "prerequisiteRank": 3, - "prerequisiteName": "Gentle Dawn", - "effectType": "hot_duration_percent", - "effectValuePerRank": 4, - "glyph": "L", - "description": "Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.", + "slug": "radiance-applies-renew", + "name": "Radiance applies Renew", + "maxRank": 1, + "tier": 1, + "branch": 5, + "prerequisiteTalentId": null, + "prerequisiteRank": 0, + "prerequisiteName": null, + "effectType": "radiance_applies_renew", + "effectValuePerRank": 0, + "glyph": "~", + "description": "Radiance applies Renew at 50% duration to affected party members.", "rank": 0 }, { "id": 13, "classId": 1, - "slug": "radiant-precision", - "name": "Radiant Precision", - "maxRank": 5, - "tier": 2, - "branch": 3, - "prerequisiteTalentId": 10, - "prerequisiteRank": 3, - "prerequisiteName": "Steady Hands", - "effectType": "critical_heal_percent", - "effectValuePerRank": 1, - "glyph": "!", - "description": "Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.", + "slug": "shielded-damage-reduction", + "name": "Shielded takes less", + "maxRank": 1, + "tier": 1, + "branch": 6, + "prerequisiteTalentId": null, + "prerequisiteRank": 0, + "prerequisiteName": null, + "effectType": "shielded_damage_reduction", + "effectValuePerRank": 0, + "glyph": "D", + "description": "While shielded, the target receives 20% less damage.", "rank": 0 }, { "id": 14, "classId": 1, - "slug": "sunlit-aegis", - "name": "Sunlit Aegis", - "maxRank": 3, - "tier": 3, - "branch": 1, - "prerequisiteTalentId": 11, - "prerequisiteRank": 5, - "prerequisiteName": "Overflowing Light", - "effectType": "absorb_power_percent", - "effectValuePerRank": 5, - "glyph": "A", - "description": "Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.", + "slug": "shielded-healing-bonus", + "name": "Shielded healing boost", + "maxRank": 1, + "tier": 1, + "branch": 7, + "prerequisiteTalentId": null, + "prerequisiteRank": 0, + "prerequisiteName": null, + "effectType": "shielded_healing_bonus", + "effectValuePerRank": 0, + "glyph": "+", + "description": "While shielded, the target receives 20% more healing.", "rank": 0 }, { "id": 15, "classId": 1, - "slug": "shared-dawn", - "name": "Shared Dawn", - "maxRank": 3, - "tier": 3, - "branch": 2, - "prerequisiteTalentId": 12, - "prerequisiteRank": 5, - "prerequisiteName": "Lingering Rays", - "effectType": "party_heal_percent", - "effectValuePerRank": 5, - "glyph": "*", - "description": "Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.", - "rank": 0 - }, - { - "id": 16, - "classId": 1, - "slug": "miracle-worker", - "name": "Miracle Worker", + "slug": "mend-reduces-radiance", + "name": "Mend lowers Radiance", "maxRank": 1, - "tier": 4, - "branch": 2, - "prerequisiteTalentId": 15, - "prerequisiteRank": 3, - "prerequisiteName": "Shared Dawn", - "effectType": "cooldown_reduction_percent", - "effectValuePerRank": 10, - "glyph": "S", - "description": "Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.", + "tier": 1, + "branch": 8, + "prerequisiteTalentId": null, + "prerequisiteRank": 0, + "prerequisiteName": null, + "effectType": "mend_reduces_radiance_cooldown", + "effectValuePerRank": 0, + "glyph": "*", + "description": "Casting Mend reduces the cooldown of Radiance by 2 seconds.", "rank": 0 } ]