diff --git a/IWantToHeal-Thor-v1.1.5.apk b/IWantToHeal-Thor-v1.1.5.apk new file mode 100644 index 0000000..730766c Binary files /dev/null and b/IWantToHeal-Thor-v1.1.5.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 57eb09e..4206613 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 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. diff --git a/android/app/src/main/java/com/warren/iwanttoheal/AndroidDisplayPerformance.java b/android/app/src/main/java/com/warren/iwanttoheal/AndroidDisplayPerformance.java new file mode 100644 index 0000000..e3727b6 --- /dev/null +++ b/android/app/src/main/java/com/warren/iwanttoheal/AndroidDisplayPerformance.java @@ -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; + } +} diff --git a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java index 55b2023..3949d12 100644 --- a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java +++ b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java @@ -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) { - bridge.getWebView().clearCache(true); + 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()) { diff --git a/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java b/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java index 111de01..d3f6542 100644 --- a/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java +++ b/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java @@ -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); diff --git a/src/App.css b/src/App.css index 0e0a354..ae7b2eb 100644 --- a/src/App.css +++ b/src/App.css @@ -1,5 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap'); - :root { --ink: #f4eed8; --muted: #a89f87; @@ -12,6 +10,24 @@ --green: #3f9a66; --blue: #3477bb; --purple: #8e68c4; + --body-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace; + --pixel-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace; +} + +.combat-touch-lock-status { + background: #101216; + border: 2px solid #d9b55a; + bottom: 18px; + box-shadow: 4px 4px 0 #050609; + color: #fff7df; + font-family: var(--pixel-font); + font-size: 9px; + left: 50%; + padding: 10px 12px; + pointer-events: none; + position: fixed; + transform: translateX(-50%); + z-index: 80; } * { @@ -69,7 +85,7 @@ textarea:focus-visible, } .binding-tabs button { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 48px; } @@ -108,7 +124,7 @@ textarea:focus-visible, align-items: center; color: var(--gold); display: inline-flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; justify-content: flex-end; min-height: 25px; @@ -210,7 +226,7 @@ textarea:focus-visible, .controller-icon-options > span { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; padding-left: 7px; } @@ -218,7 +234,7 @@ textarea:focus-visible, .controller-icon-options button { align-items: center; display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 8px; justify-content: center; @@ -287,7 +303,7 @@ textarea:focus-visible, .android-display-list strong { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -305,7 +321,7 @@ textarea:focus-visible, border: 2px solid #090a0d; color: var(--ink); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; min-height: 44px; outline: 2px solid #4b4855; @@ -384,7 +400,7 @@ textarea:focus-visible, } .dual-startup-prompt button { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 48px; padding: 10px 14px; @@ -431,7 +447,7 @@ textarea:focus-visible, } .controller-keyboard-grid button { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; min-height: 43px; } @@ -456,7 +472,7 @@ textarea:focus-visible, align-items: center; color: var(--muted); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 6px; gap: 18px; justify-content: flex-end; @@ -493,7 +509,7 @@ textarea:focus-visible, .dual-bottom-status strong { color: var(--red-bright); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; } @@ -587,7 +603,7 @@ textarea:focus-visible, .dual-top-progress > span { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; } @@ -664,7 +680,7 @@ textarea:focus-visible, border: 2px solid #3a3944; border-radius: 6px; color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 14px; font-weight: 600; margin-top: 10px; @@ -675,7 +691,7 @@ textarea:focus-visible, } .dual-top-member .member-target-key kbd { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: inherit; } @@ -764,7 +780,7 @@ textarea:focus-visible, align-items: center; color: var(--muted); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 12px; justify-content: flex-end; @@ -805,7 +821,43 @@ textarea:focus-visible, .dual-top-main.pvp-roguelike-dual-top .dual-top-enemy { align-items: center; display: grid; - grid-template-columns: 84px minmax(0, 1fr) minmax(96px, 108px); + grid-template-columns: 58px minmax(0, 1fr) minmax(142px, 152px); + min-height: 84px; + padding: 8px 10px; +} + +.dual-top-main.pvp-roguelike-dual-top .enemy-portrait { + flex-basis: auto; + height: 58px; +} + +.dual-top-main.pvp-roguelike-dual-top .enemy-info p { + display: none; +} + +.dual-top-main.pvp-roguelike-dual-top .bar-label { + font-size: 17px; +} + +.dual-top-main.pvp-roguelike-dual-top .dual-top-enemy .bar { + height: 18px; +} + +.dual-top-main.pvp-roguelike-dual-top .enemy-info .dual-top-resource { + justify-self: start; + margin-top: 5px; + min-width: 0; + width: min(220px, 100%); +} + +.dual-top-main.pvp-roguelike-dual-top .enemy-info .dual-top-resource strong { + font-size: 7px; + margin-bottom: 4px; + text-align: left; +} + +.dual-top-main.pvp-roguelike-dual-top .enemy-info .dual-top-resource .bar { + height: 8px; } .dual-top-main .dual-top-party-grid { @@ -842,11 +894,18 @@ textarea:focus-visible, .dual-top-cooldowns { align-content: center; display: grid; - gap: 6px; - grid-template-columns: repeat(2, 48px); + gap: 4px; + grid-template-columns: repeat(2, 46px); justify-content: end; } +.dual-top-side-status { + align-items: end; + display: grid; + gap: 4px; + justify-items: end; +} + .dual-top-spell { align-items: center; background: #20232c; @@ -862,8 +921,8 @@ textarea:focus-visible, } .dual-top-cooldowns .dual-top-spell { - height: 38px; - width: 48px; + height: 30px; + width: 46px; } .dual-top-spell:not(:disabled) { @@ -882,9 +941,9 @@ textarea:focus-visible, } .dual-top-cooldowns .dual-top-spell .spell-icon { - font-size: 16px; - height: 26px; - width: 26px; + font-size: 14px; + height: 22px; + width: 22px; } .dual-top-spell > i { @@ -899,25 +958,40 @@ textarea:focus-visible, .dual-top-spell > small { color: #fff4a8; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 10px; position: absolute; } .dual-top-cooldowns .dual-top-spell > small { - font-size: 8px; + font-size: 7px; } .dual-top-resource { align-self: center; color: #82bfff; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; justify-self: end; min-width: 220px; width: min(280px, 100%); } +.dual-top-side-status .dual-top-resource { + min-width: 0; + width: 100%; +} + +.dual-top-side-status .dual-top-resource strong { + font-size: 7px; + margin-bottom: 4px; + text-align: right; +} + +.dual-top-side-status .dual-top-resource .bar { + height: 8px; +} + .dual-top-resource strong { display: block; margin-bottom: 6px; @@ -1072,7 +1146,7 @@ textarea:focus-visible, .dual-controls-progress { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -1091,7 +1165,7 @@ textarea:focus-visible, .dual-controls-mana > span { color: var(--muted); display: block; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; margin-bottom: 6px; text-align: right; @@ -1394,7 +1468,7 @@ textarea:focus-visible, .auth-brand h1 { color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: clamp(25px, 4vw, 45px); line-height: 1.3; margin: 10px 0 22px; @@ -1418,7 +1492,7 @@ textarea:focus-visible, border: 2px solid #090a0d; color: var(--muted); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; min-height: 48px; outline: 2px solid #3e3d47; @@ -1438,7 +1512,7 @@ textarea:focus-visible, .auth-card label { color: var(--muted); display: grid; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 8px; text-transform: uppercase; @@ -1476,7 +1550,7 @@ textarea:focus-visible, align-items: center; color: var(--muted); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 10px; margin: 20px 0; @@ -1522,7 +1596,7 @@ textarea:focus-visible, background: var(--gold); border: 2px solid #090a0d; color: #19150e; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; outline: 2px solid #816630; } @@ -1537,7 +1611,7 @@ textarea:focus-visible, border: 0; color: var(--muted); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 6px; padding: 3px 0; } @@ -1585,7 +1659,7 @@ textarea:focus-visible, } .brand-button strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: clamp(10px, 1.3vw, 14px); } @@ -1598,7 +1672,7 @@ textarea:focus-visible, .character-summary span, .character-summary small { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -1619,6 +1693,7 @@ textarea:focus-visible, .header-xp { background: #090a0d; height: 5px; + overflow: hidden; width: 100px; } @@ -1628,6 +1703,9 @@ textarea:focus-visible, box-shadow: inset 0 3px #b08bdf; display: block; height: 100%; + transition: transform 180ms linear; + width: 100%; + will-change: transform; } h1, @@ -1639,7 +1717,7 @@ p { h1, h2 { color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); line-height: 1.45; } @@ -1653,7 +1731,7 @@ h2 { .eyebrow { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; letter-spacing: 1px; margin-bottom: 8px; @@ -1753,7 +1831,7 @@ h2 { .progress-summary > span, .progress-summary > small { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -1863,7 +1941,7 @@ h2 { color: var(--gold); display: flex; flex: 0 0 58px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 19px; height: 58px; justify-content: center; @@ -1875,7 +1953,7 @@ h2 { } .menu-card strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 11px; margin-bottom: 9px; } @@ -1924,7 +2002,7 @@ h2 { border: 2px solid #090a0d; color: var(--muted); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 42px; outline: 2px solid #41404a; @@ -2060,7 +2138,7 @@ h2 { border: 3px solid #0a0b0e; color: #ef7b66; display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 20px; height: 92px; justify-content: center; @@ -2193,7 +2271,7 @@ h2 { .tier-grid strong, .activity-card strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; } @@ -3208,7 +3286,7 @@ h2 { .activity-select > span, .loot-toolbar span { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; text-transform: uppercase; } @@ -3219,7 +3297,7 @@ h2 { border: 2px solid #090a0d; color: var(--ink); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 42px; outline: 2px solid #41404a; @@ -3255,7 +3333,7 @@ h2 { .difficulty-select-row label > span { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; text-transform: uppercase; } @@ -3265,7 +3343,7 @@ h2 { border: 2px solid #090a0d; color: var(--ink); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 42px; outline: 2px solid #41404a; @@ -3294,7 +3372,7 @@ h2 { .difficulty-summary strong { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; } @@ -3376,7 +3454,7 @@ h2 { color: var(--gold); display: flex; flex: 0 0 32px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; height: 32px; justify-content: center; @@ -3388,7 +3466,7 @@ h2 { } .difficulty-title strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -3480,7 +3558,7 @@ h2 { color: var(--gold); display: flex; flex: 0 0 31px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; height: 31px; justify-content: center; @@ -3503,7 +3581,7 @@ h2 { } .loot-encounter-title strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; line-height: 1.4; } @@ -3534,7 +3612,7 @@ h2 { align-items: center; color: var(--rarity-color, var(--ink)); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); justify-content: center; } @@ -3585,7 +3663,7 @@ h2 { .leaderboard-header { background: #15161c; color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -3620,7 +3698,7 @@ h2 { border: 2px solid #090a0d; color: var(--muted); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 6px; outline: 2px solid #3e3d47; padding: 8px 12px; @@ -3675,7 +3753,7 @@ h2 { border: 2px solid #08090c; color: #19150e; cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; outline: 2px solid #816630; padding: 13px 17px; @@ -3708,7 +3786,7 @@ h2 { .placeholder-runes { color: #4a4855; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 39px; margin-bottom: 25px; word-spacing: 20px; @@ -3732,14 +3810,14 @@ h2 { color: var(--gold); display: flex; flex: 0 0 64px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 64px; justify-content: center; } .talent-preview strong { color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 10px; } @@ -3776,7 +3854,7 @@ h2 { border: 2px solid; display: flex; flex: 0 0 48px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 48px; justify-content: center; } @@ -3788,13 +3866,13 @@ h2 { .talent-points strong { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 22px; } .talent-points span { color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; margin-top: 4px; } @@ -3865,7 +3943,7 @@ h2 { .effect-slot span, .effect-pool > button i { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; text-transform: uppercase; } @@ -3876,7 +3954,7 @@ h2 { } .effect-slot strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; line-height: 1.35; margin-top: 8px; @@ -3897,7 +3975,7 @@ h2 { .effect-panel-heading > span { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; } @@ -3920,7 +3998,7 @@ h2 { .selected-effect-strip strong { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; line-height: 1.35; margin-top: 5px; @@ -3986,7 +4064,7 @@ h2 { border: 1px solid #55515f; color: var(--gold); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 26px; justify-content: center; } @@ -3997,7 +4075,7 @@ h2 { } .effect-pool strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; line-height: 1.35; } @@ -4027,7 +4105,7 @@ h2 { border: 2px solid #090a0d; color: var(--ink); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; min-height: 28px; outline: 2px solid #41404a; @@ -4042,7 +4120,7 @@ h2 { .effect-pager span { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; } @@ -4084,7 +4162,7 @@ h2 { .tier-label span { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; } @@ -4137,7 +4215,7 @@ h2 { color: var(--gold); display: flex; flex: 0 0 37px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 37px; justify-content: center; } @@ -4149,7 +4227,7 @@ h2 { .talent-node-header strong { color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; line-height: 1.45; } @@ -4243,7 +4321,7 @@ h2 { border: 2px solid; display: flex; flex: 0 0 48px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 48px; justify-content: center; } @@ -4256,7 +4334,7 @@ h2 { .gear-stat strong { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 15px; } @@ -4324,7 +4402,7 @@ h2 { border: 2px solid #090a0d; color: var(--muted); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 44px; outline: 2px solid #41404a; @@ -4346,7 +4424,7 @@ h2 { .equipment-heading > span { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -4387,7 +4465,7 @@ h2 { background: #15161c; color: var(--gold); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 38px; justify-content: center; } @@ -4445,7 +4523,7 @@ h2 { border: 2px solid #090a0d; color: var(--ink); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; outline: 2px solid #41404a; padding: 8px 10px; @@ -4534,7 +4612,7 @@ h2 { } .crafting-filter-grid strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 6px; line-height: 1.35; } @@ -4551,7 +4629,7 @@ h2 { } .crafting-level-row button { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; min-height: 34px; min-width: 48px; @@ -4581,7 +4659,7 @@ h2 { border: 2px solid #090a0d; color: var(--gold); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; min-height: 34px; outline: 2px solid #41404a; @@ -4596,7 +4674,7 @@ h2 { .list-pager span { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; text-align: center; } @@ -4626,7 +4704,7 @@ h2 { background: #15161c; color: var(--gold); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 38px; justify-content: center; } @@ -4679,7 +4757,7 @@ h2 { .crafting-detail-heading span { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -4710,7 +4788,7 @@ h2 { .crafting-components span { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); text-align: center; } @@ -4752,7 +4830,7 @@ h2 { border: 2px solid #090a0d; color: var(--gold); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; margin-top: 12px; outline: 2px solid #41404a; @@ -4794,7 +4872,7 @@ h2 { background: #15161c; color: var(--gold); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 38px; justify-content: center; } @@ -4925,7 +5003,7 @@ h2 { align-items: center; color: var(--muted); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; justify-content: center; } @@ -4943,7 +5021,7 @@ h2 { border: 2px solid #090a0d; color: #ef8994; cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; outline: 2px solid #694048; padding: 11px 8px; @@ -4959,7 +5037,7 @@ h2 { border: 2px solid #090a0d; color: #89ef94; cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; outline: 2px solid #406948; padding: 11px 8px; @@ -5013,7 +5091,7 @@ h2 { border: 2px solid #090a0d; color: var(--muted); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 44px; outline: 2px solid #41404a; @@ -5073,7 +5151,7 @@ h2 { color: var(--class-color); display: flex; flex: 0 0 35px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 35px; justify-content: center; } @@ -5084,7 +5162,7 @@ h2 { } .class-picker strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; } @@ -5164,7 +5242,7 @@ h2 { .ability-slots span { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 16px; margin-bottom: 8px; } @@ -5216,7 +5294,7 @@ h2 { background: #15161c; color: var(--gold); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 38px; justify-content: center; } @@ -5250,7 +5328,7 @@ h2 { border: 2px solid var(--edge); color: var(--muted); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; height: 32px; justify-content: center; @@ -5282,7 +5360,7 @@ h2 { color: var(--red-bright); display: flex; flex: 0 0 70px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 25px; height: 70px; justify-content: center; @@ -5326,8 +5404,9 @@ h2 { .bar > i { display: block; height: 100%; - transition: width 180ms linear; - will-change: width; + transition: transform 180ms linear; + width: 100%; + will-change: transform; } .enemy-health { @@ -5350,7 +5429,7 @@ h2 { .hard-enemy-bars .enemy-health em { color: #fff7df; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; font-style: normal; left: 8px; @@ -5389,7 +5468,7 @@ h2 { .panel-heading > span { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; } @@ -5457,7 +5536,7 @@ h2 { border: 2px solid #0a0b0e; color: #21180a; display: none; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 5px; padding: 5px 7px; @@ -5520,7 +5599,7 @@ h2 { align-items: center; border: 1px solid white; display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; height: 19px; justify-content: center; @@ -5595,7 +5674,7 @@ h2 { .floating-heal { animation: floating-heal 0.9s ease-out forwards; color: #91f0b0; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; left: 50%; position: absolute; @@ -5723,7 +5802,7 @@ h2 { border: 2px solid #0a0b0e; color: #21180a; display: inline-block; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; line-height: 1; margin: 0 0 5px; @@ -5849,7 +5928,7 @@ h2 { border: 2px solid #0a0b0e; color: #ffe6a7; display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 17px; height: 42px; justify-content: center; @@ -5996,14 +6075,14 @@ h2 { .reward-summary .level-gain { color: var(--gold) !important; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 12px !important; } .level-gain small { color: #d8c79d; display: block; - font-family: 'VT323', monospace; + font-family: var(--body-font); font-size: 18px; margin-top: 9px; } @@ -6028,7 +6107,7 @@ h2 { .ability-unlock span { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); } .reward-summary .reward-error { @@ -6091,6 +6170,7 @@ h2 { } .upgrade-choice-grid button:hover, +.upgrade-choice-grid button:focus, .upgrade-choice-grid button:focus-visible { outline-color: var(--gold); } @@ -6140,7 +6220,7 @@ h2 { color: var(--gold); display: flex; flex: 0 0 38px; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 38px; justify-content: center; } @@ -6196,7 +6276,7 @@ h2 { .pvp-clear-wrap, .pvp-resource-wrap { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; text-align: right; width: 100%; @@ -6283,7 +6363,7 @@ h2 { align-items: center; color: #fff3c7; display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; font-style: normal; inset: 0; @@ -6419,7 +6499,7 @@ h2 { .pvp-upgrade-header > strong { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 16px; white-space: nowrap; } @@ -6450,6 +6530,10 @@ h2 { box-shadow: inset 0 0 0 2px #6e5727; } +.pvp-upgrade-dialog button:focus { + outline-color: var(--gold); +} + .stadium-screen { min-height: 100dvh; } @@ -6480,7 +6564,7 @@ h2 { .stadium-header > strong { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 26px; } @@ -6493,7 +6577,7 @@ h2 { .stadium-pressure-panel strong { color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 16px; } @@ -6512,7 +6596,7 @@ h2 { color: var(--muted); display: flex; flex-wrap: wrap; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 12px; gap: 16px; margin-bottom: 12px; @@ -6535,7 +6619,7 @@ h2 { border: 2px solid #0b0c0f; color: var(--ink); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); outline: 2px solid #4d4c58; } @@ -6585,7 +6669,7 @@ h2 { .stadium-shop-grid small, .stadium-shop-layout p { color: #f0e8d2; - font-family: 'VT323', monospace; + font-family: var(--body-font); font-size: 22px; line-height: 1.05; } @@ -6600,7 +6684,7 @@ h2 { border: 2px solid #08090c; color: #19150e; cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; margin-top: 22px; outline: 2px solid #816630; @@ -7336,7 +7420,7 @@ h2 { border: 2px solid #090a0d; color: var(--muted); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; outline: 2px solid #494754; padding: 10px 18px; @@ -7364,7 +7448,7 @@ h2 { background: #111217; border: 2px solid #090a0d; color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; outline: 2px solid #3e3d47; padding: 10px 12px; @@ -7378,7 +7462,7 @@ h2 { .admin-group-header { color: var(--gold); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 10px; margin: 12px 0 8px; padding: 8px 4px; @@ -7440,7 +7524,7 @@ h2 { color: var(--gold); cursor: pointer; display: inline-flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; outline: 2px solid #41404a; padding: 8px 10px; @@ -7463,7 +7547,7 @@ h2 { } .admin-glyph { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 16px; height: 32px; line-height: 32px; @@ -7492,7 +7576,7 @@ h2 { .admin-item-stats > span { color: var(--green); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -7506,7 +7590,7 @@ h2 { align-items: center; color: var(--muted); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 8px; text-transform: uppercase; @@ -7519,7 +7603,7 @@ h2 { border: 2px solid #090a0d; color: var(--ink); flex: 1; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; outline: 2px solid #3e3d47; padding: 6px 8px; @@ -7552,7 +7636,7 @@ h2 { align-items: center; color: var(--muted); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 8px; text-transform: uppercase; @@ -7563,7 +7647,7 @@ h2 { border: 2px solid #090a0d; color: var(--ink); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 36px; outline: 2px solid #41404a; @@ -7572,7 +7656,7 @@ h2 { .admin-loot-title { color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 9px; margin: 8px 0; } @@ -7605,13 +7689,13 @@ h2 { .admin-loot-weight { color: var(--muted); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } .admin-loot-chance { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; } @@ -7619,7 +7703,7 @@ h2 { background: #111217; border: 2px solid #090a0d; color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; outline: 2px solid #3e3d47; padding: 4px 6px; @@ -7635,7 +7719,7 @@ h2 { border: 2px solid #08090c; color: #fff; cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; outline: 2px solid #7a2940; padding: 8px 12px; @@ -7652,7 +7736,7 @@ h2 { .admin-add-section summary { color: var(--gold); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; margin-bottom: 8px; text-transform: uppercase; @@ -7673,7 +7757,7 @@ h2 { color: var(--muted); display: flex; flex-direction: column; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 4px; text-transform: uppercase; @@ -7684,7 +7768,7 @@ h2 { background: #15161c; border: 2px solid #090a0d; color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; outline: 2px solid #41404a; padding: 8px 10px; @@ -7708,7 +7792,7 @@ h2 { color: var(--muted); display: flex; flex-direction: column; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 6px; text-transform: uppercase; @@ -7720,7 +7804,7 @@ h2 { border: 2px solid #090a0d; color: var(--ink); cursor: pointer; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 36px; outline: 2px solid #41404a; @@ -7761,7 +7845,7 @@ h2 { color: var(--muted); display: flex; flex-direction: column; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 8px; text-transform: uppercase; @@ -7774,7 +7858,7 @@ h2 { color: var(--ink); cursor: pointer; flex: 1; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-height: 36px; outline: 2px solid #41404a; @@ -7806,7 +7890,7 @@ h2 { color: var(--muted); display: flex; flex-direction: column; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; gap: 6px; text-transform: uppercase; @@ -7816,7 +7900,7 @@ h2 { background: #111217; border: 2px solid #090a0d; color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-width: 230px; outline: 2px solid #3e3d47; @@ -7871,7 +7955,7 @@ h2 { background: var(--class-color, var(--gold)); color: #111217; display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 13px; height: 38px; justify-content: center; @@ -7911,7 +7995,7 @@ h2 { } .admin-class-hero h2 { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 13px; } @@ -7936,7 +8020,7 @@ h2 { .admin-class-table-head { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; padding: 0 10px; text-transform: uppercase; @@ -8002,7 +8086,7 @@ h2 { .admin-class-talent > div > span { color: var(--gold); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); width: 26px; } @@ -8014,7 +8098,7 @@ h2 { .admin-class-talent em { color: var(--green); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; font-style: normal; } @@ -8048,7 +8132,7 @@ h2 { background: #111217; border: 2px solid #090a0d; color: var(--ink); - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 8px; min-width: 180px; outline: 2px solid #3e3d47; @@ -8101,7 +8185,7 @@ h2 { background: #15161c; color: var(--gold); display: flex; - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); height: 34px; justify-content: center; } @@ -8112,7 +8196,7 @@ h2 { } .workshop-bottom-grid strong { - font-family: 'Press Start 2P', monospace; + font-family: var(--pixel-font); font-size: 7px; line-height: 1.25; } diff --git a/src/App.tsx b/src/App.tsx index 59220e6..d0803dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { Level {profile.character.level} Item Level {profile.gearStats.averageItemLevel.toFixed(1)}
- +
+
Controller Icons {(['xbox', 'playstation', 'nintendo'] as const).map((style) => ( diff --git a/src/components/SpellBars.tsx b/src/components/SpellBars.tsx index 4ef1882..de7cef7 100644 --- a/src/components/SpellBars.tsx +++ b/src/components/SpellBars.tsx @@ -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}`} {speedMultiplier === 2 && 2x speed} -
+
) }) diff --git a/src/components/barStyles.ts b/src/components/barStyles.ts new file mode 100644 index 0000000..e65ac9a --- /dev/null +++ b/src/components/barStyles.ts @@ -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`, + } +} diff --git a/src/dualScreen.tsx b/src/dualScreen.tsx index 735deea..024df2f 100644 --- a/src/dualScreen.tsx +++ b/src/dualScreen.tsx @@ -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() { {Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}
- +
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? ( <> @@ -489,7 +490,7 @@ export function DualScreenBottomDisplay() { {Math.floor(state.opponentResource)} / {state.opponentMaxResource}
- +
) : null} @@ -527,7 +528,7 @@ export function DualScreenBottomDisplay() { {state.resourceName} {Math.floor(state.resource)} / {state.maxResource} {state.speedMultiplier === 2 && 2x speed}
- +
@@ -678,13 +679,23 @@ export function DualScreenTopCombat({ {Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}
- +
-

{state.encounterDescription}

+ {state.opponentParty && ( +
+ {state.resourceName} {Math.floor(state.resource)} / {state.maxResource} +
+ +
+
+ )} + {!state.opponentParty &&

{state.encounterDescription}

} {state.opponentParty && ( -
- {spellButtons} +
+
+ {spellButtons} +
)} @@ -720,7 +731,7 @@ export function DualScreenTopCombat({
{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}
- +
diff --git a/src/hooks/useCooldownClock.ts b/src/hooks/useCooldownClock.ts new file mode 100644 index 0000000..c42fa41 --- /dev/null +++ b/src/hooks/useCooldownClock.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react' +import { cooldownNow, hasActiveCooldowns } from '../combat/spellCasting' + +export function useCooldownClock( + cooldowns: Record, + 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 +} diff --git a/src/hooks/useFloatingCombatText.ts b/src/hooks/useFloatingCombatText.ts index 4680d5f..5eff774 100644 --- a/src/hooks/useFloatingCombatText.ts +++ b/src/hooks/useFloatingCombatText.ts @@ -15,41 +15,63 @@ type FloatingCombatTextOptions = { durationMs?: number } -const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100 - export function useFloatingCombatText({ durationMs = 900, }: FloatingCombatTextOptions = {}) { const [floatingTexts, setFloatingTexts] = useState([]) const nextId = useRef(1) const expirationsRef = useRef(new Map()) + const cleanupTimerRef = useRef(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(() => { + clearCleanupTimer() expirationsRef.current.clear() setFloatingTexts([]) - }, []) + }, [clearCleanupTimer]) const addFloatingText = useCallback((entry: Omit) => { 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]) + scheduleCleanup() + }, [durationMs, scheduleCleanup]) - useEffect(() => { - 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) - }, []) + useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer]) const floatingTextsByMember = useMemo( () => groupFloatingTextsByMember(floatingTexts), diff --git a/src/hooks/useSpellSlots.ts b/src/hooks/useSpellSlots.ts new file mode 100644 index 0000000..b6d4ea3 --- /dev/null +++ b/src/hooks/useSpellSlots.ts @@ -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 + 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(() => { + 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]) +} diff --git a/src/index.css b/src/index.css index 6b7b7b3..7996f57 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/input.tsx b/src/input.tsx index 5540a31..6bf58d4 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -37,6 +37,7 @@ export const INPUT_ACTIONS = [ 'targetParty6', 'toggleTargetGroup', 'toggleSpeed', + 'toggleTouchLock', 'pause', ] as const @@ -66,6 +67,7 @@ export const ACTION_LABELS: Record = { 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 = { targetParty6: 'F6', toggleTargetGroup: 'Tab', toggleSpeed: 'Backquote', + toggleTouchLock: 'F7', pause: 'Escape', }, controller: { @@ -115,9 +118,10 @@ export const DEFAULT_BINDINGS: Record = { 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(null) @@ -187,6 +193,21 @@ function loadBindings(): Record { 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(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()) const repeatRef = useRef>({}) 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) => { - document.documentElement.dataset.inputDevice = 'pc' + if (event.pointerType !== 'touch') { + document.documentElement.dataset.inputDevice = 'pc' + } const target = event.target if (!(target instanceof Element)) return const control = target.closest(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(() => ({ 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 }) {
)} + {touchLockMessage && ( +
+ {touchLockMessage} +
+ )} ) }