Compare commits

...
3 Commits
Author SHA1 Message Date
Warren H 5b8c32dc41 Android build v1.1.7 2026-06-30 14:23:45 -04:00
Warren H df6306fe7a Android build v1.1.6 2026-06-30 13:59:36 -04:00
Warren H f6d408f594 Android build v1.1.5 2026-06-30 13:30:33 -04:00
25 changed files with 1227 additions and 324 deletions
Binary file not shown.
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 83 versionCode 86
versionName "1.1.4" versionName "1.1.7"
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,26 +1,45 @@
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.InputDevice;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.View; import android.view.View;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import java.io.File; import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
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 long DPAD_THROTTLE_MS = 220; private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
private long lastDpadDispatchAt = 0; private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long DPAD_THROTTLE_MS = 55;
private final long[] lastDpadDispatchAt = new long[16];
private final Set<String> activeMotionTokens = new HashSet<>();
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
@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) {
bridge.getWebView().clearCache(true); if (shouldClearWebViewCache) {
bridge.getWebView().clearCache(true);
markWebViewCacheClearedForCurrentVersion();
}
} }
loadIntentUrl(); loadIntentUrl();
} }
@@ -35,7 +54,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 +99,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()) {
@@ -94,24 +147,104 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0; boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true; if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
String script = dispatchNativeControllerToken(token, repeat);
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
} }
return true; return true;
} }
private boolean shouldThrottleDpad() { @Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
Set<String> currentTokens = new HashSet<>();
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_X),
"Button14",
"Button15"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_Y),
"Button12",
"Button13"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_X),
"Axis0-",
"Axis0+"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_Y),
"Axis1-",
"Axis1+"
);
boolean hadMotionTokens = !activeMotionTokens.isEmpty();
long now = SystemClock.uptimeMillis(); long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true; for (String token : currentTokens) {
lastDpadDispatchAt = now; boolean repeat = activeMotionTokens.contains(token);
long lastDispatchAt = lastMotionDispatchAt.containsKey(token)
? lastMotionDispatchAt.get(token)
: 0L;
if (!repeat || now - lastDispatchAt >= DPAD_THROTTLE_MS) {
dispatchNativeControllerToken(token, repeat);
lastMotionDispatchAt.put(token, now);
}
}
activeMotionTokens.clear();
activeMotionTokens.addAll(currentTokens);
if (currentTokens.isEmpty() && !hadMotionTokens) {
return super.dispatchGenericMotionEvent(event);
}
return true;
}
private boolean isControllerMotionEvent(MotionEvent event) {
int source = event.getSource();
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
}
private void addAxisTokens(
Set<String> tokens,
float value,
String negativeToken,
String positiveToken
) {
if (value <= -AXIS_DEAD_ZONE) tokens.add(negativeToken);
if (value >= AXIS_DEAD_ZONE) tokens.add(positiveToken);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt[buttonIndex] = now;
return false; return false;
} }
@@ -125,10 +258,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
private String controllerToken(int keyCode) { private String controllerToken(int keyCode) {
switch (keyCode) { switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A: case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0"; return "Button0";
case KeyEvent.KEYCODE_BUTTON_B: case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_BACK: case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1"; return "Button1";
case KeyEvent.KEYCODE_BUTTON_X: case KeyEvent.KEYCODE_BUTTON_X:
return "Button2"; return "Button2";
@@ -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);
+437 -154
View File
File diff suppressed because it is too large Load Diff
+3 -2
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 })))
@@ -455,7 +456,7 @@ function App() {
const canShowCloudSync = account.id !== -1 && cloudSync.available const canShowCloudSync = account.id !== -1 && cloudSync.available
return ( return (
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}> <main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
<header className="topbar app-header"> <header className="topbar app-header">
<button <button
className="brand-button" className="brand-button"
@@ -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}`}
+4 -1
View File
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './App.css' import './App.css'
import { AdminScreen } from './components/AdminScreen' import { AdminScreen } from './components/AdminScreen'
import { InputProvider } from './input'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AdminScreen onBack={() => window.close()} /> <InputProvider>
<AdminScreen onBack={() => window.close()} />
</InputProvider>
</StrictMode>, </StrictMode>,
) )
+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,
} }
} }
+28 -22
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,
@@ -1230,9 +1236,9 @@ export function CombatScreen({
useDualScreenPublisher(dualScreenState, dualScreenEnabled) useDualScreenPublisher(dualScreenState, dualScreenEnabled)
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">
+26 -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])
@@ -1428,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>
@@ -1468,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>
@@ -1499,6 +1509,7 @@ export function PvPRoguelikeScreen({
bindings={activeBindings} bindings={activeBindings}
canCast={status === 'playing' && !playerDone && playerAlive} canCast={status === 'playing' && !playerDone && playerAlive}
className="pvp-bottom-spell-bar" className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle} iconStyle={controllerIconStyle}
onCast={castPlayerSpell} onCast={castPlayerSpell}
resource={playerSide.resource} resource={playerSide.resource}
+26 -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,
} }
@@ -1168,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"
@@ -1192,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>
@@ -1226,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">
@@ -1256,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">
@@ -1280,6 +1289,7 @@ export function PvpStadiumScreen({
bindings={activeBindings} bindings={activeBindings}
canCast={status === 'playing' && playerAlive} canCast={status === 'playing' && playerAlive}
className="pvp-bottom-spell-bar" className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle} iconStyle={controllerIconStyle}
onCast={castPlayerSpell} onCast={castPlayerSpell}
resource={playerSide.resource} resource={playerSide.resource}
+30 -25
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,
@@ -89,33 +91,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
return ( return (
<section className="content-screen settings-screen"> <section className="content-screen settings-screen">
<div className="screen-heading"> <div className="settings-nav">
<div> <button className="back-button settings-back-button" onClick={onBack} type="button">Back</button>
<p className="eyebrow">Game Options</p> <nav className="settings-tabs" role="tablist" aria-label="Settings sections">
<h1>Settings</h1> {([
</div> { key: 'display', label: 'Display' },
<button className="back-button" onClick={onBack} type="button">Back</button> { key: 'input', label: 'Input' },
{ key: 'bindings', label: 'Bindings' },
] as const).map((tab) => (
<button
aria-selected={settingsTab === tab.key}
className={settingsTab === tab.key ? 'selected' : ''}
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</nav>
</div> </div>
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{([
{ key: 'display', label: 'Display' },
{ key: 'input', label: 'Input' },
{ key: 'bindings', label: 'Bindings' },
] as const).map((tab) => (
<button
aria-selected={settingsTab === tab.key}
className={settingsTab === tab.key ? 'selected' : ''}
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</nav>
{settingsTab === 'display' && ( {settingsTab === 'display' && (
<section className="dual-screen-settings settings-tab-panel"> <section className="dual-screen-settings settings-tab-panel">
<div> <div>
@@ -180,6 +177,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) => (
+9 -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>
) )
}) })
@@ -39,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle, iconStyle,
resourceName, resourceName,
disabled, disabled,
focusable = true,
onCast, onCast,
emptyKeyPrefix = 'empty', emptyKeyPrefix = 'empty',
}: { }: {
@@ -47,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle: ControllerIconStyle iconStyle: ControllerIconStyle
resourceName: string resourceName: string
disabled?: boolean disabled?: boolean
focusable?: boolean
onCast: (spell: Spell) => void onCast: (spell: Spell) => void
emptyKeyPrefix?: string emptyKeyPrefix?: string
}) { }) {
@@ -60,9 +63,11 @@ export const SpellButton = memo(function SpellButton({
return ( return (
<button <button
className="spell" className="spell"
data-controller-nav={focusable ? undefined : 'skip'}
disabled={disabled} disabled={disabled}
key={spell.id} key={spell.id}
onClick={() => onCast(spell)} onClick={() => onCast(spell)}
tabIndex={focusable ? undefined : -1}
title={spell.description} title={spell.description}
type="button" type="button"
> >
@@ -94,6 +99,7 @@ export const SpellBar = memo(function SpellBar({
canCast, canCast,
onCast, onCast,
className = 'spell-bar six-slots vertical-spell-bar', className = 'spell-bar six-slots vertical-spell-bar',
focusable = true,
}: { }: {
spells: SpellSlot[] spells: SpellSlot[]
bindings: Record<string, string> bindings: Record<string, string>
@@ -103,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
canCast: boolean canCast: boolean
onCast: (spell: Spell) => void onCast: (spell: Spell) => void
className?: string className?: string
focusable?: boolean
}) { }) {
return ( return (
<div className={className}> <div className={className}>
@@ -118,6 +125,7 @@ export const SpellBar = memo(function SpellBar({
<SpellButton <SpellButton
binding={bindings[`ability${slotIndex + 1}`]} binding={bindings[`ability${slotIndex + 1}`]}
disabled={!canCast || resource < spell.cost || spell.remaining > 0} disabled={!canCast || resource < spell.cost || spell.remaining > 0}
focusable={focusable}
iconStyle={iconStyle} iconStyle={iconStyle}
key={spell.id} key={spell.id}
onCast={onCast} onCast={onCast}
+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`,
}
}
+31 -9
View File
@@ -19,9 +19,11 @@ import {
dispatchExternalGameAction, dispatchExternalGameAction,
type ControllerIconStyle, type ControllerIconStyle,
type InputAction, type InputAction,
useGameAction,
} 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'
@@ -401,6 +403,12 @@ export function DualScreenBottomDisplay() {
channel?.close() channel?.close()
} }
useGameAction((action) => {
if (!state || state.opponentParty) return
if (action === 'confirm' || action === 'back' || action === 'toggleTouchLock') return
sendAction(action)
})
if (!state && workshopState) { if (!state && workshopState) {
return ( return (
<main className="dual-bottom-display workshop-bottom-display"> <main className="dual-bottom-display workshop-bottom-display">
@@ -448,7 +456,10 @@ export function DualScreenBottomDisplay() {
} }
return ( return (
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}> <main
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
data-combat-active={!state.opponentParty && state.status === 'playing' && !state.paused ? 'true' : 'false'}
>
<header className="dual-controls-header"> <header className="dual-controls-header">
<div> <div>
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p> <p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
@@ -480,7 +491,7 @@ 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 ? ( {typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
<> <>
@@ -489,7 +500,7 @@ export function DualScreenBottomDisplay() {
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong> <strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
</div> </div>
<div className="bar mana-bar"> <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> </div>
</> </>
) : null} ) : null}
@@ -527,7 +538,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>
@@ -648,6 +659,7 @@ export function DualScreenTopCombat({
return ( return (
<button <button
className="dual-top-spell" className="dual-top-spell"
data-controller-nav={state.opponentParty ? 'skip' : undefined}
disabled={ disabled={
!state.playerIsAlive !state.playerIsAlive
|| state.resource < spell.cost || state.resource < spell.cost
@@ -678,13 +690,23 @@ 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>
)}
{!state.opponentParty && <p>{state.encounterDescription}</p>}
</div> </div>
{state.opponentParty && ( {state.opponentParty && (
<div className="dual-top-cooldowns" aria-label="Cooldowns"> <div className="dual-top-side-status">
{spellButtons} <div className="dual-top-cooldowns" aria-label="Cooldowns">
{spellButtons}
</div>
</div> </div>
)} )}
</section> </section>
@@ -720,7 +742,7 @@ export function DualScreenTopCombat({
<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>
+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
}
+40 -18
View File
@@ -15,41 +15,63 @@ 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 clearCleanupTimer = useCallback(() => {
if (cleanupTimerRef.current === null) return
window.clearTimeout(cleanupTimerRef.current)
cleanupTimerRef.current = null
}, [])
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
cleanupTimerRef.current = window.setTimeout(() => {
cleanupTimerRef.current = null
const now = performance.now()
let removed = false
for (const [id, expiresAt] of expirationsRef.current) {
if (expiresAt > now) continue
expirationsRef.current.delete(id)
removed = true
}
if (removed) {
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
}
scheduleNext()
}, Math.max(0, nextExpiry - performance.now()))
}
scheduleNext()
}, [clearCleanupTimer])
const clearFloatingTexts = useCallback(() => { const clearFloatingTexts = useCallback(() => {
clearCleanupTimer()
expirationsRef.current.clear() expirationsRef.current.clear()
setFloatingTexts([]) setFloatingTexts([])
}, []) }, [clearCleanupTimer])
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => { const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
if (entry.value <= 0) return if (entry.value <= 0) return
const id = nextId.current++ const id = nextId.current++
expirationsRef.current.set(id, performance.now() + durationMs) expirationsRef.current.set(id, performance.now() + durationMs)
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText)) setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
}, [durationMs]) scheduleCleanup()
}, [durationMs, scheduleCleanup])
useEffect(() => { useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
const timer = window.setInterval(() => {
const now = performance.now()
let removed = false
for (const [id, expiresAt] of expirationsRef.current) {
if (expiresAt > now) continue
expirationsRef.current.delete(id)
removed = true
}
if (!removed) return
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [])
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;
} }
+224 -35
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',
}, },
} }
@@ -126,10 +130,20 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action' const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
const MAIN_CONTENT_SELECTOR = '.menu-screen, .content-screen, .dungeon-run-screen'
const HEADER_CONTENT_SELECTOR = '.app-header'
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60 const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30 const GAMEPAD_MENU_POLL_MS = 1000 / 30
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250 const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
const CONTROLLER_REPEAT_INITIAL_MS = 260
const CONTROLLER_REPEAT_MS = 85
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
let lastControllerFocus: HTMLElement | null = null let lastControllerFocus: HTMLElement | null = null
@@ -144,11 +158,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 +203,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 +232,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 +257,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'
} }
@@ -231,6 +266,16 @@ function isVisible(element: HTMLElement) {
return element.getClientRects().length > 0 return element.getClientRects().length > 0
} }
function focusableDescendants(scope: ParentNode) {
return Array.from(
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(isVisible)
}
function uniqueElements(elements: HTMLElement[]) {
return elements.filter((element, index) => elements.indexOf(element) === index)
}
function focusableElements() { function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard') const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen') const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
@@ -239,10 +284,22 @@ function focusableElements() {
'.result-screen, .binding-capture, .dual-startup-prompt', '.result-screen, .binding-capture, .dual-startup-prompt',
), ),
).find(isVisible) ).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document const mainContent = Array.from(
return Array.from( document.querySelectorAll<HTMLElement>(MAIN_CONTENT_SELECTOR),
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR), ).find(isVisible)
const overlay = keyboard ?? pauseMenu ?? dialog
if (overlay) return focusableDescendants(overlay)
const visibleHeaders = Array.from(
document.querySelectorAll<HTMLElement>(HEADER_CONTENT_SELECTOR),
).filter(isVisible) ).filter(isVisible)
if (!mainContent) return visibleHeaders.length > 0 ? [] : focusableDescendants(document)
const headerControls = visibleHeaders.flatMap(
(header) => focusableDescendants(header),
)
return uniqueElements([
...focusableDescendants(mainContent),
...headerControls,
])
} }
function rememberFocusableControl(element: HTMLElement) { function rememberFocusableControl(element: HTMLElement) {
@@ -266,6 +323,23 @@ function currentFocusableControl(candidates = focusableElements()) {
return null return null
} }
function rangeDistance(startA: number, endA: number, startB: number, endB: number) {
if (endA < startB) return startB - endA
if (endB < startA) return startA - endB
return 0
}
function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
const options = Array.from(select.options).filter((option) => !option.disabled)
const currentIndex = options.findIndex((option) => option.index === select.selectedIndex)
const nextOption = options[currentIndex + direction]
if (!nextOption) return false
select.selectedIndex = nextOption.index
select.dispatchEvent(new Event('input', { bubbles: true }))
select.dispatchEvent(new Event('change', { bubbles: true }))
return true
}
export function focusFirstControl() { export function focusFirstControl() {
const first = focusableElements()[0] const first = focusableElements()[0]
if (first) focusControl(first) if (first) focusControl(first)
@@ -286,21 +360,48 @@ function moveFocus(action: InputAction) {
const currentY = currentRect.top + currentRect.height / 2 const currentY = currentRect.top + currentRect.height / 2
const vertical = action === 'navigateUp' || action === 'navigateDown' const vertical = action === 'navigateUp' || action === 'navigateDown'
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
const currentIndex = candidates.indexOf(current)
const adjacent = candidates[currentIndex + direction]
const fallbackToAdjacent = () => {
if (adjacent) focusControl(adjacent)
}
const ranked = candidates const ranked = candidates
.filter((candidate) => candidate !== current) .filter((candidate) => candidate !== current)
.map((candidate) => { .map((candidate, index) => {
const rect = candidate.getBoundingClientRect() const rect = candidate.getBoundingClientRect()
const x = rect.left + rect.width / 2 const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2 const y = rect.top + rect.height / 2
const primary = vertical ? y - currentY : x - currentX const primary = vertical
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY) ? direction > 0
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 } ? rect.top - currentRect.bottom
: currentRect.top - rect.bottom
: direction > 0
? rect.left - currentRect.right
: currentRect.left - rect.right
const secondary = vertical
? rangeDistance(currentRect.left, currentRect.right, rect.left, rect.right)
: rangeDistance(currentRect.top, currentRect.bottom, rect.top, rect.bottom)
return {
candidate,
index,
primary,
secondary,
score: Math.max(0, primary) + secondary * 2.5 + (vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)) * 0.1,
}
}) })
.filter(({ primary }) => Math.sign(primary) === direction) .filter(({ primary }) => primary >= -4)
.sort((a, b) => a.score - b.score) const sameRowHorizontal = !vertical
? ranked.filter(({ secondary }) => secondary === 0)
: ranked
if (sameRowHorizontal.length === 0) {
fallbackToAdjacent()
return
}
sameRowHorizontal.sort((a, b) => a.score - b.score || a.secondary - b.secondary || a.index - b.index)
const next = ranked[0]?.candidate const next = sameRowHorizontal[0]?.candidate
if (!next) return if (!next) return
focusControl(next) focusControl(next)
} }
@@ -428,6 +529,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 +537,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,13 +588,38 @@ 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') {
if (uiOverlay || !combatActive) moveFocus(action) 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) {
const active = currentFocusableControl()
if (
active instanceof HTMLSelectElement
&& (action === 'navigateUp' || action === 'navigateDown')
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
) {
return
}
moveFocus(action)
}
} else if (action === 'confirm') { } else if (action === 'confirm') {
const active = currentFocusableControl() const active = currentFocusableControl()
if (isTextInput(active)) { if (isTextInput(active)) {
setKeyboardInput(active) setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl()) window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLSelectElement) {
const select = active as HTMLSelectElement & { showPicker?: () => void }
if (select.showPicker) select.showPicker()
else active.click()
} else if ( } else if (
active active
&& active.matches('button:not(:disabled), [role="button"]') && active.matches('button:not(:disabled), [role="button"]')
@@ -528,12 +656,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
document.querySelector('[data-combat-active="true"]'), document.querySelector('[data-combat-active="true"]'),
) )
const uiOverlay = hasUiOverlay() const uiOverlay = hasUiOverlay()
const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
const uiPriority = [ const uiPriority = [
'navigateUp', 'navigateUp',
'navigateDown', 'navigateDown',
@@ -551,8 +673,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',
@@ -568,18 +692,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft', 'navigateLeft',
'navigateRight', 'navigateRight',
] satisfies InputAction[] ] satisfies InputAction[]
const action = menuDpadActions[token] && (!combatActive || uiOverlay) const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: uiOverlay : uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token) ? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
: combatActive && preferencesRef.current.directPartyTargeting : combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find( ? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token, (candidate) => bindingsRef.current.controller[candidate] === token,
) )
: combatActive && menuDpadActions[token] : combatActive && DPAD_NAV_ACTIONS[token]
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: !combatActive && menuDpadActions[token] : !combatActive && DPAD_NAV_ACTIONS[token]
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: (combatActive ? combatPriority : INPUT_ACTIONS).find( : (combatActive ? combatPriority : INPUT_ACTIONS).find(
(candidate) => bindingsRef.current.controller[candidate] === token, (candidate) => bindingsRef.current.controller[candidate] === token,
) )
@@ -621,7 +745,9 @@ export function InputProvider({ children }: { children: ReactNode }) {
rememberFocusableControl(target) rememberFocusableControl(target)
} }
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
document.documentElement.dataset.inputDevice = 'pc' if (event.pointerType !== 'touch') {
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 +771,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 +795,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
} }
@@ -705,11 +852,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
const action = INPUT_ACTIONS.find( const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.controller[candidate] === token, (candidate) => bindingsRef.current.controller[candidate] === token,
) )
const canRepeat = action?.startsWith('navigate') ?? false const canRepeat = Boolean(action?.startsWith('navigate') || DPAD_NAV_ACTIONS[token])
const nextRepeat = repeatRef.current[token] ?? 0 const nextRepeat = repeatRef.current[token] ?? 0
if (pressed || (canRepeat && time >= nextRepeat)) { if (pressed || (canRepeat && time >= nextRepeat)) {
dispatchControllerToken(token, !pressed) dispatchControllerToken(token, !pressed)
repeatRef.current[token] = time + (pressed ? 360 : 125) repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS)
} }
}) })
@@ -735,7 +882,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 +922,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 +937,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 +1005,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>
) )
} }
+3 -1
View File
@@ -9,7 +9,9 @@ import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt }
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? ( {new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
<DualScreenBottomDisplay /> <InputProvider>
<DualScreenBottomDisplay />
</InputProvider>
) : ( ) : (
<DualScreenProvider> <DualScreenProvider>
<DualScreenStartupPrompt /> <DualScreenStartupPrompt />