Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 814eb1998d | |||
| 7fe62d8c82 |
+187
-19
@@ -43,40 +43,208 @@ Keep the game server bound to `127.0.0.1`. Set `TRUST_PROXY=1` only when the
|
|||||||
server can be reached solely through your local reverse proxy. This lets account
|
server can be reached solely through your local reverse proxy. This lets account
|
||||||
limits use the visitor's public IP instead of the proxy's address.
|
limits use the visitor's public IP instead of the proxy's address.
|
||||||
|
|
||||||
## Separate auth server
|
## TrueNAS single-container hosting
|
||||||
|
|
||||||
The auth routes can run as their own Node process. This is useful when you want
|
### TrueNAS SCALE runbook
|
||||||
`auth.phenomrom.com` to stay available while the game server is being rebuilt or
|
|
||||||
changed.
|
|
||||||
|
|
||||||
On the TrueNAS host, run the auth process against the same project data folder:
|
This is the simplest TrueNAS setup. One container serves the browser game,
|
||||||
|
auth routes, game API routes, and one SQLite database. Use this when you want
|
||||||
|
`iwanttoheal.phenomrom.com` to host the playable browser version and you want
|
||||||
|
code updates to be a Git pull plus app restart.
|
||||||
|
|
||||||
```sh
|
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
|
||||||
npm ci
|
|
||||||
npm run db:init
|
Repository:
|
||||||
AUTH_HOST=127.0.0.1 AUTH_PORT=4174 TRUST_PROXY=1 COOKIE_SECURE=1 AUTH_CORS_ORIGINS=https://phenomrom.com npm run auth:start
|
|
||||||
|
```text
|
||||||
|
https://git.whoagland.com/phenom/i-want-to-heal.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Point `auth.phenomrom.com` at that process through HTTPS:
|
TrueNAS paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the app directory and clone the repo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal
|
||||||
|
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the persistent data folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p /mnt/usbssds/apps/iwanttoheal/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that the production server file exists:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls /mnt/usbssds/apps/iwanttoheal/app/server/production.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
If that file is missing, push the latest code to `git.whoagland.com` from the
|
||||||
|
development machine, then pull on TrueNAS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
|
||||||
|
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
|
||||||
|
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
|
||||||
|
|
||||||
|
### TrueNAS app YAML
|
||||||
|
|
||||||
|
In TrueNAS:
|
||||||
|
|
||||||
|
1. Open **Apps**.
|
||||||
|
2. Open **Discover**.
|
||||||
|
3. Click the three-dot menu.
|
||||||
|
4. Choose **Install via YAML**.
|
||||||
|
5. Name the app `iwanttoheal`.
|
||||||
|
6. Paste this YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
iwanttoheal:
|
||||||
|
image: node:24-bookworm-slim
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -lc "npm ci && npm run db:init && npm run build && npm start"
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: "4173"
|
||||||
|
TRUST_PROXY: "1"
|
||||||
|
COOKIE_SECURE: "1"
|
||||||
|
CORS_ORIGINS: "http://localhost,https://localhost,capacitor://localhost,https://iwanttoheal.phenomrom.com,https://auth.phenomrom.com"
|
||||||
|
ports:
|
||||||
|
- "4173:4173"
|
||||||
|
volumes:
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal/app:/app
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal/data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
The app listens inside Docker on port `4173`. The database lives at
|
||||||
|
`/mnt/usbssds/apps/iwanttoheal/data/game.db` because that host directory is
|
||||||
|
mounted into the container as `/app/data`. The startup command installs
|
||||||
|
dependencies, applies schema migrations, builds the web app, and starts the
|
||||||
|
production server.
|
||||||
|
|
||||||
|
Test the local TrueNAS service:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://TRUENAS-IP:4173/api/auth/session
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"account":null,"profile":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
|
||||||
|
Point `iwanttoheal.phenomrom.com` at the TrueNAS app through HTTPS. Do not expose
|
||||||
|
port `4173` directly to the internet. Put Caddy or another reverse proxy in
|
||||||
|
front:
|
||||||
|
|
||||||
```caddyfile
|
```caddyfile
|
||||||
|
iwanttoheal.phenomrom.com {
|
||||||
|
reverse_proxy TRUENAS-IP:4173
|
||||||
|
}
|
||||||
|
|
||||||
auth.phenomrom.com {
|
auth.phenomrom.com {
|
||||||
reverse_proxy 127.0.0.1:4174
|
reverse_proxy TRUENAS-IP:4173
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the web or mobile app with the auth base URL set separately from the game
|
Both hostnames can point at the same container. `iwanttoheal.phenomrom.com`
|
||||||
API:
|
serves the browser game. `auth.phenomrom.com` stays available as an auth URL for
|
||||||
|
Android or other clients that need a dedicated auth hostname.
|
||||||
|
|
||||||
|
DNS should point both hostnames at the public IP or dynamic DNS name that reaches
|
||||||
|
the reverse proxy. Forward public ports `80` and `443` to the reverse proxy host.
|
||||||
|
|
||||||
|
Test the public game and auth URLs:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com npm run build
|
curl https://iwanttoheal.phenomrom.com
|
||||||
|
curl https://auth.phenomrom.com/api/auth/session
|
||||||
```
|
```
|
||||||
|
|
||||||
For a Capacitor wrapper, set `window.CAPACITOR_AUTH_API_BASE_URL` to
|
Expected auth response:
|
||||||
`https://auth.phenomrom.com` the same way `window.CAPACITOR_API_BASE_URL` is set.
|
|
||||||
The app stores the returned bearer token locally and sends it with later API
|
```json
|
||||||
requests, so auth works across subdomains and inside the mobile WebView. Existing
|
{"account":null,"profile":null}
|
||||||
same-origin cookie sessions still work when auth is served by the game server.
|
```
|
||||||
|
|
||||||
|
### App build config
|
||||||
|
|
||||||
|
For the hosted browser game, no separate auth build setting is needed. The web
|
||||||
|
app can call same-origin routes like `/api/auth/login` and `/api/profile`.
|
||||||
|
|
||||||
|
For an Android build that should use the TrueNAS-hosted game API, build with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run android:apk:truenas
|
||||||
|
```
|
||||||
|
|
||||||
|
If you intentionally want Android auth calls to use `auth.phenomrom.com`, also
|
||||||
|
set `VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com`. Otherwise, leave it
|
||||||
|
unset and auth uses the same base URL as the game API.
|
||||||
|
|
||||||
|
Android runs the bundled web app from a local Capacitor origin, not from
|
||||||
|
`iwanttoheal.phenomrom.com`. The hosted server must allow that origin through
|
||||||
|
CORS, which is why the TrueNAS YAML includes `http://localhost`,
|
||||||
|
`https://localhost`, and `capacitor://localhost`.
|
||||||
|
|
||||||
|
### Updating the TrueNAS game app
|
||||||
|
|
||||||
|
Push changes from the development machine to `git.whoagland.com`, then pull them
|
||||||
|
on TrueNAS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the `iwanttoheal` app in the TrueNAS Apps UI after pulling. The app
|
||||||
|
command runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start` on
|
||||||
|
startup, so dependency, schema, and browser bundle changes are applied each time
|
||||||
|
the container restarts.
|
||||||
|
|
||||||
|
Normal update workflow:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# development machine
|
||||||
|
git add .
|
||||||
|
git commit -m "Update game"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# TrueNAS shell
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the TrueNAS app.
|
||||||
|
|
||||||
|
### Existing auth-only app
|
||||||
|
|
||||||
|
If `iwanttoheal-auth` was already created during earlier testing, the simplest
|
||||||
|
path is to stop that app and use the single `iwanttoheal` app above. The single
|
||||||
|
container serves both domains and avoids two processes sharing one SQLite file.
|
||||||
|
|
||||||
## Account limits
|
## Account limits
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 39
|
versionCode 42
|
||||||
versionName "1.0.23"
|
versionName "1.0.25"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "npm run offline:export && npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
|
"build": "npm run offline:export && npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
|
||||||
"android:sync": "npm run build && cap sync android",
|
"android:sync": "npm run build && cap sync android",
|
||||||
|
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:sync",
|
||||||
"android:open": "cap open android",
|
"android:open": "cap open android",
|
||||||
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
||||||
|
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
||||||
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
||||||
"db:backup": "node scripts/backup-db.mjs",
|
"db:backup": "node scripts/backup-db.mjs",
|
||||||
"db:init": "node scripts/init-db.mjs",
|
"db:init": "node scripts/init-db.mjs",
|
||||||
|
|||||||
+10
@@ -131,6 +131,13 @@ function App() {
|
|||||||
})
|
})
|
||||||
}, [screen])
|
}, [screen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authChecked || !account || !profile || screen === 'combat') return
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
focusFirstControl()
|
||||||
|
})
|
||||||
|
}, [account, authChecked, profile, screen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
||||||
}, [selectedDifficultyId])
|
}, [selectedDifficultyId])
|
||||||
@@ -148,6 +155,9 @@ function App() {
|
|||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
setError('')
|
setError('')
|
||||||
setServerMessage('')
|
setServerMessage('')
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
focusFirstControl()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ export function CombatScreen({
|
|||||||
const partyRef = useRef(partyTemplate)
|
const partyRef = useRef(partyTemplate)
|
||||||
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
|
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
|
||||||
const elapsedTicksRef = useRef(0)
|
const elapsedTicksRef = useRef(0)
|
||||||
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
const encounter = encounters[encounterIndex]
|
const encounter = encounters[encounterIndex]
|
||||||
const currentPart = getCurrentPart(encounterIndex)
|
const currentPart = getCurrentPart(encounterIndex)
|
||||||
const firstEncounterIndex = (startPart - 1) * 3
|
const firstEncounterIndex = (startPart - 1) * 3
|
||||||
@@ -415,6 +416,11 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
}, [paused])
|
}, [paused])
|
||||||
|
|
||||||
|
const setSelectedTargetId = useCallback((id: string) => {
|
||||||
|
selectedIdRef.current = id
|
||||||
|
setSelectedId(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||||
const entry = { id: nextLogId.current++, text, tone }
|
const entry = { id: nextLogId.current++, text, tone }
|
||||||
setLog((current) => [entry, ...current].slice(0, 60))
|
setLog((current) => [entry, ...current].slice(0, 60))
|
||||||
@@ -467,7 +473,7 @@ export function CombatScreen({
|
|||||||
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
||||||
setRoguelikeStage(1)
|
setRoguelikeStage(1)
|
||||||
setParty(freshParty)
|
setParty(freshParty)
|
||||||
setSelectedId(partyTemplate[0].id)
|
setSelectedTargetId(partyTemplate[0].id)
|
||||||
setResource(maxResource)
|
setResource(maxResource)
|
||||||
setEncounterIndex(initialEncounterIndex)
|
setEncounterIndex(initialEncounterIndex)
|
||||||
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
|
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
|
||||||
@@ -494,7 +500,7 @@ export function CombatScreen({
|
|||||||
runStartedAtRef.current = Date.now()
|
runStartedAtRef.current = Date.now()
|
||||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||||
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
||||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, startPart, staticEncounters])
|
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setSelectedTargetId, startPart, staticEncounters])
|
||||||
|
|
||||||
const castSpell = useCallback(
|
const castSpell = useCallback(
|
||||||
(spell: Spell) => {
|
(spell: Spell) => {
|
||||||
@@ -502,26 +508,27 @@ export function CombatScreen({
|
|||||||
if (status !== 'playing' || cooldowns[spell.id] > 0 || resource < effectiveCost) return
|
if (status !== 'playing' || cooldowns[spell.id] > 0 || resource < effectiveCost) return
|
||||||
const healer = partyRef.current.find((member) => member.id === 'mira')
|
const healer = partyRef.current.find((member) => member.id === 'mira')
|
||||||
if (!healer || healer.health <= 0) return
|
if (!healer || healer.health <= 0) return
|
||||||
const selected = partyRef.current.find((member) => member.id === selectedId)
|
const targetId = selectedIdRef.current
|
||||||
|
const selected = partyRef.current.find((member) => member.id === targetId)
|
||||||
if (!selected || selected.health <= 0) return
|
if (!selected || selected.health <= 0) return
|
||||||
const extraTarget = (blockedIds: string[]) => partyRef.current
|
const extraTarget = (blockedIds: string[]) => partyRef.current
|
||||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||||
const directTargets = new Set([selectedId])
|
const directTargets = new Set([targetId])
|
||||||
const hotTargets = new Set<string>()
|
const hotTargets = new Set<string>()
|
||||||
const shieldTargets = new Set<string>()
|
const shieldTargets = new Set<string>()
|
||||||
if (spell.kind === 'hot') hotTargets.add(selectedId)
|
if (spell.kind === 'hot') hotTargets.add(targetId)
|
||||||
if (spell.kind === 'shield') shieldTargets.add(selectedId)
|
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
||||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
||||||
const extra = extraTarget([selectedId])
|
const extra = extraTarget([targetId])
|
||||||
if (extra) directTargets.add(extra.id)
|
if (extra) directTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
||||||
const extra = extraTarget([selectedId])
|
const extra = extraTarget([targetId])
|
||||||
if (extra) hotTargets.add(extra.id)
|
if (extra) hotTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
||||||
hotTargets.add(selectedId)
|
hotTargets.add(targetId)
|
||||||
}
|
}
|
||||||
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
||||||
for (let index = 0; index < extraTargets; index += 1) {
|
for (let index = 0; index < extraTargets; index += 1) {
|
||||||
@@ -599,7 +606,7 @@ export function CombatScreen({
|
|||||||
setParty(nextParty)
|
setParty(nextParty)
|
||||||
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
||||||
},
|
},
|
||||||
[activeSetEffects, addFloatingHeal, addLog, cooldowns, freeCastReady, resource, roguelikeUpgrades, selectedId, status],
|
[activeSetEffects, addFloatingHeal, addLog, cooldowns, freeCastReady, resource, roguelikeUpgrades, status],
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishRun = useCallback(
|
const finishRun = useCallback(
|
||||||
@@ -664,18 +671,18 @@ export function CombatScreen({
|
|||||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||||
const living = partyRef.current.filter((member) => member.health > 0)
|
const living = partyRef.current.filter((member) => member.health > 0)
|
||||||
if (living.length === 0) return
|
if (living.length === 0) return
|
||||||
const currentIndex = living.findIndex((member) => member.id === selectedId)
|
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
const nextIndex = currentIndex < 0
|
const nextIndex = currentIndex < 0
|
||||||
? 0
|
? 0
|
||||||
: (currentIndex + direction + living.length) % living.length
|
: (currentIndex + direction + living.length) % living.length
|
||||||
setSelectedId(living[nextIndex].id)
|
setSelectedTargetId(living[nextIndex].id)
|
||||||
}, [selectedId])
|
}, [setSelectedTargetId])
|
||||||
|
|
||||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||||
const columns = dungeon.partySize >= 10 ? 6 : 3
|
const columns = dungeon.partySize >= 10 ? 6 : 3
|
||||||
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedId)
|
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
if (currentIndex < 0) {
|
if (currentIndex < 0) {
|
||||||
setSelectedId(partyRef.current[0].id)
|
setSelectedTargetId(partyRef.current[0].id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currentRow = Math.floor(currentIndex / columns)
|
const currentRow = Math.floor(currentIndex / columns)
|
||||||
@@ -709,14 +716,14 @@ export function CombatScreen({
|
|||||||
: Math.abs(b.column - currentColumn)
|
: Math.abs(b.column - currentColumn)
|
||||||
return aPrimary - bPrimary || aSecondary - bSecondary
|
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||||
})
|
})
|
||||||
if (candidates[0]) setSelectedId(candidates[0].member.id)
|
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
||||||
}, [dungeon.partySize, selectedId])
|
}, [dungeon.partySize, setSelectedTargetId])
|
||||||
|
|
||||||
const selectDirectTarget = useCallback((slot: number) => {
|
const selectDirectTarget = useCallback((slot: number) => {
|
||||||
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
|
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
|
||||||
const member = partyRef.current[index]
|
const member = partyRef.current[index]
|
||||||
if (member) setSelectedId(member.id)
|
if (member) setSelectedTargetId(member.id)
|
||||||
}, [dungeon.partySize, targetGroup])
|
}, [dungeon.partySize, setSelectedTargetId, targetGroup])
|
||||||
|
|
||||||
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
||||||
if (!roguelikeMode) return
|
if (!roguelikeMode) return
|
||||||
@@ -778,9 +785,9 @@ export function CombatScreen({
|
|||||||
setTargetGroup((current) => {
|
setTargetGroup((current) => {
|
||||||
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6))
|
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6))
|
||||||
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
||||||
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedId)
|
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||||
if (nextMember) setSelectedId(nextMember.id)
|
if (nextMember) setSelectedTargetId(nextMember.id)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -1134,7 +1141,7 @@ export function CombatScreen({
|
|||||||
<button
|
<button
|
||||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => setSelectedId(member.id)}
|
onClick={() => setSelectedTargetId(member.id)}
|
||||||
aria-pressed={selectedId === member.id}
|
aria-pressed={selectedId === member.id}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -1219,7 +1226,7 @@ export function CombatScreen({
|
|||||||
{dualScreenEnabled && (
|
{dualScreenEnabled && (
|
||||||
<DualScreenTopCombat
|
<DualScreenTopCombat
|
||||||
state={dualScreenState}
|
state={dualScreenState}
|
||||||
onSelectTarget={setSelectedId}
|
onSelectTarget={setSelectedTargetId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user