Release v0.1.1 2026-07-10
This commit is contained in:
@@ -1,34 +1,33 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
|
||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>("top");
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() =>
|
||||
new URLSearchParams(window.location.search).get("display") === "bottom" ? "bottom" : "top"
|
||||
);
|
||||
const activeSurfaceRef = useRef(activeSurface);
|
||||
activeSurfaceRef.current = activeSurface;
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
||||
let frame = 0;
|
||||
let selectHeld = false;
|
||||
const dedicatedSurface = new URLSearchParams(window.location.search).has("display");
|
||||
if (dedicatedSurface) return;
|
||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||
if (token === "Button8" && !repeat) toggle();
|
||||
});
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab" || event.repeat) return;
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
};
|
||||
const poll = () => {
|
||||
const held = navigator.getGamepads?.()[0]?.buttons[8]?.pressed ?? false;
|
||||
if (held && !selectHeld) toggle();
|
||||
selectHeld = held;
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
frame = requestAnimationFrame(poll);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
unsubscribeSurface();
|
||||
cancelAnimationFrame(frame);
|
||||
unsubscribeController();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
+43
-10
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data";
|
||||
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
@@ -51,15 +51,24 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
|
||||
|
||||
function LoginScreen() {
|
||||
const signIn = useFrontendStore((state) => state.signIn);
|
||||
const createAccount = useFrontendStore((state) => state.createAccount);
|
||||
const continueOffline = useFrontendStore((state) => state.continueOffline);
|
||||
const notice = useFrontendStore((state) => state.notice);
|
||||
const [hunterId, setHunterId] = useState("wayfinder");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "sign-in", run: () => signIn(hunterId) },
|
||||
{ id: "username", run: () => usernameRef.current?.focus() },
|
||||
{ id: "password", run: () => passwordRef.current?.focus() },
|
||||
{ id: "sign-in", run: () => { void signIn(username, password); } },
|
||||
{ id: "create-account", run: () => { void createAccount(username, password); } },
|
||||
{ id: "offline", run: continueOffline },
|
||||
], [continueOffline, hunterId, signIn]);
|
||||
], [continueOffline, createAccount, password, signIn, username]);
|
||||
const controller = useMenuController(actions);
|
||||
|
||||
const submitSignIn = () => { void signIn(username, password); };
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
top={
|
||||
@@ -71,17 +80,41 @@ function LoginScreen() {
|
||||
<h1>Keep everyone standing.</h1>
|
||||
<p>Your save always lives on this device. Sign in only when you want a second copy for PC ↔ AYN Thor handoff.</p>
|
||||
</div>
|
||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); signIn(hunterId); }}>
|
||||
<label htmlFor="hunter-id">Hunter ID</label>
|
||||
<input id="hunter-id" value={hunterId} onChange={(event) => setHunterId(event.target.value)} autoComplete="username" />
|
||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input
|
||||
ref={usernameRef}
|
||||
id="account-username"
|
||||
className={controller.focusedId === "username" ? "is-controller-focused" : ""}
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
onFocus={() => controller.focus("username")}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="account-password">Password</label>
|
||||
<input
|
||||
ref={passwordRef}
|
||||
id="account-password"
|
||||
className={controller.focusedId === "password" ? "is-controller-focused" : ""}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onFocus={() => controller.focus("password")}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
|
||||
<span>Sign in & sync</span><small>Online saves enabled</small>
|
||||
</FocusButton>
|
||||
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
|
||||
<span>Create account</span><small>Required for first sync</small>
|
||||
</FocusButton>
|
||||
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
|
||||
<span>Continue with offline save</span><small>No account required</small>
|
||||
</FocusButton>
|
||||
</form>
|
||||
{notice && <div className="front-notice">{notice}</div>}
|
||||
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
|
||||
<ControllerLegend />
|
||||
</FrontSurface>
|
||||
}
|
||||
@@ -92,8 +125,8 @@ function LoginScreen() {
|
||||
<span className="context-kicker">How saving works</span>
|
||||
<ol>
|
||||
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
|
||||
<li><b>02</b><span><strong>Sync when ready</strong><small>Upload any slot after signing in.</small></span></li>
|
||||
<li><b>03</b><span><strong>Move devices</strong><small>Download the online copy and overwrite local.</small></span></li>
|
||||
<li><b>02</b><span><strong>Create or sign in</strong><small>Username and password unlock online sync.</small></span></li>
|
||||
<li><b>03</b><span><strong>Move devices</strong><small>Sign in, then upload or download an online copy.</small></span></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="device-route"><span>PC</span><i>↔</i><b>ONLINE COPY</b><i>↔</i><span>THOR</span></div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useAnimations, useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import { getControllerMovement } from "../input/controller";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { MemberId, PulseKind } from "../game/types";
|
||||
@@ -9,6 +10,12 @@ import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
|
||||
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
|
||||
const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href;
|
||||
const SPIDER_TEXTURE_URLS: Record<string, string> = {
|
||||
"Spinnen_Bein_tex_2.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_2.jpg", import.meta.url).href,
|
||||
"SH3.png": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/SH3.png", import.meta.url).href,
|
||||
"Spinnen_Bein_tex_COLOR_.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_COLOR_.jpg", import.meta.url).href,
|
||||
"haar_detail_NRM.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/haar_detail_NRM.jpg", import.meta.url).href,
|
||||
};
|
||||
const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href;
|
||||
const PARTY_MODEL_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
|
||||
@@ -41,6 +48,11 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
|
||||
orin: "Spellcast_Shoot",
|
||||
vale: "Dualwield_Melee_Attack_Chop",
|
||||
};
|
||||
const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
|
||||
const angle = (index / 10) * Math.PI * 2;
|
||||
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
|
||||
});
|
||||
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function encounterBossAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -59,6 +71,13 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
|
||||
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
|
||||
}
|
||||
|
||||
const configureSpiderLoader: NonNullable<Parameters<typeof useGLTF>[3]> = (loader) => {
|
||||
loader.manager.setURLModifier((url) => {
|
||||
const fileName = url.slice(url.lastIndexOf("/") + 1);
|
||||
return SPIDER_TEXTURE_URLS[fileName] ?? url;
|
||||
});
|
||||
};
|
||||
|
||||
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
|
||||
|
||||
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
|
||||
@@ -208,11 +227,24 @@ function PartyCharacterModel({
|
||||
}
|
||||
|
||||
function Arena() {
|
||||
const columns = useMemo(() => {
|
||||
return Array.from({ length: 10 }, (_, index) => {
|
||||
const angle = (index / 10) * Math.PI * 2;
|
||||
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
|
||||
const pillarInstances = useRef<THREE.InstancedMesh>(null);
|
||||
const torchInstances = useRef<THREE.InstancedMesh>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const pillars = pillarInstances.current;
|
||||
const torches = torchInstances.current;
|
||||
if (!pillars || !torches) return;
|
||||
const matrix = new THREE.Matrix4();
|
||||
ARENA_COLUMNS.forEach(([x, z], index) => {
|
||||
matrix.makeTranslation(x, 1.1, z - 1);
|
||||
pillars.setMatrixAt(index, matrix);
|
||||
matrix.makeTranslation(x, 2.6, z - 1);
|
||||
torches.setMatrixAt(index, matrix);
|
||||
torches.setColorAt(index, ARENA_TORCH_COLORS[index % ARENA_TORCH_COLORS.length]);
|
||||
});
|
||||
pillars.instanceMatrix.needsUpdate = true;
|
||||
torches.instanceMatrix.needsUpdate = true;
|
||||
if (torches.instanceColor) torches.instanceColor.needsUpdate = true;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -229,19 +261,16 @@ function Arena() {
|
||||
<circleGeometry args={[2.1, 48]} />
|
||||
<meshStandardMaterial color="#27342d" roughness={1} />
|
||||
</mesh>
|
||||
{columns.map(([x, z], index) => (
|
||||
<group key={index} position={[x, 0, z - 1]}>
|
||||
<mesh castShadow receiveShadow position={[0, 1.1, 0]}>
|
||||
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
|
||||
<meshStandardMaterial color="#26342f" roughness={0.8} />
|
||||
</mesh>
|
||||
<pointLight color={index % 2 ? "#dd7b38" : "#6fc9ba"} intensity={2.2} distance={5} position={[0, 2.7, 0]} />
|
||||
<mesh position={[0, 2.6, 0]}>
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial color={index % 2 ? "#ff9a4f" : "#77ddce"} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
|
||||
<meshStandardMaterial color="#26342f" roughness={0.8} />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial />
|
||||
</instancedMesh>
|
||||
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
|
||||
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
|
||||
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
|
||||
</group>
|
||||
);
|
||||
@@ -365,11 +394,9 @@ function PlayerCharacter() {
|
||||
if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
|
||||
inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
|
||||
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
|
||||
const gamepad = navigator.getGamepads?.()[0];
|
||||
if (gamepad) {
|
||||
inputX += Math.abs(gamepad.axes[0] ?? 0) > 0.18 ? gamepad.axes[0] : 0;
|
||||
inputZ += Math.abs(gamepad.axes[1] ?? 0) > 0.18 ? gamepad.axes[1] : 0;
|
||||
}
|
||||
const controller = getControllerMovement();
|
||||
inputX += controller.x;
|
||||
inputZ += controller.y;
|
||||
}
|
||||
const length = Math.hypot(inputX, inputZ);
|
||||
if (length > 0.05) {
|
||||
@@ -587,7 +614,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||
const defeated = bossHp <= 0;
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const gltf = useGLTF(config.url, false, true);
|
||||
const gltf = useGLTF(config.url, false, true, kind === "vexa" ? configureSpiderLoader : undefined);
|
||||
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
const { actions } = useAnimations(gltf.animations, model);
|
||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
|
||||
Reference in New Issue
Block a user