Android build v1.0.35

This commit is contained in:
Warren H
2026-06-20 16:10:35 -04:00
parent 1281be69d8
commit 2300973164
15 changed files with 877 additions and 293 deletions
+104 -37
View File
@@ -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<CombatLogEntry[]>([
{ 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<string>()
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)),
[isRoguelike, profile.setBonuses],
const activeEffects = useMemo(
() => {
const effects = new Set<string>(
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<string>()
const shieldTargets = new Set<string>()
@@ -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`}
</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
</div>
</div>
+78 -18
View File
@@ -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<SideState>(() => 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<CpuDifficulty | null>(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({
<div className="resource-row pvp-resource-row">
<div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
</div>
</div>
+159 -119
View File
@@ -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<number | null>(null)
const [talentPage, setTalentPage] = useState(0)
const [resetting, setResetting] = useState(false)
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(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<DualScreenWorkshopState | null>(() => {
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 && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Growth</p>
<h1>Talents</h1>
<h1>Spell Effects</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="talent-toolbar">
<div className="talent-toolbar spell-effect-toolbar">
<div className="talent-class-summary">
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
{gameClass.name[0]}
</span>
<div>
<p className="eyebrow">{gameClass.name} Tree</p>
<h2>Shape Your Healing Style</h2>
<p className="eyebrow">{gameClass.name} Effects</p>
<h2>Modify Your Spells</h2>
</div>
</div>
<div className="talent-points">
<strong>{profile.character.talentPoints}</strong>
<span>Available</span>
<small>{classPointsSpent} spent in this tree</small>
<strong>{selectedEffects.length}/{capacity}</strong>
<span>Active</span>
<small>Slots unlock at levels 5, 10, 15, 20</small>
</div>
</div>
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
{tierPages.map((pageTiers, index) => (
<button
aria-selected={talentPage === index}
className={talentPage === index ? 'active' : ''}
key={pageTiers.join('-')}
onClick={() => setTalentPage(index)}
role="tab"
type="button"
>
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
</button>
))}
</nav>
{!isEffectClass ? (
<div className="talent-empty-state">
<h2>Spell effects coming soon for {gameClass.name}.</h2>
<p>This replacement system starts with the first class.</p>
</div>
) : (
<div className="spell-effect-layout">
<section className="effect-slots-panel">
<p className="eyebrow">Active Slots</p>
{EFFECT_SLOT_LEVELS.map((level, index) => {
const effect = selectedEffects[index]
const unlocked = profile.character.level >= level
return (
<button
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
disabled={!effect}
key={level}
onClick={() => effect && setSelectedTalentId(effect.id)}
type="button"
>
<span>Lv {level}</span>
<strong>{effect?.name ?? (unlocked ? 'Empty Slot' : 'Locked')}</strong>
<small>{effect?.description ?? (unlocked ? 'Choose an effect from the pool.' : `Reach level ${level}.`)}</small>
</button>
)
})}
</section>
<div className="talent-tree">
{visibleTiers.map((tier) => {
const requiredPoints = (tier - 1) * 5
return (
<section className="talent-tier" key={tier}>
<div className="tier-label">
<span>Tier {tier}</span>
<small>
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
</small>
<section className="effect-pool-panel">
<div className="effect-panel-heading">
<div>
<p className="eyebrow">Effect Pool</p>
<h2>Choose and Swap</h2>
</div>
<div className="tier-talents">
{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 (
<article
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
key={talent.id}
style={{ gridColumn: talent.branch }}
>
<div className="talent-node-header">
<span>{talent.glyph}</span>
<div>
<strong>{talent.name}</strong>
<small>Rank {talent.rank}/{talent.maxRank}</small>
</div>
</div>
<p>{talent.description}</p>
<div className="rank-pips">
{Array.from({ length: talent.maxRank }, (_, index) => (
<i className={index < talent.rank ? 'filled' : ''} key={index} />
))}
</div>
<button
disabled={Boolean(reason) || isBusy}
onClick={() => purchaseRank(talent)}
type="button"
>
{isBusy ? 'Saving...' : reason || 'Add Rank'}
</button>
</article>
)
})}
</div>
</section>
)
})}
</div>
<span>{selectedEffects.length}/{capacity} active</span>
</div>
<div className="effect-pool">
{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 (
<button
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
disabled={Boolean(reason) || isBusy}
key={talent.id}
onClick={() => {
setSelectedTalentId(talent.id)
void toggleEffect(talent)
}}
type="button"
>
<span>{talent.glyph}</span>
<div>
<strong>{talent.name}</strong>
<small>{talent.description}</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>
<button
className="primary-button"
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
onClick={() => toggleEffect(selectedTalent)}
type="button"
>
{busyTalentId === selectedTalent.id
? 'Saving...'
: selectedTalent.rank > 0
? 'Remove Effect'
: 'Activate Effect'}
</button>
</>
) : (
<p>No effect selected.</p>
)}
</aside>
</div>
)}
<footer className="talent-footer">
<span>{message || 'Talent changes are saved immediately.'}</span>
<span>{message || 'Spell effect changes are saved immediately.'}</span>
<button
className="text-button"
disabled={classPointsSpent === 0 || resetting}
onClick={refundTree}
disabled={selectedEffects.length === 0 || resetting}
onClick={clearEffects}
type="button"
>
{resetting ? 'Refunding...' : 'Reset Tree'}
{resetting ? 'Clearing...' : 'Clear Effects'}
</button>
</footer>
</>