Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89bc1b6ab8 | ||
|
|
5b8c32dc41 | ||
|
|
df6306fe7a |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 84
|
||||
versionName "1.1.5"
|
||||
versionCode 87
|
||||
versionName "1.1.8"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -7,19 +7,27 @@ import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.SystemClock;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
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 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;
|
||||
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
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
@@ -139,24 +147,104 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
|
||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
boolean repeat = event.getRepeatCount() > 0;
|
||||
if (isDpadToken(token) && shouldThrottleDpad()) return true;
|
||||
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);
|
||||
}
|
||||
);
|
||||
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
|
||||
dispatchNativeControllerToken(token, repeat);
|
||||
}
|
||||
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();
|
||||
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
|
||||
lastDpadDispatchAt = now;
|
||||
for (String token : currentTokens) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -170,10 +258,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
private String controllerToken(int keyCode) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A:
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_NUMPAD_ENTER:
|
||||
return "Button0";
|
||||
case KeyEvent.KEYCODE_BUTTON_B:
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
case KeyEvent.KEYCODE_ESCAPE:
|
||||
return "Button1";
|
||||
case KeyEvent.KEYCODE_BUTTON_X:
|
||||
return "Button2";
|
||||
|
||||
+409
@@ -1433,6 +1433,14 @@ textarea:focus-visible,
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-shell.settings-shell {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.game-shell.settings-shell > .app-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -1988,6 +1996,14 @@ h2 {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-tabs,
|
||||
.talent-page-tabs {
|
||||
display: grid;
|
||||
@@ -1996,6 +2012,10 @@ h2 {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-tabs button,
|
||||
.talent-page-tabs button {
|
||||
background: #15161c;
|
||||
@@ -2025,9 +2045,15 @@ h2 {
|
||||
height: calc(100dvh - 92px);
|
||||
}
|
||||
|
||||
.settings-shell .settings-screen {
|
||||
height: calc(100dvh - 12px);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-tab-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-bindings-panel {
|
||||
@@ -2045,6 +2071,7 @@ h2 {
|
||||
.settings-bindings-panel .binding-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.equipment-screen .gear-summary,
|
||||
@@ -6950,6 +6977,95 @@ h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
gap: 12px;
|
||||
max-height: calc(100dvh - 20px);
|
||||
}
|
||||
|
||||
.auth-brand,
|
||||
.auth-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.auth-brand h1 {
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
|
||||
.auth-brand > p:last-child {
|
||||
font-size: 17px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.auth-tabs button {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.auth-card form {
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.auth-card label {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.auth-card input {
|
||||
font-size: 16px;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.auth-card .primary-button {
|
||||
min-height: 34px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.auth-message {
|
||||
font-size: 13px;
|
||||
line-height: 1.1;
|
||||
margin-top: 8px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.offline-divider {
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.offline-entry {
|
||||
gap: 7px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.offline-entry p:not(.eyebrow) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.offline-entry h2 {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.offline-resume-button,
|
||||
.offline-new-button {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.screen-heading {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
@@ -6962,6 +7078,178 @@ h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.settings-screen {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.settings-shell .settings-screen {
|
||||
height: calc(100dvh - 12px);
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-tabs button,
|
||||
.settings-back-button {
|
||||
font-size: 7px;
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.settings-tab-panel {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.dual-screen-settings,
|
||||
.controller-preferences {
|
||||
gap: 10px;
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dual-screen-settings.settings-tab-panel,
|
||||
.controller-preferences.settings-tab-panel {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dual-screen-settings {
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(214px, auto);
|
||||
}
|
||||
|
||||
.dual-screen-settings h2,
|
||||
.settings-heading h2 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dual-screen-settings p:not(.eyebrow),
|
||||
.controller-preferences p:not(.eyebrow) {
|
||||
font-size: 14px;
|
||||
line-height: 1.15;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dual-screen-settings > small {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dual-screen-actions {
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
gap: 7px;
|
||||
min-width: 214px;
|
||||
}
|
||||
|
||||
.dual-screen-actions button,
|
||||
.controller-preferences button {
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.controller-preferences {
|
||||
align-items: stretch;
|
||||
align-content: start;
|
||||
grid-template-columns: minmax(0, 1fr) repeat(2, minmax(158px, auto));
|
||||
}
|
||||
|
||||
.controller-preferences h3 {
|
||||
color: var(--ink);
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.controller-icon-options {
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(112px, 1fr) repeat(3, minmax(104px, auto));
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.controller-icon-options > span {
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
.controller-icon-options button {
|
||||
font-size: 6px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.controller-style-preview .controller-face-icon {
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
}
|
||||
|
||||
.controller-style-preview .controller-face-playstation {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.settings-bindings-panel .settings-heading {
|
||||
padding: 4px 0 5px;
|
||||
}
|
||||
|
||||
.settings-heading > p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-heading .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.binding-tabs {
|
||||
gap: 7px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.binding-tabs button {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.binding-list {
|
||||
gap: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.binding-list button {
|
||||
min-height: 24px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.binding-list button > span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.binding-list kbd {
|
||||
font-size: 6px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.binding-list .controller-face-icon {
|
||||
font-size: 10px;
|
||||
height: 17px;
|
||||
min-width: 17px;
|
||||
}
|
||||
|
||||
.binding-list .controller-face-playstation {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.settings-footer .text-button {
|
||||
padding: 6px 9px;
|
||||
}
|
||||
|
||||
.dungeon-card {
|
||||
gap: 12px;
|
||||
grid-template-columns: 72px 1fr;
|
||||
@@ -7141,6 +7429,127 @@ h2 {
|
||||
.ability-library {
|
||||
max-height: 190px;
|
||||
}
|
||||
|
||||
.enemy-card {
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.enemy-portrait {
|
||||
flex-basis: 48px;
|
||||
font-size: 17px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.enemy-info p {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
margin-top: 4px;
|
||||
max-height: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.combat-layout {
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(248px, 0.85fr);
|
||||
margin-top: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.party-panel,
|
||||
.combat-log {
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.party-panel-top {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.party-grid {
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.party-member {
|
||||
min-height: 72px;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.member-header {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.member-header small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.member-health {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.member-effects {
|
||||
gap: 3px;
|
||||
margin-top: 4px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.member-effects span {
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.combat-side-rail .panel-heading .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.combat-side-rail .panel-heading h2 {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vertical-spell-bar,
|
||||
.vertical-spell-bar.six-slots {
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.vertical-spell-bar .spell {
|
||||
min-height: 54px;
|
||||
padding: 5px 5px 5px 36px;
|
||||
}
|
||||
|
||||
.vertical-spell-bar .spell-icon {
|
||||
font-size: 10px;
|
||||
height: 22px;
|
||||
left: 6px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.vertical-spell-bar .spell strong {
|
||||
font-size: 11px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vertical-spell-bar .spell small {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vertical-spell-bar .spell kbd {
|
||||
font-size: 8px;
|
||||
min-height: 16px;
|
||||
padding: 1px 4px;
|
||||
right: 3px;
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
|
||||
+1
-1
@@ -456,7 +456,7 @@ function App() {
|
||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||
|
||||
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">
|
||||
<button
|
||||
className="brand-button"
|
||||
|
||||
+4
-1
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './App.css'
|
||||
import { AdminScreen } from './components/AdminScreen'
|
||||
import { InputProvider } from './input'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AdminScreen onBack={() => window.close()} />
|
||||
<InputProvider>
|
||||
<AdminScreen onBack={() => window.close()} />
|
||||
</InputProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1509,6 +1509,7 @@ export function PvPRoguelikeScreen({
|
||||
bindings={activeBindings}
|
||||
canCast={status === 'playing' && !playerDone && playerAlive}
|
||||
className="pvp-bottom-spell-bar"
|
||||
focusable={false}
|
||||
iconStyle={controllerIconStyle}
|
||||
onCast={castPlayerSpell}
|
||||
resource={playerSide.resource}
|
||||
|
||||
@@ -1289,6 +1289,7 @@ export function PvpStadiumScreen({
|
||||
bindings={activeBindings}
|
||||
canCast={status === 'playing' && playerAlive}
|
||||
className="pvp-bottom-spell-bar"
|
||||
focusable={false}
|
||||
iconStyle={controllerIconStyle}
|
||||
onCast={castPlayerSpell}
|
||||
resource={playerSide.resource}
|
||||
|
||||
@@ -91,33 +91,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
|
||||
return (
|
||||
<section className="content-screen settings-screen">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Game Options</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
<div className="settings-nav">
|
||||
<button className="back-button settings-back-button" onClick={onBack} type="button">Back</button>
|
||||
<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>
|
||||
</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' && (
|
||||
<section className="dual-screen-settings settings-tab-panel">
|
||||
<div>
|
||||
|
||||
@@ -40,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
|
||||
iconStyle,
|
||||
resourceName,
|
||||
disabled,
|
||||
focusable = true,
|
||||
onCast,
|
||||
emptyKeyPrefix = 'empty',
|
||||
}: {
|
||||
@@ -48,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
|
||||
iconStyle: ControllerIconStyle
|
||||
resourceName: string
|
||||
disabled?: boolean
|
||||
focusable?: boolean
|
||||
onCast: (spell: Spell) => void
|
||||
emptyKeyPrefix?: string
|
||||
}) {
|
||||
@@ -61,9 +63,11 @@ export const SpellButton = memo(function SpellButton({
|
||||
return (
|
||||
<button
|
||||
className="spell"
|
||||
data-controller-nav={focusable ? undefined : 'skip'}
|
||||
disabled={disabled}
|
||||
key={spell.id}
|
||||
onClick={() => onCast(spell)}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
title={spell.description}
|
||||
type="button"
|
||||
>
|
||||
@@ -95,6 +99,7 @@ export const SpellBar = memo(function SpellBar({
|
||||
canCast,
|
||||
onCast,
|
||||
className = 'spell-bar six-slots vertical-spell-bar',
|
||||
focusable = true,
|
||||
}: {
|
||||
spells: SpellSlot[]
|
||||
bindings: Record<string, string>
|
||||
@@ -104,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
|
||||
canCast: boolean
|
||||
onCast: (spell: Spell) => void
|
||||
className?: string
|
||||
focusable?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -119,6 +125,7 @@ export const SpellBar = memo(function SpellBar({
|
||||
<SpellButton
|
||||
binding={bindings[`ability${slotIndex + 1}`]}
|
||||
disabled={!canCast || resource < spell.cost || spell.remaining > 0}
|
||||
focusable={focusable}
|
||||
iconStyle={iconStyle}
|
||||
key={spell.id}
|
||||
onCast={onCast}
|
||||
|
||||
+21
-1
@@ -19,6 +19,7 @@ import {
|
||||
dispatchExternalGameAction,
|
||||
type ControllerIconStyle,
|
||||
type InputAction,
|
||||
useGameAction,
|
||||
} from './input'
|
||||
import { ControllerBindingLabel } from './components/ControllerIcons'
|
||||
import { PartyMemberFrame } from './components/PartyFrames'
|
||||
@@ -149,6 +150,16 @@ function formatDualTime(seconds: number) {
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function shouldRelayBottomAction(
|
||||
action: InputAction,
|
||||
state: DualScreenCombatState | null,
|
||||
) {
|
||||
if (!state) return false
|
||||
if (action === 'confirm' || action === 'toggleTouchLock') return false
|
||||
if (state.status === 'playing') return true
|
||||
return action === 'pause' || action === 'back'
|
||||
}
|
||||
|
||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||
@@ -402,6 +413,11 @@ export function DualScreenBottomDisplay() {
|
||||
channel?.close()
|
||||
}
|
||||
|
||||
useGameAction((action) => {
|
||||
if (!shouldRelayBottomAction(action, state)) return
|
||||
sendAction(action)
|
||||
})
|
||||
|
||||
if (!state && workshopState) {
|
||||
return (
|
||||
<main className="dual-bottom-display workshop-bottom-display">
|
||||
@@ -449,7 +465,10 @@ export function DualScreenBottomDisplay() {
|
||||
}
|
||||
|
||||
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.status === 'playing' && !state.paused ? 'true' : 'false'}
|
||||
>
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
||||
@@ -649,6 +668,7 @@ export function DualScreenTopCombat({
|
||||
return (
|
||||
<button
|
||||
className="dual-top-spell"
|
||||
data-controller-nav={state.opponentParty ? 'skip' : undefined}
|
||||
disabled={
|
||||
!state.playerIsAlive
|
||||
|| state.resource < spell.cost
|
||||
|
||||
+111
-26
@@ -130,10 +130,20 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
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 = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display'
|
||||
const HEADER_CONTENT_SELECTOR = '.app-header'
|
||||
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
|
||||
const GAMEPAD_MENU_POLL_MS = 1000 / 30
|
||||
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
|
||||
|
||||
@@ -256,6 +266,16 @@ function isVisible(element: HTMLElement) {
|
||||
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() {
|
||||
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
|
||||
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
|
||||
@@ -264,10 +284,22 @@ function focusableElements() {
|
||||
'.result-screen, .binding-capture, .dual-startup-prompt',
|
||||
),
|
||||
).find(isVisible)
|
||||
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
|
||||
return Array.from(
|
||||
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
const mainContent = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(MAIN_CONTENT_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)
|
||||
if (!mainContent) return visibleHeaders.length > 0 ? [] : focusableDescendants(document)
|
||||
const headerControls = visibleHeaders.flatMap(
|
||||
(header) => focusableDescendants(header),
|
||||
)
|
||||
return uniqueElements([
|
||||
...focusableDescendants(mainContent),
|
||||
...headerControls,
|
||||
])
|
||||
}
|
||||
|
||||
function rememberFocusableControl(element: HTMLElement) {
|
||||
@@ -277,6 +309,7 @@ function rememberFocusableControl(element: HTMLElement) {
|
||||
function focusControl(element: HTMLElement) {
|
||||
rememberFocusableControl(element)
|
||||
element.focus({ preventScroll: true })
|
||||
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
function currentFocusableControl(candidates = focusableElements()) {
|
||||
@@ -291,6 +324,23 @@ function currentFocusableControl(candidates = focusableElements()) {
|
||||
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() {
|
||||
const first = focusableElements()[0]
|
||||
if (first) focusControl(first)
|
||||
@@ -311,21 +361,48 @@ function moveFocus(action: InputAction) {
|
||||
const currentY = currentRect.top + currentRect.height / 2
|
||||
const vertical = action === 'navigateUp' || action === 'navigateDown'
|
||||
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
|
||||
.filter((candidate) => candidate !== current)
|
||||
.map((candidate) => {
|
||||
.map((candidate, index) => {
|
||||
const rect = candidate.getBoundingClientRect()
|
||||
const x = rect.left + rect.width / 2
|
||||
const y = rect.top + rect.height / 2
|
||||
const primary = vertical ? y - currentY : x - currentX
|
||||
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)
|
||||
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 }
|
||||
const primary = vertical
|
||||
? direction > 0
|
||||
? 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)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.filter(({ primary }) => primary >= -4)
|
||||
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
|
||||
focusControl(next)
|
||||
}
|
||||
@@ -524,12 +601,26 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
setTouchLockMessage('')
|
||||
}, 1400)
|
||||
} else if (action.startsWith('navigate')) {
|
||||
if (uiOverlay || !combatActive) moveFocus(action)
|
||||
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') {
|
||||
const active = currentFocusableControl()
|
||||
if (isTextInput(active)) {
|
||||
setKeyboardInput(active)
|
||||
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 (
|
||||
active
|
||||
&& active.matches('button:not(:disabled), [role="button"]')
|
||||
@@ -566,12 +657,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
document.querySelector('[data-combat-active="true"]'),
|
||||
)
|
||||
const uiOverlay = hasUiOverlay()
|
||||
const menuDpadActions: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
Button14: 'navigateLeft',
|
||||
Button15: 'navigateRight',
|
||||
}
|
||||
const uiPriority = [
|
||||
'navigateUp',
|
||||
'navigateDown',
|
||||
@@ -608,18 +693,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'navigateLeft',
|
||||
'navigateRight',
|
||||
] satisfies InputAction[]
|
||||
const action = menuDpadActions[token] && (!combatActive || uiOverlay)
|
||||
? menuDpadActions[token]
|
||||
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
|
||||
? DPAD_NAV_ACTIONS[token]
|
||||
: uiOverlay
|
||||
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
||||
: combatActive && preferencesRef.current.directPartyTargeting
|
||||
? [...directTargetActions, ...combatPriority].find(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
: combatActive && menuDpadActions[token]
|
||||
? menuDpadActions[token]
|
||||
: !combatActive && menuDpadActions[token]
|
||||
? menuDpadActions[token]
|
||||
: combatActive && DPAD_NAV_ACTIONS[token]
|
||||
? DPAD_NAV_ACTIONS[token]
|
||||
: !combatActive && DPAD_NAV_ACTIONS[token]
|
||||
? DPAD_NAV_ACTIONS[token]
|
||||
: (combatActive ? combatPriority : INPUT_ACTIONS).find(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
@@ -768,11 +853,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const action = INPUT_ACTIONS.find(
|
||||
(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
|
||||
if (pressed || (canRepeat && time >= nextRepeat)) {
|
||||
dispatchControllerToken(token, !pressed)
|
||||
repeatRef.current[token] = time + (pressed ? 360 : 125)
|
||||
repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+3
-1
@@ -9,7 +9,9 @@ import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt }
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
|
||||
<DualScreenBottomDisplay />
|
||||
<InputProvider>
|
||||
<DualScreenBottomDisplay />
|
||||
</InputProvider>
|
||||
) : (
|
||||
<DualScreenProvider>
|
||||
<DualScreenStartupPrompt />
|
||||
|
||||
Reference in New Issue
Block a user