Android build v1.1.5

This commit is contained in:
Warren H
2026-06-30 13:30:33 -04:00
parent 1797154726
commit f6d408f594
21 changed files with 763 additions and 254 deletions
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 83
versionName "1.1.4"
versionCode 84
versionName "1.1.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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;
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.SystemClock;
import android.view.KeyEvent;
@@ -12,15 +16,22 @@ import java.io.File;
public abstract class ControllerBridgeActivity extends BridgeActivity {
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 long lastDpadDispatchAt = 0;
@Override
public void onCreate(Bundle savedInstanceState) {
clearWebViewServiceWorkers();
boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
super.onCreate(savedInstanceState);
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
if (bridge != null) {
if (shouldClearWebViewCache) {
bridge.getWebView().clearCache(true);
markWebViewCacheClearedForCurrentVersion();
}
}
loadIntentUrl();
}
@@ -35,7 +46,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enableImmersiveMode();
if (hasFocus) {
enableImmersiveMode();
AndroidDisplayPerformance.preferBatteryRefreshRate(
getWindow(),
getWindowManager().getDefaultDisplay()
);
}
}
@Override
@@ -74,6 +91,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
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) {
if (!file.exists()) return;
if (file.isDirectory()) {
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
private final class TopDisplayPresentation extends Presentation {
private final String initialUrl;
private final Display display;
TopDisplayPresentation(Context context, Display display, String initialUrl) {
super(context, display);
this.display = display;
this.initialUrl = initialUrl;
}
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK);
+238 -154
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -20,6 +20,7 @@ import {
type GameMode,
} from './gameRepository'
import { focusFirstControl } from './input.tsx'
import { barFillStyle } from './components/barStyles'
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
@@ -469,7 +470,7 @@ function App() {
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={{ width: `${experiencePercent}%` }} />
<span style={barFillStyle(experiencePercent)} />
</div>
<button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
+60 -2
View File
@@ -1,21 +1,79 @@
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(
spell: Spell,
resource: number,
cooldowns: Record<string, 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(
cooldowns: Record<string, number>,
spell: Spell,
cooldownMultiplier = 1,
now = cooldownNow(),
) {
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
if (cooldownMs <= 0) {
const nextCooldowns = { ...cooldowns }
delete nextCooldowns[spell.id]
return nextCooldowns
}
return {
...cooldowns,
[spell.id]: spell.cooldown * cooldownMultiplier,
[spell.id]: now + cooldownMs,
}
}
+26 -20
View File
@@ -46,7 +46,7 @@ import {
spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
@@ -58,7 +58,11 @@ import {
appendCombatLog,
type BasicFloatingCombatText,
} from '../combat/combatPresentation'
import { canCastSpell } from '../combat/spellCasting'
import {
canCastSpell,
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import {
applySpellEffectProfile,
buildSpellTargetPlan,
@@ -69,9 +73,11 @@ import {
useInput,
} from '../input'
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
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 { ResultScreen } from './ResultScreen'
import {
@@ -668,10 +674,10 @@ export function CombatScreen({
})
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
resourceSpentRef.current += effectiveCost
const nextCooldowns = { ...current.cooldowns }
let nextCooldowns = current.cooldowns
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
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({
current,
@@ -855,7 +861,7 @@ export function CombatScreen({
const runCombatTick = useCallback(() => {
const current = combatRef.current
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)
const living = current.party.filter((member) => member.health > 0)
@@ -1161,17 +1167,17 @@ export function CombatScreen({
}).reverse(),
[enemyCount, enemyHealth, encounter.maxHealth],
)
const spellSlots = useMemo<SpellSlot[]>(() => profile.abilitySlots.map((abilityId, slotIndex) => {
const spell = spellByKey.get(String(slotIndex + 1))
return abilityId && spell
? {
...spell,
cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
slotIndex,
remaining: cooldowns[spell.id] ?? 0,
}
: null
}), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey])
const spellSlotCost = useCallback(
(spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
[freeCastReady, roguelikeUpgradeCounts],
)
const spellSlots = useSpellSlots({
spells,
abilitySlots: profile.abilitySlots,
cooldowns,
active: status === 'playing' && !paused,
cost: spellSlotCost,
})
const dualScreenState = useMemo(() => buildCombatDualScreenState({
difficultyName: difficulty.name,
dungeonName: dungeon.name,
@@ -1232,7 +1238,7 @@ export function CombatScreen({
return (
<main
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">
<div>
@@ -1266,13 +1272,13 @@ export function CombatScreen({
<div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => (
<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>
</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>
</div>
+3 -2
View File
@@ -7,6 +7,7 @@ import {
memberHotEffects,
} from '../combat/rules'
import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type FloatingCombatText = {
id: number
@@ -131,8 +132,8 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />}
<span style={barFillStyle((member.health / maxHealth) * 100)} />
{member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
</div>
<div className="floating-combat-texts" aria-hidden="true">
+25 -15
View File
@@ -13,9 +13,11 @@ import { completeRoguelike, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
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 { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
import {
@@ -37,10 +39,14 @@ import {
spellExtraTargets,
spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine'
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import {
nextPvpRoguelikeStage,
resolvePvpRoguelikeCombatOutcome,
@@ -398,12 +404,16 @@ export function PvPRoguelikeScreen({
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
...spell,
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
slotIndex,
remaining: playerSide.cooldowns[spell.id] ?? 0,
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
const playerSpellSlotCost = useCallback(
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
[playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
)
const playerSpellSlots = useSpellSlots({
spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo(
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
[cpuSide.buffs, selfBuffChoicesCatalog],
@@ -880,9 +890,9 @@ export function PvPRoguelikeScreen({
damageReductionTargets: new Set<string>(),
groupTargets,
}
const nextCooldowns = { ...current.cooldowns }
let 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 = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
}
return applyPvpSpellCast({
current,
@@ -994,7 +1004,7 @@ export function PvPRoguelikeScreen({
...side,
party: nextParty,
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)),
}
}, [activeSpellEffects, addFloatingHeal, maxResource])
@@ -1428,13 +1438,13 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap">
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
<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 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 className="bar mana-bar"><span style={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
</div>
</div>
</div>
@@ -1468,12 +1478,12 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap">
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
<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 className="pvp-resource-wrap">
<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>
+25 -16
View File
@@ -11,9 +11,11 @@ import { completeRoguelike } from '../profile'
import type { CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository'
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 { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
@@ -31,10 +33,13 @@ import {
spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine'
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
} from '../combat/spellCasting'
import {
appendCombatLog,
STADIUM_COMBAT_LOG_LIMIT,
@@ -400,12 +405,16 @@ export function PvpStadiumScreen({
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
const playerAlive = playerSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
...spell,
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
slotIndex,
remaining: playerSide.cooldowns[spell.id] ?? 0,
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
const playerSpellSlotCost = useCallback(
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
[playerBuffCounts, playerSide.freeCastReady],
)
const playerSpellSlots = useSpellSlots({
spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo(
() => summarizeStacks(cpuSide.buffs, buffCatalog),
[buffCatalog, cpuSide.buffs],
@@ -790,7 +799,7 @@ export function PvpStadiumScreen({
...side,
party: nextParty,
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
cooldowns: pruneExpiredCooldowns(side.cooldowns),
survivalSeconds: nextSurvival,
dampeningPercent,
}
@@ -1168,16 +1177,16 @@ export function PvpStadiumScreen({
</section>
<section className="dual-top-spell-strip">
{starterSpells.map((spell, slotIndex) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady)
{playerSpellSlots.map((spell, slotIndex) => {
if (!spell) return null
const remaining = spell.remaining
const percent = remaining > 0
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return (
<button
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}
onClick={() => castPlayerSpell(spell)}
type="button"
@@ -1192,7 +1201,7 @@ export function PvpStadiumScreen({
<div className="dual-top-resource">
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
<div className="bar mana-bar">
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
<span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
</div>
</div>
</section>
@@ -1226,7 +1235,7 @@ export function PvpStadiumScreen({
</div>
<div className="pvp-resource-wrap">
<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 className="party-grid pvp-party-grid">
@@ -1256,7 +1265,7 @@ export function PvpStadiumScreen({
</div>
<div className="pvp-resource-wrap">
<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 className="party-grid pvp-party-grid">
+10
View File
@@ -32,11 +32,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
capture,
controllerIconStyle,
directPartyTargeting,
combatTouchLocked,
beginCapture,
cancelCapture,
resetBindings,
setControllerIconStyle,
setDirectPartyTargeting,
setCombatTouchLocked,
} = useInput()
const {
enabled: dualScreenEnabled,
@@ -180,6 +182,14 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</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">
<span>Controller Icons</span>
{(['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 { Spell } from '../game'
import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type SpellSlot = (Spell & {
cost: number
@@ -28,7 +29,7 @@ export const ResourceBar = memo(function ResourceBar({
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
</span>
{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>
)
})
+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`,
}
}
+18 -7
View File
@@ -22,6 +22,7 @@ import {
} from './input'
import { ControllerBindingLabel } from './components/ControllerIcons'
import { PartyMemberFrame } from './components/PartyFrames'
import { barFillStyle } from './components/barStyles'
import { groupFloatingTextsByMember } from './combat/combatPresentation'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
@@ -480,7 +481,7 @@ export function DualScreenBottomDisplay() {
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div>
<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>
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
<>
@@ -489,7 +490,7 @@ export function DualScreenBottomDisplay() {
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
</div>
<div className="bar mana-bar">
<span style={{ width: `${Math.max(0, (state.opponentResource / state.opponentMaxResource) * 100)}%` }} />
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
</div>
</>
) : null}
@@ -527,7 +528,7 @@ export function DualScreenBottomDisplay() {
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div>
</div>
</section>
@@ -678,14 +679,24 @@ export function DualScreenTopCombat({
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
</div>
<div className="bar enemy-health">
<span style={{ width: `${enemyPercent}%` }} />
</div>
<p>{state.encounterDescription}</p>
<span style={barFillStyle(enemyPercent)} />
</div>
{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>
)}
{!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>
@@ -720,7 +731,7 @@ export function DualScreenTopCombat({
<div className="dual-top-resource">
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
<div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div>
</div>
</section>
+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
}
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
durationMs = 900,
}: FloatingCombatTextOptions = {}) {
const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
const nextId = useRef(1)
const expirationsRef = useRef(new Map<number, number>())
const cleanupTimerRef = useRef<number | null>(null)
const clearFloatingTexts = useCallback(() => {
expirationsRef.current.clear()
setFloatingTexts([])
const clearCleanupTimer = useCallback(() => {
if (cleanupTimerRef.current === null) return
window.clearTimeout(cleanupTimerRef.current)
cleanupTimerRef.current = null
}, [])
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))
}, [durationMs])
const scheduleCleanup = useCallback(() => {
function scheduleNext() {
clearCleanupTimer()
let nextExpiry = Infinity
for (const expiresAt of expirationsRef.current.values()) {
nextExpiry = Math.min(nextExpiry, expiresAt)
}
if (!Number.isFinite(nextExpiry)) return
useEffect(() => {
const timer = window.setInterval(() => {
cleanupTimerRef.current = window.setTimeout(() => {
cleanupTimerRef.current = null
const now = performance.now()
let removed = false
for (const [id, expiresAt] of expirationsRef.current) {
@@ -45,11 +47,31 @@ export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
expirationsRef.current.delete(id)
removed = true
}
if (!removed) return
if (removed) {
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(
() => 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)),
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
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;
text-rendering: optimizeLegibility;
}
+113 -8
View File
@@ -37,6 +37,7 @@ export const INPUT_ACTIONS = [
'targetParty6',
'toggleTargetGroup',
'toggleSpeed',
'toggleTouchLock',
'pause',
] as const
@@ -66,6 +67,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group',
toggleSpeed: 'Toggle 2x Speed',
toggleTouchLock: 'Toggle Combat Touch Lock',
pause: 'Pause Menu',
}
@@ -93,6 +95,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty6: 'F6',
toggleTargetGroup: 'Tab',
toggleSpeed: 'Backquote',
toggleTouchLock: 'F7',
pause: 'Escape',
},
controller: {
@@ -115,9 +118,10 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15',
targetParty4: 'Button13',
targetParty5: 'Button4',
targetParty6: 'Button10',
toggleTargetGroup: 'Button6',
targetParty6: 'Button6',
toggleTargetGroup: 'Button8',
toggleSpeed: 'Button11',
toggleTouchLock: 'Button10',
pause: 'Button9',
},
}
@@ -144,11 +148,13 @@ type InputContextValue = {
lastDevice: InputDevice
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
combatTouchLocked: boolean
beginCapture: (device: InputDevice, action: InputAction) => void
cancelCapture: () => void
resetBindings: (device: InputDevice) => void
setControllerIconStyle: (style: ControllerIconStyle) => void
setDirectPartyTargeting: (enabled: boolean) => void
setCombatTouchLocked: (locked: boolean) => void
}
const InputContext = createContext<InputContextValue | null>(null)
@@ -187,6 +193,21 @@ function loadBindings(): Record<InputDevice, InputBindings> {
if (savedController?.targetParty6 === 'Button11') {
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 {
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
controller,
@@ -201,15 +222,18 @@ function loadPreferences() {
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
controllerIconStyle?: ControllerIconStyle
directPartyTargeting?: boolean
combatTouchLocked?: boolean
}
return {
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
directPartyTargeting: saved.directPartyTargeting ?? false,
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
}
} catch {
return {
controllerIconStyle: 'xbox' as ControllerIconStyle,
directPartyTargeting: false,
combatTouchLocked: Capacitor.isNativePlatform(),
}
}
}
@@ -223,6 +247,7 @@ function bindingGroup(action: InputAction) {
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
if (action === 'pause') return 'pause'
if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system'
return 'navigation'
}
@@ -428,6 +453,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(loadPreferences)
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
const [keyboardShift, setKeyboardShift] = useState(false)
const [touchLockMessage, setTouchLockMessage] = useState('')
const bindingsRef = useRef(bindings)
const preferencesRef = useRef(preferences)
const captureRef = useRef(capture)
@@ -435,6 +461,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({})
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
const touchLockMessageTimerRef = useRef(0)
useEffect(() => {
bindingsRef.current = bindings
@@ -485,7 +512,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
setLastDevice(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)
} else if (action === 'confirm') {
const active = currentFocusableControl()
@@ -551,8 +589,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
'targetParty6',
'toggleTargetGroup',
'toggleSpeed',
'toggleTouchLock',
] satisfies InputAction[]
const combatPriority = [
'toggleTouchLock',
'pause',
'toggleSpeed',
'ability1',
@@ -621,7 +661,9 @@ export function InputProvider({ children }: { children: ReactNode }) {
rememberFocusableControl(target)
}
const onPointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') {
document.documentElement.dataset.inputDevice = 'pc'
}
const target = event.target
if (!(target instanceof Element)) return
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
@@ -645,8 +687,14 @@ export function InputProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
}, [dispatchControllerToken])
useEffect(() => () => {
window.clearTimeout(touchLockMessageTimerRef.current)
}, [])
useEffect(() => {
let focusFrame = 0
const ensureFocus = () => {
focusFrame = 0
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
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(() => {
window.requestAnimationFrame(ensureFocus)
scheduleEnsureFocus()
})
observer.observe(document.getElementById('root') ?? document.body, {
attributes: true,
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'],
childList: true,
subtree: true,
})
window.requestAnimationFrame(ensureFocus)
return () => observer.disconnect()
scheduleEnsureFocus()
return () => {
if (focusFrame) window.cancelAnimationFrame(focusFrame)
observer.disconnect()
}
}, [])
useEffect(() => {
let timer = 0
if (Capacitor.isNativePlatform() && capture?.device !== 'controller') {
previousTokensRef.current = new Set()
repeatRef.current = {}
return undefined
}
const nextDelay = () => {
if (Capacitor.isNativePlatform()) {
return GAMEPAD_COMBAT_POLL_MS
}
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
}
@@ -735,7 +798,39 @@ export function InputProvider({ children }: { children: ReactNode }) {
window.removeEventListener('gamepadconnected', onGamepadConnected)
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>(() => ({
bindings,
@@ -743,6 +838,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
lastDevice,
controllerIconStyle: preferences.controllerIconStyle,
directPartyTargeting: preferences.directPartyTargeting,
combatTouchLocked: preferences.combatTouchLocked,
beginCapture: (device, action) => setCapture({ device, action }),
cancelCapture: () => setCapture(null),
resetBindings: (device) => setBindings((current) => ({
@@ -757,6 +853,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
...current,
directPartyTargeting,
})),
setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({
...current,
combatTouchLocked,
})),
}), [bindings, capture, lastDevice, preferences])
function typeKeyboardKey(key: string) {
@@ -821,6 +921,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
</section>
</div>
)}
{touchLockMessage && (
<div className="combat-touch-lock-status" aria-live="polite">
{touchLockMessage}
</div>
)}
</InputContext.Provider>
)
}