Compare commits

...
2 Commits
Author SHA1 Message Date
Warren H f6d408f594 Android build v1.1.5 2026-06-30 13:30:33 -04:00
Warren H 1797154726 Android build v1.1.4 2026-06-29 23:06:37 -04:00
22 changed files with 847 additions and 274 deletions
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 82 versionCode 84
versionName "1.1.3" versionName "1.1.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -0,0 +1,51 @@
package com.warren.iwanttoheal;
import android.view.Display;
import android.view.Window;
import android.view.WindowManager;
final class AndroidDisplayPerformance {
private static final float TARGET_REFRESH_RATE = 60.0f;
private AndroidDisplayPerformance() {}
static void preferBatteryRefreshRate(Window window, Display display) {
if (window == null || display == null) return;
Display.Mode currentMode = display.getMode();
Display.Mode selectedMode = closestBatteryMode(display, currentMode);
if (selectedMode == null) return;
WindowManager.LayoutParams attributes = window.getAttributes();
if (attributes.preferredDisplayModeId == selectedMode.getModeId()) return;
attributes.preferredDisplayModeId = selectedMode.getModeId();
window.setAttributes(attributes);
}
private static Display.Mode closestBatteryMode(Display display, Display.Mode currentMode) {
Display.Mode selectedMode = null;
float selectedScore = Float.MAX_VALUE;
for (Display.Mode mode : display.getSupportedModes()) {
if (
currentMode != null
&& (
mode.getPhysicalWidth() != currentMode.getPhysicalWidth()
|| mode.getPhysicalHeight() != currentMode.getPhysicalHeight()
)
) {
continue;
}
float refreshRate = mode.getRefreshRate();
float score = Math.abs(refreshRate - TARGET_REFRESH_RATE);
if (refreshRate > TARGET_REFRESH_RATE + 0.5f) score += 1000.0f;
if (score < selectedScore) {
selectedMode = mode;
selectedScore = score;
}
}
return selectedMode;
}
}
@@ -1,6 +1,10 @@
package com.warren.iwanttoheal; package com.warren.iwanttoheal;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.SystemClock; import android.os.SystemClock;
import android.view.KeyEvent; import android.view.KeyEvent;
@@ -12,15 +16,22 @@ import java.io.File;
public abstract class ControllerBridgeActivity extends BridgeActivity { public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
private static final long DPAD_THROTTLE_MS = 220; private static final long DPAD_THROTTLE_MS = 220;
private long lastDpadDispatchAt = 0; private long lastDpadDispatchAt = 0;
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
clearWebViewServiceWorkers(); boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
if (bridge != null) { if (bridge != null) {
if (shouldClearWebViewCache) {
bridge.getWebView().clearCache(true); bridge.getWebView().clearCache(true);
markWebViewCacheClearedForCurrentVersion();
}
} }
loadIntentUrl(); loadIntentUrl();
} }
@@ -35,7 +46,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
@Override @Override
public void onWindowFocusChanged(boolean hasFocus) { public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus); super.onWindowFocusChanged(hasFocus);
if (hasFocus) enableImmersiveMode(); if (hasFocus) {
enableImmersiveMode();
AndroidDisplayPerformance.preferBatteryRefreshRate(
getWindow(),
getWindowManager().getDefaultDisplay()
);
}
} }
@Override @Override
@@ -74,6 +91,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
deleteIfExists(new File(webViewData, "Service Worker")); deleteIfExists(new File(webViewData, "Service Worker"));
} }
private boolean shouldClearWebViewCacheForCurrentVersion() {
return performancePreferences().getLong(CACHE_CLEARED_VERSION_KEY, -1L)
!= currentVersionCode();
}
private void markWebViewCacheClearedForCurrentVersion() {
performancePreferences()
.edit()
.putLong(CACHE_CLEARED_VERSION_KEY, currentVersionCode())
.apply();
}
private SharedPreferences performancePreferences() {
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
}
private long currentVersionCode() {
try {
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return packageInfo.getLongVersionCode();
}
return packageInfo.versionCode;
} catch (PackageManager.NameNotFoundException exception) {
return -1L;
}
}
private void deleteIfExists(File file) { private void deleteIfExists(File file) {
if (!file.exists()) return; if (!file.exists()) return;
if (file.isDirectory()) { if (file.isDirectory()) {
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
private final class TopDisplayPresentation extends Presentation { private final class TopDisplayPresentation extends Presentation {
private final String initialUrl; private final String initialUrl;
private final Display display;
TopDisplayPresentation(Context context, Display display, String initialUrl) { TopDisplayPresentation(Context context, Display display, String initialUrl) {
super(context, display); super(context, display);
this.display = display;
this.initialUrl = initialUrl; this.initialUrl = initialUrl;
} }
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
); );
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
FrameLayout container = new FrameLayout(getContext()); FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK); container.setBackgroundColor(Color.BLACK);
+262 -145
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -20,6 +20,7 @@ import {
type GameMode, type GameMode,
} from './gameRepository' } from './gameRepository'
import { focusFirstControl } from './input.tsx' import { focusFirstControl } from './input.tsx'
import { barFillStyle } from './components/barStyles'
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen }))) const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen }))) const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
@@ -469,7 +470,7 @@ function App() {
<small>Level {profile.character.level}</small> <small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small> <small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}> <div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={{ width: `${experiencePercent}%` }} /> <span style={barFillStyle(experiencePercent)} />
</div> </div>
<button className="logout-button" onClick={signOut} type="button"> <button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`} {gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
+60 -2
View File
@@ -1,21 +1,79 @@
import type { Spell } from '../game' import type { Spell } from '../game'
export function cooldownNow() {
return Date.now()
}
export function cooldownRemaining(
cooldowns: Record<string, number>,
spellId: string,
now = cooldownNow(),
) {
return Math.max(0, ((cooldowns[spellId] ?? 0) - now) / 1000)
}
export function hasActiveCooldowns(
cooldowns: Record<string, number>,
now = cooldownNow(),
) {
return Object.values(cooldowns).some((readyAtMs) => readyAtMs > now)
}
export function pruneExpiredCooldowns(
cooldowns: Record<string, number>,
now = cooldownNow(),
) {
let changed = false
const nextCooldowns: Record<string, number> = {}
for (const id in cooldowns) {
if (cooldowns[id] <= now) {
changed = true
continue
}
nextCooldowns[id] = cooldowns[id]
}
return changed ? nextCooldowns : cooldowns
}
export function reduceCooldown(
cooldowns: Record<string, number>,
spellId: string,
seconds: number,
now = cooldownNow(),
) {
const readyAtMs = cooldowns[spellId]
if (!readyAtMs || readyAtMs <= now) return cooldowns
const nextReadyAtMs = readyAtMs - seconds * 1000
const nextCooldowns = { ...cooldowns }
if (nextReadyAtMs <= now) delete nextCooldowns[spellId]
else nextCooldowns[spellId] = nextReadyAtMs
return nextCooldowns
}
export function canCastSpell( export function canCastSpell(
spell: Spell, spell: Spell,
resource: number, resource: number,
cooldowns: Record<string, number>, cooldowns: Record<string, number>,
resourceCost: number, resourceCost: number,
now = cooldownNow(),
) { ) {
return resource >= resourceCost && (cooldowns[spell.id] ?? 0) <= 0 return resource >= resourceCost && cooldownRemaining(cooldowns, spell.id, now) <= 0
} }
export function putSpellOnCooldown( export function putSpellOnCooldown(
cooldowns: Record<string, number>, cooldowns: Record<string, number>,
spell: Spell, spell: Spell,
cooldownMultiplier = 1, cooldownMultiplier = 1,
now = cooldownNow(),
) { ) {
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
if (cooldownMs <= 0) {
const nextCooldowns = { ...cooldowns }
delete nextCooldowns[spell.id]
return nextCooldowns
}
return { return {
...cooldowns, ...cooldowns,
[spell.id]: spell.cooldown * cooldownMultiplier, [spell.id]: now + cooldownMs,
} }
} }
+26 -20
View File
@@ -46,7 +46,7 @@ import {
spellPowerMultiplier, spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine' import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
import { applyCastStateUpdate } from '../combat/combatStateTransitions' import { applyCastStateUpdate } from '../combat/combatStateTransitions'
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads' import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
@@ -58,7 +58,11 @@ import {
appendCombatLog, appendCombatLog,
type BasicFloatingCombatText, type BasicFloatingCombatText,
} from '../combat/combatPresentation' } from '../combat/combatPresentation'
import { canCastSpell } from '../combat/spellCasting' import {
canCastSpell,
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import { import {
applySpellEffectProfile, applySpellEffectProfile,
buildSpellTargetPlan, buildSpellTargetPlan,
@@ -69,9 +73,11 @@ import {
useInput, useInput,
} from '../input' } from '../input'
import { useFloatingCombatText } from '../hooks/useFloatingCombatText' import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { PartyMemberFrame } from './PartyFrames' import { PartyMemberFrame } from './PartyFrames'
import { ResourceBar, SpellBar, type SpellSlot } from './SpellBars' import { ResourceBar, SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels' import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen' import { ResultScreen } from './ResultScreen'
import { import {
@@ -668,10 +674,10 @@ export function CombatScreen({
}) })
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value)) floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
resourceSpentRef.current += effectiveCost resourceSpentRef.current += effectiveCost
const nextCooldowns = { ...current.cooldowns } let nextCooldowns = current.cooldowns
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) { if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
const radiance = spellByName.get('Radiance') const radiance = spellByName.get('Radiance')
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2) if (radiance) nextCooldowns = reduceCooldown(nextCooldowns, radiance.id, 2)
} }
setCombat(applyCastStateUpdate({ setCombat(applyCastStateUpdate({
current, current,
@@ -855,7 +861,7 @@ export function CombatScreen({
const runCombatTick = useCallback(() => { const runCombatTick = useCallback(() => {
const current = combatRef.current const current = combatRef.current
const nextElapsedTicks = current.elapsedTicks + 1 const nextElapsedTicks = current.elapsedTicks + 1
const nextCooldowns = advanceCooldowns(current.cooldowns, tickSeconds(TICK_MS)) const nextCooldowns = pruneExpiredCooldowns(current.cooldowns)
let nextResource = regenerateResource(current.resource, 2.4, maxResource) let nextResource = regenerateResource(current.resource, 2.4, maxResource)
const living = current.party.filter((member) => member.health > 0) const living = current.party.filter((member) => member.health > 0)
@@ -1161,17 +1167,17 @@ export function CombatScreen({
}).reverse(), }).reverse(),
[enemyCount, enemyHealth, encounter.maxHealth], [enemyCount, enemyHealth, encounter.maxHealth],
) )
const spellSlots = useMemo<SpellSlot[]>(() => profile.abilitySlots.map((abilityId, slotIndex) => { const spellSlotCost = useCallback(
const spell = spellByKey.get(String(slotIndex + 1)) (spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
return abilityId && spell [freeCastReady, roguelikeUpgradeCounts],
? { )
...spell, const spellSlots = useSpellSlots({
cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady), spells,
slotIndex, abilitySlots: profile.abilitySlots,
remaining: cooldowns[spell.id] ?? 0, cooldowns,
} active: status === 'playing' && !paused,
: null cost: spellSlotCost,
}), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey]) })
const dualScreenState = useMemo(() => buildCombatDualScreenState({ const dualScreenState = useMemo(() => buildCombatDualScreenState({
difficultyName: difficulty.name, difficultyName: difficulty.name,
dungeonName: dungeon.name, dungeonName: dungeon.name,
@@ -1232,7 +1238,7 @@ export function CombatScreen({
return ( return (
<main <main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`} className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={paused ? 'false' : 'true'} data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
> >
{!dualScreenEnabled && <header className="topbar"> {!dualScreenEnabled && <header className="topbar">
<div> <div>
@@ -1266,13 +1272,13 @@ export function CombatScreen({
<div className="hard-enemy-bars"> <div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => ( {enemyHealthSegments.map((segment) => (
<div className="bar enemy-health" key={segment.index}> <div className="bar enemy-health" key={segment.index}>
<span style={{ width: `${segment.percent}%` }} /> <span style={barFillStyle(segment.percent)} />
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em> <em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div> <div className="bar enemy-health"><span style={barFillStyle(enemyPercent)} /></div>
)} )}
<p>{encounter.description}</p> <p>{encounter.description}</p>
</div> </div>
+3 -2
View File
@@ -7,6 +7,7 @@ import {
memberHotEffects, memberHotEffects,
} from '../combat/rules' } from '../combat/rules'
import { ControllerBindingLabel } from './ControllerIcons' import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type FloatingCombatText = { export type FloatingCombatText = {
id: number id: number
@@ -131,8 +132,8 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>} {showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
</div> </div>
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} /> <span style={barFillStyle((member.health / maxHealth) * 100)} />
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />} {member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>} {showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
</div> </div>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
+29 -15
View File
@@ -13,9 +13,11 @@ import { completeRoguelike, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile' import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames' import { PartyMemberFrame } from './PartyFrames'
import { SpellBar, type SpellSlot } from './SpellBars' import { SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { focusFirstControl, useGameAction, useInput } from '../input' import { focusFirstControl, useGameAction, useInput } from '../input'
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer' import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText' import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { import {
@@ -37,10 +39,14 @@ import {
spellExtraTargets, spellExtraTargets,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine' import { advanceMemberTick } from '../combat/combatEngine'
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads' import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting' import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import { import {
nextPvpRoguelikeStage, nextPvpRoguelikeStage,
resolvePvpRoguelikeCombatOutcome, resolvePvpRoguelikeCombatOutcome,
@@ -398,12 +404,16 @@ export function PvPRoguelikeScreen({
const cpuAlive = cpuSide.party.some((member) => member.health > 0) const cpuAlive = cpuSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs]) const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs]) const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({ const playerSpellSlotCost = useCallback(
...spell, (spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady), [playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
slotIndex, )
remaining: playerSide.cooldowns[spell.id] ?? 0, const playerSpellSlots = useSpellSlots({
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells]) spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo( const opponentBuffSummary = useMemo(
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none', () => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
[cpuSide.buffs, selfBuffChoicesCatalog], [cpuSide.buffs, selfBuffChoicesCatalog],
@@ -880,9 +890,9 @@ export function PvPRoguelikeScreen({
damageReductionTargets: new Set<string>(), damageReductionTargets: new Set<string>(),
groupTargets, groupTargets,
} }
const nextCooldowns = { ...current.cooldowns } let nextCooldowns = current.cooldowns
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) { if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2) nextCooldowns = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
} }
return applyPvpSpellCast({ return applyPvpSpellCast({
current, current,
@@ -994,7 +1004,7 @@ export function PvPRoguelikeScreen({
...side, ...side,
party: nextParty, party: nextParty,
resource: regenerateResource(side.resource, 2.4, maxResource), resource: regenerateResource(side.resource, 2.4, maxResource),
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)), cooldowns: pruneExpiredCooldowns(side.cooldowns),
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)), enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
} }
}, [activeSpellEffects, addFloatingHeal, maxResource]) }, [activeSpellEffects, addFloatingHeal, maxResource])
@@ -1339,6 +1349,9 @@ export function PvPRoguelikeScreen({
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'), opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party, opponentParty: cpuSide.party,
opponentEnemyHealth: cpuSide.enemyHealth, opponentEnemyHealth: cpuSide.enemyHealth,
opponentResource: cpuSide.resource,
opponentMaxResource: maxResource,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary, opponentBuffSummary,
opponentDebuffSummary, opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts, floatingTexts: dualScreenFloatingTexts,
@@ -1362,6 +1375,7 @@ export function PvPRoguelikeScreen({
cpuDifficulty, cpuDifficulty,
cpuSide.enemyHealth, cpuSide.enemyHealth,
cpuSide.party, cpuSide.party,
cpuSide.resource,
directPartyTargeting, directPartyTargeting,
encounter.description, encounter.description,
encounter.enemyName, encounter.enemyName,
@@ -1424,13 +1438,13 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap"> <div className="pvp-clear-wrap">
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span> <span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar"> <div className="bar enemy-health boss-bar">
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} /> <span style={barFillStyle((playerSide.enemyHealth / encounter.maxHealth) * 100)} />
</div> </div>
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1464,12 +1478,12 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap"> <div className="pvp-clear-wrap">
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span> <span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar"> <div className="bar enemy-health boss-bar">
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} /> <span style={barFillStyle((cpuSide.enemyHealth / encounter.maxHealth) * 100)} />
</div> </div>
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / maxResource) * 100)} /></div>
</div> </div>
</div> </div>
</div> </div>
+29 -16
View File
@@ -11,9 +11,11 @@ import { completeRoguelike } from '../profile'
import type { CharacterProfile } from '../profile' import type { CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames' import { PartyMemberFrame } from './PartyFrames'
import { SpellBar, type SpellSlot } from './SpellBars' import { SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input' import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer' import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText' import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { useDualScreen, useDualScreenPublisher } from '../dualScreen' import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
@@ -31,10 +33,13 @@ import {
spellPowerMultiplier, spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine' import { advanceMemberTick } from '../combat/combatEngine'
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads' import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting' import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
} from '../combat/spellCasting'
import { import {
appendCombatLog, appendCombatLog,
STADIUM_COMBAT_LOG_LIMIT, STADIUM_COMBAT_LOG_LIMIT,
@@ -400,12 +405,16 @@ export function PvpStadiumScreen({
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}` const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
const playerAlive = playerSide.party.some((member) => member.health > 0) const playerAlive = playerSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs]) const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({ const playerSpellSlotCost = useCallback(
...spell, (spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady), [playerBuffCounts, playerSide.freeCastReady],
slotIndex, )
remaining: playerSide.cooldowns[spell.id] ?? 0, const playerSpellSlots = useSpellSlots({
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells]) spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo( const opponentBuffSummary = useMemo(
() => summarizeStacks(cpuSide.buffs, buffCatalog), () => summarizeStacks(cpuSide.buffs, buffCatalog),
[buffCatalog, cpuSide.buffs], [buffCatalog, cpuSide.buffs],
@@ -790,7 +799,7 @@ export function PvpStadiumScreen({
...side, ...side,
party: nextParty, party: nextParty,
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE), resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)), cooldowns: pruneExpiredCooldowns(side.cooldowns),
survivalSeconds: nextSurvival, survivalSeconds: nextSurvival,
dampeningPercent, dampeningPercent,
} }
@@ -1055,6 +1064,9 @@ export function PvpStadiumScreen({
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'), opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party, opponentParty: cpuSide.party,
opponentEnemyHealth: 0, opponentEnemyHealth: 0,
opponentResource: cpuSide.resource,
opponentMaxResource: MAX_RESOURCE,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary, opponentBuffSummary,
opponentDebuffSummary, opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts, floatingTexts: dualScreenFloatingTexts,
@@ -1083,6 +1095,7 @@ export function PvpStadiumScreen({
controllerIconStyle, controllerIconStyle,
cpuDifficulty, cpuDifficulty,
cpuSide.party, cpuSide.party,
cpuSide.resource,
cpuSide.survivalSeconds, cpuSide.survivalSeconds,
directPartyTargeting, directPartyTargeting,
dualScreenFloatingTexts, dualScreenFloatingTexts,
@@ -1164,16 +1177,16 @@ export function PvpStadiumScreen({
</section> </section>
<section className="dual-top-spell-strip"> <section className="dual-top-spell-strip">
{starterSpells.map((spell, slotIndex) => { {playerSpellSlots.map((spell, slotIndex) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0 if (!spell) return null
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady) const remaining = spell.remaining
const percent = remaining > 0 const percent = remaining > 0
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100) ? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
: 0 : 0
return ( return (
<button <button
className="dual-top-spell" className="dual-top-spell"
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused} disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < spell.cost || paused}
key={spell.id} key={spell.id}
onClick={() => castPlayerSpell(spell)} onClick={() => castPlayerSpell(spell)}
type="button" type="button"
@@ -1188,7 +1201,7 @@ export function PvpStadiumScreen({
<div className="dual-top-resource"> <div className="dual-top-resource">
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong> <strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /> <span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
</div> </div>
</div> </div>
</section> </section>
@@ -1222,7 +1235,7 @@ export function PvpStadiumScreen({
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span> <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} /></div>
</div> </div>
</div> </div>
<div className="party-grid pvp-party-grid"> <div className="party-grid pvp-party-grid">
@@ -1252,7 +1265,7 @@ export function PvpStadiumScreen({
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span> <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / MAX_RESOURCE) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
</div> </div>
</div> </div>
<div className="party-grid pvp-party-grid"> <div className="party-grid pvp-party-grid">
+10
View File
@@ -32,11 +32,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
capture, capture,
controllerIconStyle, controllerIconStyle,
directPartyTargeting, directPartyTargeting,
combatTouchLocked,
beginCapture, beginCapture,
cancelCapture, cancelCapture,
resetBindings, resetBindings,
setControllerIconStyle, setControllerIconStyle,
setDirectPartyTargeting, setDirectPartyTargeting,
setCombatTouchLocked,
} = useInput() } = useInput()
const { const {
enabled: dualScreenEnabled, enabled: dualScreenEnabled,
@@ -180,6 +182,14 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
> >
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'} {directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button> </button>
<button
aria-pressed={combatTouchLocked}
className={combatTouchLocked ? 'selected' : ''}
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
type="button"
>
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
</button>
<div className="controller-icon-options"> <div className="controller-icon-options">
<span>Controller Icons</span> <span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => ( {(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
+2 -1
View File
@@ -2,6 +2,7 @@ import { memo } from 'react'
import type { ControllerIconStyle } from '../input' import type { ControllerIconStyle } from '../input'
import type { Spell } from '../game' import type { Spell } from '../game'
import { ControllerBindingLabel } from './ControllerIcons' import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type SpellSlot = (Spell & { export type SpellSlot = (Spell & {
cost: number cost: number
@@ -28,7 +29,7 @@ export const ResourceBar = memo(function ResourceBar({
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`} {unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
</span> </span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((resource / maxResource) * 100)} /></div>
</div> </div>
) )
}) })
+16
View File
@@ -0,0 +1,16 @@
import type { CSSProperties } from 'react'
function clampScale(value: number) {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(0, value))
}
export function barFillStyle(
percent: number,
origin: 'left' | 'right' = 'left',
): CSSProperties {
return {
transform: `scaleX(${clampScale(percent / 100)})`,
transformOrigin: `${origin} center`,
}
}
+65 -31
View File
@@ -22,6 +22,7 @@ import {
} from './input' } from './input'
import { ControllerBindingLabel } from './components/ControllerIcons' import { ControllerBindingLabel } from './components/ControllerIcons'
import { PartyMemberFrame } from './components/PartyFrames' import { PartyMemberFrame } from './components/PartyFrames'
import { barFillStyle } from './components/barStyles'
import { groupFloatingTextsByMember } from './combat/combatPresentation' import { groupFloatingTextsByMember } from './combat/combatPresentation'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled' const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
@@ -47,6 +48,9 @@ export type DualScreenCombatState = {
opponentClassName?: string opponentClassName?: string
opponentParty?: PartyMember[] opponentParty?: PartyMember[]
opponentEnemyHealth?: number opponentEnemyHealth?: number
opponentResource?: number
opponentMaxResource?: number
opponentResourceName?: string
opponentBuffSummary?: string opponentBuffSummary?: string
opponentDebuffSummary?: string opponentDebuffSummary?: string
floatingTexts: Array<{ floatingTexts: Array<{
@@ -477,8 +481,19 @@ export function DualScreenBottomDisplay() {
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong> <strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div> </div>
<div className="bar enemy-health boss-bar"> <div className="bar enemy-health boss-bar">
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} /> <span style={barFillStyle(((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)} />
</div> </div>
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
<>
<div>
<p className="eyebrow">{state.opponentResourceName ?? state.resourceName}</p>
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
</div>
<div className="bar mana-bar">
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
</div>
</>
) : null}
</section> </section>
)} )}
@@ -513,7 +528,7 @@ export function DualScreenBottomDisplay() {
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span> <span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} /> <span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div> </div>
</div> </div>
</section> </section>
@@ -626,8 +641,34 @@ export function DualScreenTopCombat({
[state.floatingTexts], [state.floatingTexts],
) )
const spellButtons = state.spells.map((spell, slotIndex) => {
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
const percent = spell.remaining > 0
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return ( return (
<div className="dual-top-main"> <button
className="dual-top-spell"
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => onCastSpell?.(spell)}
type="button"
>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
</button>
)
})
return (
<div className={`dual-top-main ${state.opponentParty ? 'pvp-roguelike-dual-top' : ''}`}>
<section className="dual-top-enemy"> <section className="dual-top-enemy">
<div className="enemy-portrait" aria-hidden="true"> <div className="enemy-portrait" aria-hidden="true">
{state.encounterIsBoss ? 'B' : 'M'} {state.encounterIsBoss ? 'B' : 'M'}
@@ -638,10 +679,25 @@ export function DualScreenTopCombat({
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span> <span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
</div> </div>
<div className="bar enemy-health"> <div className="bar enemy-health">
<span style={{ width: `${enemyPercent}%` }} /> <span style={barFillStyle(enemyPercent)} />
</div> </div>
<p>{state.encounterDescription}</p> {state.opponentParty && (
<div className="dual-top-resource">
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
<div className="bar mana-bar">
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div> </div>
</div>
)}
{!state.opponentParty && <p>{state.encounterDescription}</p>}
</div>
{state.opponentParty && (
<div className="dual-top-side-status">
<div className="dual-top-cooldowns" aria-label="Cooldowns">
{spellButtons}
</div>
</div>
)}
</section> </section>
<section className="dual-top-party"> <section className="dual-top-party">
@@ -669,39 +725,17 @@ export function DualScreenTopCombat({
</div> </div>
</section> </section>
{!state.opponentParty && (
<section className="dual-top-spell-strip"> <section className="dual-top-spell-strip">
{state.spells.map((spell, slotIndex) => { {spellButtons}
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
const percent = spell.remaining > 0
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return (
<button
className="dual-top-spell"
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => onCastSpell?.(spell)}
type="button"
>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
</button>
)
})}
<div className="dual-top-resource"> <div className="dual-top-resource">
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong> <strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} /> <span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div> </div>
</div> </div>
</section> </section>
)}
</div> </div>
) )
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react'
import { cooldownNow, hasActiveCooldowns } from '../combat/spellCasting'
export function useCooldownClock(
cooldowns: Record<string, number>,
active: boolean,
intervalMs = 100,
) {
const [now, setNow] = useState(cooldownNow)
useEffect(() => {
if (!active || !hasActiveCooldowns(cooldowns)) return undefined
let timer = 0
let startTimer = 0
const tick = () => {
const nextNow = cooldownNow()
setNow(nextNow)
if (timer && !hasActiveCooldowns(cooldowns, nextNow)) {
window.clearInterval(timer)
timer = 0
}
}
startTimer = window.setTimeout(() => {
tick()
timer = window.setInterval(tick, intervalMs)
}, 0)
return () => {
window.clearTimeout(startTimer)
if (timer) window.clearInterval(timer)
}
}, [active, cooldowns, intervalMs])
return now
}
+39 -17
View File
@@ -15,29 +15,31 @@ type FloatingCombatTextOptions = {
durationMs?: number durationMs?: number
} }
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({ export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
durationMs = 900, durationMs = 900,
}: FloatingCombatTextOptions = {}) { }: FloatingCombatTextOptions = {}) {
const [floatingTexts, setFloatingTexts] = useState<TText[]>([]) const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
const nextId = useRef(1) const nextId = useRef(1)
const expirationsRef = useRef(new Map<number, number>()) const expirationsRef = useRef(new Map<number, number>())
const cleanupTimerRef = useRef<number | null>(null)
const clearFloatingTexts = useCallback(() => { const clearCleanupTimer = useCallback(() => {
expirationsRef.current.clear() if (cleanupTimerRef.current === null) return
setFloatingTexts([]) window.clearTimeout(cleanupTimerRef.current)
cleanupTimerRef.current = null
}, []) }, [])
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => { const scheduleCleanup = useCallback(() => {
if (entry.value <= 0) return function scheduleNext() {
const id = nextId.current++ clearCleanupTimer()
expirationsRef.current.set(id, performance.now() + durationMs) let nextExpiry = Infinity
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText)) for (const expiresAt of expirationsRef.current.values()) {
}, [durationMs]) nextExpiry = Math.min(nextExpiry, expiresAt)
}
if (!Number.isFinite(nextExpiry)) return
useEffect(() => { cleanupTimerRef.current = window.setTimeout(() => {
const timer = window.setInterval(() => { cleanupTimerRef.current = null
const now = performance.now() const now = performance.now()
let removed = false let removed = false
for (const [id, expiresAt] of expirationsRef.current) { for (const [id, expiresAt] of expirationsRef.current) {
@@ -45,11 +47,31 @@ export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
expirationsRef.current.delete(id) expirationsRef.current.delete(id)
removed = true removed = true
} }
if (!removed) return if (removed) {
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id))) setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS) }
return () => window.clearInterval(timer) scheduleNext()
}, []) }, Math.max(0, nextExpiry - performance.now()))
}
scheduleNext()
}, [clearCleanupTimer])
const clearFloatingTexts = useCallback(() => {
clearCleanupTimer()
expirationsRef.current.clear()
setFloatingTexts([])
}, [clearCleanupTimer])
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
if (entry.value <= 0) return
const id = nextId.current++
expirationsRef.current.set(id, performance.now() + durationMs)
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
scheduleCleanup()
}, [durationMs, scheduleCleanup])
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
const floatingTextsByMember = useMemo( const floatingTextsByMember = useMemo(
() => groupFloatingTextsByMember(floatingTexts), () => groupFloatingTextsByMember(floatingTexts),
+38
View File
@@ -0,0 +1,38 @@
import { useMemo } from 'react'
import type { Spell } from '../game'
import type { SpellSlot } from '../components/SpellBars'
import { cooldownRemaining } from '../combat/spellCasting'
import { useCooldownClock } from './useCooldownClock'
type UseSpellSlotsOptions = {
spells: readonly Spell[]
cooldowns: Record<string, number>
active: boolean
cost: (spell: Spell) => number
abilitySlots?: readonly (number | null)[]
}
export function useSpellSlots({
spells,
cooldowns,
active,
cost,
abilitySlots,
}: UseSpellSlotsOptions) {
const now = useCooldownClock(cooldowns, active)
return useMemo<SpellSlot[]>(() => {
const slotCount = abilitySlots?.length ?? spells.length
return Array.from({ length: slotCount }, (_, slotIndex) => {
if (abilitySlots && !abilitySlots[slotIndex]) return null
const spell = spells[slotIndex]
if (!spell) return null
return {
...spell,
cost: cost(spell),
slotIndex,
remaining: cooldownRemaining(cooldowns, spell.id, now),
}
})
}, [abilitySlots, cooldowns, cost, now, spells])
}
+3 -1
View File
@@ -3,7 +3,9 @@
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)), linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px); repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
color: #f4eed8; color: #f4eed8;
font-family: 'VT323', Consolas, monospace; --body-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
--pixel-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
font-family: var(--body-font);
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
+113 -8
View File
@@ -37,6 +37,7 @@ export const INPUT_ACTIONS = [
'targetParty6', 'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
'toggleSpeed', 'toggleSpeed',
'toggleTouchLock',
'pause', 'pause',
] as const ] as const
@@ -66,6 +67,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty6: 'Target Party Member 6', targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group', toggleTargetGroup: 'Switch Raid Target Group',
toggleSpeed: 'Toggle 2x Speed', toggleSpeed: 'Toggle 2x Speed',
toggleTouchLock: 'Toggle Combat Touch Lock',
pause: 'Pause Menu', pause: 'Pause Menu',
} }
@@ -93,6 +95,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty6: 'F6', targetParty6: 'F6',
toggleTargetGroup: 'Tab', toggleTargetGroup: 'Tab',
toggleSpeed: 'Backquote', toggleSpeed: 'Backquote',
toggleTouchLock: 'F7',
pause: 'Escape', pause: 'Escape',
}, },
controller: { controller: {
@@ -115,9 +118,10 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15', targetParty3: 'Button15',
targetParty4: 'Button13', targetParty4: 'Button13',
targetParty5: 'Button4', targetParty5: 'Button4',
targetParty6: 'Button10', targetParty6: 'Button6',
toggleTargetGroup: 'Button6', toggleTargetGroup: 'Button8',
toggleSpeed: 'Button11', toggleSpeed: 'Button11',
toggleTouchLock: 'Button10',
pause: 'Button9', pause: 'Button9',
}, },
} }
@@ -144,11 +148,13 @@ type InputContextValue = {
lastDevice: InputDevice lastDevice: InputDevice
controllerIconStyle: ControllerIconStyle controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean directPartyTargeting: boolean
combatTouchLocked: boolean
beginCapture: (device: InputDevice, action: InputAction) => void beginCapture: (device: InputDevice, action: InputAction) => void
cancelCapture: () => void cancelCapture: () => void
resetBindings: (device: InputDevice) => void resetBindings: (device: InputDevice) => void
setControllerIconStyle: (style: ControllerIconStyle) => void setControllerIconStyle: (style: ControllerIconStyle) => void
setDirectPartyTargeting: (enabled: boolean) => void setDirectPartyTargeting: (enabled: boolean) => void
setCombatTouchLocked: (locked: boolean) => void
} }
const InputContext = createContext<InputContextValue | null>(null) const InputContext = createContext<InputContextValue | null>(null)
@@ -187,6 +193,21 @@ function loadBindings(): Record<InputDevice, InputBindings> {
if (savedController?.targetParty6 === 'Button11') { if (savedController?.targetParty6 === 'Button11') {
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6 controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
} }
if (
savedController?.targetParty6 === undefined
|| savedController.targetParty6 === 'Button10'
) {
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
}
if (
savedController?.toggleTargetGroup === undefined
|| savedController.toggleTargetGroup === 'Button6'
) {
controller.toggleTargetGroup = DEFAULT_BINDINGS.controller.toggleTargetGroup
}
if (savedController?.toggleTouchLock === undefined) {
controller.toggleTouchLock = DEFAULT_BINDINGS.controller.toggleTouchLock
}
return { return {
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc }, pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
controller, controller,
@@ -201,15 +222,18 @@ function loadPreferences() {
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as { const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
controllerIconStyle?: ControllerIconStyle controllerIconStyle?: ControllerIconStyle
directPartyTargeting?: boolean directPartyTargeting?: boolean
combatTouchLocked?: boolean
} }
return { return {
controllerIconStyle: saved.controllerIconStyle ?? 'xbox', controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
directPartyTargeting: saved.directPartyTargeting ?? false, directPartyTargeting: saved.directPartyTargeting ?? false,
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
} }
} catch { } catch {
return { return {
controllerIconStyle: 'xbox' as ControllerIconStyle, controllerIconStyle: 'xbox' as ControllerIconStyle,
directPartyTargeting: false, directPartyTargeting: false,
combatTouchLocked: Capacitor.isNativePlatform(),
} }
} }
} }
@@ -223,6 +247,7 @@ function bindingGroup(action: InputAction) {
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting' if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting' if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
if (action === 'pause') return 'pause' if (action === 'pause') return 'pause'
if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system'
return 'navigation' return 'navigation'
} }
@@ -428,6 +453,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(loadPreferences) const [preferences, setPreferences] = useState(loadPreferences)
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null) const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
const [keyboardShift, setKeyboardShift] = useState(false) const [keyboardShift, setKeyboardShift] = useState(false)
const [touchLockMessage, setTouchLockMessage] = useState('')
const bindingsRef = useRef(bindings) const bindingsRef = useRef(bindings)
const preferencesRef = useRef(preferences) const preferencesRef = useRef(preferences)
const captureRef = useRef(capture) const captureRef = useRef(capture)
@@ -435,6 +461,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const previousTokensRef = useRef(new Set<string>()) const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({}) const repeatRef = useRef<Record<string, number>>({})
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform()) const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
const touchLockMessageTimerRef = useRef(0)
useEffect(() => { useEffect(() => {
bindingsRef.current = bindings bindingsRef.current = bindings
@@ -485,7 +512,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
setLastDevice(device) setLastDevice(device)
document.documentElement.dataset.inputDevice = device document.documentElement.dataset.inputDevice = device
if (action.startsWith('navigate')) { if (action === 'toggleTouchLock') {
setPreferences((current) => ({
...current,
combatTouchLocked: !current.combatTouchLocked,
}))
window.clearTimeout(touchLockMessageTimerRef.current)
const nextLocked = !preferencesRef.current.combatTouchLocked
setTouchLockMessage(`Combat touch ${nextLocked ? 'locked' : 'unlocked'}`)
touchLockMessageTimerRef.current = window.setTimeout(() => {
setTouchLockMessage('')
}, 1400)
} else if (action.startsWith('navigate')) {
if (uiOverlay || !combatActive) moveFocus(action) if (uiOverlay || !combatActive) moveFocus(action)
} else if (action === 'confirm') { } else if (action === 'confirm') {
const active = currentFocusableControl() const active = currentFocusableControl()
@@ -551,8 +589,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
'targetParty6', 'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
'toggleSpeed', 'toggleSpeed',
'toggleTouchLock',
] satisfies InputAction[] ] satisfies InputAction[]
const combatPriority = [ const combatPriority = [
'toggleTouchLock',
'pause', 'pause',
'toggleSpeed', 'toggleSpeed',
'ability1', 'ability1',
@@ -621,7 +661,9 @@ export function InputProvider({ children }: { children: ReactNode }) {
rememberFocusableControl(target) rememberFocusableControl(target)
} }
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') {
document.documentElement.dataset.inputDevice = 'pc' document.documentElement.dataset.inputDevice = 'pc'
}
const target = event.target const target = event.target
if (!(target instanceof Element)) return if (!(target instanceof Element)) return
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR) const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
@@ -645,8 +687,14 @@ export function InputProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener) return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
}, [dispatchControllerToken]) }, [dispatchControllerToken])
useEffect(() => () => {
window.clearTimeout(touchLockMessageTimerRef.current)
}, [])
useEffect(() => { useEffect(() => {
let focusFrame = 0
const ensureFocus = () => { const ensureFocus = () => {
focusFrame = 0
const combatActive = document.querySelector('[data-combat-active="true"]') const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return if (combatActive) return
const candidates = focusableElements() const candidates = focusableElements()
@@ -663,22 +711,37 @@ export function InputProvider({ children }: { children: ReactNode }) {
} }
} }
} }
const scheduleEnsureFocus = () => {
if (focusFrame) return
focusFrame = window.requestAnimationFrame(ensureFocus)
}
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
window.requestAnimationFrame(ensureFocus) scheduleEnsureFocus()
}) })
observer.observe(document.getElementById('root') ?? document.body, { observer.observe(document.getElementById('root') ?? document.body, {
attributes: true, attributes: true,
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'], attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'],
childList: true, childList: true,
subtree: true, subtree: true,
}) })
window.requestAnimationFrame(ensureFocus) scheduleEnsureFocus()
return () => observer.disconnect() return () => {
if (focusFrame) window.cancelAnimationFrame(focusFrame)
observer.disconnect()
}
}, []) }, [])
useEffect(() => { useEffect(() => {
let timer = 0 let timer = 0
if (Capacitor.isNativePlatform() && capture?.device !== 'controller') {
previousTokensRef.current = new Set()
repeatRef.current = {}
return undefined
}
const nextDelay = () => { const nextDelay = () => {
if (Capacitor.isNativePlatform()) {
return GAMEPAD_COMBAT_POLL_MS
}
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) { if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
} }
@@ -735,7 +798,39 @@ export function InputProvider({ children }: { children: ReactNode }) {
window.removeEventListener('gamepadconnected', onGamepadConnected) window.removeEventListener('gamepadconnected', onGamepadConnected)
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected) window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
} }
}, [assignBinding, dispatchControllerToken]) }, [assignBinding, capture, dispatchControllerToken])
useEffect(() => {
const shouldBlockTouch = () => (
Capacitor.isNativePlatform()
&& preferencesRef.current.combatTouchLocked
&& isCombatActive()
&& !hasUiOverlay()
)
const blockTouch = (event: Event) => {
if (
typeof PointerEvent !== 'undefined'
&& event instanceof PointerEvent
&& event.pointerType !== 'touch'
) return
if (!shouldBlockTouch()) return
event.preventDefault()
event.stopImmediatePropagation()
}
const options = { capture: true, passive: false }
document.addEventListener('pointerdown', blockTouch, options)
document.addEventListener('pointerup', blockTouch, options)
document.addEventListener('touchstart', blockTouch, options)
document.addEventListener('touchmove', blockTouch, options)
document.addEventListener('touchend', blockTouch, options)
return () => {
document.removeEventListener('pointerdown', blockTouch, options)
document.removeEventListener('pointerup', blockTouch, options)
document.removeEventListener('touchstart', blockTouch, options)
document.removeEventListener('touchmove', blockTouch, options)
document.removeEventListener('touchend', blockTouch, options)
}
}, [])
const contextValue = useMemo<InputContextValue>(() => ({ const contextValue = useMemo<InputContextValue>(() => ({
bindings, bindings,
@@ -743,6 +838,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
lastDevice, lastDevice,
controllerIconStyle: preferences.controllerIconStyle, controllerIconStyle: preferences.controllerIconStyle,
directPartyTargeting: preferences.directPartyTargeting, directPartyTargeting: preferences.directPartyTargeting,
combatTouchLocked: preferences.combatTouchLocked,
beginCapture: (device, action) => setCapture({ device, action }), beginCapture: (device, action) => setCapture({ device, action }),
cancelCapture: () => setCapture(null), cancelCapture: () => setCapture(null),
resetBindings: (device) => setBindings((current) => ({ resetBindings: (device) => setBindings((current) => ({
@@ -757,6 +853,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
...current, ...current,
directPartyTargeting, directPartyTargeting,
})), })),
setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({
...current,
combatTouchLocked,
})),
}), [bindings, capture, lastDevice, preferences]) }), [bindings, capture, lastDevice, preferences])
function typeKeyboardKey(key: string) { function typeKeyboardKey(key: string) {
@@ -821,6 +921,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
</section> </section>
</div> </div>
)} )}
{touchLockMessage && (
<div className="combat-touch-lock-status" aria-live="polite">
{touchLockMessage}
</div>
)}
</InputContext.Provider> </InputContext.Provider>
) )
} }