Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e48bb4fb6 | ||
|
|
fb41147197 | ||
|
|
aa9f5eff20 | ||
|
|
18c416d4d2 | ||
|
|
77fd434226 | ||
|
|
122f159b94 | ||
|
|
35553c18dd | ||
|
|
bef71d391a | ||
|
|
b48b3a4f8f | ||
|
|
076f6cf97c | ||
|
|
6726c600e4 |
@@ -1,5 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
backups/
|
||||
game_assets/
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
@@ -9,3 +12,4 @@ vite.config.d.ts
|
||||
!.env.example
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
/public/basis/
|
||||
|
||||
@@ -6,6 +6,13 @@ These requirements apply to every screen, feature, game system, and code change
|
||||
|
||||
AYN Thor Android app is the primary product. Browser build is a secondary access path for players without an AYN Thor. Do not weaken the Thor experience to simplify the browser version; provide an adaptive browser fallback instead.
|
||||
|
||||
## Game asset workflow
|
||||
|
||||
- `game_assets/` is an ignored local source library. Never load or import a runtime asset directly from it.
|
||||
- When adopting a model, animation, texture, audio file, or other game asset from `game_assets/`, copy it into the tracked `src/assets/game/` runtime library first. Preserve its path relative to `game_assets/`.
|
||||
- Use `pnpm assets:import <path-within-game_assets>` for the copy. Use `--replace` only to deliberately update an existing runtime copy. Copy required companion files and license/attribution notices with assets that are not self-contained.
|
||||
- Reference only `src/assets/game/` copies from game code. Keep `game_assets/` as source material for future imports, conversions, and authoring work.
|
||||
|
||||
## AYN Thor display and layout targets
|
||||
|
||||
Design and test against both physical displays in landscape orientation. Physical panel resolution and Android CSS/layout viewport are different concepts; record and validate both.
|
||||
@@ -84,14 +91,29 @@ Stable locked 60 FPS is a release requirement, not a best-effort goal.
|
||||
- Favor algorithms and data layouts that reduce CPU and RAM together. Do not trade a large permanent memory increase for a tiny unmeasured CPU gain.
|
||||
- Performance fixes must preserve deterministic rules, input responsiveness, visual clarity, and controller accessibility.
|
||||
|
||||
## Verification scope
|
||||
|
||||
- Verify the changed code and its direct dependencies. Do not run tests, visual QA, or controller checks for unrelated screens or systems by default.
|
||||
- For a screen change, check that screen's applicable display layouts, browser fallback, and controller path only.
|
||||
- For a reusable mechanic or domain change, run its focused tests plus direct consumers affected by the change.
|
||||
- Run viewport, display-layout, and browser-fallback testing only when a change adds or modifies a screen, menu, modal, HUD, overlay, or other UI/layout behavior.
|
||||
- Combat rules, encounter mechanics, world-space telegraphs, animations, VFX, tuning, and other gameplay-only changes do not require viewport testing unless they also change UI or screen layout.
|
||||
- Expand to broader regression or full-suite verification only for shared foundations, cross-cutting changes, risky refactors, release validation, or when explicitly requested.
|
||||
- State what was verified and any deliberately unverified scope in the handoff.
|
||||
|
||||
## Definition of done
|
||||
|
||||
A screen or mechanic is not complete until:
|
||||
A screen or UI change is not complete until:
|
||||
|
||||
1. Its main-display UI is designed and verified at the approximately `960 x 540` Android CSS/layout viewport, with rendering validated against the `1920 x 1080` physical panel.
|
||||
2. Its applicable secondary UI is designed and verified at the approximately `620 x 540` Android CSS/layout viewport, with rendering validated against the `1240 x 1080` physical panel.
|
||||
3. It has a complete single-display browser fallback.
|
||||
4. Every action is reachable and understandable using only a controller, with no click-to-focus step.
|
||||
5. Shared game logic is modular, typed, and testable outside the renderer/UI.
|
||||
6. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation.
|
||||
7. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
|
||||
|
||||
Every code change is not complete until:
|
||||
|
||||
1. Shared game logic is modular, typed, and testable outside the renderer/UI.
|
||||
2. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation.
|
||||
3. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
|
||||
|
||||
Gameplay-only mechanic and domain changes do not inherit the screen/UI viewport requirements above.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# TrueNAS deployment
|
||||
|
||||
This game uses the same proven local-Gitea pattern as `testgame`: clone from the
|
||||
Gitea bare repository on the TrueNAS filesystem, mount that working checkout
|
||||
into one Node container, and update it with a local Git pull plus app restart.
|
||||
|
||||
## What the TrueNAS server does
|
||||
|
||||
The `iwanttoheal-mmo` TrueNAS app is the game's live online server. One Node
|
||||
process serves the browser bundle and authenticated `/api` routes on port `4173`.
|
||||
The reverse proxy exposes both at `https://iwanttoheal.phenomrom.com`.
|
||||
|
||||
SQLite persists accounts, sessions, cloud saves, boss-kill rankings, and
|
||||
roguelike records under `/app/data/game.db`. The separate data mount survives
|
||||
container replacement. TrueNAS Gitea remains the source repository used for
|
||||
deployment; it is not the game database.
|
||||
|
||||
## Paths
|
||||
|
||||
```text
|
||||
Local Gitea bare repository:
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||
|
||||
Runnable working checkout:
|
||||
/mnt/usbssds/apps/iwanttoheal-mmo/app
|
||||
|
||||
Persistent game data:
|
||||
/mnt/usbssds/apps/iwanttoheal-mmo/data
|
||||
|
||||
Public URL:
|
||||
https://iwanttoheal.phenomrom.com
|
||||
|
||||
Container/host port:
|
||||
4173
|
||||
```
|
||||
|
||||
The bare Gitea repository is not a runnable application directory. Use a local
|
||||
`git clone`; do not copy the bare repository with `cp`. Local clone/pull keeps
|
||||
the source transfer on TrueNAS and creates the working tree the container needs.
|
||||
|
||||
## First installation
|
||||
|
||||
This configuration uses a separate `iwanttoheal-mmo` working directory, so it
|
||||
does not modify the old app checkout or its data.
|
||||
|
||||
Confirm the new repository path. If the first command fails, use the search:
|
||||
|
||||
```sh
|
||||
sudo test -d /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||
sudo find /mnt -type d -name "i-want-to-heal-mmo.git" -prune -print 2>/dev/null
|
||||
```
|
||||
|
||||
Clone entirely through the local filesystem:
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo/{app,data}
|
||||
sudo git config --global --add safe.directory \
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||
sudo git clone \
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git \
|
||||
/mnt/usbssds/apps/iwanttoheal-mmo/app
|
||||
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal-mmo
|
||||
```
|
||||
|
||||
Verify the checkout:
|
||||
|
||||
```sh
|
||||
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app remote -v
|
||||
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app branch --show-current
|
||||
ls /mnt/usbssds/apps/iwanttoheal-mmo/app/package.json
|
||||
```
|
||||
|
||||
Expected branch: `main`. Expected origin: local Gitea path above.
|
||||
|
||||
## Install as a TrueNAS app
|
||||
|
||||
1. Open **Apps**.
|
||||
2. Open **Discover**.
|
||||
3. Open the three-dot menu.
|
||||
4. Select **Install via YAML**.
|
||||
5. Name the app `iwanttoheal-mmo`.
|
||||
6. Paste:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
iwanttoheal:
|
||||
image: node:24-bookworm-slim
|
||||
command: >-
|
||||
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start"
|
||||
environment:
|
||||
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
|
||||
DATA_DIR: /app/data
|
||||
HOST: 0.0.0.0
|
||||
PORT: "4173"
|
||||
init: true
|
||||
ports:
|
||||
- "4173:4173"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
||||
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
|
||||
working_dir: /app
|
||||
```
|
||||
|
||||
Do not remove the `/app/data` mount or place the SQLite database inside the source
|
||||
checkout. Back up `/mnt/usbssds/apps/iwanttoheal-mmo/data/game.db` before database
|
||||
migrations or destructive maintenance.
|
||||
|
||||
From the app checkout, create a consistent SQLite backup with:
|
||||
|
||||
```sh
|
||||
DATA_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/data \
|
||||
BACKUP_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/backups \
|
||||
corepack pnpm run db:backup
|
||||
```
|
||||
|
||||
The separate volume protects the old app files and data. The YAML still maps
|
||||
host port `4173`, so the old and MMO apps cannot run simultaneously while both
|
||||
use that host port. Stop the old app, or assign this app an unused host port and
|
||||
separate reverse-proxy hostname.
|
||||
|
||||
After deployment, test from the TrueNAS shell or another LAN machine:
|
||||
|
||||
```sh
|
||||
curl -I http://TRUENAS-IP:4173
|
||||
```
|
||||
|
||||
Expected result: HTTP `200`. Keep the existing HTTPS reverse proxy pointed at
|
||||
`TRUENAS-IP:4173` for `iwanttoheal.phenomrom.com`.
|
||||
|
||||
## Update workflow
|
||||
|
||||
Push `main` from the development Mac. Then run on TrueNAS:
|
||||
|
||||
```sh
|
||||
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app pull --ff-only \
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git \
|
||||
main
|
||||
```
|
||||
|
||||
This transfers Git objects locally from Gitea storage; it does not download the
|
||||
game through HTTPS. Restart `iwanttoheal-mmo` in the TrueNAS Apps UI afterward. The
|
||||
container startup command installs locked dependencies, rebuilds the browser
|
||||
bundle, and starts port `4173`.
|
||||
|
||||
For simultaneous operation, keep the new app name/directory and also assign an
|
||||
unused host port plus a separate reverse-proxy hostname.
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
Playable low-poly third-person combat vertical slice for AYN Thor's dual displays.
|
||||
|
||||
Offline-first frontend includes three timestamped save slots, optional account sync, local/online overwrite controls, Hunter Profile statistics, boss collection logs, Settings, and PvE/PvP mode entry points.
|
||||
Offline-first frontend includes three timestamped save slots, TrueNAS accounts
|
||||
and cloud saves, Hunter Profile statistics, boss and roguelike leaderboards,
|
||||
boss collection logs, Settings, and PvE/PvP mode entry points. Offline saves
|
||||
remain playable without an account and can be uploaded after sign-in.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev:api
|
||||
# In a second terminal:
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The development server listens on `0.0.0.0:4173`.
|
||||
The Vite development server listens on `0.0.0.0:4173` and proxies `/api` to the
|
||||
local production/API server on `127.0.0.1:4174`. Both processes use the same
|
||||
client API contract as TrueNAS.
|
||||
|
||||
## Android / AYN Thor test APK
|
||||
|
||||
@@ -23,6 +30,10 @@ installable debug APK:
|
||||
pnpm android:apk
|
||||
```
|
||||
|
||||
Build explicitly against the TrueNAS API with `pnpm android:apk:truenas`.
|
||||
Native builds also default to `https://iwanttoheal.phenomrom.com` when no API
|
||||
base override is supplied.
|
||||
|
||||
Output is written under `android/app/build/outputs/apk/debug/`. With Android
|
||||
platform tools and a connected Thor, build and install it with:
|
||||
|
||||
@@ -38,25 +49,45 @@ state rather than two independent WebViews.
|
||||
|
||||
## TrueNAS deployment
|
||||
|
||||
The production preview server uses the existing deployment address and port:
|
||||
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
The live TrueNAS web server uses the existing deployment address and port:
|
||||
|
||||
- Public URL: `https://iwanttoheal.phenomrom.com`
|
||||
- Host/container port: `4173`
|
||||
- App directory: `/mnt/usbssds/apps/iwanttoheal/app`
|
||||
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
|
||||
|
||||
Clone the repository into the app directory, then deploy `compose.yaml`. Compared
|
||||
with the old game configuration:
|
||||
### Server architecture
|
||||
|
||||
TrueNAS is the online production game server. Its Node process serves the browser
|
||||
application and authenticated `/api` routes on port `4173`; the reverse proxy
|
||||
exposes both at the public URL above. SQLite data persists at `/app/data/game.db`
|
||||
through the separate TrueNAS data mount.
|
||||
|
||||
The server owns account credentials, 30-day sessions, three cloud-save slots per
|
||||
account, boss-kill rankings, and roguelike highest-round rankings. Passwords use
|
||||
scrypt with per-account salts; clients store only opaque session tokens. Local
|
||||
saves remain available for offline play. Gitea is a separate TrueNAS service used
|
||||
for source hosting and deployment.
|
||||
|
||||
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
|
||||
deploy `compose.yaml`. The expected source path is:
|
||||
|
||||
```text
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||
```
|
||||
|
||||
Current configuration:
|
||||
|
||||
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
|
||||
- remove `npm run db:init` because this game has no server database;
|
||||
- remove `COOKIE_SECURE`, `CORS_ORIGINS`, and `TRUST_PROXY`; the static Vite
|
||||
preview server does not consume them;
|
||||
- remove `/app/data`; offline saves live in each player's browser/Android WebView
|
||||
storage, not on TrueNAS.
|
||||
- run `pnpm db:init` before starting the production server;
|
||||
- mount `/mnt/usbssds/apps/iwanttoheal-mmo/data` at `/app/data`;
|
||||
- keep `CORS_ORIGINS` configured for the public site and Capacitor host;
|
||||
- keep the source checkout and persistent data in separate mounts.
|
||||
|
||||
The existing reverse proxy can keep forwarding the public hostname to port
|
||||
`4173`. Add server data and authentication environment variables only when an
|
||||
actual sync API is introduced.
|
||||
The existing reverse proxy forwards the public hostname to port `4173`, including
|
||||
all `/api` routes.
|
||||
|
||||
## Publish updates to Gitea
|
||||
|
||||
@@ -66,11 +97,10 @@ The repository target is:
|
||||
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
|
||||
```
|
||||
|
||||
Create a Gitea access token with repository write permission, expose it only for
|
||||
the publishing process, then publish from `main`:
|
||||
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
|
||||
optionally override it for one run. Publish from `main`:
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN="..."
|
||||
pnpm publish:gitea -- --message "Describe the update"
|
||||
```
|
||||
|
||||
@@ -80,11 +110,11 @@ version tag and Gitea prerelease, and uploads the APK plus its SHA-256 file. It
|
||||
uses `package.json` version unless that version is already tagged, then advances
|
||||
the patch number. Pass `--version 0.2.0` to choose an explicit version.
|
||||
|
||||
If the TrueNAS app directory is mounted locally, publishing also performs a
|
||||
fast-forward-only pull. Otherwise it prints the clone/pull command to run on
|
||||
TrueNAS before restarting the app. The pull refuses to run when the TrueNAS
|
||||
checkout still points at the old game's repository; move or archive that checkout
|
||||
and clone the new repository first.
|
||||
If the publisher runs where TrueNAS paths are visible, it prefers a
|
||||
fast-forward-only pull directly from TrueNAS-local Gitea storage. Otherwise it
|
||||
prints the exact local clone/pull commands to run in the TrueNAS shell. The pull
|
||||
refuses to run when the checkout still points at the old game's repository;
|
||||
archive that checkout and clone the new repository first.
|
||||
|
||||
Preview checks without changing Git or TrueNAS:
|
||||
|
||||
@@ -92,27 +122,27 @@ Preview checks without changing Git or TrueNAS:
|
||||
pnpm publish:gitea -- --dry-run
|
||||
```
|
||||
|
||||
Git push credentials still come from Git's credential manager. Gitea release API
|
||||
credentials come from `GITEA_TOKEN`. Never store access tokens or Android signing
|
||||
keys in this repository.
|
||||
Git push credentials still come from Git's credential manager. The configured
|
||||
Gitea token handles release creation and APK uploads. Android signing keys remain
|
||||
outside the repository.
|
||||
|
||||
## Controls
|
||||
|
||||
- `WASD` / left stick: move
|
||||
- `Q` and `E` / D-pad: cycle party target
|
||||
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
||||
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities
|
||||
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
|
||||
- `M`: tactical map
|
||||
- `I`: inventory and item tooltip
|
||||
- `Enter` / Start: begin or reset encounter
|
||||
- `Enter` / `START`: begin or reset encounter
|
||||
|
||||
Touch controls on lower display support party targeting, ability casting, map, and inventory.
|
||||
|
||||
## Prototype scope
|
||||
## Current game scope
|
||||
|
||||
- Five-member AI party with Disc Priest healer
|
||||
- Animated Druid healer, Knight tank, Ranger, Rogue, and Mage party models
|
||||
- One animated boss: Bulldrome, using the Bull model at 180% of its original prototype scale
|
||||
- Animated bosses using canonical tracked game models
|
||||
- Telegraph, charge, 0.75-second knockdown, and return-to-tank behavior
|
||||
- Three-charge cycle into a five-second stack marker and 300-damage shared pounce
|
||||
- Tank pressure, party-wide Cinder Nova, dispellable Ember Brand
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.phenomrom.iwanttoheal;
|
||||
|
||||
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 android.view.WindowManager;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Routes every Thor controller event into one JavaScript input service. */
|
||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
private static final float AXIS_DEAD_ZONE = 0.45f;
|
||||
private static final long REPEAT_THROTTLE_MS = 55L;
|
||||
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) {
|
||||
super.onCreate(savedInstanceState);
|
||||
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
dispatchNativeControllerToken("Button1", false);
|
||||
}
|
||||
});
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
WindowManager.LayoutParams attributes = getWindow().getAttributes();
|
||||
attributes.preferredRefreshRate = 60.0f;
|
||||
getWindow().setAttributes(attributes);
|
||||
if (bridge != null && bridge.getWebView() != null) {
|
||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
bridge.getWebView().setFocusable(true);
|
||||
bridge.getWebView().setFocusableInTouchMode(true);
|
||||
bridge.getWebView().requestFocus();
|
||||
}
|
||||
enterImmersiveMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
clearHeldControllerState();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
enterImmersiveMode();
|
||||
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) enterImmersiveMode();
|
||||
else clearHeldControllerState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchTouchEvent(MotionEvent event) {
|
||||
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
|
||||
bridge.getWebView().requestFocus();
|
||||
}
|
||||
return super.dispatchTouchEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
String token = controllerToken(event.getKeyCode());
|
||||
if (token == null || bridge == null) return super.dispatchKeyEvent(event);
|
||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
boolean repeat = event.getRepeatCount() > 0;
|
||||
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
|
||||
dispatchNativeControllerToken(token, repeat);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(MotionEvent event) {
|
||||
if (
|
||||
bridge == null
|
||||
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|
||||
|| !isControllerMotionEvent(event)
|
||||
) {
|
||||
return super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||
float rightStickX = controllerAxisValue(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RX);
|
||||
float rightStickY = controllerAxisValue(event, MotionEvent.AXIS_RZ, MotionEvent.AXIS_RY);
|
||||
dispatchNativeControllerMotion(leftStickX, leftStickY, rightStickX, rightStickY);
|
||||
|
||||
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, leftStickX, "Axis0-", "Axis0+");
|
||||
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
|
||||
|
||||
long now = SystemClock.uptimeMillis();
|
||||
for (String token : currentTokens) {
|
||||
boolean repeat = activeMotionTokens.contains(token);
|
||||
long lastDispatch = lastMotionDispatchAt.containsKey(token)
|
||||
? lastMotionDispatchAt.get(token)
|
||||
: 0L;
|
||||
if (!repeat || now - lastDispatch >= REPEAT_THROTTLE_MS) {
|
||||
dispatchNativeControllerToken(token, repeat);
|
||||
lastMotionDispatchAt.put(token, now);
|
||||
}
|
||||
}
|
||||
activeMotionTokens.clear();
|
||||
activeMotionTokens.addAll(currentTokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void enterImmersiveMode() {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
|
||||
getWindow(),
|
||||
getWindow().getDecorView()
|
||||
);
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars());
|
||||
controller.setSystemBarsBehavior(
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
);
|
||||
}
|
||||
|
||||
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 negative, String positive) {
|
||||
if (value <= -AXIS_DEAD_ZONE) tokens.add(negative);
|
||||
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
|
||||
}
|
||||
|
||||
private float controllerAxisValue(MotionEvent event, int primaryAxis, int fallbackAxis) {
|
||||
InputDevice device = event.getDevice();
|
||||
if (device != null && device.getMotionRange(primaryAxis) != null) {
|
||||
return event.getAxisValue(primaryAxis);
|
||||
}
|
||||
return event.getAxisValue(fallbackAxis);
|
||||
}
|
||||
|
||||
private void dispatchNativeControllerToken(String token, boolean repeat) {
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
|
||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||
bridge.getWebView().post(() -> {
|
||||
bridge.getWebView().requestFocus();
|
||||
bridge.getWebView().evaluateJavascript(script, null);
|
||||
});
|
||||
}
|
||||
|
||||
private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) {
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
|
||||
+ "{detail:{moveX:" + moveX + ",moveY:" + moveY
|
||||
+ ",lookX:" + lookX + ",lookY:" + lookY + "}}));";
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
|
||||
}
|
||||
|
||||
private void clearHeldControllerState() {
|
||||
activeMotionTokens.clear();
|
||||
lastMotionDispatchAt.clear();
|
||||
dispatchNativeControllerMotion(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
|
||||
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
private boolean shouldThrottleDpad(String token) {
|
||||
int index = Integer.parseInt(token.substring("Button".length()));
|
||||
long now = SystemClock.uptimeMillis();
|
||||
if (now - lastDpadDispatchAt[index] < REPEAT_THROTTLE_MS) return true;
|
||||
lastDpadDispatchAt[index] = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDpadToken(String token) {
|
||||
return token.equals("Button12")
|
||||
|| token.equals("Button13")
|
||||
|| token.equals("Button14")
|
||||
|| token.equals("Button15");
|
||||
}
|
||||
|
||||
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_ESCAPE:
|
||||
return "Button1";
|
||||
case KeyEvent.KEYCODE_BUTTON_X: return "Button2";
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: return "Button3";
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: return "Button4";
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: return "Button5";
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: return "Button6";
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: return "Button7";
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT: return "Button8";
|
||||
case KeyEvent.KEYCODE_BUTTON_START: return "Button9";
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: return "Button10";
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: return "Button11";
|
||||
case KeyEvent.KEYCODE_DPAD_UP: return "Button12";
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: return "Button13";
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: return "Button14";
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: return "Button15";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,11 @@
|
||||
package com.phenomrom.iwanttoheal;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
public class MainActivity extends ControllerBridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(ThorDualScreenPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
WindowManager.LayoutParams attributes = getWindow().getAttributes();
|
||||
attributes.preferredRefreshRate = 60.0f;
|
||||
getWindow().setAttributes(attributes);
|
||||
getBridge().getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
getBridge().getWebView().setFocusableInTouchMode(true);
|
||||
getBridge().getWebView().requestFocus();
|
||||
enterImmersiveMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) enterImmersiveMode();
|
||||
}
|
||||
|
||||
private void enterImmersiveMode() {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
|
||||
getWindow(),
|
||||
getWindow().getDecorView()
|
||||
);
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars());
|
||||
controller.setSystemBarsBehavior(
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.phenomrom.iwanttoheal;
|
||||
|
||||
import android.app.Presentation;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.Display;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
/** Keeps a dedicated WebView visible on each AYN Thor panel for the activity lifetime. */
|
||||
@CapacitorPlugin(name = "ThorDualScreen")
|
||||
public class ThorDualScreenPlugin extends Plugin implements DisplayManager.DisplayListener {
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private DisplayManager displayManager;
|
||||
private ThorPresentation presentation;
|
||||
private boolean forceEnabled;
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
displayManager = (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
|
||||
displayManager.registerDisplayListener(this, handler);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getDisplays(PluginCall call) {
|
||||
Display current = currentDisplay();
|
||||
JSArray displays = new JSArray();
|
||||
for (Display display : displayManager.getDisplays()) displays.put(describeDisplay(display, current));
|
||||
JSObject result = new JSObject();
|
||||
result.put("currentDisplayId", current == null ? -1 : current.getDisplayId());
|
||||
result.put("displays", displays);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void forceBothDisplays(PluginCall call) {
|
||||
forceEnabled = true;
|
||||
getActivity().runOnUiThread(() -> {
|
||||
try {
|
||||
DisplayPlan plan = createPlan();
|
||||
if (plan.target == null) {
|
||||
call.reject("No active secondary Android display is available.");
|
||||
return;
|
||||
}
|
||||
boolean alreadyOpen = presentation != null
|
||||
&& presentation.isShowing()
|
||||
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId();
|
||||
if (!alreadyOpen) openPlan(plan);
|
||||
JSObject result = describeDisplay(plan.target, currentDisplay());
|
||||
result.put("opened", true);
|
||||
result.put("topOnActivity", plan.currentIsTop);
|
||||
call.resolve(result);
|
||||
} catch (Exception exception) {
|
||||
closePresentation();
|
||||
call.reject("Unable to force both Thor displays.", exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleOnResume() {
|
||||
if (forceEnabled) scheduleEnsurePresentation(0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleOnDestroy() {
|
||||
forceEnabled = false;
|
||||
if (displayManager != null) displayManager.unregisterDisplayListener(this);
|
||||
handler.removeCallbacksAndMessages(null);
|
||||
closePresentation();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayAdded(int displayId) {
|
||||
if (forceEnabled) scheduleEnsurePresentation(100L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayRemoved(int displayId) {
|
||||
if (presentation != null && presentation.getDisplay().getDisplayId() == displayId) {
|
||||
closePresentation();
|
||||
}
|
||||
if (forceEnabled) scheduleEnsurePresentation(250L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayChanged(int displayId) {
|
||||
if (forceEnabled) scheduleEnsurePresentation(100L);
|
||||
}
|
||||
|
||||
private void scheduleEnsurePresentation(long delayMs) {
|
||||
handler.removeCallbacks(ensurePresentation);
|
||||
handler.postDelayed(ensurePresentation, delayMs);
|
||||
}
|
||||
|
||||
private final Runnable ensurePresentation = () -> {
|
||||
if (!forceEnabled || getActivity() == null || getActivity().isFinishing()) return;
|
||||
DisplayPlan plan = createPlan();
|
||||
if (plan.target == null) return;
|
||||
if (
|
||||
presentation != null
|
||||
&& presentation.isShowing()
|
||||
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId()
|
||||
) return;
|
||||
openPlan(plan);
|
||||
};
|
||||
|
||||
private void openPlan(DisplayPlan plan) {
|
||||
String baseUrl = bridge.getLocalUrl();
|
||||
String targetUrl = baseUrl + "/?display=" + (plan.targetIsTop ? "top" : "bottom") + "&role=presentation";
|
||||
String currentUrl = baseUrl + "/?display=" + (plan.currentIsTop ? "top" : "bottom") + "&role=activity";
|
||||
closePresentation();
|
||||
presentation = new ThorPresentation(
|
||||
getActivity(),
|
||||
plan.target,
|
||||
targetUrl
|
||||
);
|
||||
presentation.setOnDismissListener(dialog -> {
|
||||
presentation = null;
|
||||
notifyListeners("displayDisconnected", new JSObject());
|
||||
if (forceEnabled) scheduleEnsurePresentation(300L);
|
||||
});
|
||||
presentation.show();
|
||||
bridge.getWebView().loadUrl(currentUrl);
|
||||
}
|
||||
|
||||
private DisplayPlan createPlan() {
|
||||
Display current = currentDisplay();
|
||||
int currentId = current == null ? -1 : current.getDisplayId();
|
||||
Display[] displays = displayManager.getDisplays();
|
||||
Display top = largestDisplay(displays);
|
||||
boolean currentIsTop = top != null && top.getDisplayId() == currentId;
|
||||
Display target = currentIsTop
|
||||
? smallestOtherDisplay(displays, currentId)
|
||||
: top != null && top.getDisplayId() != currentId ? top : largestOtherDisplay(displays, currentId);
|
||||
boolean targetIsTop = target != null && top != null && target.getDisplayId() == top.getDisplayId();
|
||||
return new DisplayPlan(target, targetIsTop, currentIsTop);
|
||||
}
|
||||
|
||||
private Display currentDisplay() {
|
||||
return getActivity().getWindow().getDecorView().getDisplay();
|
||||
}
|
||||
|
||||
private Display largestDisplay(Display[] displays) {
|
||||
Display selected = null;
|
||||
long pixels = -1L;
|
||||
for (Display display : displays) {
|
||||
if (display.getState() == Display.STATE_OFF) continue;
|
||||
long candidate = pixelCount(display);
|
||||
if (candidate > pixels) {
|
||||
selected = display;
|
||||
pixels = candidate;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private Display largestOtherDisplay(Display[] displays, int currentId) {
|
||||
Display selected = null;
|
||||
long pixels = -1L;
|
||||
for (Display display : displays) {
|
||||
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
|
||||
long candidate = pixelCount(display);
|
||||
if (candidate > pixels) {
|
||||
selected = display;
|
||||
pixels = candidate;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private Display smallestOtherDisplay(Display[] displays, int currentId) {
|
||||
Display selected = null;
|
||||
long pixels = Long.MAX_VALUE;
|
||||
for (Display display : displays) {
|
||||
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
|
||||
long candidate = pixelCount(display);
|
||||
if (candidate < pixels) {
|
||||
selected = display;
|
||||
pixels = candidate;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private long pixelCount(Display display) {
|
||||
Display.Mode mode = display.getMode();
|
||||
return (long) mode.getPhysicalWidth() * mode.getPhysicalHeight();
|
||||
}
|
||||
|
||||
private JSObject describeDisplay(Display display, Display current) {
|
||||
Display.Mode mode = display.getMode();
|
||||
JSObject result = new JSObject();
|
||||
result.put("id", display.getDisplayId());
|
||||
result.put("name", display.getName());
|
||||
result.put("width", mode.getPhysicalWidth());
|
||||
result.put("height", mode.getPhysicalHeight());
|
||||
result.put("refreshRate", mode.getRefreshRate());
|
||||
result.put("isCurrent", current != null && current.getDisplayId() == display.getDisplayId());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void closePresentation() {
|
||||
if (presentation == null) return;
|
||||
ThorPresentation closing = presentation;
|
||||
presentation = null;
|
||||
closing.setOnDismissListener(null);
|
||||
closing.dismiss();
|
||||
}
|
||||
|
||||
private final class ThorPresentation extends Presentation {
|
||||
private final String initialUrl;
|
||||
private WebView webView;
|
||||
|
||||
ThorPresentation(Context context, Display display, String initialUrl) {
|
||||
super(context, display);
|
||||
this.initialUrl = initialUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().setBackgroundDrawableResource(android.R.color.black);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
WindowManager.LayoutParams attributes = getWindow().getAttributes();
|
||||
attributes.preferredRefreshRate = 60.0f;
|
||||
getWindow().setAttributes(attributes);
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
);
|
||||
|
||||
FrameLayout container = new FrameLayout(getContext());
|
||||
container.setBackgroundColor(Color.BLACK);
|
||||
webView = new WebView(getContext());
|
||||
configureWebView(webView);
|
||||
container.addView(webView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
));
|
||||
setContentView(container);
|
||||
webView.loadUrl(initialUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
if (webView != null) {
|
||||
webView.stopLoading();
|
||||
webView.destroy();
|
||||
webView = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void configureWebView(WebView webView) {
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||
settings.setAllowFileAccess(true);
|
||||
settings.setAllowContentAccess(true);
|
||||
webView.setBackgroundColor(Color.BLACK);
|
||||
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
webView.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
return bridge.getLocalServer().shouldInterceptRequest(request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static final class DisplayPlan {
|
||||
private final Display target;
|
||||
private final boolean targetIsTop;
|
||||
private final boolean currentIsTop;
|
||||
|
||||
DisplayPlan(Display target, boolean targetIsTop, boolean currentIsTop) {
|
||||
this.target = target;
|
||||
this.targetIsTop = targetIsTop;
|
||||
this.currentIsTop = currentIsTop;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ services:
|
||||
iwanttoheal:
|
||||
image: node:24-bookworm-slim
|
||||
command: >-
|
||||
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
|
||||
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start"
|
||||
environment:
|
||||
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
|
||||
DATA_DIR: /app/data
|
||||
HOST: 0.0.0.0
|
||||
PORT: "4173"
|
||||
init: true
|
||||
@@ -11,5 +13,6 @@ services:
|
||||
- "4173:4173"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/usbssds/apps/iwanttoheal/app:/app
|
||||
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
||||
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
|
||||
working_dir: /app
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
canonical_username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_salt TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_token_hash_idx ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hunter_saves (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
|
||||
hunter_name TEXT NOT NULL,
|
||||
save_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (account_id, slot_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS boss_kill_records (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
|
||||
boss_id TEXT NOT NULL,
|
||||
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (account_id, slot_id, boss_id),
|
||||
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS boss_kill_rank_idx
|
||||
ON boss_kill_records (boss_id, kills DESC, updated_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roguelike_records (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
|
||||
highest_round INTEGER NOT NULL DEFAULT 0 CHECK (highest_round >= 0),
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (account_id, slot_id),
|
||||
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS roguelike_rank_idx
|
||||
ON roguelike_records (highest_round DESC, updated_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rogue_trials_endless_records (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
|
||||
highest_boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (highest_boss_kills >= 0),
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (account_id, slot_id),
|
||||
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx
|
||||
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
|
||||
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,35 +0,0 @@
|
||||
# Blender3D MTL File: Rathalos.blend
|
||||
# Material Count: 3
|
||||
newmtl Rathalos2_em002_BM_02.png
|
||||
Ns 96.078431
|
||||
Ka 0.000000 0.000000 0.000000
|
||||
Kd 0.640000 0.640000 0.640000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ni 1.000000
|
||||
d 1.000000
|
||||
illum 0
|
||||
map_Kd em002_BM_02.png
|
||||
|
||||
|
||||
newmtl Rathalos1_em002_BM_01.png
|
||||
Ns 96.078431
|
||||
Ka 0.000000 0.000000 0.000000
|
||||
Kd 0.640000 0.640000 0.640000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ni 1.000000
|
||||
d 1.000000
|
||||
illum 0
|
||||
map_Kd em002_BM_01.png
|
||||
|
||||
|
||||
newmtl Rathalos3_em002_BM_03.png
|
||||
Ns 96.078431
|
||||
Ka 0.000000 0.000000 0.000000
|
||||
Kd 0.640000 0.640000 0.640000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ni 1.000000
|
||||
d 0.000000
|
||||
illum 0
|
||||
map_Kd em002_BM_03.png
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 769 KiB |
|
Before Width: | Height: | Size: 415 KiB |
@@ -1,64 +0,0 @@
|
||||
|
||||
|
||||
KayKit : Character Animations (1.1)
|
||||
|
||||
Created/distributed by Kay Lousberg (www.kaylousberg.com)
|
||||
Creation date: 10/12/2025 09:00
|
||||
|
||||
------------------------------
|
||||
|
||||
License: (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
You can support me by purchasing an asset pack at the EXTRA or SOURCE tiers, leaving a donation/tip on itch.io, or joining my Patreon.
|
||||
If you wish to credit me you can do so by crediting "Kay Lousberg, www.kaylousberg.com" (this is not mandatory)
|
||||
|
||||
------------------------------
|
||||
|
||||
This asset pack is here thanks to all the wonderful people who support KayKit on Patreon and those who buy EXTRA or SOURCE packs on itch.io.
|
||||
|
||||
And a big, special thank you to my Super Supporters on Patreon:
|
||||
|
||||
- Brian McBarron
|
||||
- Silva
|
||||
- Joseph Preston
|
||||
- Leon Burkhardt
|
||||
- NickyBHobbying
|
||||
- Emilia
|
||||
- Darren Laing
|
||||
- Magic Pig Games
|
||||
- Lennart Steinke
|
||||
- OWSC
|
||||
- tenthtonman
|
||||
- Ducain23
|
||||
- flo-bit
|
||||
- Jezzy Sukaemi
|
||||
- Martyn Rushton
|
||||
- Joe
|
||||
- estee
|
||||
- Gustavo
|
||||
- AngyZinn
|
||||
- Alex Cea
|
||||
- Matthew A
|
||||
- Stephen Williams
|
||||
- Wenpu Ng
|
||||
- Kevin
|
||||
- Legionof Light
|
||||
- Dazusu
|
||||
- Arnaud Schlupp
|
||||
- JB
|
||||
- Neil Castle
|
||||
- Cedrico Serbruyns
|
||||
- ovenaut
|
||||
- Pawel Jarosz
|
||||
|
||||
------------------------------
|
||||
|
||||
|
||||
Patreon: http://patreon.com/kaylousberg
|
||||
|
||||
Follow here for updates:
|
||||
http://twitter.com/KayLousberg
|
||||
https://mastodon.gamedev.place/@Kay
|
||||
http://twitter.com/KayLousberg
|
||||
@@ -1,136 +0,0 @@
|
||||
{
|
||||
"asset" : {
|
||||
"generator" : "Khronos glTF Blender I/O v3.4.50",
|
||||
"version" : "2.0"
|
||||
},
|
||||
"scene" : 0,
|
||||
"scenes" : [
|
||||
{
|
||||
"name" : "Scene",
|
||||
"nodes" : [
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"nodes" : [
|
||||
{
|
||||
"mesh" : 0,
|
||||
"name" : "banner_blue"
|
||||
}
|
||||
],
|
||||
"materials" : [
|
||||
{
|
||||
"name" : "texture",
|
||||
"pbrMetallicRoughness" : {
|
||||
"baseColorTexture" : {
|
||||
"index" : 0
|
||||
},
|
||||
"metallicFactor" : 0,
|
||||
"roughnessFactor" : 0.44999998807907104
|
||||
}
|
||||
}
|
||||
],
|
||||
"meshes" : [
|
||||
{
|
||||
"name" : "banner_blue",
|
||||
"primitives" : [
|
||||
{
|
||||
"attributes" : {
|
||||
"POSITION" : 0,
|
||||
"TEXCOORD_0" : 1,
|
||||
"NORMAL" : 2
|
||||
},
|
||||
"indices" : 3,
|
||||
"material" : 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"textures" : [
|
||||
{
|
||||
"sampler" : 0,
|
||||
"source" : 0
|
||||
}
|
||||
],
|
||||
"images" : [
|
||||
{
|
||||
"mimeType" : "image/png",
|
||||
"name" : "dungeon_texture",
|
||||
"uri" : "dungeon_texture.png"
|
||||
}
|
||||
],
|
||||
"accessors" : [
|
||||
{
|
||||
"bufferView" : 0,
|
||||
"componentType" : 5126,
|
||||
"count" : 187,
|
||||
"max" : [
|
||||
0.7500076293945312,
|
||||
3.7274699211120605,
|
||||
0.6895275712013245
|
||||
],
|
||||
"min" : [
|
||||
-0.749992847442627,
|
||||
0.5313930511474609,
|
||||
0.3776381015777588
|
||||
],
|
||||
"type" : "VEC3"
|
||||
},
|
||||
{
|
||||
"bufferView" : 1,
|
||||
"componentType" : 5126,
|
||||
"count" : 187,
|
||||
"type" : "VEC2"
|
||||
},
|
||||
{
|
||||
"bufferView" : 2,
|
||||
"componentType" : 5126,
|
||||
"count" : 187,
|
||||
"type" : "VEC3"
|
||||
},
|
||||
{
|
||||
"bufferView" : 3,
|
||||
"componentType" : 5123,
|
||||
"count" : 291,
|
||||
"type" : "SCALAR"
|
||||
}
|
||||
],
|
||||
"bufferViews" : [
|
||||
{
|
||||
"buffer" : 0,
|
||||
"byteLength" : 2244,
|
||||
"byteOffset" : 0,
|
||||
"target" : 34962
|
||||
},
|
||||
{
|
||||
"buffer" : 0,
|
||||
"byteLength" : 1496,
|
||||
"byteOffset" : 2244,
|
||||
"target" : 34962
|
||||
},
|
||||
{
|
||||
"buffer" : 0,
|
||||
"byteLength" : 2244,
|
||||
"byteOffset" : 3740,
|
||||
"target" : 34962
|
||||
},
|
||||
{
|
||||
"buffer" : 0,
|
||||
"byteLength" : 582,
|
||||
"byteOffset" : 5984,
|
||||
"target" : 34963
|
||||
}
|
||||
],
|
||||
"samplers" : [
|
||||
{
|
||||
"magFilter" : 9729,
|
||||
"minFilter" : 9987
|
||||
}
|
||||
],
|
||||
"buffers" : [
|
||||
{
|
||||
"byteLength" : 6568,
|
||||
"uri" : "banner_blue.bin"
|
||||
}
|
||||
]
|
||||
}
|
||||