mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-05-05 03:53:57 +00:00
Add multiplayer, cloud sync, crash reporting, scene player, and memories features
This commit is contained in:
parent
120a57c09a
commit
182dfd9f1f
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,3 +5,7 @@ isle.wasm.map
|
||||
isle.js
|
||||
LEGO
|
||||
save-editor.bin
|
||||
.dev.vars
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
|
||||
15
README.md
15
README.md
@ -54,6 +54,8 @@ A custom web frontend for the Emscripten port of [isle-portable](https://github.
|
||||
| `npm run dev` | Start development server with hot reload |
|
||||
| `npm run build` | Build for production (outputs to `dist/`) |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm run deploy` | Deploy to dev environment (R2) |
|
||||
| `npm run deploy -- production` | Deploy to production environment (R2) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
@ -65,13 +67,14 @@ isle.pizza/
|
||||
│ ├── stores.js # Svelte stores for state management
|
||||
│ ├── core/
|
||||
│ │ ├── formats/ # Binary file parsers/serializers (WDB, save games, animations, textures)
|
||||
│ │ ├── rendering/ # Three.js renderers (BaseRenderer, VehiclePartRenderer, ActorRenderer, etc.)
|
||||
│ │ ├── rendering/ # OGL renderers (BaseRenderer, VehiclePartRenderer, ActorRenderer, etc.)
|
||||
│ │ ├── savegame/ # Save game constants, actor data, color tables
|
||||
│ │ └── ... # Audio, OPFS, service worker, asset loading
|
||||
│ └── lib/ # UI components and pages (save editor, configure, etc.)
|
||||
│ │ └── ... # Audio, OPFS, cloud sync, auth, service worker, asset loading
|
||||
│ └── lib/ # UI components and pages (save editor, multiplayer, scene player, etc.)
|
||||
├── server/ # Cloudflare Workers backend (auth, cloud sync, crash reports, memories)
|
||||
├── public/
|
||||
│ └── images/ # UI images (menu buttons, tab icons)
|
||||
├── scripts/ # Build and asset generation scripts
|
||||
├── scripts/ # Build, asset generation, and deploy scripts
|
||||
├── src-sw/ # Service worker source
|
||||
├── index.html # HTML entry point
|
||||
├── isle.js # Emscripten JS (not in repo, build from isle-portable)
|
||||
@ -92,9 +95,11 @@ Alternatively, a [Docker image that bundles the runtime with this frontend](http
|
||||
## Tech Stack
|
||||
|
||||
- [Svelte 5](https://svelte.dev/) - UI framework
|
||||
- [Three.js](https://threejs.org/) - 3D rendering for save editor previews
|
||||
- [OGL](https://ogl.dev/) - 3D rendering for save editor previews
|
||||
- [Vite](https://vitejs.dev/) - Build tool and dev server
|
||||
- [Workbox](https://developer.chrome.com/docs/workbox/) - Service worker and offline support
|
||||
- [Hono](https://hono.dev/) - Backend API framework (Cloudflare Workers)
|
||||
- [better-auth](https://www.better-auth.com/) - Authentication (Discord)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
17
index.html
17
index.html
@ -3,26 +3,27 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>LEGO® Island</title>
|
||||
|
||||
<meta name="description"
|
||||
content="Play the classic 1997 PC game LEGO® Island directly in your web browser! This fan-made web port, built with Emscripten from the isledecomp project, brings the beloved adventure back to life.">
|
||||
content="Play LEGO® Island (1997) in your browser — no download required. A faithful open-source recreation built from the original decompiled source code, running on desktop, mobile, and more.">
|
||||
<meta name="keywords"
|
||||
content="LEGO Island, LEGO, classic game, 1997, web port, browser game, Emscripten, isledecomp, retro gaming, play online, Infomaniac, Brickster, Pepper Roni">
|
||||
<meta name="author" content="isledecomp/isle.pizza">
|
||||
content="LEGO Island, LEGO, 1997, browser game, play online, no download, WebAssembly, open source, retro gaming, Pepper Roni, Infomaniac, Brickster, isledecomp">
|
||||
<meta name="author" content="isledecomp">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://isle.pizza/">
|
||||
<meta name="twitter:title" content="LEGO® Island - Online Web Port">
|
||||
<meta name="twitter:description" content="Play the classic 1997 PC game LEGO® Island directly in your web browser!">
|
||||
<meta name="twitter:title" content="LEGO® Island — Play in Your Browser">
|
||||
<meta name="twitter:description" content="The classic 1997 LEGO® Island adventure, faithfully rebuilt to run in modern browsers. No download, no install — just play.">
|
||||
<meta name="twitter:image" content="https://isle.pizza/images/island.webp">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://isle.pizza/">
|
||||
<meta property="og:title" content="LEGO® Island - Online Web Port">
|
||||
<meta property="og:description" content="Play the classic 1997 PC game LEGO® Island directly in your web browser!">
|
||||
<meta property="og:title" content="LEGO® Island — Play in Your Browser">
|
||||
<meta property="og:description" content="The classic 1997 LEGO® Island adventure, faithfully rebuilt to run in modern browsers. No download, no install — just play.">
|
||||
<meta property="og:image" content="https://isle.pizza/images/island.webp">
|
||||
<meta property="og:site_name" content="LEGO Island Web Port">
|
||||
<meta property="og:site_name" content="LEGO® Island">
|
||||
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
|
||||
4129
package-lock.json
generated
4129
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@ -9,18 +9,27 @@
|
||||
"check": "svelte-check --fail-on-warnings",
|
||||
"preview": "vite preview",
|
||||
"prepare:assets": "node scripts/prepare.js",
|
||||
"generate:save-editor-assets": "node scripts/generate-save-editor-assets.js"
|
||||
"generate:save-editor-assets": "node scripts/generate-save-editor-assets.js",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"deploy:assets": "bash scripts/deploy.sh --include-assets",
|
||||
"deploy:prod": "bash scripts/deploy.sh production",
|
||||
"deploy:prod:assets": "bash scripts/deploy.sh production --include-assets"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.5",
|
||||
"three": "^0.182.0"
|
||||
"better-auth": "^1.5.5",
|
||||
"ogl": "^1.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"svelte": "^5.46.4",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.4.0",
|
||||
"workbox-cli": "^7.3.0"
|
||||
"typescript": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"workbox-cli": "^7.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"tmp": "^0.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/images/infoface.webp
Normal file
BIN
public/images/infoface.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/images/infosign.webp
Normal file
BIN
public/images/infosign.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/images/multi.webp
Normal file
BIN
public/images/multi.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
public/images/nick_closeup.webp
Normal file
BIN
public/images/nick_closeup.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
143
scripts/deploy.sh
Executable file
143
scripts/deploy.sh
Executable file
@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
WRANGLER="$PROJECT_DIR/server/node_modules/.bin/wrangler"
|
||||
R2_BUCKET="${R2_BUCKET:-isle}"
|
||||
|
||||
# ── Parse arguments ────────────────────────────────────────────
|
||||
ENV=""
|
||||
SKIP_BUILD=false
|
||||
INCLUDE_ASSETS=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-build) SKIP_BUILD=true ;;
|
||||
--include-assets) INCLUDE_ASSETS=true ;;
|
||||
dev|production) ENV="$arg" ;;
|
||||
*) echo "Unknown argument: $arg"; echo "Usage: $0 [dev|production] [--skip-build] [--include-assets]"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ENV="${ENV:-dev}"
|
||||
|
||||
# ── Environment configuration ──────────────────────────────────
|
||||
case "$ENV" in
|
||||
dev)
|
||||
R2_PREFIX="dev/"
|
||||
DOMAIN="dev.isle.pizza"
|
||||
;;
|
||||
production)
|
||||
R2_PREFIX=""
|
||||
DOMAIN="isle.pizza"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Check prerequisites ───────────────────────────────────────
|
||||
if [ ! -f "$WRANGLER" ]; then
|
||||
echo "Error: wrangler not found at $WRANGLER"
|
||||
echo "Run 'npm install' in $PROJECT_DIR/server/ first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Compute versions ──────────────────────────────────────────
|
||||
cd "$PROJECT_DIR"
|
||||
FRONTEND_VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
|
||||
WASM_VERSION=""
|
||||
if [ -f "$PROJECT_DIR/isle.js" ]; then
|
||||
WASM_VERSION=$(grep -oP 'wasmVersion"\]\s*=\s*"\K[^"]+' "$PROJECT_DIR/isle.js" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
echo "Environment: $ENV"
|
||||
echo "R2 prefix: ${R2_PREFIX:-(root)}"
|
||||
echo "Domain: $DOMAIN"
|
||||
echo "Frontend version: $FRONTEND_VERSION"
|
||||
echo "WASM version: ${WASM_VERSION:-not found}"
|
||||
echo ""
|
||||
|
||||
# ── Production safety gate ─────────────────────────────────────
|
||||
if [ "$ENV" = "production" ]; then
|
||||
echo "WARNING: You are about to deploy to PRODUCTION ($DOMAIN)"
|
||||
read -r -p "Continue? [y/N] " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
echo "Building frontend..."
|
||||
cd "$PROJECT_DIR"
|
||||
RELAY_URL="wss://relay.isle.pizza" API_URL="https://api.isle.pizza" BUILD_VERSION="$FRONTEND_VERSION" npm run build
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Verify dist ────────────────────────────────────────────────
|
||||
if [ ! -d "$PROJECT_DIR/dist" ] || [ ! -f "$PROJECT_DIR/dist/index.html" ]; then
|
||||
echo "Error: dist/ directory not found or empty. Run without --skip-build first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Content-type mapping ──────────────────────────────────────
|
||||
get_content_type() {
|
||||
case "${1##*.}" in
|
||||
html) echo "text/html; charset=utf-8" ;;
|
||||
js) echo "application/javascript; charset=utf-8" ;;
|
||||
css) echo "text/css; charset=utf-8" ;;
|
||||
json) echo "application/json; charset=utf-8" ;;
|
||||
wasm) echo "application/wasm" ;;
|
||||
webp) echo "image/webp" ;;
|
||||
png) echo "image/png" ;;
|
||||
svg) echo "image/svg+xml" ;;
|
||||
gif) echo "image/gif" ;;
|
||||
mp3) echo "audio/mpeg" ;;
|
||||
pdf) echo "application/pdf" ;;
|
||||
bin) echo "application/octet-stream" ;;
|
||||
map) echo "application/json" ;;
|
||||
*) echo "application/octet-stream" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Upload dist/ to R2 ────────────────────────────────────────
|
||||
echo "Uploading to R2 (bucket: $R2_BUCKET, prefix: ${R2_PREFIX:-(root)})..."
|
||||
|
||||
cd "$PROJECT_DIR/dist"
|
||||
find . -type f | sort | while read -r file; do
|
||||
file="${file#./}"
|
||||
|
||||
# Skip asset directories unless --include-assets
|
||||
if [ "$INCLUDE_ASSETS" = false ]; then
|
||||
case "$file" in
|
||||
images/*|audio/*|pdf/*|workbox/*) continue ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
key="${R2_PREFIX}${file}"
|
||||
ct=$(get_content_type "$file")
|
||||
echo " $key ($ct)"
|
||||
"$WRANGLER" r2 object put "$R2_BUCKET/$key" --file "$PROJECT_DIR/dist/$file" --content-type "$ct" --remote 2>/dev/null
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# ── Upload source map to symbols/ ─────────────────────────────
|
||||
if [ -n "$WASM_VERSION" ] && [ -f "$PROJECT_DIR/isle.wasm.map" ]; then
|
||||
SYMBOLS_KEY="symbols/${WASM_VERSION}/isle.wasm.map"
|
||||
echo "Uploading source map: $SYMBOLS_KEY"
|
||||
"$WRANGLER" r2 object put "$R2_BUCKET/$SYMBOLS_KEY" --file "$PROJECT_DIR/isle.wasm.map" --content-type "application/json" --remote 2>/dev/null
|
||||
else
|
||||
echo "Warning: Skipping source map upload (wasm version: '${WASM_VERSION:-}', map file exists: $([ -f "$PROJECT_DIR/isle.wasm.map" ] && echo yes || echo no))"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Deploy complete!"
|
||||
echo " Environment: $ENV ($DOMAIN)"
|
||||
echo " Frontend version: $FRONTEND_VERSION"
|
||||
echo " WASM version: ${WASM_VERSION:-unknown}"
|
||||
echo ""
|
||||
echo "Remember to purge the Cloudflare cache for $DOMAIN."
|
||||
20
server/Dockerfile
Normal file
20
server/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY server/src/ ./src/
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY server/wrangler.toml server/tsconfig.json ./
|
||||
COPY server/.dev.vars* ./
|
||||
|
||||
EXPOSE 8788
|
||||
|
||||
# Apply D1 migrations then start wrangler dev.
|
||||
# wrangler runs as PID 1 via exec so it receives SIGINT (Ctrl+C).
|
||||
CMD node_modules/.bin/wrangler d1 migrations apply isle-pizza --local && \
|
||||
exec node_modules/.bin/wrangler dev --ip 0.0.0.0 --port 8788
|
||||
60
server/migrations/0001_initial.sql
Normal file
60
server/migrations/0001_initial.sql
Normal file
@ -0,0 +1,60 @@
|
||||
-- better-auth core tables (generated via getSchema with anonymous plugin)
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL UNIQUE,
|
||||
"emailVerified" INTEGER NOT NULL,
|
||||
"image" TEXT,
|
||||
"createdAt" TEXT NOT NULL,
|
||||
"updatedAt" TEXT NOT NULL,
|
||||
"isAnonymous" INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "session" (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
"expiresAt" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL UNIQUE,
|
||||
"createdAt" TEXT NOT NULL,
|
||||
"updatedAt" TEXT NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"userId" TEXT NOT NULL REFERENCES "user"(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "account" (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL REFERENCES "user"(id),
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"idToken" TEXT,
|
||||
"accessTokenExpiresAt" TEXT,
|
||||
"refreshTokenExpiresAt" TEXT,
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TEXT NOT NULL,
|
||||
"updatedAt" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "verification" (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" TEXT NOT NULL,
|
||||
"createdAt" TEXT NOT NULL,
|
||||
"updatedAt" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Memory completions: every animation completion event is recorded
|
||||
CREATE TABLE IF NOT EXISTS memory_completions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
object_id INTEGER NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
completed_at INTEGER NOT NULL,
|
||||
char_index INTEGER NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
participants TEXT NOT NULL DEFAULT '[]',
|
||||
UNIQUE(user_id, event_id)
|
||||
);
|
||||
20
server/migrations/0002_drop_char_columns.sql
Normal file
20
server/migrations/0002_drop_char_columns.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- Drop redundant char_index/display_name columns (always equal to participants[0])
|
||||
-- and enforce non-empty participants via CHECK constraint.
|
||||
CREATE TABLE IF NOT EXISTS memory_completions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
object_id INTEGER NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
completed_at INTEGER NOT NULL,
|
||||
participants TEXT NOT NULL CHECK(json_array_length(participants) > 0),
|
||||
UNIQUE(user_id, event_id)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO memory_completions_new (id, user_id, object_id, event_id, completed_at, participants)
|
||||
SELECT id, user_id, object_id, event_id, completed_at, participants
|
||||
FROM memory_completions
|
||||
WHERE json_array_length(participants) > 0;
|
||||
|
||||
DROP TABLE IF EXISTS memory_completions;
|
||||
|
||||
ALTER TABLE memory_completions_new RENAME TO memory_completions;
|
||||
6
server/migrations/0003_object_id_to_anim_index.sql
Normal file
6
server/migrations/0003_object_id_to_anim_index.sql
Normal file
@ -0,0 +1,6 @@
|
||||
-- Rename object_id to anim_index and convert values.
|
||||
-- ACT1 animations: anim_index = object_id - 500 (world slot 0).
|
||||
-- All existing records are ACT1, so the conversion is straightforward.
|
||||
ALTER TABLE memory_completions RENAME COLUMN object_id TO anim_index;
|
||||
|
||||
UPDATE memory_completions SET anim_index = anim_index - 500;
|
||||
2
server/migrations/0004_add_language.sql
Normal file
2
server/migrations/0004_add_language.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- Add language column to track which language version was used when the memory was recorded.
|
||||
ALTER TABLE memory_completions ADD COLUMN language TEXT NOT NULL DEFAULT 'en';
|
||||
9
server/migrations/0005_crash_reports.sql
Normal file
9
server/migrations/0005_crash_reports.sql
Normal file
@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS crash_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stack TEXT NOT NULL,
|
||||
build_version TEXT,
|
||||
wasm_version TEXT,
|
||||
user_agent TEXT,
|
||||
user_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
15
server/migrations/0006_cloud_storage.sql
Normal file
15
server/migrations/0006_cloud_storage.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- Cloud save files (BLOBs)
|
||||
CREATE TABLE IF NOT EXISTS user_saves (
|
||||
user_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, filename)
|
||||
);
|
||||
|
||||
-- Cloud config (isle.ini text)
|
||||
CREATE TABLE IF NOT EXISTS user_config (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
ini_text TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
1885
server/package-lock.json
generated
Normal file
1885
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
server/package.json
Normal file
18
server/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "isle-pizza-api",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev --port 8788",
|
||||
"deploy": "wrangler deploy",
|
||||
"db:migrate:local": "wrangler d1 migrations apply isle-pizza --local",
|
||||
"db:migrate:remote": "wrangler d1 migrations apply isle-pizza --remote"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-auth": "^1.5.0",
|
||||
"hono": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.0.0",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
29
server/src/account.ts
Normal file
29
server/src/account.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Env, Variables } from "./auth";
|
||||
|
||||
const account = new Hono<{ Bindings: Env; Variables: Variables }>();
|
||||
|
||||
// Delete account and all associated data
|
||||
account.delete("/", async (c) => {
|
||||
const session = c.get("session");
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
await c.env.DB.batch([
|
||||
c.env.DB.prepare("DELETE FROM user_saves WHERE user_id = ?").bind(userId),
|
||||
c.env.DB.prepare("DELETE FROM user_config WHERE user_id = ?").bind(userId),
|
||||
c.env.DB.prepare("DELETE FROM memory_completions WHERE user_id = ?").bind(userId),
|
||||
c.env.DB.prepare("UPDATE crash_reports SET user_id = NULL WHERE user_id = ?").bind(userId),
|
||||
c.env.DB.prepare('DELETE FROM "session" WHERE "userId" = ?').bind(userId),
|
||||
c.env.DB.prepare('DELETE FROM "account" WHERE "userId" = ?').bind(userId),
|
||||
c.env.DB.prepare('DELETE FROM "user" WHERE id = ?').bind(userId),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("Account deletion failed for user", userId, e);
|
||||
return c.json({ error: "Account deletion failed" }, 500);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export { account };
|
||||
51
server/src/auth.ts
Normal file
51
server/src/auth.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { anonymous } from "better-auth/plugins";
|
||||
|
||||
export type Variables = {
|
||||
session: { user: { id: string } };
|
||||
};
|
||||
|
||||
export type Env = {
|
||||
DB: D1Database;
|
||||
API_URL: string;
|
||||
BETTER_AUTH_SECRET: string;
|
||||
DISCORD_CLIENT_ID?: string;
|
||||
DISCORD_CLIENT_SECRET?: string;
|
||||
};
|
||||
|
||||
export function createAuth(env: Env) {
|
||||
return betterAuth({
|
||||
database: env.DB,
|
||||
baseURL: env.API_URL,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
plugins: [
|
||||
anonymous({
|
||||
onLinkAccount: async ({ anonymousUser, newUser }) => {
|
||||
// Transfer all data from anonymous to linked account
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
"UPDATE memory_completions SET user_id = ? WHERE user_id = ?"
|
||||
).bind(newUser.user.id, anonymousUser.user.id),
|
||||
env.DB.prepare(
|
||||
"UPDATE user_saves SET user_id = ? WHERE user_id = ?"
|
||||
).bind(newUser.user.id, anonymousUser.user.id),
|
||||
env.DB.prepare(
|
||||
"UPDATE user_config SET user_id = ? WHERE user_id = ?"
|
||||
).bind(newUser.user.id, anonymousUser.user.id),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
],
|
||||
socialProviders: {
|
||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||
? {
|
||||
discord: {
|
||||
clientId: env.DISCORD_CLIENT_ID,
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
trustedOrigins: ["http://localhost:5173", "http://localhost:3000", "https://isle.pizza", "https://dev.isle.pizza"],
|
||||
});
|
||||
}
|
||||
207
server/src/cloud.ts
Normal file
207
server/src/cloud.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Env, Variables } from "./auth";
|
||||
|
||||
interface SaveRow {
|
||||
filename: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface ConfigRow {
|
||||
ini_text: string;
|
||||
}
|
||||
|
||||
const VALID_SAVE_FILES = new Set([
|
||||
"G0.GS", "G1.GS", "G2.GS", "G3.GS", "G4.GS",
|
||||
"G5.GS", "G6.GS", "G7.GS", "G8.GS",
|
||||
"Players.gsi", "History.gsi",
|
||||
]);
|
||||
|
||||
const MAX_SAVE_SIZE = 100 * 1024; // 100 KB per file
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function computeHash(buffer: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(buffer));
|
||||
return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function decodeSave(data: string): ArrayBuffer | null {
|
||||
try {
|
||||
const buffer = base64ToArrayBuffer(data);
|
||||
return buffer.byteLength <= MAX_SAVE_SIZE ? buffer : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertConfig(db: D1Database, userId: string, config: string) {
|
||||
await db
|
||||
.prepare("INSERT OR REPLACE INTO user_config (user_id, ini_text) VALUES (?, ?)")
|
||||
.bind(userId, config.slice(0, 65536))
|
||||
.run();
|
||||
}
|
||||
|
||||
const cloud = new Hono<{ Bindings: Env; Variables: Variables }>();
|
||||
|
||||
// Helper: build a response entry that sends server data to the client
|
||||
async function serverDataEntry(filename: string, data: ArrayBuffer) {
|
||||
const hash = await computeHash(data);
|
||||
return { filename, data: arrayBufferToBase64(data), hash };
|
||||
}
|
||||
|
||||
// Helper: build a response entry that confirms a hash (no data transfer)
|
||||
function confirmEntry(filename: string, hash: string) {
|
||||
return { filename, data: null, hash };
|
||||
}
|
||||
|
||||
// Bidirectional save sync — per-file dirty-state merge
|
||||
cloud.post("/saves/sync", async (c) => {
|
||||
const session = c.get("session");
|
||||
|
||||
const body = await c.req.json<{
|
||||
files: Array<{
|
||||
filename: string;
|
||||
data: string | null;
|
||||
hash: string | null;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
if (!Array.isArray(body.files)) {
|
||||
return c.json({ error: "Invalid files array" }, 400);
|
||||
}
|
||||
|
||||
const existing = await c.env.DB.prepare(
|
||||
"SELECT filename, data FROM user_saves WHERE user_id = ?"
|
||||
).bind(session.user.id).all<SaveRow>();
|
||||
|
||||
const serverFiles = new Map(existing.results.map((r) => [r.filename, r.data]));
|
||||
const upserts: D1PreparedStatement[] = [];
|
||||
const stmt = c.env.DB.prepare(
|
||||
"INSERT OR REPLACE INTO user_saves (user_id, filename, data) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
const responseFiles = [];
|
||||
const clientFilenames = new Set<string>();
|
||||
|
||||
for (const { filename, data, hash } of body.files) {
|
||||
if (!VALID_SAVE_FILES.has(filename)) continue;
|
||||
clientFilenames.add(filename);
|
||||
const serverData = serverFiles.get(filename);
|
||||
|
||||
if (data) {
|
||||
// Client has dirty data — accept it
|
||||
const buffer = decodeSave(data);
|
||||
if (buffer) {
|
||||
upserts.push(stmt.bind(session.user.id, filename, buffer));
|
||||
responseFiles.push(confirmEntry(filename, await computeHash(buffer)));
|
||||
}
|
||||
} else if (serverData) {
|
||||
// Client is clean — send server data only if hashes differ
|
||||
const serverHash = await computeHash(serverData);
|
||||
responseFiles.push(hash === serverHash
|
||||
? confirmEntry(filename, serverHash)
|
||||
: await serverDataEntry(filename, serverData));
|
||||
}
|
||||
}
|
||||
|
||||
// Server-only files the client didn't mention
|
||||
for (const [filename, data] of serverFiles) {
|
||||
if (!clientFilenames.has(filename) && VALID_SAVE_FILES.has(filename)) {
|
||||
responseFiles.push(await serverDataEntry(filename, data));
|
||||
}
|
||||
}
|
||||
|
||||
if (upserts.length > 0) await c.env.DB.batch(upserts);
|
||||
return c.json({ files: responseFiles });
|
||||
});
|
||||
|
||||
// Incremental save upload (debounced during gameplay)
|
||||
cloud.post("/saves", async (c) => {
|
||||
const session = c.get("session");
|
||||
|
||||
const body = await c.req.json<{
|
||||
saves: Array<{ filename: string; data: string }>;
|
||||
}>();
|
||||
|
||||
if (!Array.isArray(body.saves) || body.saves.length === 0) {
|
||||
return c.json({ error: "No files provided" }, 400);
|
||||
}
|
||||
const files = body.saves;
|
||||
|
||||
const batch: D1PreparedStatement[] = [];
|
||||
const hashes: Array<{ filename: string; hash: string }> = [];
|
||||
const stmt = c.env.DB.prepare(
|
||||
"INSERT OR REPLACE INTO user_saves (user_id, filename, data) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const save of files) {
|
||||
if (!VALID_SAVE_FILES.has(save.filename)) continue;
|
||||
if (typeof save.data !== "string") continue;
|
||||
const buffer = decodeSave(save.data);
|
||||
if (!buffer) continue;
|
||||
batch.push(stmt.bind(session.user.id, save.filename, buffer));
|
||||
hashes.push({ filename: save.filename, hash: await computeHash(buffer) });
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
await c.env.DB.batch(batch);
|
||||
}
|
||||
|
||||
return c.json({ ok: true, hashes });
|
||||
});
|
||||
|
||||
// Sync config on login
|
||||
cloud.post("/config/sync", async (c) => {
|
||||
const session = c.get("session");
|
||||
|
||||
// Check if server has existing config
|
||||
const existing = await c.env.DB.prepare(
|
||||
"SELECT ini_text FROM user_config WHERE user_id = ?"
|
||||
)
|
||||
.bind(session.user.id)
|
||||
.first<ConfigRow>();
|
||||
|
||||
if (existing) {
|
||||
// Server has data — return it
|
||||
return c.json({ config: existing.ini_text });
|
||||
}
|
||||
|
||||
// Server has no data — store what client sends
|
||||
const body = await c.req.json<{ config: string }>();
|
||||
if (typeof body.config !== "string") {
|
||||
return c.json({ error: "Invalid config" }, 400);
|
||||
}
|
||||
|
||||
await upsertConfig(c.env.DB, session.user.id, body.config);
|
||||
return c.json({ config: null });
|
||||
});
|
||||
|
||||
// Upload config (incremental)
|
||||
cloud.post("/config", async (c) => {
|
||||
const session = c.get("session");
|
||||
const body = await c.req.json<{ config: string }>();
|
||||
if (typeof body.config !== "string") {
|
||||
return c.json({ error: "Invalid config" }, 400);
|
||||
}
|
||||
|
||||
await upsertConfig(c.env.DB, session.user.id, body.config);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export { cloud };
|
||||
44
server/src/crashes.ts
Normal file
44
server/src/crashes.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Hono } from "hono";
|
||||
import { createAuth, type Env } from "./auth";
|
||||
|
||||
type Variables = {
|
||||
session: { user: { id: string } };
|
||||
};
|
||||
|
||||
export const crashes = new Hono<{ Bindings: Env; Variables: Variables }>();
|
||||
|
||||
crashes.post("/", async (c) => {
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
const { stack, buildVersion, wasmVersion } = body;
|
||||
|
||||
if (!stack || typeof stack !== "string") {
|
||||
return c.json({ error: "stack is required" }, 400);
|
||||
}
|
||||
|
||||
const stk = stack.slice(0, 8192);
|
||||
const bv = typeof buildVersion === "string" ? buildVersion.slice(0, 40) : null;
|
||||
const wv = typeof wasmVersion === "string" ? wasmVersion.slice(0, 40) : null;
|
||||
const ua = (c.req.header("user-agent") || "").slice(0, 512);
|
||||
|
||||
// Try to get user_id from session (no auth required — crash reports are anonymous by default)
|
||||
let userId: string | null = null;
|
||||
try {
|
||||
const auth = createAuth(c.env);
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
userId = session?.user?.id || null;
|
||||
} catch {}
|
||||
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO crash_reports (stack, build_version, wasm_version, user_agent, user_id) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(stk, bv, wv, ua, userId)
|
||||
.run();
|
||||
|
||||
return c.body(null, 204);
|
||||
});
|
||||
99
server/src/index.ts
Normal file
99
server/src/index.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { Hono, type Context, type Next } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { createAuth, type Env, type Variables } from "./auth";
|
||||
import { memories } from "./memories";
|
||||
import { crashes } from "./crashes";
|
||||
import { account } from "./account";
|
||||
import { cloud } from "./cloud";
|
||||
|
||||
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
|
||||
|
||||
// CORS for frontend
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: ["http://localhost:5173", "http://localhost:3000", "https://isle.pizza", "https://dev.isle.pizza"],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Health check
|
||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
|
||||
// better-auth handles all /api/auth/* routes
|
||||
app.all("/api/auth/*", async (c) => {
|
||||
const auth = createAuth(c.env);
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
|
||||
// Public endpoint: look up a memory completion by eventId (no auth needed)
|
||||
app.get("/api/memory/:eventId", async (c) => {
|
||||
const eventId = c.req.param("eventId");
|
||||
if (!eventId || eventId.length > 16) {
|
||||
return c.json({ error: "Invalid eventId" }, 400);
|
||||
}
|
||||
|
||||
const result = await c.env.DB.prepare(
|
||||
"SELECT anim_index, event_id, completed_at, participants, language FROM memory_completions WHERE event_id = ? LIMIT 1"
|
||||
)
|
||||
.bind(eventId)
|
||||
.first<{
|
||||
anim_index: number;
|
||||
event_id: string;
|
||||
completed_at: number;
|
||||
participants: string;
|
||||
language: string;
|
||||
}>();
|
||||
|
||||
if (!result) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
let participants: unknown[];
|
||||
try {
|
||||
participants = JSON.parse(result.participants || "[]");
|
||||
} catch {
|
||||
participants = [];
|
||||
}
|
||||
|
||||
return c.json({
|
||||
animIndex: result.anim_index,
|
||||
eventId: result.event_id,
|
||||
completedAt: result.completed_at,
|
||||
participants,
|
||||
language: result.language,
|
||||
});
|
||||
});
|
||||
|
||||
// Auth middleware for protected routes
|
||||
const authMiddleware = async (c: Context<{ Bindings: Env; Variables: Variables }>, next: Next) => {
|
||||
const auth = createAuth(c.env);
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
c.set("session", session);
|
||||
await next();
|
||||
};
|
||||
|
||||
// Auth-protected memory routes
|
||||
app.use("/api/memories", authMiddleware);
|
||||
app.use("/api/memories/*", authMiddleware);
|
||||
app.route("/api/memories", memories);
|
||||
|
||||
// Account management (delete account)
|
||||
app.use("/api/account", authMiddleware);
|
||||
app.route("/api/account", account);
|
||||
|
||||
// Cloud sync routes (all auth-protected)
|
||||
app.use("/api/cloud/*", authMiddleware);
|
||||
app.route("/api/cloud", cloud);
|
||||
|
||||
// Crash reporting (no auth required)
|
||||
app.route("/api/crash", crashes);
|
||||
|
||||
export default app;
|
||||
158
server/src/memories.ts
Normal file
158
server/src/memories.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Env } from "./auth";
|
||||
|
||||
type AuthSession = {
|
||||
user: { id: string };
|
||||
};
|
||||
|
||||
type Variables = {
|
||||
session: AuthSession;
|
||||
};
|
||||
|
||||
/** Row shape returned by queries on memory_completions */
|
||||
interface CompletionRow {
|
||||
user_id: string;
|
||||
anim_index: number;
|
||||
event_id: string;
|
||||
completed_at: number;
|
||||
participants: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const VALID_LANGUAGES = new Set([
|
||||
"da", "el", "en", "fr", "de", "it", "jp", "ko", "pt", "ru", "es",
|
||||
]);
|
||||
|
||||
function isValidLanguage(lang: unknown): lang is string {
|
||||
return typeof lang === "string" && VALID_LANGUAGES.has(lang);
|
||||
}
|
||||
|
||||
function getUserCompletions(db: D1Database, userId: string) {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT anim_index, event_id, completed_at, participants, language FROM memory_completions WHERE user_id = ? ORDER BY completed_at DESC"
|
||||
)
|
||||
.bind(userId)
|
||||
.all<CompletionRow>();
|
||||
}
|
||||
|
||||
function isValidCompletion(c: {
|
||||
animIndex: unknown;
|
||||
eventId: unknown;
|
||||
language: unknown;
|
||||
}): boolean {
|
||||
if (
|
||||
typeof c.animIndex !== "number" ||
|
||||
!Number.isInteger(c.animIndex) ||
|
||||
c.animIndex < 0
|
||||
)
|
||||
return false;
|
||||
if (typeof c.eventId !== "string" || c.eventId.length > 16) return false;
|
||||
if (!isValidLanguage(c.language)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Auth-protected memory routes (mounted behind auth middleware) */
|
||||
const memories = new Hono<{ Bindings: Env; Variables: Variables }>();
|
||||
|
||||
// Record a single completion
|
||||
memories.post("/", async (c) => {
|
||||
const session = c.get("session");
|
||||
const body = await c.req.json<{
|
||||
animIndex: number;
|
||||
eventId: string;
|
||||
participants: Array<{ charIndex: number; displayName: string }>;
|
||||
language: string;
|
||||
}>();
|
||||
|
||||
if (
|
||||
!isValidCompletion(body) ||
|
||||
!Array.isArray(body.participants) ||
|
||||
body.participants.length === 0
|
||||
) {
|
||||
return c.json({ error: "Invalid completion data" }, 400);
|
||||
}
|
||||
|
||||
const participantsJson = JSON.stringify(body.participants);
|
||||
|
||||
await c.env.DB.prepare(
|
||||
"INSERT OR IGNORE INTO memory_completions (user_id, anim_index, event_id, completed_at, participants, language) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(
|
||||
session.user.id,
|
||||
body.animIndex,
|
||||
body.eventId,
|
||||
Math.floor(Date.now() / 1000),
|
||||
participantsJson,
|
||||
body.language
|
||||
)
|
||||
.run();
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// Bulk import from IndexedDB (sync on login)
|
||||
memories.post("/sync", async (c) => {
|
||||
const session = c.get("session");
|
||||
const body = await c.req.json<{
|
||||
completions: Array<{
|
||||
animIndex: number;
|
||||
eventId: string;
|
||||
t: number;
|
||||
participants?: Array<{ charIndex: number; displayName: string }>;
|
||||
language: string;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
if (!Array.isArray(body.completions)) {
|
||||
return c.json({ error: "Invalid completions array" }, 400);
|
||||
}
|
||||
|
||||
const stmt = c.env.DB.prepare(
|
||||
"INSERT OR IGNORE INTO memory_completions (user_id, anim_index, event_id, completed_at, participants, language) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
// Find existing event_ids for this user to avoid duplicates
|
||||
const existingEvents = await c.env.DB.prepare(
|
||||
"SELECT event_id FROM memory_completions WHERE user_id = ?"
|
||||
)
|
||||
.bind(session.user.id)
|
||||
.all<Pick<CompletionRow, "event_id">>();
|
||||
const existingSet = new Set(
|
||||
existingEvents.results.map((r) => r.event_id)
|
||||
);
|
||||
|
||||
// Insert new completions (deduplicate by event_id)
|
||||
const batch: D1PreparedStatement[] = [];
|
||||
for (const completion of body.completions) {
|
||||
if (!isValidCompletion(completion)) continue;
|
||||
if (existingSet.has(completion.eventId)) continue;
|
||||
if (
|
||||
!Array.isArray(completion.participants) ||
|
||||
completion.participants.length === 0
|
||||
)
|
||||
continue;
|
||||
|
||||
batch.push(
|
||||
stmt.bind(
|
||||
session.user.id,
|
||||
completion.animIndex,
|
||||
completion.eventId,
|
||||
completion.t || Math.floor(Date.now() / 1000),
|
||||
JSON.stringify(completion.participants),
|
||||
completion.language
|
||||
)
|
||||
);
|
||||
existingSet.add(completion.eventId);
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
await c.env.DB.batch(batch);
|
||||
}
|
||||
|
||||
// Return full merged set
|
||||
const merged = await getUserCompletions(c.env.DB, session.user.id);
|
||||
return c.json({ completions: merged.results });
|
||||
});
|
||||
|
||||
export { memories };
|
||||
11
server/tsconfig.json
Normal file
11
server/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
27
server/wrangler.toml
Normal file
27
server/wrangler.toml
Normal file
@ -0,0 +1,27 @@
|
||||
name = "isle-pizza-api"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-03-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "isle-pizza"
|
||||
database_id = "local"
|
||||
migrations_dir = "migrations"
|
||||
|
||||
[vars]
|
||||
API_URL = "http://localhost:8788"
|
||||
|
||||
[env.production]
|
||||
name = "isle-api"
|
||||
workers_dev = false
|
||||
preview_urls = false
|
||||
|
||||
[[env.production.d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "isle-pizza"
|
||||
database_id = "23b0f585-fa7a-4c35-8684-428aac9b5f89"
|
||||
migrations_dir = "migrations"
|
||||
|
||||
[env.production.vars]
|
||||
API_URL = "https://api.isle.pizza"
|
||||
4
site.config.js
Normal file
4
site.config.js
Normal file
@ -0,0 +1,4 @@
|
||||
export default {
|
||||
relayUrl: process.env.RELAY_URL || 'ws://localhost:8787',
|
||||
apiUrl: process.env.API_URL || 'http://localhost:8788',
|
||||
};
|
||||
@ -1,20 +1,32 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
|
||||
import { currentPage, debugEnabled } from './stores.js';
|
||||
import { registerServiceWorker, checkCacheStatus } from './core/service-worker.js';
|
||||
import { currentPage, debugEnabled, gameRunning, multiplayerRoom, scenePlayerEventId, scenePlayerData, matchPathRoute, parseRoute, tryDecodeSceneData, initialInvalidRoom } from './stores.js';
|
||||
import { showToast } from './core/toast.js';
|
||||
import { registerServiceWorker, checkCacheStatus, requestPersistentStorage } from './core/service-worker.js';
|
||||
import { setupCanvasEvents } from './core/emscripten.js';
|
||||
import { initMemories } from './core/memories.js';
|
||||
import { initCloudSync } from './core/cloud-sync.js';
|
||||
import { initAuth } from './core/auth.js';
|
||||
import { initThumbnails } from './core/thumbnails.js';
|
||||
import TopContent from './lib/TopContent.svelte';
|
||||
import AccountIndicator from './lib/AccountIndicator.svelte';
|
||||
import MemoriesPage from './lib/MemoriesPage.svelte';
|
||||
import Controls from './lib/Controls.svelte';
|
||||
import ReadMePage from './lib/ReadMePage.svelte';
|
||||
import ConfigurePage from './lib/ConfigurePage.svelte';
|
||||
import FreeStuffPage from './lib/FreeStuffPage.svelte';
|
||||
import SaveEditorPage from './lib/SaveEditorPage.svelte';
|
||||
import MultiplayerPage from './lib/MultiplayerPage.svelte';
|
||||
import UpdatePopup from './lib/UpdatePopup.svelte';
|
||||
import GoodbyePopup from './lib/GoodbyePopup.svelte';
|
||||
import ConfigToast from './lib/ConfigToast.svelte';
|
||||
import DebugPanel from './lib/DebugPanel.svelte';
|
||||
import ScenePlayerPage from './lib/ScenePlayerPage.svelte';
|
||||
import MultiplayerOverlay from './lib/multiplayer/MultiplayerOverlay.svelte';
|
||||
import WhatsNewBanner from './lib/WhatsNewBanner.svelte';
|
||||
import CanvasWrapper from './lib/CanvasWrapper.svelte';
|
||||
import CrashOverlay from './lib/CrashOverlay.svelte';
|
||||
|
||||
async function positionTooltip(trigger) {
|
||||
const tooltip = trigger.querySelector('.tooltip-content');
|
||||
@ -87,28 +99,70 @@
|
||||
checkCacheStatus();
|
||||
}
|
||||
|
||||
// Request persistent storage to protect OPFS data from browser eviction
|
||||
requestPersistentStorage();
|
||||
|
||||
// Setup canvas events
|
||||
setupCanvasEvents();
|
||||
|
||||
// Initialize memory persistence (IndexedDB), cloud sync, auth, and building thumbnails
|
||||
initMemories();
|
||||
initCloudSync();
|
||||
initAuth();
|
||||
initThumbnails();
|
||||
|
||||
// Setup global tooltip positioning
|
||||
setupTooltips();
|
||||
|
||||
// Initialize history state based on current page
|
||||
const initialPath = window.location.pathname;
|
||||
const initialHash = window.location.hash;
|
||||
if (initialHash) {
|
||||
// Set up proper history state for the current hash
|
||||
const state = { page: $currentPage };
|
||||
const pathRoute = matchPathRoute(initialPath);
|
||||
|
||||
if (pathRoute) {
|
||||
Object.assign(state, pathRoute);
|
||||
} else if (initialHash.startsWith('#r/') && $multiplayerRoom) {
|
||||
state.room = $multiplayerRoom;
|
||||
}
|
||||
|
||||
if (initialInvalidRoom) {
|
||||
history.replaceState(state, '', '#multiplayer');
|
||||
} else if (pathRoute) {
|
||||
history.replaceState(state, '', initialPath);
|
||||
} else if (initialHash) {
|
||||
history.replaceState({ page: 'main' }, '', window.location.pathname);
|
||||
history.pushState({ page: $currentPage }, '', initialHash);
|
||||
history.pushState({ ...state, fromApp: true }, '', initialHash);
|
||||
} else {
|
||||
history.replaceState({ page: 'main' }, '', window.location.pathname);
|
||||
history.replaceState(state, '', window.location.pathname);
|
||||
}
|
||||
|
||||
// Show error toast if initial URL had an invalid room
|
||||
if (initialInvalidRoom) {
|
||||
showToast('Invalid island URL', { error: true, duration: 3000 });
|
||||
}
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('popstate', (e) => {
|
||||
if (e.state && e.state.page && e.state.page !== 'main') {
|
||||
if (e.state && e.state.page === 'multiplayer') {
|
||||
multiplayerRoom.set(e.state.room || null);
|
||||
currentPage.set('multiplayer');
|
||||
} else if (e.state && e.state.page === 'scene-player') {
|
||||
scenePlayerEventId.set(e.state.eventId || null);
|
||||
scenePlayerData.set(tryDecodeSceneData(e.state.sceneData));
|
||||
currentPage.set('scene-player');
|
||||
} else if (e.state && e.state.page && e.state.page !== 'main') {
|
||||
currentPage.set(e.state.page);
|
||||
} else {
|
||||
currentPage.set('main');
|
||||
// No state (e.g. URL pasted in address bar) — parse route from URL
|
||||
const result = parseRoute();
|
||||
multiplayerRoom.set(result.room);
|
||||
if (result.eventId) scenePlayerEventId.set(result.eventId);
|
||||
scenePlayerData.set(tryDecodeSceneData(result.sceneData));
|
||||
currentPage.set(result.page);
|
||||
if (result.invalidRoom) {
|
||||
showToast('Invalid island URL', { error: true, duration: 3000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -122,10 +176,15 @@
|
||||
<source src="audio/install.mp3" type="audio/mpeg">
|
||||
</audio>
|
||||
|
||||
<WhatsNewBanner />
|
||||
<GoodbyePopup />
|
||||
<UpdatePopup />
|
||||
<ConfigToast />
|
||||
|
||||
{#if !$gameRunning}
|
||||
<AccountIndicator />
|
||||
{/if}
|
||||
|
||||
<main id="main-container">
|
||||
<div class="page-wrapper" class:active={$currentPage === 'main'}>
|
||||
<TopContent />
|
||||
@ -143,7 +202,15 @@
|
||||
<div class="page-wrapper" class:active={$currentPage === 'save-editor'}>
|
||||
<SaveEditorPage />
|
||||
</div>
|
||||
|
||||
<div class="page-wrapper" class:active={$currentPage === 'multiplayer'}>
|
||||
<MultiplayerPage />
|
||||
</div>
|
||||
<div class="page-wrapper" class:active={$currentPage === 'memories'}>
|
||||
<MemoriesPage />
|
||||
</div>
|
||||
<div class="page-wrapper" class:active={$currentPage === 'scene-player'}>
|
||||
<ScenePlayerPage />
|
||||
</div>
|
||||
<div class="footer-disclaimer">
|
||||
<p>LEGO® and LEGO Island™ are trademarks of The LEGO Group.</p>
|
||||
<p>This is an unofficial fan project and is not affiliated with or endorsed by The LEGO Group.</p>
|
||||
@ -151,7 +218,7 @@
|
||||
|
||||
<div class="app-footer">
|
||||
{#if __BUILD_TIME__}
|
||||
<p>Last updated: {__BUILD_TIME__}</p>
|
||||
<p>Last updated: {__BUILD_TIME__}{#if __BUILD_VERSION__} ({__BUILD_VERSION__}){/if}</p>
|
||||
{:else}
|
||||
<p><strong>DEVELOPMENT MODE</strong></p>
|
||||
{/if}
|
||||
@ -159,6 +226,9 @@
|
||||
</main>
|
||||
|
||||
<CanvasWrapper />
|
||||
<CrashOverlay />
|
||||
|
||||
<MultiplayerOverlay />
|
||||
|
||||
{#if $debugEnabled}
|
||||
<DebugPanel />
|
||||
|
||||
969
src/app.css
969
src/app.css
File diff suppressed because it is too large
Load Diff
208
src/core/animation/keyframeEval.js
Normal file
208
src/core/animation/keyframeEval.js
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Shared keyframe evaluation functions for animation playback.
|
||||
*
|
||||
* Mirrors the backend's keyframe evaluation pipeline from legoanim.cpp:
|
||||
* FindKeys, GetRotation, GetTranslation, GetScale, CreateLocalTransform
|
||||
*
|
||||
* Used by both ScenePlayerRenderer (direct per-frame matrix application)
|
||||
* and AnimatedRenderer (track-based animation via AnimationMixer).
|
||||
*/
|
||||
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { Quat } from 'ogl/src/math/Quat.js';
|
||||
import { Mat4 } from 'ogl/src/math/Mat4.js';
|
||||
|
||||
/**
|
||||
* Locate keyframe bracket for a given time.
|
||||
* Mirrors FindKeys in legoanim.cpp:960.
|
||||
*
|
||||
* @param {Array<{time: number}>} keys - Keyframe array (sorted by time)
|
||||
* @param {number} time - Current time in ms
|
||||
* @returns {{ n: number, i: number }}
|
||||
* n=0: no applicable key (before first or empty)
|
||||
* n=1: use single key at index i
|
||||
* n=2: interpolate between keys[i] and keys[i+1]
|
||||
*/
|
||||
export function findKeys(keys, time) {
|
||||
if (keys.length === 0) return { n: 0 };
|
||||
if (time < keys[0].time) return { n: 0 };
|
||||
if (time > keys[keys.length - 1].time) return { n: 1, i: keys.length - 1 };
|
||||
|
||||
let idx = 0;
|
||||
for (let j = 0; j < keys.length - 1; j++) {
|
||||
if (time >= keys[j + 1].time) {
|
||||
idx = j + 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (time === keys[idx].time) return { n: 1, i: idx };
|
||||
if (idx < keys.length - 1) return { n: 2, i: idx };
|
||||
return { n: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate rotation keyframes at a given time.
|
||||
* Mirrors GetRotation in legoanim.cpp:843.
|
||||
*
|
||||
* Negates quaternion X to match the vertex X-negation in BaseRenderer geometry building.
|
||||
*
|
||||
* @param {Array<{time, flags, x, y, z, w}>} keys - Rotation keyframes (quaternion)
|
||||
* @param {number} time - Current time in ms
|
||||
* @returns {Mat4} Rotation matrix
|
||||
*/
|
||||
export function evaluateRotation(keys, time) {
|
||||
const r = findKeys(keys, time);
|
||||
const toQuat = (k) => new Quat(-k.x, k.y, k.z, k.w);
|
||||
|
||||
if (r.n === 0) return new Mat4();
|
||||
|
||||
if (r.n === 1) {
|
||||
// c_active flag (0x01)
|
||||
return (keys[r.i].flags & 1)
|
||||
? new Mat4().fromQuaternion(toQuat(keys[r.i]))
|
||||
: new Mat4();
|
||||
}
|
||||
|
||||
// n === 2: interpolate between keys[i] and keys[i+1]
|
||||
const before = keys[r.i];
|
||||
const after = keys[r.i + 1];
|
||||
|
||||
if ((before.flags & 1) || (after.flags & 1)) {
|
||||
const beforeQ = toQuat(before);
|
||||
|
||||
// c_skipInterpolation flag (0x04)
|
||||
if (after.flags & 4) {
|
||||
return new Mat4().fromQuaternion(beforeQ);
|
||||
}
|
||||
|
||||
const afterQ = toQuat(after);
|
||||
|
||||
// c_negateRotation flag (0x02)
|
||||
if (after.flags & 2) {
|
||||
afterQ.set(-afterQ[0], -afterQ[1], -afterQ[2], -afterQ[3]);
|
||||
}
|
||||
|
||||
const t = (time - before.time) / (after.time - before.time);
|
||||
const result = new Quat().copy(beforeQ).slerp(afterQ, t);
|
||||
return new Mat4().fromQuaternion(result);
|
||||
}
|
||||
|
||||
return new Mat4();
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate translation keyframes at a given time.
|
||||
* Mirrors GetTranslation in legoanim.cpp:779.
|
||||
*
|
||||
* Negates X to match the vertex X-negation in BaseRenderer geometry building.
|
||||
* Strictly checks the IsActive flag (0x01), matching the reference implementation.
|
||||
*
|
||||
* @param {Array<{time, flags, x, y, z}>} keys - Translation keyframes
|
||||
* @param {number} time - Current time in ms
|
||||
* @returns {Vec3|null} Translation vector, or null if inactive
|
||||
*/
|
||||
export function evaluateTranslation(keys, time) {
|
||||
const r = findKeys(keys, time);
|
||||
|
||||
if (r.n === 0) return null;
|
||||
|
||||
if (r.n === 1) {
|
||||
if (!(keys[r.i].flags & 1)) return null;
|
||||
return new Vec3(-keys[r.i].x, keys[r.i].y, keys[r.i].z);
|
||||
}
|
||||
|
||||
// n === 2: interpolate
|
||||
const before = keys[r.i];
|
||||
const after = keys[r.i + 1];
|
||||
if (!(before.flags & 1) && !(after.flags & 1)) return null;
|
||||
|
||||
const t = (time - before.time) / (after.time - before.time);
|
||||
return new Vec3(
|
||||
-(before.x + t * (after.x - before.x)),
|
||||
before.y + t * (after.y - before.y),
|
||||
before.z + t * (after.z - before.z),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate scale keyframes at a given time.
|
||||
* Mirrors GetScale in legoanim.cpp:906.
|
||||
*
|
||||
* No IsActive check — the reference implementation does not check it for scale keys.
|
||||
*
|
||||
* @param {Array<{time, flags, x, y, z}>} keys - Scale keyframes
|
||||
* @param {number} time - Current time in ms
|
||||
* @returns {Vec3|null} Scale vector, or null if no applicable key
|
||||
*/
|
||||
export function evaluateScale(keys, time) {
|
||||
const r = findKeys(keys, time);
|
||||
|
||||
if (r.n === 0) return null;
|
||||
|
||||
if (r.n === 1) {
|
||||
return new Vec3(keys[r.i].x, keys[r.i].y, keys[r.i].z);
|
||||
}
|
||||
|
||||
// n === 2: interpolate
|
||||
const before = keys[r.i];
|
||||
const after = keys[r.i + 1];
|
||||
const t = (time - before.time) / (after.time - before.time);
|
||||
return new Vec3(
|
||||
before.x + t * (after.x - before.x),
|
||||
before.y + t * (after.y - before.y),
|
||||
before.z + t * (after.z - before.z),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a local transform matrix from a node's keyframes at the given time.
|
||||
* Mirrors CreateLocalTransform in legoanim.cpp:742.
|
||||
*
|
||||
* Order: Scale -> Rotation * Scale -> + Translation
|
||||
*
|
||||
* @param {{ translationKeys, rotationKeys, scaleKeys }} nodeData
|
||||
* @param {number} time - Current time in ms
|
||||
* @returns {Mat4}
|
||||
*/
|
||||
export function evaluateLocalTransform(nodeData, time) {
|
||||
let mat = new Mat4();
|
||||
|
||||
if (nodeData.scaleKeys.length) {
|
||||
const s = evaluateScale(nodeData.scaleKeys, time);
|
||||
if (s) mat.scale(s);
|
||||
|
||||
if (nodeData.rotationKeys.length) {
|
||||
mat = evaluateRotation(nodeData.rotationKeys, time).multiply(mat);
|
||||
}
|
||||
} else if (nodeData.rotationKeys.length) {
|
||||
mat = evaluateRotation(nodeData.rotationKeys, time);
|
||||
}
|
||||
|
||||
if (nodeData.translationKeys.length) {
|
||||
const v = evaluateTranslation(nodeData.translationKeys, time);
|
||||
if (v) {
|
||||
mat[12] += v[0];
|
||||
mat[13] += v[1];
|
||||
mat[14] += v[2];
|
||||
}
|
||||
}
|
||||
|
||||
return mat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate visibility from morph keyframes at the given time.
|
||||
* Mirrors GetVisibility in legoanim.cpp:937.
|
||||
*
|
||||
* Uses step interpolation: returns the last key value at or before the given time.
|
||||
*
|
||||
* @param {Array<{time, visible: boolean}>} morphKeys
|
||||
* @param {number} time - Current time in ms
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function getVisibility(morphKeys, time) {
|
||||
const r = findKeys(morphKeys, time);
|
||||
return (r.n === 0) ? true : morphKeys[r.i].visible;
|
||||
}
|
||||
24
src/core/animation/stringUtils.js
Normal file
24
src/core/animation/stringUtils.js
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Shared string utilities for animation and model name handling.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip trailing LOD suffix (digits and underscores) from a model name.
|
||||
* Mirrors Multiplayer::TrimLODSuffix from isle-portable.
|
||||
* Example: "bird01" -> "bird", "jail_01" -> "jail"
|
||||
*/
|
||||
export function trimLODSuffix(name) {
|
||||
let s = name;
|
||||
while (s.length > 1 && (s[s.length - 1] >= '0' && s[s.length - 1] <= '9' || s[s.length - 1] === '_')) {
|
||||
s = s.slice(0, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip leading '*' prefix from animation actor names.
|
||||
* In the backend, '*' marks actors resolved against extra ROIs rather than the root.
|
||||
*/
|
||||
export function stripStar(name) {
|
||||
return name.startsWith('*') ? name.slice(1) : name;
|
||||
}
|
||||
61
src/core/auth.js
Normal file
61
src/core/auth.js
Normal file
@ -0,0 +1,61 @@
|
||||
// Authentication client for isle.pizza API (better-auth)
|
||||
import { createAuthClient } from 'better-auth/client';
|
||||
import { anonymousClient } from 'better-auth/client/plugins';
|
||||
import { writable } from 'svelte/store';
|
||||
import { API_URL } from './config.js';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: API_URL,
|
||||
plugins: [
|
||||
anonymousClient(),
|
||||
],
|
||||
fetchOptions: {
|
||||
credentials: 'include'
|
||||
}
|
||||
});
|
||||
|
||||
// Reactive session store: undefined = loading, null = logged out, object = logged in
|
||||
export const authSession = writable(undefined);
|
||||
|
||||
// Promise that resolves with the initial session once the auth check completes.
|
||||
// Any module that needs "do X when auth is ready" can `await authReady` instead
|
||||
// of racing with initAuth() or using coordination flags.
|
||||
let resolveAuthReady;
|
||||
export const authReady = new Promise(resolve => { resolveAuthReady = resolve; });
|
||||
|
||||
// Check session on load
|
||||
export async function initAuth() {
|
||||
try {
|
||||
const session = await authClient.getSession();
|
||||
const data = session?.data || null;
|
||||
authSession.set(data);
|
||||
resolveAuthReady(data);
|
||||
} catch (e) {
|
||||
// Not logged in or server unavailable
|
||||
authSession.set(null);
|
||||
resolveAuthReady(null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signInWithDiscord() {
|
||||
await authClient.signIn.social({ provider: 'discord', callbackURL: window.location.origin });
|
||||
}
|
||||
|
||||
export async function signInAnonymously() {
|
||||
const result = await authClient.signIn.anonymous();
|
||||
if (result?.data) {
|
||||
authSession.set(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
// Set session to null first so subscribers (e.g. memories.js) can
|
||||
// react immediately, even if the server call fails.
|
||||
authSession.set(null);
|
||||
try {
|
||||
await authClient.signOut();
|
||||
} catch (e) {
|
||||
// Session is already cleared locally; server-side cleanup is best-effort
|
||||
console.warn('[Auth] signOut request failed:', e);
|
||||
}
|
||||
}
|
||||
14
src/core/base64.js
Normal file
14
src/core/base64.js
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* URL-safe base64 encoding/decoding (RFC 4648 §5).
|
||||
* Standard btoa() produces +, /, = which break in URL path segments.
|
||||
*/
|
||||
|
||||
export function toUrlSafeBase64(str) {
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function fromUrlSafeBase64(encoded) {
|
||||
let s = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
return atob(s);
|
||||
}
|
||||
295
src/core/cloud-sync.js
Normal file
295
src/core/cloud-sync.js
Normal file
@ -0,0 +1,295 @@
|
||||
// Cloud sync for save files and config
|
||||
import { authSession, authReady } from './auth.js';
|
||||
import { API_URL } from './config.js';
|
||||
import { readBinaryFile, writeBinaryFile, writeTextFile, getFileHandle, getOpfsRoot, CONFIG_FILE } from './opfs.js';
|
||||
import { PLAYERS_FILE, HISTORY_FILE, getSaveFileName } from './savegame/constants.js';
|
||||
import { clearPlayersCache } from './savegame/index.js';
|
||||
import { configVersion, savesVersion } from '../stores.js';
|
||||
|
||||
// All save files we sync
|
||||
const SAVE_FILES = [
|
||||
...Array.from({ length: 9 }, (_, i) => getSaveFileName(i)),
|
||||
PLAYERS_FILE,
|
||||
HISTORY_FILE,
|
||||
];
|
||||
|
||||
let currentSession = null;
|
||||
let opfsAvailable = false;
|
||||
|
||||
// --- Sync state (localStorage) ---
|
||||
|
||||
const SYNC_STATE_KEY = 'isle-cloud-sync';
|
||||
|
||||
function loadSyncState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SYNC_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw);
|
||||
return { synced: state.synced || {}, dirty: new Set(state.dirty || []) };
|
||||
}
|
||||
} catch {}
|
||||
return { synced: {}, dirty: new Set() };
|
||||
}
|
||||
|
||||
function saveSyncState(state) {
|
||||
try {
|
||||
localStorage.setItem(SYNC_STATE_KEY, JSON.stringify({
|
||||
synced: state.synced,
|
||||
dirty: [...state.dirty],
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let syncState = loadSyncState();
|
||||
|
||||
function markDirty(filename) {
|
||||
syncState.dirty.add(filename);
|
||||
saveSyncState(syncState);
|
||||
}
|
||||
|
||||
function markClean(filename, hash) {
|
||||
syncState.dirty.delete(filename);
|
||||
syncState.synced[filename] = hash;
|
||||
saveSyncState(syncState);
|
||||
}
|
||||
|
||||
function trackWrite(filename) {
|
||||
markDirty(filename);
|
||||
if (currentSession) {
|
||||
pendingFiles.add(filename);
|
||||
scheduleSaveUpload();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hashing ---
|
||||
|
||||
async function hashBuffer(buffer) {
|
||||
const hash = await crypto.subtle.digest('SHA-256', buffer);
|
||||
return Array.from(new Uint8Array(hash), b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
export async function initCloudSync() {
|
||||
opfsAvailable = !!(await getOpfsRoot());
|
||||
if (!opfsAvailable) return;
|
||||
|
||||
currentSession = await authReady;
|
||||
if (currentSession) {
|
||||
await syncOnLogin();
|
||||
}
|
||||
|
||||
authSession.subscribe(session => {
|
||||
if (session === undefined) return;
|
||||
const wasLoggedIn = !!currentSession;
|
||||
const nowLoggedIn = !!session;
|
||||
currentSession = session;
|
||||
if (wasLoggedIn === nowLoggedIn) return;
|
||||
if (nowLoggedIn) {
|
||||
syncOnLogin();
|
||||
} else {
|
||||
// On logout, cancel pending uploads and clear sync state
|
||||
if (uploadTimer) clearTimeout(uploadTimer);
|
||||
uploadTimer = null;
|
||||
pendingFiles.clear();
|
||||
syncState = { synced: {}, dirty: new Set() };
|
||||
saveSyncState(syncState);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('opfs-save-slot-written', (e) => {
|
||||
trackWrite(getSaveFileName(e.detail.slot));
|
||||
trackWrite(HISTORY_FILE);
|
||||
});
|
||||
|
||||
window.addEventListener('opfs-save-state-changed', () => {
|
||||
for (const f of SAVE_FILES) trackWrite(f);
|
||||
});
|
||||
|
||||
window.addEventListener('opfs-save-file-written', (e) => {
|
||||
trackWrite(e.detail.filename);
|
||||
});
|
||||
|
||||
// Listen for config writes
|
||||
window.addEventListener('opfs-config-written', (e) => {
|
||||
if (currentSession) {
|
||||
uploadConfig(e.detail.iniText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function syncOnLogin() {
|
||||
await syncSaves().catch(e => console.warn('[CloudSync] Save sync failed:', e));
|
||||
await syncConfig().catch(e => console.warn('[CloudSync] Config sync failed:', e));
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// --- Save sync (bidirectional, hash-based) ---
|
||||
|
||||
async function syncSaves() {
|
||||
const files = [];
|
||||
|
||||
for (const filename of SAVE_FILES) {
|
||||
const data = await readBinaryFile(filename);
|
||||
const localHash = data ? await hashBuffer(data) : null;
|
||||
const syncedHash = syncState.synced[filename] || null;
|
||||
const isDirty = localHash !== null && (localHash !== syncedHash || syncState.dirty.has(filename));
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
data: isDirty ? arrayBufferToBase64(data) : null,
|
||||
hash: localHash,
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_URL}/api/cloud/saves/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ files }),
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const { files: responseFiles } = await res.json();
|
||||
if (!Array.isArray(responseFiles)) return;
|
||||
|
||||
let wroteAnyFile = false;
|
||||
|
||||
for (const serverFile of responseFiles) {
|
||||
if (serverFile.data) {
|
||||
// Server sent data — write to OPFS
|
||||
try {
|
||||
const buffer = base64ToArrayBuffer(serverFile.data);
|
||||
await writeBinaryFile(serverFile.filename, buffer, true);
|
||||
if (serverFile.filename === PLAYERS_FILE) clearPlayersCache();
|
||||
markClean(serverFile.filename, serverFile.hash);
|
||||
wroteAnyFile = true;
|
||||
} catch (e) {
|
||||
console.warn('[CloudSync] Failed to write save file:', serverFile.filename, e);
|
||||
}
|
||||
} else if (serverFile.hash) {
|
||||
// Server accepted our data or confirmed same content
|
||||
markClean(serverFile.filename, serverFile.hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (wroteAnyFile) {
|
||||
savesVersion.update(n => n + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Debounced incremental upload ---
|
||||
|
||||
const pendingFiles = new Set();
|
||||
let uploadTimer = null;
|
||||
const UPLOAD_DEBOUNCE_MS = 3000;
|
||||
|
||||
function scheduleSaveUpload() {
|
||||
if (uploadTimer) clearTimeout(uploadTimer);
|
||||
uploadTimer = setTimeout(flushSaveUpload, UPLOAD_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function flushSaveUpload() {
|
||||
if (uploadTimer) clearTimeout(uploadTimer);
|
||||
uploadTimer = null;
|
||||
if (!currentSession) {
|
||||
pendingFiles.clear();
|
||||
return;
|
||||
}
|
||||
const filenames = [...pendingFiles];
|
||||
pendingFiles.clear();
|
||||
if (filenames.length === 0) return;
|
||||
|
||||
const saves = [];
|
||||
for (const filename of filenames) {
|
||||
const data = await readBinaryFile(filename);
|
||||
if (data) {
|
||||
saves.push({ filename, data: arrayBufferToBase64(data) });
|
||||
}
|
||||
}
|
||||
|
||||
if (saves.length === 0) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/cloud/saves`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ saves }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { hashes } = await res.json();
|
||||
if (Array.isArray(hashes)) {
|
||||
for (const { filename, hash } of hashes) {
|
||||
markClean(filename, hash);
|
||||
}
|
||||
}
|
||||
} else if (res.status !== 401) {
|
||||
for (const fn of filenames) pendingFiles.add(fn);
|
||||
scheduleSaveUpload();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[CloudSync] Save upload failed:', e);
|
||||
for (const fn of filenames) pendingFiles.add(fn);
|
||||
scheduleSaveUpload();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config sync ---
|
||||
|
||||
async function syncConfig() {
|
||||
// Read local config
|
||||
let localConfig = null;
|
||||
try {
|
||||
const handle = await getFileHandle(CONFIG_FILE, false);
|
||||
if (handle) {
|
||||
const file = await handle.getFile();
|
||||
localConfig = await file.text();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const res = await fetch(`${API_URL}/api/cloud/config/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ config: localConfig || '' }),
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const { config: serverConfig } = await res.json();
|
||||
if (!serverConfig) return;
|
||||
|
||||
// Server returned config — write to OPFS (cloud overrides local)
|
||||
await writeTextFile(CONFIG_FILE, serverConfig, true);
|
||||
configVersion.update(n => n + 1);
|
||||
}
|
||||
|
||||
function uploadConfig(iniText) {
|
||||
fetch(`${API_URL}/api/cloud/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ config: iniText }),
|
||||
}).catch(e => console.warn('[CloudSync] Config upload failed:', e));
|
||||
}
|
||||
1
src/core/config.js
Normal file
1
src/core/config.js
Normal file
@ -0,0 +1 @@
|
||||
export const API_URL = typeof __API_URL__ !== 'undefined' ? __API_URL__ : 'http://localhost:8788';
|
||||
@ -6,28 +6,23 @@ self.onmessage = async (event) => {
|
||||
const THROTTLE_MS = 100;
|
||||
|
||||
try {
|
||||
const fileMetadataPromises = missingFiles.map(fileUrl =>
|
||||
fetch(fileUrl, { method: 'HEAD', headers: { 'Accept-Language': language } })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`Failed to HEAD ${fileUrl}`);
|
||||
return { url: fileUrl, size: Number(response.headers.get('content-length')) || 0 };
|
||||
})
|
||||
);
|
||||
const fileMetadata = await Promise.all(fileMetadataPromises);
|
||||
const totalBytesToDownload = fileMetadata.reduce((sum, file) => sum + file.size, 0);
|
||||
let bytesDownloaded = 0;
|
||||
const totalFiles = missingFiles.length;
|
||||
let filesDownloaded = 0;
|
||||
let lastProgressUpdate = 0;
|
||||
|
||||
const cache = await caches.open(cacheName);
|
||||
|
||||
for (const file of fileMetadata) {
|
||||
const request = new Request(file.url, { headers: { 'Accept-Language': language } });
|
||||
for (const fileUrl of missingFiles) {
|
||||
const request = new Request(fileUrl, { headers: { 'Accept-Language': language } });
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to fetch ${file.url}`);
|
||||
throw new Error(`Failed to fetch ${fileUrl}`);
|
||||
}
|
||||
|
||||
const fileSize = Number(response.headers.get('content-length')) || 0;
|
||||
let fileBytesRead = 0;
|
||||
|
||||
const [streamForCaching, streamForProgress] = response.body.tee();
|
||||
const cachePromise = cache.put(request, new Response(streamForCaching, response));
|
||||
|
||||
@ -36,18 +31,18 @@ self.onmessage = async (event) => {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
bytesDownloaded += value.length;
|
||||
fileBytesRead += value.length;
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastProgressUpdate > THROTTLE_MS) {
|
||||
lastProgressUpdate = now;
|
||||
self.postMessage({
|
||||
action: 'install_progress',
|
||||
progress: (bytesDownloaded / totalBytesToDownload) * 100,
|
||||
});
|
||||
const fileProgress = fileSize > 0 ? fileBytesRead / fileSize : 0;
|
||||
const progress = ((filesDownloaded + fileProgress) / totalFiles) * 100;
|
||||
self.postMessage({ action: 'install_progress', progress });
|
||||
}
|
||||
}
|
||||
await cachePromise;
|
||||
filesDownloaded++;
|
||||
}
|
||||
|
||||
self.postMessage({ action: 'install_complete', success: true });
|
||||
@ -1,8 +1,21 @@
|
||||
// Emscripten-related functions for game launching and canvas events
|
||||
import { gameRunning, debugUIVisible } from '../stores.js';
|
||||
import { gameRunning, debugUIVisible, multiplayerPlayerCount, thirdPersonEnabled, showNameBubbles, allowCustomize, connectionStatus, animationState, gameCrashed } from '../stores.js';
|
||||
import { recordCompletion } from './memories.js';
|
||||
import { pauseInstallAudio } from './audio.js';
|
||||
import { API_URL } from './config.js';
|
||||
|
||||
const DEFAULT_RENDERER = "0 0x682656f3 0x0 0x0 0x4000000"; // WebGL default
|
||||
let progressUpdates = 0;
|
||||
|
||||
export function launchGame() {
|
||||
pauseInstallAudio();
|
||||
|
||||
const rendererSelect = document.getElementById('renderer-select');
|
||||
const rendererValue = rendererSelect ? rendererSelect.value : DEFAULT_RENDERER;
|
||||
|
||||
startGame(rendererValue);
|
||||
}
|
||||
|
||||
export function startGame(rendererValue) {
|
||||
const mainContainer = document.getElementById('main-container');
|
||||
const canvasWrapper = document.getElementById('canvas-wrapper');
|
||||
@ -17,11 +30,27 @@ export function startGame(rendererValue) {
|
||||
document.documentElement.style.overscrollBehavior = 'none';
|
||||
|
||||
window.Module["disableOffscreenCanvases"] ||= rendererValue === "0 0x682656f3 0x0 0x0 0x2000000";
|
||||
console.log("disableOffscreenCanvases: " + window.Module["disableOffscreenCanvases"]);
|
||||
|
||||
window.Module["removeRunDependency"]("isle");
|
||||
canvas.focus();
|
||||
gameRunning.set(true);
|
||||
|
||||
// Prevent browser zoom on trackpad pinch-to-zoom (browsers send wheel events
|
||||
// with ctrlKey: true). Only prevent default for pinch-zoom (ctrlKey) or when
|
||||
// the target is the canvas — allow normal scrolling in UI overlays.
|
||||
document.addEventListener('wheel', function (event) {
|
||||
if (event.ctrlKey || event.target === canvas) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Safari fires proprietary gesture events for trackpad pinch separately
|
||||
document.addEventListener('gesturestart', function (event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
document.addEventListener('gesturechange', function (event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
export function setupCanvasEvents() {
|
||||
@ -43,7 +72,88 @@ export function setupCanvasEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('playerCountChanged', function (event) {
|
||||
multiplayerPlayerCount.set(event.detail.count);
|
||||
});
|
||||
|
||||
canvas.addEventListener('thirdPersonChanged', function (event) {
|
||||
thirdPersonEnabled.set(event.detail.enabled);
|
||||
});
|
||||
|
||||
canvas.addEventListener('nameBubblesChanged', function (event) {
|
||||
showNameBubbles.set(event.detail.enabled);
|
||||
});
|
||||
|
||||
canvas.addEventListener('allowCustomizeChanged', function (event) {
|
||||
allowCustomize.set(event.detail.enabled);
|
||||
});
|
||||
|
||||
const STATUS_MAP = { 0: 'connected', 1: 'reconnecting', 2: 'failed', 3: 'rejected' };
|
||||
canvas.addEventListener('connectionStatusChanged', function (event) {
|
||||
const status = STATUS_MAP[event.detail.status] || null;
|
||||
connectionStatus.set(status);
|
||||
if (status === 'rejected') {
|
||||
sessionStorage.setItem('mp-rejected', '1');
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('animationsAvailable', function (event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.json);
|
||||
animationState.set(data);
|
||||
} catch (e) {
|
||||
console.error('[Anim] Failed to parse:', e);
|
||||
animationState.set(null);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('animationCompleted', function (event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.json);
|
||||
recordCompletion(data.animIndex, data.eventId, data.participants);
|
||||
} catch (e) {
|
||||
console.error('[Memory] Failed to process completion:', e);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('extensionProgress', function (event) {
|
||||
statusMessageBar.innerHTML = 'Loading ' + event.detail.name + '... please wait! <code>' + event.detail.progress + '%</code>';
|
||||
});
|
||||
|
||||
canvas.addEventListener('saveSlotWritten', function (event) {
|
||||
window.dispatchEvent(new CustomEvent('opfs-save-slot-written', {
|
||||
detail: { slot: event.detail.slot }
|
||||
}));
|
||||
});
|
||||
|
||||
canvas.addEventListener('saveStateChanged', function () {
|
||||
window.dispatchEvent(new CustomEvent('opfs-save-state-changed'));
|
||||
});
|
||||
|
||||
let crashed = false;
|
||||
window.addEventListener('game-crash', function (event) {
|
||||
if (crashed) return;
|
||||
crashed = true;
|
||||
var detail = event.detail;
|
||||
gameCrashed.set({
|
||||
stack: detail.stack,
|
||||
buildVersion: detail.buildVersion,
|
||||
wasmVersion: detail.wasmVersion
|
||||
});
|
||||
|
||||
// Send crash report to server (fire-and-forget, survives page unload)
|
||||
try {
|
||||
fetch(API_URL + '/api/crash', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stack: detail.stack,
|
||||
buildVersion: detail.buildVersion,
|
||||
wasmVersion: detail.wasmVersion
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive: true,
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
@ -81,6 +81,16 @@ export class BinaryReader {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read 64-bit float (little-endian)
|
||||
* @returns {number}
|
||||
*/
|
||||
readF64() {
|
||||
const value = this.view.getFloat64(this.offset, true);
|
||||
this.offset += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ASCII string of specified length
|
||||
* @param {number} length - Number of bytes to read
|
||||
|
||||
317
src/core/formats/FlcDecoder.js
Normal file
317
src/core/formats/FlcDecoder.js
Normal file
@ -0,0 +1,317 @@
|
||||
/**
|
||||
* FLIC/FLC delta frame decoder for phoneme lip-sync animation.
|
||||
* Ported from isle-portable/LEGO1/omni/src/video/flic.cpp.
|
||||
*
|
||||
* Maintains an 8-bit indexed pixel buffer and 256-entry RGB palette.
|
||||
* Each call to decodeFrame() applies one delta frame.
|
||||
* Call toRGBA() to convert the current state to RGBA pixels for texture upload.
|
||||
*/
|
||||
|
||||
// Chunk types
|
||||
const FLI_CHUNK_COLOR256 = 4;
|
||||
const FLI_CHUNK_SS2 = 7;
|
||||
const FLI_CHUNK_COLOR64 = 11;
|
||||
const FLI_CHUNK_LC = 12;
|
||||
const FLI_CHUNK_BLACK = 13;
|
||||
const FLI_CHUNK_BRUN = 15;
|
||||
const FLI_CHUNK_COPY = 16;
|
||||
const FLI_CHUNK_FRAME = 0xf1fa;
|
||||
|
||||
export class FlcDecoder {
|
||||
/**
|
||||
* @param {{ width: number, height: number, speed: number }} header - FLIC header
|
||||
*/
|
||||
constructor(header) {
|
||||
this.width = header.width;
|
||||
this.height = header.height;
|
||||
this.speed = header.speed;
|
||||
this.stride = (this.width + 3) & ~3; // 4-byte aligned row stride
|
||||
this.pixels = new Uint8Array(this.stride * this.height);
|
||||
this.palette = new Uint8Array(256 * 3); // RGB triplets
|
||||
this.paletteChanged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one FLC frame, updating pixels and palette.
|
||||
* @param {ArrayBuffer} frameBuffer - Raw frame data from SI phoneme chunk
|
||||
* @returns {boolean} True if palette was changed
|
||||
*/
|
||||
decodeFrame(frameBuffer) {
|
||||
const view = new DataView(frameBuffer);
|
||||
if (frameBuffer.byteLength < 4) return false;
|
||||
|
||||
const rectCount = view.getInt32(0, true);
|
||||
let frameStart = 4 + rectCount * 16;
|
||||
|
||||
if (frameStart + 16 > frameBuffer.byteLength) return false;
|
||||
|
||||
const frameType = view.getUint16(frameStart + 4, true);
|
||||
if (frameType !== FLI_CHUNK_FRAME) return false;
|
||||
|
||||
const chunkCount = view.getUint16(frameStart + 6, true);
|
||||
this.paletteChanged = false;
|
||||
|
||||
let offset = frameStart + 16;
|
||||
for (let i = 0; i < chunkCount && offset + 6 <= frameBuffer.byteLength; i++) {
|
||||
const chunkSize = view.getUint32(offset, true);
|
||||
const chunkType = view.getUint16(offset + 4, true);
|
||||
const dataOffset = offset + 6;
|
||||
const dataLen = chunkSize - 6;
|
||||
|
||||
switch (chunkType) {
|
||||
case FLI_CHUNK_COLOR256:
|
||||
case FLI_CHUNK_COLOR64:
|
||||
this._decodeColors(new Uint8Array(frameBuffer, dataOffset, dataLen));
|
||||
this.paletteChanged = true;
|
||||
break;
|
||||
case FLI_CHUNK_SS2:
|
||||
this._decodeSS2(new Uint8Array(frameBuffer, dataOffset, dataLen));
|
||||
break;
|
||||
case FLI_CHUNK_LC:
|
||||
this._decodeLC(new Uint8Array(frameBuffer, dataOffset, dataLen));
|
||||
break;
|
||||
case FLI_CHUNK_BLACK:
|
||||
this.pixels.fill(0);
|
||||
break;
|
||||
case FLI_CHUNK_BRUN:
|
||||
this._decodeBrun(new Uint8Array(frameBuffer, dataOffset, dataLen));
|
||||
break;
|
||||
case FLI_CHUNK_COPY:
|
||||
this._decodeCopy(new Uint8Array(frameBuffer, dataOffset, dataLen));
|
||||
break;
|
||||
}
|
||||
|
||||
offset += chunkSize;
|
||||
}
|
||||
|
||||
return this.paletteChanged;
|
||||
}
|
||||
|
||||
/** Convert current indexed pixels to RGBA. */
|
||||
toRGBA() {
|
||||
const rgba = new Uint8Array(this.width * this.height * 4);
|
||||
const pal = this.palette;
|
||||
const pix = this.pixels;
|
||||
const w = this.width;
|
||||
const h = this.height;
|
||||
const stride = this.stride;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
const srcRow = stride * y;
|
||||
const dstRow = w * y * 4;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = pix[srcRow + x];
|
||||
const pi = idx * 3;
|
||||
const di = dstRow + x * 4;
|
||||
rgba[di] = pal[pi];
|
||||
rgba[di + 1] = pal[pi + 1];
|
||||
rgba[di + 2] = pal[pi + 2];
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
/** Convert to ImageData for canvas/texture upload. */
|
||||
toImageData() {
|
||||
const rgba = this.toRGBA();
|
||||
return new ImageData(new Uint8ClampedArray(rgba.buffer), this.width, this.height);
|
||||
}
|
||||
|
||||
// ── Palette decoder ──
|
||||
|
||||
_decodeColors(data) {
|
||||
let pos = 0;
|
||||
if (data.length < 2) return;
|
||||
const packets = data[pos] | (data[pos + 1] << 8);
|
||||
pos += 2;
|
||||
|
||||
let colorIndex = 0;
|
||||
for (let p = 0; p < packets; p++) {
|
||||
if (pos + 1 >= data.length) return;
|
||||
colorIndex += data[pos++];
|
||||
let colorCount = data[pos++];
|
||||
if (colorCount === 0) colorCount = 256;
|
||||
|
||||
if (pos + colorCount * 3 > data.length) return;
|
||||
for (let c = 0; c < colorCount; c++) {
|
||||
const pi = (colorIndex + c) * 3;
|
||||
this.palette[pi] = data[pos++];
|
||||
this.palette[pi + 1] = data[pos++];
|
||||
this.palette[pi + 2] = data[pos++];
|
||||
}
|
||||
colorIndex += colorCount;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pixel decoders ──
|
||||
|
||||
/** BRUN: Byte run-length compression (first frame). Rows bottom-to-top. */
|
||||
_decodeBrun(data) {
|
||||
let pos = 0;
|
||||
const w = this.width, h = this.height, stride = this.stride, pix = this.pixels;
|
||||
|
||||
for (let row = h - 1; row >= 0; row--) {
|
||||
const rowOffset = stride * row;
|
||||
let col = 0;
|
||||
pos++; // skip packet count byte
|
||||
|
||||
while (col < w) {
|
||||
let count = data[pos++];
|
||||
if (count > 127) count -= 256;
|
||||
|
||||
if (count >= 0) {
|
||||
const pixel = data[pos++];
|
||||
for (let i = 0; i < count && col < w; i++, col++) {
|
||||
pix[rowOffset + col] = pixel;
|
||||
}
|
||||
} else {
|
||||
count = -count;
|
||||
for (let i = 0; i < count && col < w; i++, col++) {
|
||||
pix[rowOffset + col] = data[pos++];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** LC: Line compression (byte-oriented delta). */
|
||||
_decodeLC(data) {
|
||||
let pos = 0;
|
||||
const h = this.height, stride = this.stride, pix = this.pixels, w = this.width;
|
||||
|
||||
if (data.length < 4) return;
|
||||
const skipLines = data[pos] | (data[pos + 1] << 8);
|
||||
pos += 2;
|
||||
const lineCount = data[pos] | (data[pos + 1] << 8);
|
||||
pos += 2;
|
||||
|
||||
let row = h - skipLines - 1;
|
||||
|
||||
for (let line = 0; line < lineCount; line++) {
|
||||
const packets = data[pos++];
|
||||
let col = 0;
|
||||
const rowOffset = stride * row;
|
||||
|
||||
for (let p = 0; p < packets; p++) {
|
||||
col += data[pos++];
|
||||
|
||||
let type = data[pos++];
|
||||
if (type > 127) type -= 256;
|
||||
|
||||
if (type < 0) {
|
||||
const count = -type;
|
||||
const pixel = data[pos++];
|
||||
for (let i = 0; i < count && col < w; i++, col++) {
|
||||
pix[rowOffset + col] = pixel;
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < type && col < w; i++, col++) {
|
||||
pix[rowOffset + col] = data[pos++];
|
||||
}
|
||||
}
|
||||
}
|
||||
row--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SS2: Word-oriented delta compression.
|
||||
* 1:1 translation of DecodeSS2 in flic.cpp:357-453.
|
||||
*/
|
||||
_decodeSS2(data) {
|
||||
let pos = 0;
|
||||
const h = this.height, w = this.width, stride = this.stride;
|
||||
const pix = this.pixels, xmax = w - 1;
|
||||
|
||||
if (data.length < 2) return;
|
||||
let lines = data[pos] | (data[pos + 1] << 8);
|
||||
pos += 2;
|
||||
let row = h - 1;
|
||||
|
||||
let state = 0, token = 0, column = 0;
|
||||
|
||||
for (;;) {
|
||||
switch (state) {
|
||||
case 1: // skip_lines
|
||||
row += token;
|
||||
// fall through to start_packet
|
||||
case 0: // start_packet
|
||||
token = this._readS16LE(data, pos); pos += 2;
|
||||
if (token >= 0) { state = 2; continue; }
|
||||
if ((token & 0xFFFF) & 0x4000) { state = 1; continue; }
|
||||
|
||||
// Last byte
|
||||
if (row >= 0 && row < h) pix[stride * row + xmax] = token & 0xff;
|
||||
token = this._readS16LE(data, pos); pos += 2;
|
||||
|
||||
if (!token) {
|
||||
row--;
|
||||
if (--lines > 0) { state = 0; continue; }
|
||||
return;
|
||||
}
|
||||
// fall through to column_loop
|
||||
|
||||
case 2: // column_loop
|
||||
column = 0;
|
||||
// fall through to column_loop_inner
|
||||
|
||||
case 3: { // column_loop_inner
|
||||
column += data[pos++];
|
||||
let type = data[pos++];
|
||||
if (type > 127) type -= 256;
|
||||
type += type;
|
||||
|
||||
if (type >= 0) {
|
||||
// Literal copy
|
||||
const end = Math.min(column + type, w);
|
||||
for (let i = column; i < end; i++) {
|
||||
pix[stride * row + i] = data[pos + (i - column)];
|
||||
}
|
||||
column += type;
|
||||
pos += type;
|
||||
|
||||
if (--token !== 0) { state = 3; continue; }
|
||||
row--;
|
||||
if (--lines > 0) { state = 0; continue; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Word run
|
||||
type = -type;
|
||||
const lo = data[pos++];
|
||||
const hi = data[pos++];
|
||||
const end = Math.min(column + type, w);
|
||||
for (let i = column; i < end; i++) {
|
||||
pix[stride * row + i] = ((i - column) & 1) ? hi : lo;
|
||||
}
|
||||
column += type;
|
||||
|
||||
if (--token !== 0) { state = 3; continue; }
|
||||
row--;
|
||||
if (--lines > 0) { state = 0; continue; }
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read little-endian signed 16-bit value. */
|
||||
_readS16LE(data, pos) {
|
||||
const v = data[pos] | (data[pos + 1] << 8);
|
||||
return v > 32767 ? v - 65536 : v;
|
||||
}
|
||||
|
||||
/** COPY: Uncompressed frame data. Rows bottom-to-top. */
|
||||
_decodeCopy(data) {
|
||||
let pos = 0;
|
||||
const w = this.width, h = this.height, stride = this.stride, pix = this.pixels;
|
||||
|
||||
for (let row = h - 1; row >= 0; row--) {
|
||||
const rowOffset = stride * row;
|
||||
for (let x = 0; x < w; x++) {
|
||||
pix[rowOffset + x] = data[pos++];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
src/core/formats/SICompositeParser.js
Normal file
253
src/core/formats/SICompositeParser.js
Normal file
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Parses a composite SI object (scene animation) into its component parts:
|
||||
* animation tree, audio tracks, and phoneme (lip-sync) tracks.
|
||||
*
|
||||
* Mirrors Loader::ParseComposite() from
|
||||
* isle-portable/extensions/src/multiplayer/animation/loader.cpp
|
||||
*/
|
||||
|
||||
import { SIFileType } from './SIParser.js';
|
||||
import { parseAnimation } from './AnimationParser.js';
|
||||
import { BinaryReader } from './BinaryReader.js';
|
||||
|
||||
/**
|
||||
* Parse a composite SI object into SceneAnimData.
|
||||
* @param {import('./SIParser.js').SIObject} composite - The composite SI object with children
|
||||
* @returns {SceneAnimData|null}
|
||||
*/
|
||||
export function parseComposite(composite) {
|
||||
const data = {
|
||||
anim: null,
|
||||
duration: 0,
|
||||
audioTracks: [],
|
||||
phonemeTracks: [],
|
||||
actionTransform: { location: [0, 0, 0], direction: [0, 0, 0], up: [0, 0, 0], valid: false },
|
||||
ptAtCamNames: [],
|
||||
hideOnStop: false,
|
||||
};
|
||||
|
||||
let hasAnim = false;
|
||||
|
||||
for (const child of composite.children) {
|
||||
const presenter = child.presenter || '';
|
||||
|
||||
if (presenter.includes('LegoPhonemePresenter')) {
|
||||
parsePhonemeChild(child, data);
|
||||
} else if (presenter.includes('LegoAnimPresenter') || presenter.includes('LegoLoopingAnimPresenter')) {
|
||||
if (!hasAnim) {
|
||||
if (parseAnimationChild(child, data)) {
|
||||
hasAnim = true;
|
||||
parseExtraDirectives(child.extraString, data);
|
||||
|
||||
// Extract action transform - use child's vectors, fall back to composite
|
||||
let source = child;
|
||||
if (Math.abs(child.direction[0]) < 1e-7 &&
|
||||
Math.abs(child.direction[1]) < 1e-7 &&
|
||||
Math.abs(child.direction[2]) < 1e-7) {
|
||||
source = composite;
|
||||
}
|
||||
|
||||
data.actionTransform.location = [source.location[0], source.location[1], source.location[2]];
|
||||
data.actionTransform.direction = [source.direction[0], source.direction[1], source.direction[2]];
|
||||
data.actionTransform.up = [source.up[0], source.up[1], source.up[2]];
|
||||
data.actionTransform.valid =
|
||||
Math.abs(data.actionTransform.direction[0]) >= 4.768e-7 ||
|
||||
Math.abs(data.actionTransform.direction[1]) >= 4.768e-7 ||
|
||||
Math.abs(data.actionTransform.direction[2]) >= 4.768e-7;
|
||||
}
|
||||
}
|
||||
} else if (child.filetype === SIFileType.WAV) {
|
||||
const track = extractAudioTrack(child);
|
||||
if (track) data.audioTracks.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
return hasAnim ? data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse animation child: first data chunk contains LegoAnim binary.
|
||||
* Format: S32 magic (0x11) + F32 boundingRadius + F32[3] center + S32 parseScene + ...
|
||||
* The animation binary after the prefix is parseable by AnimationParser.
|
||||
*
|
||||
* Mirrors Loader::ParseAnimationChild().
|
||||
*/
|
||||
function parseAnimationChild(child, data) {
|
||||
if (!child.data || child.data.length === 0) return false;
|
||||
|
||||
const buffer = child.data[0];
|
||||
if (buffer.byteLength < 4) return false;
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const magic = view.getInt32(0, true);
|
||||
if (magic !== 0x11) {
|
||||
console.warn(`[SIComposite] Unexpected animation magic: 0x${magic.toString(16)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the full animation tree using existing AnimationParser
|
||||
// The entire data[0] is the LegoAnim binary (AnimationParser expects magic 0x11 at offset 0)
|
||||
try {
|
||||
data.anim = parseAnimation(buffer);
|
||||
data.duration = data.anim.duration;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[SIComposite] Failed to parse animation:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse phoneme child: FLC header + per-frame delta data.
|
||||
* data[0] = FLIC_HEADER (20 bytes minimum)
|
||||
* data[1..N] = per-frame FLC data
|
||||
*
|
||||
* Mirrors Loader::ParsePhonemeChild().
|
||||
*/
|
||||
function parsePhonemeChild(child, data) {
|
||||
if (!child.data || child.data.length < 1) return;
|
||||
|
||||
const headerBuf = child.data[0];
|
||||
if (headerBuf.byteLength < 20) return;
|
||||
|
||||
const hdr = new DataView(headerBuf);
|
||||
const flcHeader = {
|
||||
size: hdr.getUint32(0, true),
|
||||
type: hdr.getUint16(4, true), // 0xaf12 for FLIC
|
||||
frames: hdr.getUint16(6, true),
|
||||
width: hdr.getUint16(8, true),
|
||||
height: hdr.getUint16(10, true),
|
||||
depth: hdr.getUint16(12, true),
|
||||
flags: hdr.getUint16(14, true),
|
||||
speed: hdr.getUint32(16, true), // ms between frames
|
||||
};
|
||||
|
||||
const frameData = [];
|
||||
for (let i = 1; i < child.data.length; i++) {
|
||||
if (child.data[i].byteLength < 20) {
|
||||
console.warn(`[SIComposite] Phoneme frame ${i - 1} too small (${child.data[i].byteLength} bytes), skipping`);
|
||||
continue;
|
||||
}
|
||||
frameData.push(child.data[i]);
|
||||
}
|
||||
|
||||
// ROI name from extra field
|
||||
const roiName = child.extraString.trim();
|
||||
|
||||
data.phonemeTracks.push({
|
||||
flcHeader,
|
||||
frameData,
|
||||
timeOffset: child.timeOffset,
|
||||
roiName,
|
||||
width: flcHeader.width,
|
||||
height: flcHeader.height,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract audio track: WaveFormat header + concatenated PCM data.
|
||||
* data[0] = WaveFormat (24 bytes)
|
||||
* data[1..N] = PCM audio blocks
|
||||
*
|
||||
* Mirrors SIReader::ExtractAudioTrack().
|
||||
*/
|
||||
function extractAudioTrack(child) {
|
||||
if (!child.data || child.data.length < 2) return null;
|
||||
|
||||
const fmtBuf = child.data[0];
|
||||
if (fmtBuf.byteLength < 16) return null;
|
||||
|
||||
const fmt = new DataView(fmtBuf);
|
||||
const format = {
|
||||
wFormatTag: fmt.getUint16(0, true),
|
||||
nChannels: fmt.getUint16(2, true),
|
||||
nSamplesPerSec: fmt.getUint32(4, true),
|
||||
nAvgBytesPerSec: fmt.getUint32(8, true),
|
||||
nBlockAlign: fmt.getUint16(12, true),
|
||||
wBitsPerSample: fmt.getUint16(14, true),
|
||||
};
|
||||
|
||||
// Concatenate PCM blocks
|
||||
let totalPcm = 0;
|
||||
for (let i = 1; i < child.data.length; i++) {
|
||||
totalPcm += child.data[i].byteLength;
|
||||
}
|
||||
if (totalPcm === 0) return null;
|
||||
|
||||
const pcmData = new Uint8Array(totalPcm);
|
||||
let offset = 0;
|
||||
for (let i = 1; i < child.data.length; i++) {
|
||||
pcmData.set(new Uint8Array(child.data[i]), offset);
|
||||
offset += child.data[i].byteLength;
|
||||
}
|
||||
|
||||
return {
|
||||
pcmData: pcmData.buffer,
|
||||
format,
|
||||
volume: child.volume,
|
||||
timeOffset: child.timeOffset,
|
||||
mediaSrcPath: child.filename,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HIDE_ON_STOP and PTATCAM directives from the extra string.
|
||||
* Mirrors Loader::ParseExtraDirectives().
|
||||
*/
|
||||
function parseExtraDirectives(extra, data) {
|
||||
if (!extra) return;
|
||||
|
||||
if (extra.includes('HIDE_ON_STOP')) {
|
||||
data.hideOnStop = true;
|
||||
}
|
||||
|
||||
const ptIdx = extra.indexOf('PTATCAM');
|
||||
if (ptIdx !== -1) {
|
||||
let pos = ptIdx + 7; // Skip 'PTATCAM'
|
||||
|
||||
// Skip separator character: ':', ',', ' ', '\t', '='
|
||||
if (pos < extra.length && ':, \t='.includes(extra[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
// Extract value until space or end
|
||||
const endPos = extra.indexOf(' ', pos);
|
||||
const value = endPos !== -1 ? extra.substring(pos, endPos) : extra.substring(pos);
|
||||
|
||||
// Split by ':' or ';'
|
||||
for (const token of value.split(/[:;]/)) {
|
||||
const trimmed = token.trim();
|
||||
if (trimmed) data.ptAtCamNames.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SceneAnimData
|
||||
* @property {Object} anim - Parsed animation tree from AnimationParser
|
||||
* @property {number} duration - Duration in ms
|
||||
* @property {AudioTrack[]} audioTracks
|
||||
* @property {PhonemeTrack[]} phonemeTracks
|
||||
* @property {{ location: number[], direction: number[], up: number[], valid: boolean }} actionTransform
|
||||
* @property {string[]} ptAtCamNames
|
||||
* @property {boolean} hideOnStop
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AudioTrack
|
||||
* @property {ArrayBuffer} pcmData - Raw PCM audio data
|
||||
* @property {Object} format - WaveFormat fields
|
||||
* @property {number} volume - 0-79
|
||||
* @property {number} timeOffset - ms
|
||||
* @property {string} mediaSrcPath
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PhonemeTrack
|
||||
* @property {Object} flcHeader - FLIC header fields
|
||||
* @property {ArrayBuffer[]} frameData - Per-frame FLC delta data
|
||||
* @property {number} timeOffset - ms
|
||||
* @property {string} roiName - Target actor ROI name
|
||||
* @property {number} width
|
||||
* @property {number} height
|
||||
*/
|
||||
577
src/core/formats/SIParser.js
Normal file
577
src/core/formats/SIParser.js
Normal file
@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Parser for LEGO Island SI (Streamed Interleaf) files.
|
||||
* Reimplements the core of libweaver's si::Interleaf in JavaScript.
|
||||
*
|
||||
* SI files use a RIFF container format with custom chunk types:
|
||||
* RIFF OMNI → MxHd (header) → MxOf (offset table) → LIST MxSt (data)
|
||||
*
|
||||
* Each object is defined by an MxOb chunk with metadata fields, and its
|
||||
* binary data lives in interleaved MxCh chunks within the object's MxSt.
|
||||
*
|
||||
* Uses HTTP Range requests to fetch only the needed portions of the file,
|
||||
* just like libweaver's header-only + lazy loading approach.
|
||||
*/
|
||||
|
||||
import { BinaryReader } from './BinaryReader.js';
|
||||
|
||||
// RIFF FourCC constants (little-endian u32)
|
||||
const RIFF = 0x46464952;
|
||||
const LIST = 0x5453494c;
|
||||
const MxHd = 0x6448784d;
|
||||
const MxOf = 0x664f784d;
|
||||
const MxSt = 0x7453784d;
|
||||
const MxOb = 0x624f784d;
|
||||
const MxCh = 0x6843784d;
|
||||
const MxDa = 0x6144784d;
|
||||
const OMNI = 0x494e4d4f;
|
||||
const pad_ = 0x20646170;
|
||||
|
||||
// MxOb::Type enum
|
||||
export const SIObjectType = Object.freeze({
|
||||
Null: -1,
|
||||
Video: 0x03,
|
||||
Sound: 0x04,
|
||||
World: 0x06,
|
||||
Presenter: 0x07,
|
||||
Event: 0x08,
|
||||
Animation: 0x09,
|
||||
Bitmap: 0x0a,
|
||||
Object3D: 0x0b,
|
||||
});
|
||||
|
||||
// MxOb::FileType FourCC values
|
||||
export const SIFileType = Object.freeze({
|
||||
WAV: 0x56415720, // 'WAV '
|
||||
STL: 0x4c545320, // 'STL '
|
||||
FLC: 0x434c4620, // 'FLC '
|
||||
SMK: 0x4b4d5320, // 'SMK '
|
||||
OBJ: 0x4a424f20, // 'OBJ '
|
||||
});
|
||||
|
||||
// MxCh flags
|
||||
const MXCH_FLAG_SPLIT = 0x10;
|
||||
const MXCH_FLAG_END = 0x02;
|
||||
const MXCH_HEADER_SIZE = 14; // flags(2) + objectId(4) + time(4) + dataSize(4)
|
||||
|
||||
/** Seek past a RIFF chunk, accounting for 2-byte padding alignment. */
|
||||
function seekPastChunk(r, chunkDataStart, chunkSize) {
|
||||
r.seek(Math.min(chunkDataStart + chunkSize + (chunkSize % 2), r.buffer.byteLength));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed SI object with all MxOb fields.
|
||||
*/
|
||||
export class SIObject {
|
||||
constructor() {
|
||||
this.type = SIObjectType.Null;
|
||||
this.presenter = '';
|
||||
this.name = '';
|
||||
this.id = 0;
|
||||
this.flags = 0;
|
||||
this.duration = 0;
|
||||
this.loops = 0;
|
||||
this.location = [0, 0, 0];
|
||||
this.direction = [0, 0, 0];
|
||||
this.up = [0, 0, 0];
|
||||
this.extra = null; // Uint8Array or null
|
||||
this.filename = '';
|
||||
this.filetype = 0;
|
||||
this.volume = 0;
|
||||
this.timeOffset = 0; // Set from second MxCh chunk's time field
|
||||
this.children = []; // SIObject[] for composite objects
|
||||
this.data = []; // ArrayBuffer[] - reassembled MxCh chunk data
|
||||
}
|
||||
|
||||
/** Get extra field as a string (stripping null terminators). */
|
||||
get extraString() {
|
||||
if (!this.extra || this.extra.length === 0) return '';
|
||||
let end = this.extra.length;
|
||||
while (end > 0 && this.extra[end - 1] === 0) end--;
|
||||
return new TextDecoder().decode(this.extra.subarray(0, end));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SI file reader with lazy object loading via HTTP Range requests.
|
||||
* Mirrors sireader.cpp + si::Interleaf from libweaver.
|
||||
*
|
||||
* Usage:
|
||||
* const reader = new SIReader();
|
||||
* await reader.open('/LEGO/Scripts/Isle/ISLE.SI');
|
||||
* const obj = await reader.readObject(500);
|
||||
*/
|
||||
export class SIReader {
|
||||
constructor() {
|
||||
this.url = null;
|
||||
this.language = null;
|
||||
this.version = 0;
|
||||
this.bufferSize = 0;
|
||||
this.bufferCount = 0;
|
||||
this.slotOffsets = []; // u32[] - file offsets per slot index
|
||||
this.objectCache = new Map(); // objectId → SIObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SI file: fetch header (MxHd + MxOf) via Range request.
|
||||
* @param {string} url - URL to the SI file
|
||||
* @param {string|null} [language] - Language code for Accept-Language header
|
||||
*/
|
||||
async open(url, language = null) {
|
||||
this.url = url;
|
||||
this.language = language;
|
||||
|
||||
// Fetch first 16KB to get RIFF header + MxHd + start of MxOf.
|
||||
// MxOf for ISLE.SI (~1200 objects) is about 4808 bytes, well within 16KB.
|
||||
let headerBuf = await this._fetchRange(0, 16384);
|
||||
let r = new BinaryReader(headerBuf);
|
||||
|
||||
// RIFF header
|
||||
const riffId = r.readU32();
|
||||
if (riffId !== RIFF) throw new Error(`Not a RIFF file (0x${riffId.toString(16)})`);
|
||||
r.readU32(); // riffSize
|
||||
const riffFormat = r.readU32();
|
||||
if (riffFormat !== OMNI) throw new Error(`Not an OMNI file (0x${riffFormat.toString(16)})`);
|
||||
|
||||
// Scan for MxHd and MxOf
|
||||
let mxOfStart = -1;
|
||||
let mxOfSize = 0;
|
||||
|
||||
while (r.remaining() > 8) {
|
||||
const chunkId = r.readU32();
|
||||
const chunkSize = r.readU32();
|
||||
const chunkDataStart = r.tell();
|
||||
|
||||
if (chunkId === MxHd) {
|
||||
this.version = r.readU32();
|
||||
this.bufferSize = r.readU32();
|
||||
this.bufferCount = r.readU32();
|
||||
} else if (chunkId === MxOf) {
|
||||
mxOfStart = chunkDataStart;
|
||||
mxOfSize = chunkSize;
|
||||
// Check if MxOf fits within our initial fetch
|
||||
if (chunkDataStart + chunkSize <= headerBuf.byteLength) {
|
||||
this._parseMxOf(r, chunkSize);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
r.seek(chunkDataStart + chunkSize + (chunkSize % 2));
|
||||
}
|
||||
|
||||
if (mxOfStart < 0) throw new Error('MxOf chunk not found');
|
||||
|
||||
// If MxOf didn't fit in the initial fetch, fetch the rest
|
||||
if (this.slotOffsets.length === 0) {
|
||||
const fullHeader = await this._fetchRange(0, mxOfStart + mxOfSize);
|
||||
r = new BinaryReader(fullHeader);
|
||||
r.seek(mxOfStart);
|
||||
this._parseMxOf(r, mxOfSize);
|
||||
}
|
||||
|
||||
if (this.slotOffsets.length === 0) throw new Error('Empty MxOf offset table');
|
||||
}
|
||||
|
||||
_parseMxOf(r, chunkSize) {
|
||||
const count = r.readU32();
|
||||
const entryCount = (chunkSize - 4) / 4;
|
||||
this.slotOffsets = [];
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
this.slotOffsets.push(r.readU32());
|
||||
}
|
||||
}
|
||||
|
||||
get slotCount() {
|
||||
return this.slotOffsets.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a specific object by slot index (objectId).
|
||||
* Fetches the object's MxSt chunk via Range request.
|
||||
* For composite objects, also reads child MxOb definitions and MxCh data.
|
||||
* @param {number} objectId - Slot index in the offset table
|
||||
* @returns {Promise<SIObject|null>}
|
||||
*/
|
||||
async readObject(objectId) {
|
||||
if (this.objectCache.has(objectId)) {
|
||||
return this.objectCache.get(objectId);
|
||||
}
|
||||
|
||||
if (objectId >= this.slotOffsets.length) return null;
|
||||
const offset = this.slotOffsets[objectId];
|
||||
if (offset === 0) return null;
|
||||
|
||||
// Step 1: Fetch the chunk header (8 bytes) to learn the MxSt size
|
||||
const headerBuf = await this._fetchRange(offset, 8);
|
||||
const hdr = new BinaryReader(headerBuf);
|
||||
const chunkId = hdr.readU32();
|
||||
const chunkSize = hdr.readU32();
|
||||
|
||||
// Step 2: Fetch the full chunk (header + data)
|
||||
const totalSize = 8 + chunkSize + (chunkSize % 2);
|
||||
const chunkBuf = await this._fetchRange(offset, totalSize);
|
||||
|
||||
// Step 3: Parse the chunk tree
|
||||
const r = new BinaryReader(chunkBuf);
|
||||
const obj = this._readChunkTree(r, 0);
|
||||
|
||||
if (obj) {
|
||||
this.objectCache.set(objectId, obj);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a chunk tree from a buffer.
|
||||
* @param {BinaryReader} r - Reader positioned at start of a RIFF chunk
|
||||
* @param {number} baseOffset - File offset that r.offset=0 corresponds to
|
||||
* @returns {SIObject|null}
|
||||
*/
|
||||
_readChunkTree(r, baseOffset) {
|
||||
if (r.remaining() < 8) return null;
|
||||
|
||||
const chunkId = r.readU32();
|
||||
const chunkSize = r.readU32();
|
||||
const chunkStart = r.tell();
|
||||
const chunkEnd = chunkStart + chunkSize;
|
||||
|
||||
switch (chunkId) {
|
||||
case MxSt: {
|
||||
// Container: read children within bounds
|
||||
let obj = null;
|
||||
while (r.tell() < chunkEnd && r.remaining() >= 8) {
|
||||
const child = this._readChunkTree(r, baseOffset);
|
||||
if (child instanceof SIObject && !obj) obj = child;
|
||||
}
|
||||
seekPastChunk(r, chunkStart, chunkSize);
|
||||
return obj;
|
||||
}
|
||||
|
||||
case MxOb: {
|
||||
const obj = this._parseMxOb(r, chunkEnd);
|
||||
|
||||
// Check for child LIST within MxOb boundary
|
||||
while (r.tell() < chunkEnd && r.remaining() >= 8) {
|
||||
const innerChunkId = r.readU32();
|
||||
const innerChunkSize = r.readU32();
|
||||
const innerStart = r.tell();
|
||||
|
||||
if (innerChunkId === LIST && r.remaining() >= 8) {
|
||||
const listType = r.readU32();
|
||||
if (listType === MxCh) {
|
||||
// Child object list
|
||||
const childCount = r.readU32();
|
||||
for (let i = 0; i < childCount && r.tell() < innerStart + innerChunkSize; i++) {
|
||||
const child = this._readChunkTree(r, baseOffset);
|
||||
if (child instanceof SIObject) {
|
||||
obj.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
seekPastChunk(r, innerStart, innerChunkSize);
|
||||
}
|
||||
|
||||
seekPastChunk(r, chunkStart, chunkSize);
|
||||
return obj;
|
||||
}
|
||||
|
||||
case LIST: {
|
||||
if (r.remaining() < 4) { seekPastChunk(r, chunkStart, chunkSize); return null; }
|
||||
const listType = r.readU32();
|
||||
|
||||
if (listType === MxDa || listType === MxSt) {
|
||||
// Read children within this LIST
|
||||
let obj = null;
|
||||
while (r.tell() < chunkEnd && r.remaining() >= 8) {
|
||||
const child = this._readChunkTree(r, baseOffset);
|
||||
if (child instanceof SIObject && !obj) obj = child;
|
||||
}
|
||||
seekPastChunk(r, chunkStart, chunkSize);
|
||||
return obj;
|
||||
}
|
||||
|
||||
seekPastChunk(r, chunkStart, chunkSize);
|
||||
return null;
|
||||
}
|
||||
|
||||
case MxCh: {
|
||||
// Data chunk - handled by _collectMxChFromBuffer
|
||||
seekPastChunk(r, chunkStart, chunkSize);
|
||||
return null;
|
||||
}
|
||||
|
||||
case pad_:
|
||||
default: {
|
||||
seekPastChunk(r, chunkStart, chunkSize);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MxOb fields. Field order matches libweaver's ReadObject() exactly.
|
||||
*/
|
||||
_parseMxOb(r, chunkEnd) {
|
||||
const obj = new SIObject();
|
||||
|
||||
obj.type = r.readU16();
|
||||
obj.presenter = this._readNullString(r);
|
||||
r.skip(4); // unknown1
|
||||
obj.name = this._readNullString(r);
|
||||
obj.id = r.readU32();
|
||||
obj.flags = r.readU32();
|
||||
r.skip(4); // unknown4
|
||||
obj.duration = r.readU32();
|
||||
obj.loops = r.readU32();
|
||||
|
||||
// Location, Direction, Up - 3x Vector3 (3x f64 each = 72 bytes)
|
||||
obj.location = [r.readF64(), r.readF64(), r.readF64()];
|
||||
obj.direction = [r.readF64(), r.readF64(), r.readF64()];
|
||||
obj.up = [r.readF64(), r.readF64(), r.readF64()];
|
||||
|
||||
// Extra data (u16 length prefix)
|
||||
const extraSize = r.readU16();
|
||||
if (extraSize > 0) {
|
||||
obj.extra = new Uint8Array(r.slice(extraSize));
|
||||
}
|
||||
|
||||
// Conditional fields: only for non-Presenter/World/Animation types
|
||||
if (obj.type !== SIObjectType.Presenter &&
|
||||
obj.type !== SIObjectType.World &&
|
||||
obj.type !== SIObjectType.Animation) {
|
||||
obj.filename = this._readNullString(r);
|
||||
r.skip(4); // unknown26
|
||||
r.skip(4); // unknown27
|
||||
r.skip(4); // unknown28
|
||||
obj.filetype = r.readU32();
|
||||
r.skip(4); // unknown29
|
||||
r.skip(4); // unknown30
|
||||
|
||||
if (obj.filetype === SIFileType.WAV) {
|
||||
obj.volume = r.readU32();
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* After reading MxOb tree structure, collect MxCh data for all objects.
|
||||
* Scans the same buffer that was fetched for readObject().
|
||||
* @param {SIObject} rootObj - The root composite object
|
||||
* @param {ArrayBuffer} buffer - The fetched chunk data
|
||||
*/
|
||||
_collectMxChFromBuffer(rootObj, buffer) {
|
||||
const targetIds = new Set();
|
||||
const objectsById = new Map();
|
||||
|
||||
const registerObj = (obj) => {
|
||||
targetIds.add(obj.id);
|
||||
objectsById.set(obj.id, obj);
|
||||
for (const child of obj.children) registerObj(child);
|
||||
};
|
||||
registerObj(rootObj);
|
||||
|
||||
// Phase 1: Collect MxCh entries per object, keyed by time to deduplicate.
|
||||
// The SI streaming system can have multiple chunks with the same time for the
|
||||
// same object (from buffer re-interleaving). The last one wins.
|
||||
const entriesByObj = new Map(); // objectId → Map<time, {data, flags, dataSize, fileOrder}>
|
||||
const buf = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
const mxChSig = [0x4d, 0x78, 0x43, 0x68]; // 'MxCh'
|
||||
let fileOrder = 0;
|
||||
|
||||
let pos = 0;
|
||||
while (pos <= buf.length - MXCH_HEADER_SIZE - 8) {
|
||||
if (buf[pos] !== mxChSig[0] || buf[pos + 1] !== mxChSig[1] ||
|
||||
buf[pos + 2] !== mxChSig[2] || buf[pos + 3] !== mxChSig[3]) {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const chunkSize = view.getUint32(pos + 4, true);
|
||||
if (chunkSize < MXCH_HEADER_SIZE || pos + 8 + chunkSize > buf.length) {
|
||||
pos += 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
const flags = view.getUint16(pos + 8, true);
|
||||
const objectId = view.getUint32(pos + 10, true);
|
||||
const time = view.getUint32(pos + 14, true);
|
||||
const dataSize = view.getUint32(pos + 18, true);
|
||||
|
||||
if (targetIds.has(objectId) && !(flags & MXCH_FLAG_END)) {
|
||||
const payloadSize = chunkSize - MXCH_HEADER_SIZE;
|
||||
const dataStart = pos + 8 + MXCH_HEADER_SIZE;
|
||||
|
||||
if (payloadSize > 0 && dataStart + payloadSize <= buf.length) {
|
||||
const chunkData = buffer.slice(dataStart, dataStart + payloadSize);
|
||||
if (!entriesByObj.has(objectId)) entriesByObj.set(objectId, new Map());
|
||||
const objEntries = entriesByObj.get(objectId);
|
||||
|
||||
if (flags & MXCH_FLAG_SPLIT) {
|
||||
// Split chunks: append to existing entry with same time
|
||||
const existing = objEntries.get(time);
|
||||
if (existing && existing.flags & MXCH_FLAG_SPLIT) {
|
||||
const combined = new Uint8Array(existing.data.byteLength + chunkData.byteLength);
|
||||
combined.set(new Uint8Array(existing.data), 0);
|
||||
combined.set(new Uint8Array(chunkData), existing.data.byteLength);
|
||||
existing.data = combined.buffer;
|
||||
} else {
|
||||
objEntries.set(time, { data: chunkData, flags, dataSize, fileOrder: fileOrder++ });
|
||||
}
|
||||
} else {
|
||||
// Non-split: last chunk with this time wins (overwrite)
|
||||
objEntries.set(time, { data: chunkData, flags, dataSize, fileOrder: fileOrder++ });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos += 8 + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
// Phase 2: Sort deduplicated entries by time and assemble into obj.data.
|
||||
// The header chunk (time=0xFFFFFFFF) must be first (data[0]), followed by
|
||||
// frame/data chunks sorted by ascending time.
|
||||
for (const [objectId, timeMap] of entriesByObj) {
|
||||
const obj = objectsById.get(objectId);
|
||||
const sorted = [...timeMap.entries()].sort((a, b) => {
|
||||
// 0xFFFFFFFF (header) sorts first; everything else by time ascending
|
||||
const aIsHeader = a[0] === 0xFFFFFFFF;
|
||||
const bIsHeader = b[0] === 0xFFFFFFFF;
|
||||
if (aIsHeader) return -1;
|
||||
if (bIsHeader) return 1;
|
||||
return a[0] - b[0];
|
||||
});
|
||||
|
||||
for (const [time, entry] of sorted) {
|
||||
obj.data.push(entry.data);
|
||||
if (obj.data.length === 2) obj.timeOffset = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read object and collect its data in a single operation.
|
||||
* This is the main entry point for scene loading.
|
||||
* @param {number} objectId - Slot index
|
||||
* @returns {Promise<SIObject|null>}
|
||||
*/
|
||||
async readObjectWithData(objectId) {
|
||||
if (this.objectCache.has(objectId)) {
|
||||
return this.objectCache.get(objectId);
|
||||
}
|
||||
|
||||
if (objectId >= this.slotOffsets.length) return null;
|
||||
const offset = this.slotOffsets[objectId];
|
||||
if (offset === 0) return null;
|
||||
|
||||
// Fetch the chunk header to learn total size
|
||||
const headerBuf = await this._fetchRange(offset, 8);
|
||||
const hdr = new BinaryReader(headerBuf);
|
||||
const chunkId = hdr.readU32();
|
||||
const chunkSize = hdr.readU32();
|
||||
|
||||
// Fetch the full MxSt chunk (contains MxOb definitions AND MxCh data)
|
||||
const totalSize = 8 + chunkSize + (chunkSize % 2);
|
||||
const chunkBuf = await this._fetchRange(offset, totalSize);
|
||||
|
||||
// Parse the object tree
|
||||
const r = new BinaryReader(chunkBuf);
|
||||
const obj = this._readChunkTree(r, offset);
|
||||
|
||||
if (obj) {
|
||||
// Collect MxCh data from the same buffer
|
||||
this._collectMxChFromBuffer(obj, chunkBuf);
|
||||
this.objectCache.set(objectId, obj);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a byte range from the SI file.
|
||||
* @param {number} start - Start byte offset
|
||||
* @param {number} length - Number of bytes to fetch
|
||||
* @returns {Promise<ArrayBuffer>}
|
||||
*/
|
||||
async _fetchRange(start, length) {
|
||||
const end = start + length - 1;
|
||||
const headers = { 'Range': `bytes=${start}-${end}` };
|
||||
if (this.language) {
|
||||
headers['Accept-Language'] = this.language;
|
||||
}
|
||||
const response = await fetch(this.url, { headers });
|
||||
|
||||
if (response.status === 206) {
|
||||
// Partial content - expected
|
||||
return response.arrayBuffer();
|
||||
} else if (response.ok) {
|
||||
// Server doesn't support Range (returned full file).
|
||||
// Slice the portion we need.
|
||||
const fullBuffer = await response.arrayBuffer();
|
||||
return fullBuffer.slice(start, start + length);
|
||||
} else {
|
||||
throw new Error(`Range request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read null-terminated ASCII string. */
|
||||
_readNullString(r) {
|
||||
const bytes = [];
|
||||
while (r.remaining() > 0) {
|
||||
const b = r.readU8();
|
||||
if (b === 0) break;
|
||||
bytes.push(b);
|
||||
}
|
||||
return String.fromCharCode(...bytes);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── Per-world SI file URL mapping ──
|
||||
|
||||
const SI_PATHS = Object.freeze({
|
||||
0: '/LEGO/Scripts/Isle/ISLE.SI',
|
||||
1: '/LEGO/Scripts/Act2/ACT2MAIN.SI',
|
||||
2: '/LEGO/Scripts/Act3/ACT3.SI',
|
||||
});
|
||||
|
||||
/** Singleton cache: worldSlot → Promise<SIReader>. */
|
||||
const siReaderCache = new Map();
|
||||
|
||||
/**
|
||||
* Get or create an SIReader for the given world slot.
|
||||
* The reader is opened once (header-only) and cached.
|
||||
* @param {number} worldSlot - 0=ACT1, 1=ACT2, 2=ACT3
|
||||
* @param {string|null} [language] - Language code for Accept-Language header
|
||||
* @returns {Promise<SIReader>}
|
||||
*/
|
||||
export async function getSIReader(worldSlot, language = null) {
|
||||
const cacheKey = language ? `${worldSlot}:${language}` : worldSlot;
|
||||
if (siReaderCache.has(cacheKey)) {
|
||||
return siReaderCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const url = SI_PATHS[worldSlot];
|
||||
if (!url) throw new Error(`Unknown world slot: ${worldSlot}`);
|
||||
|
||||
const reader = new SIReader();
|
||||
const promise = reader.open(url, language).then(() => {
|
||||
siReaderCache.set(cacheKey, reader);
|
||||
return reader;
|
||||
});
|
||||
siReaderCache.set(cacheKey, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive worldSlot from an animIndex.
|
||||
* animIndex = (worldSlot << 14) | localIndex
|
||||
*/
|
||||
export function decodeAnimIndex(animIndex) {
|
||||
const worldSlot = animIndex >> 14;
|
||||
const localIndex = animIndex & 0x3fff;
|
||||
return { worldSlot, localIndex };
|
||||
}
|
||||
@ -541,6 +541,27 @@ export function buildGlobalPartsMap(globalParts) {
|
||||
return partsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all renderable ROIs (root + children) with resolved LODs.
|
||||
* @param {object} roi - Root ROI node from model data
|
||||
* @param {Map} partsMap - Parts map for shared LOD resolution
|
||||
* @returns {Array<{ name: string, lods: Array }>}
|
||||
*/
|
||||
export function collectAllRois(roi, partsMap) {
|
||||
const result = [];
|
||||
const walk = (node) => {
|
||||
const lods = resolveLods(node, partsMap);
|
||||
if (lods.length > 0) {
|
||||
result.push({ name: node.name, lods });
|
||||
}
|
||||
for (const child of node.children || []) {
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(roi);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a parts lookup map from a world's parts array
|
||||
* @param {WdbParser} parser - Parser instance for reading part data
|
||||
|
||||
16
src/core/keep-visible.js
Normal file
16
src/core/keep-visible.js
Normal file
@ -0,0 +1,16 @@
|
||||
// Svelte action: prevents the game engine from hiding overlay elements.
|
||||
// The engine sets display:none on DOM elements — this observer forces them back.
|
||||
export function keepVisible(node) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (node.style.display === 'none') {
|
||||
node.style.setProperty('display', 'block', 'important');
|
||||
}
|
||||
});
|
||||
observer.observe(node, { attributes: true, attributeFilter: ['style'] });
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
228
src/core/memories.js
Normal file
228
src/core/memories.js
Normal file
@ -0,0 +1,228 @@
|
||||
// IndexedDB-based memory persistence for animation completions
|
||||
import { memoryUnlocks, memoryCompletions } from '../stores.js';
|
||||
import { authSession, authReady } from './auth.js';
|
||||
import { API_URL } from './config.js';
|
||||
import { getConfigLanguage } from './opfs.js';
|
||||
|
||||
const DB_NAME = 'isle-pizza-memories';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'completions';
|
||||
|
||||
let db = null;
|
||||
let currentSession = null;
|
||||
|
||||
export async function initMemories() {
|
||||
try {
|
||||
db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const database = e.target.result;
|
||||
const store = database.createObjectStore(STORE_NAME, { autoIncrement: true });
|
||||
store.createIndex('animIndex', 'animIndex', { unique: false });
|
||||
store.createIndex('eventId', 'eventId', { unique: true });
|
||||
};
|
||||
req.onsuccess = (e) => resolve(e.target.result);
|
||||
req.onerror = (e) => reject(e.target.error);
|
||||
});
|
||||
await rebuildStores();
|
||||
|
||||
// Wait for initial auth check to complete, then sync if logged in.
|
||||
// authReady resolves once regardless of timing — no race conditions.
|
||||
currentSession = await authReady;
|
||||
if (currentSession) {
|
||||
await syncWithServer();
|
||||
}
|
||||
|
||||
// Watch subsequent auth transitions (login/logout after init).
|
||||
// The subscription fires immediately with the current value — if it
|
||||
// matches what authReady gave us, it's a no-op.
|
||||
authSession.subscribe(session => {
|
||||
if (session === undefined) return;
|
||||
|
||||
const wasLoggedIn = !!currentSession;
|
||||
const nowLoggedIn = !!session;
|
||||
currentSession = session;
|
||||
|
||||
if (wasLoggedIn === nowLoggedIn) return;
|
||||
if (nowLoggedIn) syncWithServer();
|
||||
else clearLocalMemories();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Memory] Failed to open IndexedDB:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordCompletion(animIndex, eventId, participants) {
|
||||
if (!db) return;
|
||||
try {
|
||||
const language = await getConfigLanguage();
|
||||
let wasDuplicate = false;
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const req = tx.objectStore(STORE_NAME).add({
|
||||
animIndex,
|
||||
eventId,
|
||||
t: Math.floor(Date.now() / 1000),
|
||||
participants,
|
||||
language,
|
||||
synced: false
|
||||
});
|
||||
req.onerror = (e) => {
|
||||
if (req.error?.name === 'ConstraintError') {
|
||||
e.preventDefault();
|
||||
wasDuplicate = true;
|
||||
}
|
||||
};
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = (e) => reject(e.target.error);
|
||||
});
|
||||
|
||||
if (wasDuplicate) return;
|
||||
|
||||
await rebuildStores();
|
||||
|
||||
if (currentSession && participants.length > 0) {
|
||||
reportToServer(animIndex, eventId, participants, language);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Memory] Failed to record completion:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all completions from IndexedDB, rebuild the Svelte stores,
|
||||
* and return the records array.
|
||||
*/
|
||||
async function rebuildStores() {
|
||||
if (!db) return [];
|
||||
const records = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const req = tx.objectStore(STORE_NAME).getAll();
|
||||
req.onsuccess = (e) => resolve(e.target.result);
|
||||
req.onerror = (e) => reject(e.target.error);
|
||||
});
|
||||
memoryUnlocks.set(new Set(records.map(r => r.animIndex)));
|
||||
memoryCompletions.set(records);
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark completions as synced to server by eventId.
|
||||
*/
|
||||
async function markAsSynced(eventIdSet) {
|
||||
if (!db || eventIdSet.size === 0) return;
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.openCursor();
|
||||
req.onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) return;
|
||||
if (eventIdSet.has(cursor.value.eventId) && !cursor.value.synced) {
|
||||
cursor.update({ ...cursor.value, synced: true });
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
await new Promise((resolve, reject) => {
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = (e) => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all completions from IndexedDB and reset both stores.
|
||||
*/
|
||||
export async function clearLocalMemories() {
|
||||
if (!db) {
|
||||
memoryUnlocks.set(new Set());
|
||||
memoryCompletions.set([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = (e) => reject(e.target.error);
|
||||
});
|
||||
memoryUnlocks.set(new Set());
|
||||
memoryCompletions.set([]);
|
||||
} catch (e) {
|
||||
console.error('[Memory] Failed to clear local memories:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server sync ---
|
||||
|
||||
async function reportToServer(animIndex, eventId, participants, language) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/memories`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ animIndex, eventId, participants, language })
|
||||
});
|
||||
if (res.ok) {
|
||||
await markAsSynced(new Set([eventId]));
|
||||
await rebuildStores();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Memory] Failed to report to server:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWithServer() {
|
||||
if (!db) return;
|
||||
try {
|
||||
const localCompletions = await rebuildStores();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/memories/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
completions: localCompletions.map(c => ({
|
||||
...c,
|
||||
language: c.language || 'en'
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const { completions: serverCompletions } = await res.json();
|
||||
if (!Array.isArray(serverCompletions)) return;
|
||||
|
||||
// Merge server-only completions into IndexedDB
|
||||
const localEventIds = new Set(localCompletions.map(c => c.eventId));
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
for (const sc of serverCompletions) {
|
||||
if (!localEventIds.has(sc.event_id)) {
|
||||
store.add({
|
||||
animIndex: sc.anim_index,
|
||||
eventId: sc.event_id,
|
||||
t: sc.completed_at,
|
||||
participants: JSON.parse(sc.participants || '[]'),
|
||||
language: sc.language || 'en',
|
||||
synced: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = (e) => reject(e.target.error);
|
||||
});
|
||||
|
||||
// Mark all local records as synced (server accepted our data)
|
||||
const allEventIds = new Set([
|
||||
...localCompletions.map(c => c.eventId),
|
||||
...serverCompletions.map(c => c.event_id)
|
||||
]);
|
||||
await markAsSynced(allEventIds);
|
||||
await rebuildStores();
|
||||
} catch (e) {
|
||||
console.warn('[Memory] Sync failed:', e);
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,72 @@
|
||||
// Navigation utilities
|
||||
import { currentPage } from '../stores.js';
|
||||
import { get } from 'svelte/store';
|
||||
import { currentPage, multiplayerRoom, scenePlayerEventId, scenePlayerData } from '../stores.js';
|
||||
import { toUrlSafeBase64 } from './base64.js';
|
||||
|
||||
export function navigateTo(page) {
|
||||
if (get(currentPage) === page) return;
|
||||
currentPage.set(page);
|
||||
history.pushState({ page }, '', '#' + page);
|
||||
history.pushState({ page, fromApp: true }, '', '#' + page);
|
||||
}
|
||||
|
||||
export function navigateBack() {
|
||||
history.back();
|
||||
export function navigateToRoom(name) {
|
||||
if (get(currentPage) === 'multiplayer' && get(multiplayerRoom) === name) return;
|
||||
multiplayerRoom.set(name);
|
||||
currentPage.set('multiplayer');
|
||||
history.pushState({ page: 'multiplayer', room: name, fromApp: true }, '', '#r/' + name);
|
||||
}
|
||||
|
||||
export function navigateToMultiplayer() {
|
||||
if (get(currentPage) === 'multiplayer' && get(multiplayerRoom) === null) return;
|
||||
multiplayerRoom.set(null);
|
||||
currentPage.set('multiplayer');
|
||||
history.pushState({ page: 'multiplayer', fromApp: true }, '', '#multiplayer');
|
||||
}
|
||||
|
||||
export function navigateToMemory(eventId) {
|
||||
scenePlayerEventId.set(eventId);
|
||||
scenePlayerData.set(null);
|
||||
currentPage.set('scene-player');
|
||||
history.pushState({ page: 'scene-player', eventId, fromApp: true }, '', '/memory/' + eventId);
|
||||
}
|
||||
|
||||
export function navigateToScene(animIndex, participants, language = null, timestamp = null) {
|
||||
const data = buildSceneData(animIndex, participants, language, timestamp);
|
||||
const encoded = toUrlSafeBase64(JSON.stringify(data));
|
||||
scenePlayerEventId.set(null);
|
||||
scenePlayerData.set(data);
|
||||
currentPage.set('scene-player');
|
||||
history.pushState({ page: 'scene-player', sceneData: encoded, fromApp: true }, '', '/scene/' + encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the compact short-key data object for a /scene/ URL.
|
||||
*/
|
||||
function buildSceneData(animIndex, participants, language, timestamp) {
|
||||
const data = { a: animIndex, p: participants.map(p => ({ n: p.displayName ?? p.n, c: p.charIndex ?? p.c })) };
|
||||
if (language && language !== 'en') data.l = language;
|
||||
if (timestamp) data.t = timestamp;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode scene data into a compact URL-safe base64 string for use in /scene/ URLs.
|
||||
* Uses short keys (a/p/n/c/l/t) to minimize encoded URL length.
|
||||
* @returns {string} URL-safe base64-encoded JSON string
|
||||
*/
|
||||
export function encodeSceneData(animIndex, participants, language = null, timestamp = null) {
|
||||
return toUrlSafeBase64(JSON.stringify(buildSceneData(animIndex, participants, language, timestamp)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a unix timestamp (seconds) into a localized date/time string.
|
||||
* @param {number} timestamp - Unix timestamp in seconds
|
||||
* @returns {string} Formatted string like "Mar 29, 2026 · 3:45 PM"
|
||||
*/
|
||||
export function formatDateTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const d = new Date(timestamp * 1000);
|
||||
const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
return `${date} \u00b7 ${time}`;
|
||||
}
|
||||
|
||||
107
src/core/opfs.js
107
src/core/opfs.js
@ -1,8 +1,8 @@
|
||||
// OPFS Config Manager - handles saving/loading configuration via Origin Private File System
|
||||
import { configToastVisible, configToastMessage } from '../stores.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { getSiFilesForCache } from './service-worker.js';
|
||||
|
||||
const CONFIG_FILE = 'isle.ini';
|
||||
let toastTimeout = null;
|
||||
export const CONFIG_FILE = 'isle.ini';
|
||||
|
||||
// ============================================================================
|
||||
// Core OPFS Operations
|
||||
@ -104,7 +104,6 @@ export async function writeBinaryFile(filename, data, silent = false, toastMsg =
|
||||
worker.postMessage({ filename, buffer: data });
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
console.log(e.data.message);
|
||||
URL.revokeObjectURL(workerUrl);
|
||||
worker.terminate();
|
||||
|
||||
@ -164,51 +163,53 @@ export async function listFiles(pattern) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
* @param {string} message - Message to display
|
||||
*/
|
||||
function showToast(message) {
|
||||
if (toastTimeout) {
|
||||
clearTimeout(toastTimeout);
|
||||
}
|
||||
configToastMessage.set(message);
|
||||
configToastVisible.set(true);
|
||||
toastTimeout = setTimeout(() => configToastVisible.set(false), 2000);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config File Operations
|
||||
// ============================================================================
|
||||
|
||||
export async function loadConfig(form) {
|
||||
const handle = await getFileHandle(CONFIG_FILE, true);
|
||||
if (!handle) return null;
|
||||
|
||||
/**
|
||||
* Read the Language value from the INI config file.
|
||||
* @returns {Promise<string>} Language code (e.g. 'en', 'de'), defaults to 'en'
|
||||
*/
|
||||
export async function getConfigLanguage() {
|
||||
try {
|
||||
const handle = await getFileHandle(CONFIG_FILE, false);
|
||||
if (!handle) return 'en';
|
||||
const file = await handle.getFile();
|
||||
const text = await file.text();
|
||||
if (!text) {
|
||||
console.log('No existing config file found, using defaults.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = {};
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!text) return 'en';
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.startsWith('[') || !line.includes('=')) continue;
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
const value = valueParts.join('=').trim();
|
||||
config[key.trim()] = value;
|
||||
if (key.trim().toLowerCase() === 'language') return valueParts.join('=').trim() || 'en';
|
||||
}
|
||||
return 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
||||
applyConfigToForm(form, config);
|
||||
console.log('Config loaded from', CONFIG_FILE);
|
||||
return config;
|
||||
} catch (e) {
|
||||
console.error('Failed to load config:', e);
|
||||
export async function loadConfig(form) {
|
||||
const handle = await getFileHandle(CONFIG_FILE, false);
|
||||
if (!handle) return null;
|
||||
|
||||
const file = await handle.getFile();
|
||||
const text = await file.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = {};
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('[') || !line.includes('=')) continue;
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
const value = valueParts.join('=').trim();
|
||||
config[key.trim()] = value;
|
||||
}
|
||||
|
||||
applyConfigToForm(form, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
function applyConfigToForm(form, config) {
|
||||
@ -250,7 +251,7 @@ function applyConfigToForm(form, config) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(form, getSiFiles, silent = false) {
|
||||
export async function saveConfig(form, getSiFiles, silent = false, multiplayer = null) {
|
||||
let iniContent = '[isle]\n';
|
||||
const elements = form.elements;
|
||||
|
||||
@ -281,6 +282,9 @@ export async function saveConfig(form, getSiFiles, silent = false) {
|
||||
iniContent += "[extensions]\n";
|
||||
const value = hdTextures.checked ? 'YES' : 'NO';
|
||||
iniContent += `${hdTextures.name}=${value}\n`;
|
||||
iniContent += `Multiplayer=${multiplayer ? 'YES' : 'NO'}\n`;
|
||||
const thirdPersonCamera = elements["Third Person Camera"];
|
||||
iniContent += `Third Person Camera=${thirdPersonCamera && thirdPersonCamera.checked ? 'YES' : 'NO'}\n`;
|
||||
}
|
||||
|
||||
const siFiles = getSiFiles();
|
||||
@ -309,5 +313,32 @@ export async function saveConfig(form, getSiFiles, silent = false) {
|
||||
iniContent += `directives=${directives.join(",\\\n")}\n`;
|
||||
}
|
||||
|
||||
return writeTextFile(CONFIG_FILE, iniContent, silent);
|
||||
if (multiplayer) {
|
||||
iniContent += "[multiplayer]\n";
|
||||
iniContent += `relay url=${multiplayer.relayUrl}\n`;
|
||||
iniContent += `room=${multiplayer.room}\n`;
|
||||
if (multiplayer.actor) {
|
||||
iniContent += `actor=${multiplayer.actor}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await writeTextFile(CONFIG_FILE, iniContent, silent);
|
||||
if (result) {
|
||||
window.dispatchEvent(new CustomEvent('opfs-config-written', {
|
||||
detail: { iniText: iniContent }
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function saveConfigFromDOM(multiplayer = null) {
|
||||
const form = document.getElementById('config-form');
|
||||
if (!form) return false;
|
||||
const getSiFiles = () => {
|
||||
const hdMusic = document.getElementById('check-hd-music');
|
||||
const widescreenBgs = document.getElementById('check-widescreen-bgs');
|
||||
const badEnding = document.getElementById('check-ending');
|
||||
return getSiFilesForCache(hdMusic, widescreenBgs, badEnding);
|
||||
};
|
||||
return saveConfig(form, getSiFiles, true, multiplayer);
|
||||
}
|
||||
|
||||
@ -1,67 +1,49 @@
|
||||
import * as THREE from 'three';
|
||||
import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js';
|
||||
import { LegoColors } from '../savegame/constants.js';
|
||||
import { Transform, Mesh } from 'ogl';
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { Quat } from 'ogl/src/math/Quat.js';
|
||||
import { Mat4 } from 'ogl/src/math/Mat4.js';
|
||||
import { ActorLODs, ActorInfoInit } from '../savegame/actorConstants.js';
|
||||
import { AnimatedRenderer } from './AnimatedRenderer.js';
|
||||
import {
|
||||
SimpleAnimationMixer, AnimationClip,
|
||||
VectorTrack, QuaternionTrack, BooleanTrack, LoopOnce,
|
||||
} from './AnimationMixer.js';
|
||||
import { getVisibility } from '../animation/keyframeEval.js';
|
||||
|
||||
/**
|
||||
* Map actor index to animation suffix index (from g_characters[].m_unk0x16).
|
||||
* This maps the 66 ActorInfoInit indices to the g_cycles row index.
|
||||
*/
|
||||
const ACTOR_SUFFIX_INDEX = (() => {
|
||||
// Default all to 0 (xx)
|
||||
const map = new Array(66).fill(0);
|
||||
map[0] = 1; // pepper → Pe
|
||||
map[1] = 2; // mama → Ma
|
||||
map[2] = 3; // papa → Pa
|
||||
map[3] = 4; // nick → Ni
|
||||
map[4] = 5; // laura → La
|
||||
map[5] = 0; // infoman → xx (not in g_characters, uses default)
|
||||
map[5] = 0; // infoman → xx
|
||||
map[6] = 6; // brickstr → Br
|
||||
// 7-35: all 0 (xx) — generic NPCs
|
||||
// Note: g_characters indices don't perfectly align with ActorInfoInit indices
|
||||
// for the NPCs after brickstr. The g_characters array has 47 entries
|
||||
// matching by name. We map the special ones:
|
||||
map[37] = 9; // rd → Rd (g_characters index 36)
|
||||
map[38] = 8; // pg → Pg (g_characters index 37)
|
||||
map[39] = 7; // bd → Bd (g_characters index 38)
|
||||
map[40] = 10; // sy → Sy (g_characters index 39)
|
||||
map[56] = 1; // pep → Pe (same as pepper)
|
||||
map[37] = 9; // rd → Rd
|
||||
map[38] = 8; // pg → Pg
|
||||
map[39] = 7; // bd → Bd
|
||||
map[40] = 10; // sy → Sy
|
||||
map[56] = 1; // pep → Pe
|
||||
return map;
|
||||
})();
|
||||
|
||||
/**
|
||||
* g_cycles[11][17] — animation name table from legoanimationmanager.cpp.
|
||||
* Rows = character type suffix index, columns = mood (0-3) for walking, higher indices for other animations.
|
||||
*/
|
||||
const G_CYCLES = [
|
||||
// 0: xx
|
||||
['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs011xx','CNs012xx',null,null,null,null,null],
|
||||
// 1: Pe
|
||||
['CNs001Pe','CNs002Pe','CNs003Pe','CNs004Pe','CNs005Pe','CNs007Pe','CNs006Pe','CNs008Pe','CNs009Pe','CNs010Pe','CNs001sk',null,null,null,null,null,null], // CNs001sk = skateboard
|
||||
// 2: Ma
|
||||
['CNs001Pe','CNs002Pe','CNs003Pe','CNs004Pe','CNs005Pe','CNs007Pe','CNs006Pe','CNs008Pe','CNs009Pe','CNs010Pe','CNs001sk',null,null,null,null,null,null],
|
||||
['CNs001Ma','CNs002Ma','CNs003Ma','CNs004Ma','CNs005Ma','CNs007Ma','CNs006Ma','CNs008Ma','CNs009Ma','CNs010Ma','CNs0x4Ma',null,null,'CNs011Ma','CNs012Ma','CNs013Ma',null],
|
||||
// 3: Pa
|
||||
['CNs001Pa','CNs002Pa','CNs003Pa','CNs004Pa','CNs005Pa','CNs007Pa','CNs006Pa','CNs008Pa','CNs009Pa','CNs010Pa','CNs0x4Pa',null,null,'CNs011Pa','CNs012Pa','CNs013Pa',null],
|
||||
// 4: Ni
|
||||
['CNs001Ni','CNs002Ni','CNs003Ni','CNs004Ni','CNs005Ni','CNs007Ni','CNs006Ni','CNs008Ni','CNs009Ni','CNs010Ni','CNs011Ni','CNsx11Ni',null,null,null,null,null],
|
||||
// 5: La
|
||||
['CNs001La','CNs002La','CNs003La','CNs004La','CNs005La','CNs007La','CNs006La','CNs008La','CNs009La','CNs010La','CNs011La','CNsx11La',null,null,null,null,null],
|
||||
// 6: Br
|
||||
['CNs001Br','CNs002Br','CNs003Br','CNs004Br','CNs005Br','CNs007Br','CNs006Br','CNs008Br','CNs009Br','CNs010Br','CNs011Br','CNs900Br','CNs901Br','CNs011Br','CNs012Br','CNs013Br','CNs014Br'],
|
||||
// 7: Bd
|
||||
['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Bd','CNs012xx',null,null,null,null,null],
|
||||
// 8: Pg
|
||||
['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Pg','CNs012xx',null,null,null,null,null],
|
||||
// 9: Rd
|
||||
['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Rd','CNs012xx',null,null,null,null,null],
|
||||
// 10: Sy
|
||||
['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Sy','CNs012xx',null,null,null,null,null],
|
||||
];
|
||||
|
||||
/**
|
||||
* Map ActorLOD names (used in our part hierarchy) to animation node names.
|
||||
* Animation files use uppercase names like "BODY", "HEAD", "LEG-RT", etc.
|
||||
*/
|
||||
const PART_NAME_TO_ANIM_NODE = {
|
||||
'body': 'BODY',
|
||||
'infohat': 'INFOHAT',
|
||||
@ -75,55 +57,42 @@ const PART_NAME_TO_ANIM_NODE = {
|
||||
'leg-rt': 'LEG-RT'
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderer for full LEGO characters assembled from WDB global parts.
|
||||
* Mirrors the game's LegoCharacterManager::CreateActorROI logic.
|
||||
*/
|
||||
export class ActorRenderer extends AnimatedRenderer {
|
||||
constructor(canvas) {
|
||||
super(canvas);
|
||||
this.partGroups = []; // 10 part groups for click targeting
|
||||
this._queuedClickMove = null; // queued click animation move index (0-3)
|
||||
constructor(canvas, rendererOptions) {
|
||||
super(canvas, rendererOptions);
|
||||
this.partGroups = [];
|
||||
this._queuedClickMove = null;
|
||||
this.skipAnimations = false;
|
||||
|
||||
this.camera.position.set(2, 0.8, 3.5);
|
||||
this.camera.lookAt(0, 0.2, 0);
|
||||
this.camera.lookAt([0, 0.2, 0]);
|
||||
|
||||
this.setupControls(new THREE.Vector3(0, 0.2, 0));
|
||||
this.controls.autoRotate = false;
|
||||
this._initialAutoRotate = false;
|
||||
this.setupControls(new Vec3(0, 0.2, 0));
|
||||
if (this.controls) {
|
||||
this.controls.autoRotate = false;
|
||||
this._initialAutoRotate = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a full actor from global parts, optionally with a vehicle.
|
||||
* @param {number} actorIndex - Index into ActorInfoInit (0-65)
|
||||
* @param {Array} characters - Parsed character state from save file (66 entries)
|
||||
* @param {Map} globalPartsMap - Name→part lookup for global parts
|
||||
* @param {Array} globalTextures - Global texture list from WDB
|
||||
* @param {Map|null} vehiclePartsMap - Name→part lookup for vehicle parts (null if no vehicle)
|
||||
* @param {Array|null} vehicleTextures - Vehicle texture list (null if no vehicle)
|
||||
* @param {object|null} vehicleInfo - { vehicleModel, vehicleAnim } or null
|
||||
*/
|
||||
loadActor(actorIndex, characters, globalPartsMap, globalTextures, vehiclePartsMap, vehicleTextures, vehicleInfo) {
|
||||
this.clearModel();
|
||||
|
||||
const actorInfo = ActorInfoInit[actorIndex];
|
||||
const charState = characters[actorIndex];
|
||||
|
||||
// Build texture lookup (vehicle textures don't overwrite global ones)
|
||||
this.loadTextures(globalTextures);
|
||||
if (vehicleInfo) this.loadTextures(vehicleTextures, false);
|
||||
|
||||
this.modelGroup = new THREE.Group();
|
||||
this.modelGroup = new Transform();
|
||||
this.modelGroup.name = 'actorRoot';
|
||||
this.partGroups = [];
|
||||
this.vehicleGroup = null;
|
||||
this.vehicleInfo = vehicleInfo || null;
|
||||
|
||||
// Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10])
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const actorLOD = ActorLODs[i + 1];
|
||||
const part = actorInfo.parts[i];
|
||||
|
||||
// Resolve part name for body (i=0) and hat (i=1)
|
||||
let partName;
|
||||
if (i === 0 || i === 1) {
|
||||
partName = this.resolvePartName(part, charState, i);
|
||||
@ -133,59 +102,46 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
|
||||
if (!partName) continue;
|
||||
|
||||
// Find the part's LOD data in global parts
|
||||
const partData = globalPartsMap.get(partName.toLowerCase());
|
||||
if (!partData) continue;
|
||||
|
||||
const partGroup = new THREE.Group();
|
||||
partGroup.userData.partIndex = i;
|
||||
partGroup.userData.partName = partName;
|
||||
partGroup.userData.lodName = actorLOD.name; // for animation matching
|
||||
// Name used by Three.js PropertyBinding to match animation tracks
|
||||
const partGroup = new Transform();
|
||||
partGroup._userData = { partIndex: i, partName, lodName: actorLOD.name };
|
||||
partGroup.name = `part_${actorLOD.name}`;
|
||||
|
||||
// Resolve color/texture for this part
|
||||
const resolvedName = this.resolveNameValue(part, charState, i);
|
||||
|
||||
// Create meshes from LODs
|
||||
const lods = partData.lods || [];
|
||||
if (lods.length > 0) {
|
||||
const lod = lods[lods.length - 1]; // Highest quality
|
||||
const lod = lods[lods.length - 1];
|
||||
this.createPartMeshes(lod, actorLOD, part, resolvedName, i, partGroup);
|
||||
}
|
||||
|
||||
// Position the part using ActorLOD transform
|
||||
this.applyPartTransform(partGroup, actorLOD);
|
||||
|
||||
this.modelGroup.add(partGroup);
|
||||
this.modelGroup.addChild(partGroup);
|
||||
this.partGroups[i] = partGroup;
|
||||
}
|
||||
|
||||
// Create vehicle mesh if vehicle info is provided
|
||||
if (vehicleInfo && vehiclePartsMap) {
|
||||
this.createVehicleMesh(vehicleInfo, vehiclePartsMap);
|
||||
}
|
||||
|
||||
this.centerAndScaleModel(1.8);
|
||||
// Rotate 180° around Y so actor faces the camera (negating X for
|
||||
// left-to-right-handed conversion flips the facing direction)
|
||||
this.modelGroup.rotation.y = Math.PI;
|
||||
// Shift model up in vehicle mode so it's better framed
|
||||
if (this.vehicleGroup) {
|
||||
this.modelGroup.position.y += 0.2;
|
||||
}
|
||||
this.scene.add(this.modelGroup);
|
||||
this.scene.addChild(this.modelGroup);
|
||||
|
||||
// Load and start walking/vehicle animation based on mood
|
||||
const mood = charState?.mood ?? 0;
|
||||
this.loadAnimationForActor(actorIndex, mood, vehicleInfo);
|
||||
if (!this.skipAnimations) {
|
||||
const mood = charState?.mood ?? 0;
|
||||
this.loadAnimationForActor(actorIndex, mood, vehicleInfo);
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which part geometry to use (body variant or hat type).
|
||||
*/
|
||||
resolvePartName(part, charState, partIdx) {
|
||||
if (!part.partNameIndices || !part.partNames) return null;
|
||||
|
||||
@ -197,9 +153,6 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
return part.partNames[part.partNameIndices[nameIdx]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the color or texture name for a part.
|
||||
*/
|
||||
resolveNameValue(part, charState, partIdx) {
|
||||
if (!part.nameIndices || !part.names) return null;
|
||||
|
||||
@ -219,162 +172,100 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
return part.names[part.nameIndices[nameIdx]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create meshes for a single body part.
|
||||
*/
|
||||
createPartMeshes(lod, actorLOD, part, resolvedName, partIdx, group) {
|
||||
const useTexture = (actorLOD.flags & ActorLODFlags.USE_TEXTURE) !== 0;
|
||||
const useColor = (actorLOD.flags & ActorLODFlags.USE_COLOR) !== 0;
|
||||
// createPartMeshes is inherited from BaseRenderer
|
||||
|
||||
// Special case: body part (i=0) with partNameIndex 0 uses color instead of texture
|
||||
// (matches the C++ condition: i != 0 || part.m_partNameIndices[part.m_partNameIndex] != 0)
|
||||
const bodyUsesDefaultGeom = partIdx === 0 && part.partNameIndices &&
|
||||
part.partNameIndices[part.partNameIndex] === 0;
|
||||
|
||||
let partColor = null;
|
||||
let partTexture = null;
|
||||
|
||||
if (useTexture && !bodyUsesDefaultGeom) {
|
||||
// Look up texture by resolved name
|
||||
const texName = resolvedName?.toLowerCase();
|
||||
if (texName && this.textures.has(texName)) {
|
||||
partTexture = this.textures.get(texName);
|
||||
}
|
||||
}
|
||||
|
||||
if ((useColor || bodyUsesDefaultGeom) && !partTexture) {
|
||||
// Resolve LEGO color
|
||||
const colorEntry = LegoColors[resolvedName] || LegoColors['lego white'];
|
||||
partColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255);
|
||||
}
|
||||
|
||||
for (const mesh of lod.meshes) {
|
||||
const geometry = this.createGeometry(mesh, lod);
|
||||
if (!geometry) continue;
|
||||
|
||||
// Check for per-mesh texture from the WDB geometry
|
||||
let meshTexture = null;
|
||||
const meshTexName = mesh.properties?.textureName?.toLowerCase();
|
||||
if (meshTexName && this.textures.has(meshTexName)) {
|
||||
meshTexture = this.textures.get(meshTexName);
|
||||
}
|
||||
|
||||
let material;
|
||||
if (partTexture) {
|
||||
material = new THREE.MeshLambertMaterial({
|
||||
map: partTexture,
|
||||
side: THREE.DoubleSide,
|
||||
color: 0xffffff
|
||||
});
|
||||
} else if (meshTexture) {
|
||||
material = new THREE.MeshLambertMaterial({
|
||||
map: meshTexture,
|
||||
side: THREE.DoubleSide,
|
||||
color: 0xffffff
|
||||
});
|
||||
} else if (partColor) {
|
||||
material = new THREE.MeshLambertMaterial({
|
||||
color: partColor,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
} else {
|
||||
const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
|
||||
material = new THREE.MeshLambertMaterial({
|
||||
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
}
|
||||
|
||||
const threeMesh = new THREE.Mesh(geometry, material);
|
||||
group.add(threeMesh);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create vehicle mesh from WDB model ROIs and add to modelGroup.
|
||||
* vehiclePartsMap maps model name → array of { name, lods }.
|
||||
*/
|
||||
createVehicleMesh(vehicleInfo, vehiclePartsMap) {
|
||||
const rois = vehiclePartsMap.get(vehicleInfo.vehicleModel.toLowerCase());
|
||||
if (!rois || rois.length === 0) return;
|
||||
|
||||
this.vehicleGroup = new THREE.Group();
|
||||
this.vehicleGroup = new Transform();
|
||||
this.vehicleGroup.name = `vehicle_${vehicleInfo.vehicleModel}`;
|
||||
|
||||
for (const roi of rois) {
|
||||
const lods = roi.lods || [];
|
||||
if (lods.length === 0) continue;
|
||||
|
||||
const lod = lods[lods.length - 1]; // Highest quality
|
||||
const lod = lods[lods.length - 1];
|
||||
for (const mesh of lod.meshes) {
|
||||
const geometry = this.createGeometry(mesh, lod);
|
||||
if (!geometry) continue;
|
||||
this.vehicleGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh)));
|
||||
const program = this.createMeshProgram(mesh);
|
||||
this.vehicleGroup.addChild(new Mesh(this.gl, { geometry, program }));
|
||||
}
|
||||
}
|
||||
|
||||
this.modelGroup.add(this.vehicleGroup);
|
||||
this.modelGroup.addChild(this.vehicleGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Center and scale the actor, excluding the hat from the bounding box
|
||||
* so that changing hats doesn't shift the actor's position.
|
||||
*/
|
||||
centerAndScaleModel(scaleFactor) {
|
||||
if (!this.modelGroup) return;
|
||||
|
||||
const box = new THREE.Box3();
|
||||
// Build a temporary parent with just the parts we want to measure
|
||||
const min = new Vec3(Infinity, Infinity, Infinity);
|
||||
const max = new Vec3(-Infinity, -Infinity, -Infinity);
|
||||
let hasData = false;
|
||||
|
||||
// Update world matrices first
|
||||
this.modelGroup.updateMatrixWorld(true);
|
||||
|
||||
for (let i = 0; i < this.partGroups.length; i++) {
|
||||
if (i === 1 || !this.partGroups[i]) continue; // skip hat
|
||||
box.expandByObject(this.partGroups[i]);
|
||||
this.expandBounds(this.partGroups[i], min, max);
|
||||
hasData = true;
|
||||
}
|
||||
if (this.vehicleGroup) {
|
||||
box.expandByObject(this.vehicleGroup);
|
||||
this.expandBounds(this.vehicleGroup, min, max);
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
if (box.isEmpty()) {
|
||||
if (!hasData || min[0] === Infinity) {
|
||||
super.centerAndScaleModel(scaleFactor);
|
||||
return;
|
||||
}
|
||||
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = new Vec3(
|
||||
(min[0] + max[0]) / 2,
|
||||
(min[1] + max[1]) / 2,
|
||||
(min[2] + max[2]) / 2,
|
||||
);
|
||||
const size = new Vec3(
|
||||
max[0] - min[0],
|
||||
max[1] - min[1],
|
||||
max[2] - min[2],
|
||||
);
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const maxDim = Math.max(size[0], size[1], size[2]);
|
||||
if (maxDim > 0) {
|
||||
const scale = scaleFactor / maxDim;
|
||||
this.modelGroup.scale.setScalar(scale);
|
||||
this.modelGroup.position.copy(center).multiplyScalar(-scale);
|
||||
this.modelGroup.scale.set(scale, scale, scale);
|
||||
this.modelGroup.position.set(
|
||||
-center[0] * scale,
|
||||
-center[1] * scale,
|
||||
-center[2] * scale,
|
||||
);
|
||||
} else {
|
||||
this.modelGroup.position.sub(center);
|
||||
this.modelGroup.position.set(-center[0], -center[1], -center[2]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply position/direction/up transform from ActorLOD data.
|
||||
* The game uses CalcLocalTransform with direction/up vectors.
|
||||
*/
|
||||
applyPartTransform(group, actorLOD) {
|
||||
const pos = actorLOD.position;
|
||||
|
||||
// Negate X for our coordinate system (matching VehiclePartRenderer's -v.x)
|
||||
group.position.set(-pos[0], pos[1], pos[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which body part was clicked
|
||||
* @returns {number} Part index (0-9) or -1 if nothing hit
|
||||
*/
|
||||
getClickedPart(mouseEvent) {
|
||||
if (!this.modelGroup) return -1;
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
const mouse = [
|
||||
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
-(((mouseEvent.clientY - rect.top) / rect.height) * 2 - 1),
|
||||
];
|
||||
|
||||
this.raycaster.setFromCamera(mouse, this.camera);
|
||||
this.raycaster.castMouse(this.camera, mouse);
|
||||
|
||||
let closestPart = -1;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (let i = 0; i < this.partGroups.length; i++) {
|
||||
const partGroup = this.partGroups[i];
|
||||
@ -382,55 +273,38 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
|
||||
const meshes = [];
|
||||
partGroup.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) meshes.push(child);
|
||||
if (child instanceof Mesh) meshes.push(child);
|
||||
});
|
||||
|
||||
const intersects = this.raycaster.intersectObjects(meshes);
|
||||
if (intersects.length > 0) return i;
|
||||
const hits = this.raycaster.intersectMeshes(meshes, { cullFace: false });
|
||||
if (hits.length > 0 && hits[0].hit.distance < closestDistance) {
|
||||
closestDistance = hits[0].hit.distance;
|
||||
closestPart = i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
return closestPart;
|
||||
}
|
||||
|
||||
// ─── Animation System ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute secondary animation column index from mood, matching FUN_10063b90.
|
||||
* Primary: columns 0-3 (speed 0.7), Secondary: columns 4-6 (speed 4.0).
|
||||
* NPCs walk at speed 0.6-2.0, so most use the secondary animation which has
|
||||
* independent head/hat movement. Mood adjustment: if (mood >= 2) mood--.
|
||||
*/
|
||||
static getSecondaryAnimColumn(mood) {
|
||||
let adjMood = mood;
|
||||
if (adjMood >= 2) adjMood--;
|
||||
return adjMood + 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a click animation to play after the next model load/reload.
|
||||
* @param {number} move - The actor's m_move value (0-3)
|
||||
*/
|
||||
queueClickAnimation(move) {
|
||||
this._queuedClickMove = move;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and start the animation for the given actor. If a click animation
|
||||
* is queued, plays it first (one-shot), then resumes the walking loop.
|
||||
* Otherwise loads the walking animation from the g_cycles table using the
|
||||
* secondary (speed 4.0) variant which NPCs typically use in-game.
|
||||
* When vehicleInfo is provided, uses the vehicle animation instead.
|
||||
* Falls back to Y-axis rotation if unavailable.
|
||||
*/
|
||||
async loadAnimationForActor(actorIndex, mood = 0, vehicleInfo = undefined) {
|
||||
if (!this.modelGroup) return;
|
||||
|
||||
// Use stored vehicleInfo when not explicitly provided (e.g. resuming after click anim)
|
||||
if (vehicleInfo === undefined) {
|
||||
vehicleInfo = this.vehicleInfo;
|
||||
}
|
||||
|
||||
// If a click animation is queued (skip in vehicle mode), play it first
|
||||
if (this._queuedClickMove !== null && !vehicleInfo) {
|
||||
const move = this._queuedClickMove;
|
||||
this._queuedClickMove = null;
|
||||
@ -443,18 +317,15 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
|
||||
let animName;
|
||||
if (vehicleInfo) {
|
||||
// Vehicle mode: use the vehicle animation name
|
||||
animName = vehicleInfo.vehicleAnim;
|
||||
} else {
|
||||
const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0;
|
||||
// Use secondary animation (speed 4.0 threshold) — this is what NPCs use in-game
|
||||
// since their walking speed (0.6-2.0) exceeds the 0.7 primary threshold
|
||||
const secondaryCol = ActorRenderer.getSecondaryAnimColumn(mood);
|
||||
const primaryCol = mood;
|
||||
animName = G_CYCLES[suffixIdx]?.[secondaryCol] ?? G_CYCLES[suffixIdx]?.[primaryCol];
|
||||
}
|
||||
|
||||
if (!animName) return; // null entry in g_cycles — no animation for this combo
|
||||
if (!animName) return;
|
||||
|
||||
try {
|
||||
const animData = await this.fetchAnimationByName(animName);
|
||||
@ -462,7 +333,6 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
|
||||
const nodeToPartGroup = this.buildNodeToPartGroupMap();
|
||||
|
||||
// Map vehicle animation nodes if in vehicle mode
|
||||
if (vehicleInfo && this.vehicleGroup) {
|
||||
this.mapVehicleAnimNodes(animData, vehicleInfo, nodeToPartGroup);
|
||||
}
|
||||
@ -470,24 +340,21 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup);
|
||||
if (tracks.length === 0) return;
|
||||
|
||||
const clip = new THREE.AnimationClip('walk', -1, tracks);
|
||||
this.mixer = new THREE.AnimationMixer(this.modelGroup);
|
||||
const clip = new AnimationClip('walk', -1, tracks);
|
||||
this.mixer = new SimpleAnimationMixer(this.modelGroup);
|
||||
this.currentAction = this.mixer.clipAction(clip);
|
||||
this.currentAction.play();
|
||||
} catch (e) {
|
||||
// Animation unavailable — fall back to rotation (handled in updateAnimation())
|
||||
// Animation unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup mapping animation node names to part groups.
|
||||
*/
|
||||
buildNodeToPartGroupMap() {
|
||||
const map = new Map();
|
||||
for (let i = 0; i < this.partGroups.length; i++) {
|
||||
const pg = this.partGroups[i];
|
||||
if (!pg) continue;
|
||||
const lodName = pg.userData.lodName;
|
||||
const lodName = pg._userData.lodName;
|
||||
const animNodeName = PART_NAME_TO_ANIM_NODE[lodName];
|
||||
if (animNodeName) {
|
||||
map.set(animNodeName.toLowerCase(), pg);
|
||||
@ -496,19 +363,12 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map vehicle animation tree nodes to the vehicle group.
|
||||
* Scans the animation tree for nodes whose name (stripped of trailing
|
||||
* digits/underscores) matches the vehicle model name, and maps them
|
||||
* to the vehicleGroup so buildHierarchicalTracks can drive the vehicle.
|
||||
*/
|
||||
mapVehicleAnimNodes(animData, vehicleInfo, nodeToPartGroup) {
|
||||
const vehicleName = vehicleInfo.vehicleModel.toLowerCase();
|
||||
|
||||
const scanTree = (node) => {
|
||||
const name = node.data.name?.toLowerCase();
|
||||
if (name) {
|
||||
// Strip trailing digits and underscores to get base name
|
||||
const baseName = name.replace(/[\d_]+$/, '');
|
||||
if (baseName === vehicleName) {
|
||||
nodeToPartGroup.set(name, this.vehicleGroup);
|
||||
@ -522,11 +382,6 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
scanTree(animData.rootNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a one-shot click animation (pose/gesture) determined by the actor's
|
||||
* m_move value (0-3). After it finishes, the walking animation resumes.
|
||||
* Matches LegoEntity::ClickAnimation which uses objectId = m_move + 10.
|
||||
*/
|
||||
async playClickAnimation(move, actorIndex, mood) {
|
||||
if (!this.modelGroup) return;
|
||||
|
||||
@ -548,15 +403,14 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
const clip = new THREE.AnimationClip('click', -1, tracks);
|
||||
this.mixer = new THREE.AnimationMixer(this.modelGroup);
|
||||
const clip = new AnimationClip('click', -1, tracks);
|
||||
this.mixer = new SimpleAnimationMixer(this.modelGroup);
|
||||
const action = this.mixer.clipAction(clip);
|
||||
action.setLoop(THREE.LoopOnce);
|
||||
action.setLoop(LoopOnce);
|
||||
action.clampWhenFinished = true;
|
||||
this.currentAction = action;
|
||||
action.play();
|
||||
|
||||
// When click animation finishes, resume walking
|
||||
this.mixer.addEventListener('finished', () => {
|
||||
this.loadAnimationForActor(actorIndex, mood);
|
||||
});
|
||||
@ -566,59 +420,40 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build world-space keyframe tracks by evaluating the animation tree
|
||||
* hierarchically. At each unique keyframe time, walks the tree composing
|
||||
* parent * child transforms via matrix multiplication, then decomposes
|
||||
* to world-space position/quaternion for each part.
|
||||
*/
|
||||
buildHierarchicalTracks(animData, nodeToPartGroup) {
|
||||
const duration = animData.duration;
|
||||
|
||||
// Collect all unique keyframe times from the tree
|
||||
const timesSet = new Set([0]);
|
||||
this.collectKeyframeTimes(animData.rootNode, timesSet);
|
||||
const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b);
|
||||
|
||||
// For each time, evaluate the full tree and store world-space transforms
|
||||
const valueMap = new Map();
|
||||
const identity = new THREE.Matrix4();
|
||||
const identity = new Mat4();
|
||||
|
||||
for (const time of times) {
|
||||
this.evaluateNode(animData.rootNode, time, identity, nodeToPartGroup, valueMap, true);
|
||||
}
|
||||
|
||||
// Convert to Three.js KeyframeTracks
|
||||
const timesSec = times.map(t => t / 1000);
|
||||
const tracks = [];
|
||||
for (const [name, values] of valueMap) {
|
||||
if (name.endsWith('.position')) {
|
||||
tracks.push(new THREE.VectorKeyframeTrack(name, timesSec, values));
|
||||
tracks.push(VectorTrack(name, timesSec, values));
|
||||
} else if (name.endsWith('.quaternion')) {
|
||||
tracks.push(new THREE.QuaternionKeyframeTrack(name, timesSec, values));
|
||||
tracks.push(QuaternionTrack(name, timesSec, values));
|
||||
} else if (name.endsWith('.visible')) {
|
||||
tracks.push(new THREE.BooleanKeyframeTrack(name, timesSec, values));
|
||||
tracks.push(BooleanTrack(name, timesSec, values));
|
||||
}
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single animation node at a given time, composing its local
|
||||
* transform with the parent's world matrix. If the node maps to a part
|
||||
* group, stores the decomposed world-space position and quaternion.
|
||||
* Recurses into children with the composed matrix.
|
||||
*/
|
||||
evaluateNode(node, time, parentMatrix, nodeToPartGroup, valueMap, isRoot = false) {
|
||||
const data = node.data;
|
||||
let mat = new THREE.Matrix4();
|
||||
let mat = new Mat4();
|
||||
|
||||
// Strip XZ translation on the actor root to keep the actor in place (treadmill fix).
|
||||
// Walking anims: the root node IS the actor (named "pepper", "mama", "actor_01", etc.)
|
||||
// Click anims: actor_01 is nested under wrapper nodes like "-NPa001ns"
|
||||
const isActorRoot = isRoot || data.name?.toLowerCase() === 'actor_01';
|
||||
|
||||
// 1. Scale (applied first)
|
||||
if (data.scaleKeys.length > 0) {
|
||||
const scale = this.interpolateVertex(data.scaleKeys, time, false);
|
||||
if (scale) {
|
||||
@ -631,44 +466,38 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
mat = this.evaluateRotation(data.rotationKeys, time);
|
||||
}
|
||||
|
||||
// 2. Translation
|
||||
if (data.translationKeys.length > 0) {
|
||||
const vertex = this.interpolateVertex(data.translationKeys, time, true);
|
||||
if (vertex) {
|
||||
if (isActorRoot) {
|
||||
// Actor_01: only apply vertical (Y) to preserve bounce,
|
||||
// strip horizontal (XZ) so the actor animates in place
|
||||
mat.elements[13] += vertex.y;
|
||||
mat[13] += vertex[1];
|
||||
} else {
|
||||
mat.elements[12] += vertex.x;
|
||||
mat.elements[13] += vertex.y;
|
||||
mat.elements[14] += vertex.z;
|
||||
mat[12] += vertex[0];
|
||||
mat[13] += vertex[1];
|
||||
mat[14] += vertex[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Compose with parent: world = parent * local
|
||||
mat = parentMatrix.clone().multiply(mat);
|
||||
mat = new Mat4().copy(parentMatrix).multiply(mat);
|
||||
|
||||
// 4. If this node maps to a part group, decompose and store
|
||||
const nodeName = data.name?.toLowerCase();
|
||||
if (nodeName) {
|
||||
const partGroup = nodeToPartGroup.get(nodeName);
|
||||
if (partGroup) {
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3();
|
||||
mat.decompose(position, quaternion, scale);
|
||||
const position = new Vec3();
|
||||
const quaternion = new Quat();
|
||||
const scale = new Vec3();
|
||||
mat.decompose(quaternion, position, scale);
|
||||
|
||||
if (Math.abs(scale.x) < 1e-8 || Math.abs(scale.y) < 1e-8 || Math.abs(scale.z) < 1e-8) {
|
||||
if (Math.abs(scale[0]) < 1e-8 || Math.abs(scale[1]) < 1e-8 || Math.abs(scale[2]) < 1e-8) {
|
||||
quaternion.identity();
|
||||
}
|
||||
|
||||
const trackName = partGroup.name;
|
||||
this.pushValues(valueMap, `${trackName}.position`, position.toArray());
|
||||
this.pushValues(valueMap, `${trackName}.quaternion`, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]);
|
||||
this.pushValues(valueMap, `${trackName}.position`, [position[0], position[1], position[2]]);
|
||||
this.pushValues(valueMap, `${trackName}.quaternion`, [quaternion[0], quaternion[1], quaternion[2], quaternion[3]]);
|
||||
|
||||
// Evaluate visibility from morph keys (matches game's SetVisibility(data->GetVisibility(p_time)))
|
||||
if (data.morphKeys.length > 0) {
|
||||
const visible = this.getVisibility(data.morphKeys, time);
|
||||
this.pushValues(valueMap, `${trackName}.visible`, [visible]);
|
||||
@ -676,32 +505,15 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Recurse into children
|
||||
for (const child of node.children) {
|
||||
this.evaluateNode(child, time, mat, nodeToPartGroup, valueMap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate visibility from morph keys at a given time.
|
||||
* Matches game's GetVisibility: returns true (visible) by default,
|
||||
* or the last morph key's visible flag at or before the given time.
|
||||
*/
|
||||
getVisibility(morphKeys, time) {
|
||||
let lastKey = null;
|
||||
for (const key of morphKeys) {
|
||||
if (key.time <= time) {
|
||||
lastKey = key;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lastKey ? lastKey.visible : true;
|
||||
return getVisibility(morphKeys, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append values to a named entry in the value map.
|
||||
*/
|
||||
pushValues(map, key, values) {
|
||||
const existing = map.get(key);
|
||||
if (!existing) {
|
||||
@ -711,8 +523,6 @@ export class ActorRenderer extends AnimatedRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scene Management ────────────────────────────────────────────
|
||||
|
||||
clearModel() {
|
||||
super.clearModel();
|
||||
this.partGroups = [];
|
||||
|
||||
@ -1,29 +1,37 @@
|
||||
import * as THREE from 'three';
|
||||
import { Mesh } from 'ogl';
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { Quat } from 'ogl/src/math/Quat.js';
|
||||
import { Mat4 } from 'ogl/src/math/Mat4.js';
|
||||
import { Raycast } from 'ogl/src/extras/Raycast.js';
|
||||
import { parseAnimation } from '../formats/AnimationParser.js';
|
||||
import { fetchAnimation } from '../assetLoader.js';
|
||||
import { BaseRenderer } from './BaseRenderer.js';
|
||||
import {
|
||||
SimpleAnimationMixer, AnimationClip,
|
||||
QuaternionTrack, LoopOnce,
|
||||
} from './AnimationMixer.js';
|
||||
import {
|
||||
evaluateRotation, evaluateTranslation, evaluateScale,
|
||||
evaluateLocalTransform,
|
||||
} from '../animation/keyframeEval.js';
|
||||
|
||||
/**
|
||||
* Intermediate renderer for LEGO models with animation support.
|
||||
* Extends BaseRenderer with clock-driven animation loop, AnimationMixer
|
||||
* management, animation caching, raycasting, and shared keyframe utilities.
|
||||
* Extends BaseRenderer with animation caching, raycasting, and keyframe utilities.
|
||||
*/
|
||||
export class AnimatedRenderer extends BaseRenderer {
|
||||
constructor(canvas) {
|
||||
super(canvas);
|
||||
this.clock = new THREE.Clock();
|
||||
constructor(canvas, rendererOptions) {
|
||||
super(canvas, rendererOptions);
|
||||
this._lastTime = 0;
|
||||
this.mixer = null;
|
||||
this.currentAction = null;
|
||||
this.animationCache = new Map();
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.raycaster = new Raycast();
|
||||
this._queuedClickAnim = null;
|
||||
}
|
||||
|
||||
// ─── Animation Utilities ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch and parse an animation file by name, with caching.
|
||||
*/
|
||||
async fetchAnimationByName(animName) {
|
||||
if (this.animationCache.has(animName)) {
|
||||
return this.animationCache.get(animName);
|
||||
@ -62,97 +70,30 @@ export class AnimatedRenderer extends BaseRenderer {
|
||||
|
||||
/**
|
||||
* Evaluate rotation keyframes at a given time.
|
||||
* Handles slerp interpolation between keyframes with flag-based control.
|
||||
* Coordinate conversion: game (w,x,y,z) -> Three.js with X negated.
|
||||
* Delegates to the shared keyframe evaluation module.
|
||||
* @returns {Mat4} Rotation matrix
|
||||
*/
|
||||
evaluateRotation(keys, time) {
|
||||
const { before, after } = this.getBeforeAndAfter(keys, time);
|
||||
const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w);
|
||||
|
||||
if (!after) {
|
||||
if (before.flags & 0x01) {
|
||||
return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before));
|
||||
}
|
||||
return new THREE.Matrix4();
|
||||
}
|
||||
|
||||
if ((before.flags & 0x01) || (after.flags & 0x01)) {
|
||||
const beforeQ = toQuat(before);
|
||||
|
||||
// Flag 0x04: skip interpolation, use before value
|
||||
if (after.flags & 0x04) {
|
||||
return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ);
|
||||
}
|
||||
|
||||
let afterQ = toQuat(after);
|
||||
// Flag 0x02: negate the after quaternion before slerp
|
||||
if (after.flags & 0x02) {
|
||||
afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w);
|
||||
}
|
||||
|
||||
const t = (time - before.time) / (after.time - before.time);
|
||||
const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t);
|
||||
return new THREE.Matrix4().makeRotationFromQuaternion(result);
|
||||
}
|
||||
|
||||
return new THREE.Matrix4();
|
||||
return evaluateRotation(keys, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate translation or scale keyframes at a given time.
|
||||
* For translation: negates X for coordinate system conversion.
|
||||
* For scale: no negation.
|
||||
* Delegates to the shared keyframe evaluation module.
|
||||
* @returns {Vec3|null}
|
||||
*/
|
||||
interpolateVertex(keys, time, isTranslation) {
|
||||
const { before, after } = this.getBeforeAndAfter(keys, time);
|
||||
|
||||
const toVec = (key) => isTranslation
|
||||
? new THREE.Vector3(-key.x, key.y, key.z)
|
||||
: new THREE.Vector3(key.x, key.y, key.z);
|
||||
|
||||
if (!after) {
|
||||
if (isTranslation && !(before.flags & 0x01)) {
|
||||
if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return toVec(before);
|
||||
}
|
||||
|
||||
if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) {
|
||||
const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5;
|
||||
const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5;
|
||||
if (!bNonZero && !aNonZero) return null;
|
||||
}
|
||||
|
||||
const t = (time - before.time) / (after.time - before.time);
|
||||
return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the keyframes immediately before and after the given time.
|
||||
*/
|
||||
getBeforeAndAfter(keys, time) {
|
||||
let idx = keys.findIndex(k => k.time > time);
|
||||
if (idx < 0) idx = keys.length;
|
||||
const before = keys[Math.max(0, idx - 1)];
|
||||
return { before, after: keys[idx] || null };
|
||||
return isTranslation
|
||||
? evaluateTranslation(keys, time)
|
||||
: evaluateScale(keys, time);
|
||||
}
|
||||
|
||||
// ─── Click Animation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Queue a click animation by name. Subclasses may override to
|
||||
* accept domain-specific arguments and construct the name.
|
||||
* @param {string} animName - Animation asset name
|
||||
*/
|
||||
queueClickAnimation(animName) {
|
||||
this._queuedClickAnim = animName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the queued click animation (one-shot), then resume auto-rotate.
|
||||
*/
|
||||
async playQueuedAnimation() {
|
||||
if (!this._queuedClickAnim || !this.modelGroup) return;
|
||||
|
||||
@ -168,17 +109,17 @@ export class AnimatedRenderer extends BaseRenderer {
|
||||
|
||||
this.stopAnimation();
|
||||
|
||||
const clip = new THREE.AnimationClip('clickAnim', -1, tracks);
|
||||
this.mixer = new THREE.AnimationMixer(this.modelGroup);
|
||||
const clip = new AnimationClip('clickAnim', -1, tracks);
|
||||
this.mixer = new SimpleAnimationMixer(this.modelGroup);
|
||||
const action = this.mixer.clipAction(clip);
|
||||
action.setLoop(THREE.LoopOnce);
|
||||
action.setLoop(LoopOnce);
|
||||
action.clampWhenFinished = false;
|
||||
this.currentAction = action;
|
||||
action.play();
|
||||
|
||||
this.mixer.addEventListener('finished', () => {
|
||||
this.stopAnimation();
|
||||
this.controls.autoRotate = true;
|
||||
if (this.controls) this.controls.autoRotate = true;
|
||||
});
|
||||
} catch (e) {
|
||||
// Animation unavailable — ignore
|
||||
@ -187,36 +128,28 @@ export class AnimatedRenderer extends BaseRenderer {
|
||||
|
||||
// ─── Raycast Hit Testing ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if any mesh in the model was clicked.
|
||||
* @returns {boolean} True if any mesh was hit
|
||||
*/
|
||||
getClickedMesh(mouseEvent) {
|
||||
if (!this.modelGroup) return false;
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
const mouse = [
|
||||
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
-(((mouseEvent.clientY - rect.top) / rect.height) * 2 - 1),
|
||||
];
|
||||
|
||||
this.raycaster.setFromCamera(mouse, this.camera);
|
||||
this.raycaster.castMouse(this.camera, mouse);
|
||||
|
||||
const meshes = [];
|
||||
this.modelGroup.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) meshes.push(child);
|
||||
if (child instanceof Mesh) meshes.push(child);
|
||||
});
|
||||
|
||||
return this.raycaster.intersectObjects(meshes).length > 0;
|
||||
const hits = this.raycaster.intersectMeshes(meshes, { cullFace: false });
|
||||
return hits.length > 0;
|
||||
}
|
||||
|
||||
// ─── Simple Animation Tree Utilities ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Build rotation-only keyframe tracks by finding the deepest animated
|
||||
* node and evaluating the composed transform chain at each keyframe time.
|
||||
* Used by plant and building animations (single-group models).
|
||||
*/
|
||||
buildRotationTracks(animData) {
|
||||
const duration = animData.duration;
|
||||
const timesSet = new Set([0]);
|
||||
@ -231,23 +164,20 @@ export class AnimatedRenderer extends BaseRenderer {
|
||||
|
||||
for (const time of times) {
|
||||
const mat = this.evaluateNodeChain(animData.rootNode, targetNode, time);
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3();
|
||||
mat.decompose(position, quaternion, scale);
|
||||
const position = new Vec3();
|
||||
const quaternion = new Quat();
|
||||
const scale = new Vec3();
|
||||
mat.decompose(quaternion, position, scale);
|
||||
|
||||
timesSec.push(time / 1000);
|
||||
quatValues.push(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
|
||||
quatValues.push(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
|
||||
}
|
||||
|
||||
return [
|
||||
new THREE.QuaternionKeyframeTrack('.quaternion', timesSec, quatValues)
|
||||
QuaternionTrack('.quaternion', timesSec, quatValues)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the deepest node in the animation tree that has keyframe data.
|
||||
*/
|
||||
findAnimatedNode(node) {
|
||||
for (const child of node.children) {
|
||||
const found = this.findAnimatedNode(child);
|
||||
@ -260,16 +190,13 @@ export class AnimatedRenderer extends BaseRenderer {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the composed transform matrix from root down to targetNode.
|
||||
*/
|
||||
evaluateNodeChain(node, targetNode, time) {
|
||||
const path = [];
|
||||
if (!this.findNodePath(node, targetNode, path)) {
|
||||
return new THREE.Matrix4();
|
||||
return new Mat4();
|
||||
}
|
||||
|
||||
let mat = new THREE.Matrix4();
|
||||
let mat = new Mat4();
|
||||
for (const n of path) {
|
||||
const local = this.evaluateLocalTransform(n.data, time);
|
||||
mat.multiply(local);
|
||||
@ -277,9 +204,6 @@ export class AnimatedRenderer extends BaseRenderer {
|
||||
return mat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the path from current node to target via depth-first search.
|
||||
*/
|
||||
findNodePath(current, target, path) {
|
||||
path.push(current);
|
||||
if (current === target) return true;
|
||||
@ -290,32 +214,8 @@ export class AnimatedRenderer extends BaseRenderer {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the local transform matrix for an animation node at a given time.
|
||||
*/
|
||||
evaluateLocalTransform(data, time) {
|
||||
let mat = new THREE.Matrix4();
|
||||
|
||||
if (data.scaleKeys.length > 0) {
|
||||
const scale = this.interpolateVertex(data.scaleKeys, time, false);
|
||||
if (scale) mat.scale(scale);
|
||||
}
|
||||
|
||||
if (data.rotationKeys.length > 0) {
|
||||
const rotMat = this.evaluateRotation(data.rotationKeys, time);
|
||||
mat = rotMat.multiply(mat);
|
||||
}
|
||||
|
||||
if (data.translationKeys.length > 0) {
|
||||
const vertex = this.interpolateVertex(data.translationKeys, time, true);
|
||||
if (vertex) {
|
||||
mat.elements[12] += vertex.x;
|
||||
mat.elements[13] += vertex.y;
|
||||
mat.elements[14] += vertex.z;
|
||||
}
|
||||
}
|
||||
|
||||
return mat;
|
||||
return evaluateLocalTransform(data, time);
|
||||
}
|
||||
|
||||
// ─── Scene Management ────────────────────────────────────────────
|
||||
@ -327,12 +227,14 @@ export class AnimatedRenderer extends BaseRenderer {
|
||||
|
||||
start() {
|
||||
this.animating = true;
|
||||
this.clock.start();
|
||||
this._lastTime = performance.now();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
updateAnimation() {
|
||||
const delta = this.clock.getDelta();
|
||||
const now = performance.now();
|
||||
const delta = (now - this._lastTime) / 1000;
|
||||
this._lastTime = now;
|
||||
if (this.mixer) {
|
||||
this.mixer.update(delta);
|
||||
}
|
||||
|
||||
298
src/core/rendering/AnimationMixer.js
Normal file
298
src/core/rendering/AnimationMixer.js
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Lightweight animation system for OGL Transform nodes.
|
||||
* Resolves track names like "part_body.position" to scene graph nodes
|
||||
* and interpolates keyframed values (vector lerp, quaternion slerp, boolean step).
|
||||
*/
|
||||
|
||||
export const LoopRepeat = 0;
|
||||
export const LoopOnce = 1;
|
||||
|
||||
export class AnimationClip {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number} duration - Duration in seconds (-1 = auto from tracks)
|
||||
* @param {AnimationTrack[]} tracks
|
||||
*/
|
||||
constructor(name, duration, tracks) {
|
||||
this.name = name;
|
||||
this.tracks = tracks;
|
||||
|
||||
if (duration < 0) {
|
||||
let maxTime = 0;
|
||||
for (const track of tracks) {
|
||||
const lastTime = track.times[track.times.length - 1];
|
||||
if (lastTime > maxTime) maxTime = lastTime;
|
||||
}
|
||||
this.duration = maxTime;
|
||||
} else {
|
||||
this.duration = duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single animation track binding values to a property on a named object.
|
||||
*/
|
||||
export class AnimationTrack {
|
||||
/**
|
||||
* @param {string} name - e.g. "part_body.position" or ".quaternion"
|
||||
* @param {Float64Array|number[]} times - Keyframe times in seconds
|
||||
* @param {Float64Array|number[]} values - Keyframe values (flattened)
|
||||
* @param {'vector'|'quaternion'|'boolean'} type
|
||||
*/
|
||||
constructor(name, times, values, type = 'vector') {
|
||||
this.name = name;
|
||||
this.times = times;
|
||||
this.values = values;
|
||||
this.type = type;
|
||||
|
||||
// Parse "objectName.propertyName"
|
||||
const dot = name.lastIndexOf('.');
|
||||
this.objectName = dot > 0 ? name.substring(0, dot) : '';
|
||||
this.propertyName = name.substring(dot + 1);
|
||||
|
||||
// Size per keyframe
|
||||
if (type === 'quaternion') this.size = 4;
|
||||
else if (type === 'boolean') this.size = 1;
|
||||
else this.size = values.length / times.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Quaternion track factory
|
||||
export function QuaternionTrack(name, times, values) {
|
||||
return new AnimationTrack(name, times, values, 'quaternion');
|
||||
}
|
||||
|
||||
// Vector track factory
|
||||
export function VectorTrack(name, times, values) {
|
||||
return new AnimationTrack(name, times, values, 'vector');
|
||||
}
|
||||
|
||||
// Boolean track factory
|
||||
export function BooleanTrack(name, times, values) {
|
||||
return new AnimationTrack(name, times, values, 'boolean');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages playback of an AnimationClip on a root Transform.
|
||||
*/
|
||||
export class SimpleAnimationMixer {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this._action = null;
|
||||
this._time = 0;
|
||||
this._listeners = {};
|
||||
this._nodeCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an action for a clip.
|
||||
* @param {AnimationClip} clip
|
||||
* @returns {{ play, stop, setLoop, clampWhenFinished }}
|
||||
*/
|
||||
clipAction(clip) {
|
||||
const action = {
|
||||
clip,
|
||||
loop: LoopRepeat,
|
||||
clampWhenFinished: false,
|
||||
playing: false,
|
||||
setLoop(mode) { this.loop = mode; return this; },
|
||||
play: () => {
|
||||
action.playing = true;
|
||||
this._action = action;
|
||||
this._time = 0;
|
||||
this._resolveBindings(clip);
|
||||
},
|
||||
stop: () => {
|
||||
action.playing = false;
|
||||
if (this._action === action) this._action = null;
|
||||
},
|
||||
};
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve track names to actual Transform nodes and cache bindings.
|
||||
*/
|
||||
_resolveBindings(clip) {
|
||||
for (const track of clip.tracks) {
|
||||
if (track._target !== undefined) continue; // Already resolved
|
||||
|
||||
if (!track.objectName) {
|
||||
// Bind to root
|
||||
track._target = this.root;
|
||||
} else {
|
||||
// Find child by name
|
||||
let cached = this._nodeCache.get(track.objectName);
|
||||
if (!cached) {
|
||||
this.root.traverse((node) => {
|
||||
if (node.name === track.objectName) {
|
||||
cached = node;
|
||||
return true; // Stop traversal
|
||||
}
|
||||
});
|
||||
if (cached) this._nodeCache.set(track.objectName, cached);
|
||||
}
|
||||
track._target = cached || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance time and apply interpolated values.
|
||||
* @param {number} dt - Delta time in seconds
|
||||
*/
|
||||
update(dt) {
|
||||
const action = this._action;
|
||||
if (!action || !action.playing) return;
|
||||
|
||||
this._time += dt;
|
||||
const clip = action.clip;
|
||||
const duration = clip.duration;
|
||||
|
||||
let time = this._time;
|
||||
let finished = false;
|
||||
|
||||
if (time >= duration) {
|
||||
if (action.loop === LoopOnce) {
|
||||
time = duration;
|
||||
finished = true;
|
||||
} else {
|
||||
time = time % duration;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply each track
|
||||
for (const track of clip.tracks) {
|
||||
if (!track._target) continue;
|
||||
this._applyTrack(track, time);
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
action.playing = false;
|
||||
this._action = null;
|
||||
this._emit('finished');
|
||||
}
|
||||
}
|
||||
|
||||
_applyTrack(track, time) {
|
||||
const target = track._target;
|
||||
const prop = track.propertyName;
|
||||
const times = track.times;
|
||||
const values = track.values;
|
||||
const size = track.size;
|
||||
|
||||
// Find keyframe bracket
|
||||
let idx = 0;
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
if (times[i] > time) { idx = i; break; }
|
||||
idx = i + 1;
|
||||
}
|
||||
|
||||
if (track.type === 'boolean') {
|
||||
// Step interpolation: use last value at or before time
|
||||
const vi = Math.min(Math.max(0, idx - 1), times.length - 1);
|
||||
target[prop] = !!values[vi];
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
// Before first keyframe
|
||||
this._setProperty(target, prop, values, 0, size, track.type);
|
||||
return;
|
||||
}
|
||||
if (idx >= times.length) {
|
||||
// After last keyframe
|
||||
this._setProperty(target, prop, values, (times.length - 1) * size, size, track.type);
|
||||
return;
|
||||
}
|
||||
|
||||
// Interpolate
|
||||
const t0 = times[idx - 1];
|
||||
const t1 = times[idx];
|
||||
const alpha = (time - t0) / (t1 - t0);
|
||||
const i0 = (idx - 1) * size;
|
||||
const i1 = idx * size;
|
||||
|
||||
if (track.type === 'quaternion') {
|
||||
this._slerpQuaternion(target, prop, values, i0, i1, alpha);
|
||||
} else {
|
||||
this._lerpVector(target, prop, values, i0, i1, alpha, size);
|
||||
}
|
||||
}
|
||||
|
||||
_setProperty(target, prop, values, offset, size, type) {
|
||||
const p = target[prop];
|
||||
if (size === 1) {
|
||||
target[prop] = values[offset];
|
||||
} else if (type === 'quaternion' && p && p.set) {
|
||||
// Set all components atomically to avoid OGL Quat proxy corruption
|
||||
p.set(values[offset], values[offset + 1], values[offset + 2], values[offset + 3]);
|
||||
} else if (p && p.length !== undefined) {
|
||||
for (let i = 0; i < size; i++) p[i] = values[offset + i];
|
||||
}
|
||||
}
|
||||
|
||||
_slerpQuaternion(target, prop, values, i0, i1, t) {
|
||||
const q = target[prop];
|
||||
if (!q) return;
|
||||
|
||||
// Read quaternions
|
||||
let ax = values[i0], ay = values[i0 + 1], az = values[i0 + 2], aw = values[i0 + 3];
|
||||
let bx = values[i1], by = values[i1 + 1], bz = values[i1 + 2], bw = values[i1 + 3];
|
||||
|
||||
// Dot product
|
||||
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
||||
if (dot < 0) {
|
||||
bx = -bx; by = -by; bz = -bz; bw = -bw;
|
||||
dot = -dot;
|
||||
}
|
||||
|
||||
let s0, s1;
|
||||
if (1.0 - dot > 1e-6) {
|
||||
const omega = Math.acos(Math.min(dot, 1.0));
|
||||
const sinOmega = Math.sin(omega);
|
||||
s0 = Math.sin((1.0 - t) * omega) / sinOmega;
|
||||
s1 = Math.sin(t * omega) / sinOmega;
|
||||
} else {
|
||||
s0 = 1.0 - t;
|
||||
s1 = t;
|
||||
}
|
||||
|
||||
// Set all components atomically to avoid OGL Quat proxy corruption.
|
||||
// Setting q[0], q[1], ... individually triggers onChange per component,
|
||||
// which round-trips through euler conversion and overwrites the quaternion.
|
||||
q.set(
|
||||
s0 * ax + s1 * bx,
|
||||
s0 * ay + s1 * by,
|
||||
s0 * az + s1 * bz,
|
||||
s0 * aw + s1 * bw,
|
||||
);
|
||||
}
|
||||
|
||||
_lerpVector(target, prop, values, i0, i1, t, size) {
|
||||
const p = target[prop];
|
||||
if (!p || p.length === undefined) return;
|
||||
for (let i = 0; i < size; i++) {
|
||||
p[i] = values[i0 + i] + t * (values[i1 + i] - values[i0 + i]);
|
||||
}
|
||||
}
|
||||
|
||||
stopAllAction() {
|
||||
if (this._action) {
|
||||
this._action.playing = false;
|
||||
this._action = null;
|
||||
}
|
||||
this._time = 0;
|
||||
}
|
||||
|
||||
addEventListener(event, callback) {
|
||||
if (!this._listeners[event]) this._listeners[event] = [];
|
||||
this._listeners[event].push(callback);
|
||||
}
|
||||
|
||||
_emit(event) {
|
||||
const cbs = this._listeners[event];
|
||||
if (cbs) for (const cb of cbs) cb();
|
||||
}
|
||||
}
|
||||
@ -1,61 +1,95 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { Renderer, Camera, Transform, Mesh, Geometry, Program, Texture } from 'ogl';
|
||||
import { Orbit } from 'ogl/src/extras/Orbit.js';
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { Quat } from 'ogl/src/math/Quat.js';
|
||||
import { LAMBERT_VERTEX, LAMBERT_FRAGMENT, LIGHT_UNIFORMS } from './LambertShader.js';
|
||||
import { ActorInfoInit, ActorLODs, ActorLODFlags } from '../savegame/actorConstants.js';
|
||||
import { LegoColors } from '../savegame/constants.js';
|
||||
import { resolveLods, buildGlobalPartsMap, buildPartsMap } from '../formats/WdbParser.js';
|
||||
|
||||
/**
|
||||
* Base renderer providing shared Three.js setup, lighting, texture creation,
|
||||
* Base renderer providing shared OGL setup, texture creation,
|
||||
* geometry building, and animation loop for LEGO model viewers.
|
||||
*/
|
||||
export class BaseRenderer {
|
||||
constructor(canvas) {
|
||||
constructor(canvas, rendererOptions = {}) {
|
||||
this.canvas = canvas;
|
||||
this.animating = false;
|
||||
this.modelGroup = null;
|
||||
this.textures = new Map();
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1;
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
this.glRenderer = new Renderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true
|
||||
alpha: true,
|
||||
dpr,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
...rendererOptions
|
||||
});
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.setClearColor(0x000000, 0);
|
||||
this.gl = this.glRenderer.gl;
|
||||
|
||||
this.setupLighting();
|
||||
// Transparent clear
|
||||
this.gl.clearColor(0, 0, 0, 0);
|
||||
|
||||
this.scene = new Transform();
|
||||
|
||||
this.camera = new Camera(this.gl, { fov: 45, near: 0.1, far: 100 });
|
||||
|
||||
this.controls = null;
|
||||
this._didDrag = false;
|
||||
}
|
||||
|
||||
setupLighting() {
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.8);
|
||||
this.scene.add(ambient);
|
||||
|
||||
const sunLight = new THREE.DirectionalLight(0xffffff, 0.6);
|
||||
sunLight.position.set(1, 2, 3);
|
||||
this.scene.add(sunLight);
|
||||
}
|
||||
|
||||
setupControls(target) {
|
||||
this.controls = new OrbitControls(this.camera, this.canvas);
|
||||
this.controls.enableZoom = true;
|
||||
this.controls.enablePan = true;
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.1;
|
||||
this.controls.autoRotate = true;
|
||||
this.controls.autoRotateSpeed = 4.0;
|
||||
this.controls.target.copy(target);
|
||||
// Orbit requires a DOM element — skip in worker/offscreen contexts
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
this.controls.addEventListener('start', () => {
|
||||
this.controls.autoRotate = false;
|
||||
// OGL's Orbit stores autoRotate in a closure that can't be mutated.
|
||||
// We disable it in OGL and drive auto-rotation ourselves.
|
||||
this._orbit = new Orbit(this.camera, {
|
||||
element: this.canvas,
|
||||
target: new Vec3(target[0], target[1], target[2]),
|
||||
enableZoom: true,
|
||||
enablePan: true,
|
||||
ease: 0.15,
|
||||
inertia: 0.85,
|
||||
autoRotate: false,
|
||||
autoRotateSpeed: 1.0,
|
||||
});
|
||||
|
||||
// Wrap with mutable autoRotate
|
||||
// Pre-allocate temporaries for the auto-rotate update to avoid per-frame GC
|
||||
const _rotAxis = new Vec3(0, 1, 0);
|
||||
const _rotQuat = new Quat();
|
||||
const _rotOffset = new Vec3();
|
||||
|
||||
this.controls = {
|
||||
target: this._orbit.target,
|
||||
autoRotate: true,
|
||||
autoRotateSpeed: 4.0,
|
||||
forcePosition: () => this._orbit.forcePosition(),
|
||||
remove: () => this._orbit.remove(),
|
||||
update: () => {
|
||||
if (this.controls.autoRotate) {
|
||||
const angle = ((2 * Math.PI) / 60 / 60) * this.controls.autoRotateSpeed;
|
||||
_rotQuat.fromAxisAngle(_rotAxis, -angle);
|
||||
_rotOffset.copy(this.camera.position).sub(this.controls.target);
|
||||
_rotOffset.applyQuaternion(_rotQuat);
|
||||
this.camera.position.copy(this.controls.target).add(_rotOffset);
|
||||
this._orbit.forcePosition();
|
||||
}
|
||||
this._orbit.update();
|
||||
},
|
||||
};
|
||||
|
||||
this._onPointerDown = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
this._didDrag = false;
|
||||
this._pointerStart = { x: e.clientX, y: e.clientY };
|
||||
// Stop auto-rotate on user interaction
|
||||
this.controls.autoRotate = false;
|
||||
};
|
||||
this._onPointerMove = (e) => {
|
||||
if (!this._pointerStart) return;
|
||||
@ -63,17 +97,49 @@ export class BaseRenderer {
|
||||
const dy = e.clientY - this._pointerStart.y;
|
||||
if (dx * dx + dy * dy > 9) this._didDrag = true;
|
||||
};
|
||||
this._onPointerUp = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (this._pointerStart && !this._didDrag) {
|
||||
// OGL Orbit calls preventDefault() on touchstart, which
|
||||
// suppresses the browser's synthesized click event on mobile.
|
||||
// Dispatch a synthetic click so canvas onclick handlers work.
|
||||
const syntheticClick = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
screenX: e.screenX,
|
||||
screenY: e.screenY,
|
||||
button: 0,
|
||||
});
|
||||
syntheticClick._synthetic = true;
|
||||
this.canvas.dispatchEvent(syntheticClick);
|
||||
}
|
||||
this._pointerStart = null;
|
||||
};
|
||||
// Suppress native click events so only our synthetic clicks reach handlers.
|
||||
// This prevents double-firing on desktop where native clicks still work.
|
||||
this._onNativeClickCapture = (e) => {
|
||||
if (!e._synthetic) {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
this.canvas.addEventListener('pointerdown', this._onPointerDown);
|
||||
this.canvas.addEventListener('pointermove', this._onPointerMove);
|
||||
this.canvas.addEventListener('pointerup', this._onPointerUp);
|
||||
this.canvas.addEventListener('click', this._onNativeClickCapture, true);
|
||||
|
||||
this._initialAutoRotate = this.controls.autoRotate;
|
||||
this.controls.saveState();
|
||||
this._initialAutoRotate = true;
|
||||
this._savedCameraPos = new Vec3().copy(this.camera.position);
|
||||
this._savedTarget = new Vec3().copy(this.controls.target);
|
||||
}
|
||||
|
||||
resetView() {
|
||||
if (!this.controls) return;
|
||||
this.controls.reset();
|
||||
this.camera.position.copy(this._savedCameraPos);
|
||||
this.controls.target.copy(this._savedTarget);
|
||||
this.controls.forcePosition();
|
||||
this.controls.autoRotate = this._initialAutoRotate;
|
||||
}
|
||||
|
||||
@ -82,15 +148,19 @@ export class BaseRenderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Three.js texture from parsed texture data
|
||||
* Create an OGL texture from parsed palette-indexed texture data.
|
||||
*/
|
||||
createTexture(textureData) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = textureData.width;
|
||||
canvas.height = textureData.height;
|
||||
const w = textureData.width;
|
||||
const h = textureData.height;
|
||||
const canvas = typeof document !== 'undefined'
|
||||
? document.createElement('canvas')
|
||||
: new OffscreenCanvas(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const imageData = ctx.createImageData(textureData.width, textureData.height);
|
||||
const imageData = ctx.createImageData(w, h);
|
||||
for (let i = 0; i < textureData.pixels.length; i++) {
|
||||
const colorIdx = textureData.pixels[i];
|
||||
const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 };
|
||||
@ -101,18 +171,20 @@ export class BaseRenderer {
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.minFilter = THREE.NearestFilter;
|
||||
texture.magFilter = THREE.NearestFilter;
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
const texture = new Texture(this.gl, {
|
||||
image: canvas,
|
||||
minFilter: this.gl.NEAREST,
|
||||
magFilter: this.gl.NEAREST,
|
||||
wrapS: this.gl.REPEAT,
|
||||
wrapT: this.gl.REPEAT,
|
||||
generateMipmaps: false,
|
||||
flipY: true,
|
||||
});
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the texture lookup map from an array of texture data objects.
|
||||
* @param {Array} textures - Texture data with name, width, height, palette, pixels
|
||||
* @param {boolean} overwrite - If false, skip textures already in the map
|
||||
*/
|
||||
loadTextures(textures, overwrite = true) {
|
||||
if (!textures) return;
|
||||
@ -126,33 +198,86 @@ export class BaseRenderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a material for a mesh using its texture or color properties.
|
||||
* @param {object} mesh - Mesh with properties (textureName, color)
|
||||
* @param {THREE.Color} [fallbackColor] - Color when mesh has no texture or color
|
||||
* Create an OGL Program (shader material) for a mesh.
|
||||
* @param {object} mesh - Mesh data with properties (textureName, color)
|
||||
* @param {number[]|null} fallbackColor - [r, g, b] normalized, or null
|
||||
* @returns {Program}
|
||||
*/
|
||||
createMeshMaterial(mesh, fallbackColor = null) {
|
||||
createMeshProgram(mesh, fallbackColor = null) {
|
||||
const meshTexName = mesh.properties?.textureName?.toLowerCase();
|
||||
if (meshTexName && this.textures.has(meshTexName)) {
|
||||
return new THREE.MeshLambertMaterial({
|
||||
map: this.textures.get(meshTexName),
|
||||
side: THREE.DoubleSide,
|
||||
color: 0xffffff
|
||||
});
|
||||
return this.createTexturedProgram(this.textures.get(meshTexName));
|
||||
}
|
||||
|
||||
const meshColor = mesh.properties?.color;
|
||||
const color = meshColor
|
||||
? new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255)
|
||||
: (fallbackColor || new THREE.Color(0.5, 0.5, 0.5));
|
||||
? [meshColor.r / 255, meshColor.g / 255, meshColor.b / 255]
|
||||
: (fallbackColor || [0.5, 0.5, 0.5]);
|
||||
|
||||
return new THREE.MeshLambertMaterial({
|
||||
color,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
return this.createColoredProgram(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single geometry from mesh data
|
||||
* Create a Lambert program with a texture map.
|
||||
* @param {Texture} texture - OGL Texture to use
|
||||
* @param {number} [opacity=1]
|
||||
* @param {object} [opts] - Extra Program options (e.g. transparent, depthWrite)
|
||||
*/
|
||||
createTexturedProgram(texture, opacity = 1, opts = {}) {
|
||||
return this._createLambertProgram({
|
||||
tMap: { value: texture },
|
||||
uUseTexture: { value: 1 },
|
||||
uColor: { value: [1, 1, 1] },
|
||||
uOpacity: { value: opacity },
|
||||
}, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lambert program with a solid color.
|
||||
* @param {number[]} color - [r, g, b] normalized
|
||||
* @param {number} [opacity=1]
|
||||
* @param {object} [opts] - Extra Program options
|
||||
*/
|
||||
createColoredProgram(color, opacity = 1, opts = {}) {
|
||||
return this._createLambertProgram({
|
||||
tMap: { value: this._emptyTexture() },
|
||||
uUseTexture: { value: 0 },
|
||||
uColor: { value: color },
|
||||
uOpacity: { value: opacity },
|
||||
}, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lambert-shaded Program with the given extra uniforms.
|
||||
*/
|
||||
_createLambertProgram(extraUniforms, opts = {}) {
|
||||
return new Program(this.gl, {
|
||||
vertex: LAMBERT_VERTEX,
|
||||
fragment: LAMBERT_FRAGMENT,
|
||||
uniforms: {
|
||||
...LIGHT_UNIFORMS,
|
||||
...extraUniforms,
|
||||
},
|
||||
cullFace: false, // DoubleSide
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
_emptyTextureCache = null;
|
||||
_emptyTexture() {
|
||||
if (!this._emptyTextureCache) {
|
||||
this._emptyTextureCache = new Texture(this.gl, {
|
||||
image: new Uint8Array([255, 255, 255, 255]),
|
||||
width: 1,
|
||||
height: 1,
|
||||
generateMipmaps: false,
|
||||
});
|
||||
}
|
||||
return this._emptyTextureCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single OGL Geometry from mesh data.
|
||||
*/
|
||||
createGeometry(mesh, lod) {
|
||||
if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) {
|
||||
@ -177,25 +302,26 @@ export class BaseRenderer {
|
||||
const meshNormals = [];
|
||||
const meshUvs = [];
|
||||
const indices = [];
|
||||
let vertexCount = 0;
|
||||
|
||||
for (let i = 0; i < vertexIndicesPacked.length; i++) {
|
||||
const packed = vertexIndicesPacked[i];
|
||||
|
||||
if ((packed & 0x80000000) !== 0) {
|
||||
indices.push(meshVertices.length);
|
||||
indices.push(vertexCount++);
|
||||
|
||||
const gv = packed & 0xFFFF;
|
||||
const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 };
|
||||
meshVertices.push([-v.x, v.y, v.z]);
|
||||
meshVertices.push(-v.x, v.y, v.z);
|
||||
|
||||
const gn = (packed >>> 16) & 0x7fff;
|
||||
const n = lod.normals[gn] || { x: 0, y: 1, z: 0 };
|
||||
meshNormals.push([-n.x, n.y, n.z]);
|
||||
meshNormals.push(-n.x, n.y, n.z);
|
||||
|
||||
if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) {
|
||||
const tex = textureIndicesFlat[i];
|
||||
const uv = lod.textureVertices[tex] || { u: 0, v: 0 };
|
||||
meshUvs.push([uv.u, 1 - uv.v]);
|
||||
meshUvs.push(uv.u, 1 - uv.v);
|
||||
}
|
||||
} else {
|
||||
indices.push(packed & 0xFFFF);
|
||||
@ -209,52 +335,335 @@ export class BaseRenderer {
|
||||
indices[i + 2] = temp;
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3));
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3));
|
||||
geometry.setIndex(indices);
|
||||
const attrs = {
|
||||
position: { size: 3, data: new Float32Array(meshVertices) },
|
||||
normal: { size: 3, data: new Float32Array(meshNormals) },
|
||||
index: { data: new Uint32Array(indices) },
|
||||
};
|
||||
|
||||
if (hasTexture && meshUvs.length > 0) {
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2));
|
||||
attrs.uv = { size: 2, data: new Float32Array(meshUvs) };
|
||||
} else {
|
||||
// OGL requires all shader attributes present; provide zeroed UVs
|
||||
attrs.uv = { size: 2, data: new Float32Array(vertexCount * 2) };
|
||||
}
|
||||
|
||||
return geometry;
|
||||
return new Geometry(this.gl, attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand axis-aligned bounding box with vertices from a Transform hierarchy.
|
||||
* Vertices are transformed to world space before comparison.
|
||||
*/
|
||||
expandBounds(transform, min, max) {
|
||||
transform.traverse((node) => {
|
||||
if (!(node instanceof Mesh) || !node.geometry) return;
|
||||
const posAttr = node.geometry.attributes.position;
|
||||
if (!posAttr) return;
|
||||
|
||||
const data = posAttr.data;
|
||||
const wm = node.worldMatrix;
|
||||
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
const x = data[i], y = data[i + 1], z = data[i + 2];
|
||||
const wx = wm[0] * x + wm[4] * y + wm[8] * z + wm[12];
|
||||
const wy = wm[1] * x + wm[5] * y + wm[9] * z + wm[13];
|
||||
const wz = wm[2] * x + wm[6] * y + wm[10] * z + wm[14];
|
||||
|
||||
if (wx < min[0]) min[0] = wx;
|
||||
if (wy < min[1]) min[1] = wy;
|
||||
if (wz < min[2]) min[2] = wz;
|
||||
if (wx > max[0]) max[0] = wx;
|
||||
if (wy > max[1]) max[1] = wy;
|
||||
if (wz > max[2]) max[2] = wz;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute axis-aligned bounding box of a Transform hierarchy.
|
||||
* Returns { min: Vec3, max: Vec3, center: Vec3, size: Vec3 }.
|
||||
*/
|
||||
computeBoundingBox(transform) {
|
||||
const min = new Vec3(Infinity, Infinity, Infinity);
|
||||
const max = new Vec3(-Infinity, -Infinity, -Infinity);
|
||||
|
||||
transform.updateMatrixWorld(true);
|
||||
this.expandBounds(transform, min, max);
|
||||
|
||||
const center = new Vec3(
|
||||
(min[0] + max[0]) / 2,
|
||||
(min[1] + max[1]) / 2,
|
||||
(min[2] + max[2]) / 2,
|
||||
);
|
||||
const size = new Vec3(
|
||||
max[0] - min[0],
|
||||
max[1] - min[1],
|
||||
max[2] - min[2],
|
||||
);
|
||||
return { min, max, center, size };
|
||||
}
|
||||
|
||||
centerAndScaleModel(scaleFactor) {
|
||||
if (!this.modelGroup) return;
|
||||
|
||||
const box = new THREE.Box3().setFromObject(this.modelGroup);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const { center, size } = this.computeBoundingBox(this.modelGroup);
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const maxDim = Math.max(size[0], size[1], size[2]);
|
||||
if (maxDim > 0) {
|
||||
const scale = scaleFactor / maxDim;
|
||||
this.modelGroup.scale.setScalar(scale);
|
||||
// Position must account for scale: Three.js applies scale before
|
||||
// translation, so vertex v maps to (position + scale * v).
|
||||
// To center: position = -center * scale → v maps to scale*(v - center).
|
||||
this.modelGroup.position.copy(center).multiplyScalar(-scale);
|
||||
this.modelGroup.scale.set(scale, scale, scale);
|
||||
this.modelGroup.position.set(
|
||||
-center[0] * scale,
|
||||
-center[1] * scale,
|
||||
-center[2] * scale,
|
||||
);
|
||||
} else {
|
||||
this.modelGroup.position.sub(center);
|
||||
this.modelGroup.position.set(-center[0], -center[1], -center[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared Character/Prop Assembly ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Assemble the default 10-part character model for a given actor index.
|
||||
* Uses default part/name indices (no character state customization).
|
||||
*
|
||||
* Shared by ScenePlayerRenderer (scene character assembly) and ActorRenderer
|
||||
* (character preview with customization via resolvePartName/resolveNameValue overrides).
|
||||
*
|
||||
* @param {number} characterIndex - Index into ActorInfoInit
|
||||
* @param {Map} globalPartsMap - From buildGlobalPartsMap()
|
||||
* @returns {Array<[string, Transform]>} Pairs of [partName, transformGroup]
|
||||
*/
|
||||
assembleCharacterParts(characterIndex, globalPartsMap) {
|
||||
const actorInfo = ActorInfoInit[characterIndex];
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const actorLOD = ActorLODs[i + 1];
|
||||
const part = actorInfo.parts[i];
|
||||
|
||||
// Resolve the geometry part name
|
||||
let partName;
|
||||
if (i === 0 || i === 1) {
|
||||
if (!part.partNameIndices || !part.partNames) continue;
|
||||
partName = part.partNames[part.partNameIndices[part.partNameIndex]];
|
||||
} else {
|
||||
partName = actorLOD.parentName;
|
||||
}
|
||||
if (!partName) continue;
|
||||
|
||||
const partData = globalPartsMap.get(partName.toLowerCase());
|
||||
if (!partData) continue;
|
||||
|
||||
const partGroup = new Transform();
|
||||
const groupName = `part_${actorLOD.name}`;
|
||||
partGroup.name = groupName;
|
||||
|
||||
// Resolve the texture/color name (default index, no charState)
|
||||
let resolvedName = null;
|
||||
if (part.nameIndices && part.names) {
|
||||
resolvedName = part.names[part.nameIndices[part.nameIndex]];
|
||||
}
|
||||
|
||||
const lods = partData.lods || [];
|
||||
if (lods.length > 0) {
|
||||
this.createPartMeshes(lods[lods.length - 1], actorLOD, part, resolvedName, i, partGroup);
|
||||
}
|
||||
|
||||
// Apply LOD position offset with X-negation for coordinate system conversion
|
||||
partGroup.position.set(-actorLOD.position[0], actorLOD.position[1], actorLOD.position[2]);
|
||||
|
||||
result.push([groupName, partGroup]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create meshes for one character part, selecting the appropriate
|
||||
* texture or color program based on LOD flags.
|
||||
*
|
||||
* Mirrors the rendering portion of ActorRenderer's original createPartMeshes.
|
||||
*
|
||||
* @param {object} lod - LOD data with meshes, vertices, normals, etc.
|
||||
* @param {object} actorLOD - ActorLODs entry with flags
|
||||
* @param {object} part - ActorInfoInit part entry
|
||||
* @param {string|null} resolvedName - Texture or color name to use
|
||||
* @param {number} partIdx - Part index (0-9)
|
||||
* @param {Transform} group - Parent transform to add meshes to
|
||||
*/
|
||||
createPartMeshes(lod, actorLOD, part, resolvedName, partIdx, group) {
|
||||
const useTexture = (actorLOD.flags & ActorLODFlags.USE_TEXTURE) !== 0;
|
||||
const useColor = (actorLOD.flags & ActorLODFlags.USE_COLOR) !== 0;
|
||||
|
||||
const bodyUsesDefaultGeom = partIdx === 0 && part.partNameIndices &&
|
||||
part.partNameIndices[part.partNameIndex] === 0;
|
||||
|
||||
let partColor = null;
|
||||
let partTexture = null;
|
||||
|
||||
if (useTexture && !bodyUsesDefaultGeom) {
|
||||
const texName = resolvedName?.toLowerCase();
|
||||
if (texName && this.textures.has(texName)) {
|
||||
partTexture = this.textures.get(texName);
|
||||
}
|
||||
}
|
||||
|
||||
if ((useColor || bodyUsesDefaultGeom) && !partTexture) {
|
||||
const colorEntry = LegoColors[resolvedName] || LegoColors['lego white'];
|
||||
if (colorEntry) {
|
||||
partColor = [colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255];
|
||||
}
|
||||
}
|
||||
|
||||
for (const mesh of lod.meshes) {
|
||||
const geometry = this.createGeometry(mesh, lod);
|
||||
if (!geometry) continue;
|
||||
|
||||
// Check for mesh-level texture
|
||||
let meshTexture = null;
|
||||
const meshTexName = mesh.properties?.textureName?.toLowerCase();
|
||||
if (meshTexName && this.textures.has(meshTexName)) {
|
||||
meshTexture = this.textures.get(meshTexName);
|
||||
}
|
||||
|
||||
let program;
|
||||
if (partTexture && mesh.properties?.textureName) {
|
||||
program = this.createTexturedProgram(partTexture);
|
||||
} else if (meshTexture) {
|
||||
program = this.createTexturedProgram(meshTexture);
|
||||
} else if (partColor) {
|
||||
program = this.createColoredProgram(partColor);
|
||||
} else {
|
||||
// Fallback: material alias color or mesh default color
|
||||
let color = null;
|
||||
if (mesh.properties?.useAlias && mesh.properties?.materialName) {
|
||||
const alias = LegoColors[mesh.properties.materialName.toLowerCase()];
|
||||
if (alias) color = [alias.r / 255, alias.g / 255, alias.b / 255];
|
||||
}
|
||||
if (!color) {
|
||||
const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
|
||||
color = [meshColor.r / 255, meshColor.g / 255, meshColor.b / 255];
|
||||
}
|
||||
program = this.createColoredProgram(color);
|
||||
}
|
||||
|
||||
group.addChild(new Mesh(this.gl, { geometry, program }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a hierarchical prop model from WDB data.
|
||||
* Searches all worlds for a matching model name, parses its model data,
|
||||
* and builds a Transform tree with meshes.
|
||||
*
|
||||
* @param {string} name - Lowercased model name to search for
|
||||
* @param {object} parser - WDB parser instance
|
||||
* @param {object} wdb - Parsed WDB data
|
||||
* @param {Map} worldPartsMaps - Cache of per-world parts maps
|
||||
* @param {Map} [globalPartsMap] - Global parts map (fallback)
|
||||
* @returns {Transform|null}
|
||||
*/
|
||||
assemblePropHierarchical(name, parser, wdb, worldPartsMaps, globalPartsMap) {
|
||||
for (const world of wdb.worlds || []) {
|
||||
for (const model of world.models || []) {
|
||||
if (model.name.toLowerCase() !== name) continue;
|
||||
try {
|
||||
const modelData = parser.parseModelData(model.dataOffset);
|
||||
if (!modelData?.roi) continue;
|
||||
|
||||
if (modelData.textures) {
|
||||
this.loadTextures(modelData.textures, false);
|
||||
}
|
||||
|
||||
let worldPartsMap = worldPartsMaps.get(world.name);
|
||||
if (!worldPartsMap) {
|
||||
worldPartsMap = buildPartsMap(parser, world.parts);
|
||||
worldPartsMaps.set(world.name, worldPartsMap);
|
||||
}
|
||||
|
||||
return this.buildROITree(modelData.roi, worldPartsMap);
|
||||
} catch (e) {
|
||||
console.warn(`[BaseRenderer] Prop error (${name}):`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check global parts
|
||||
if (globalPartsMap) {
|
||||
const part = globalPartsMap.get(name);
|
||||
if (part?.lods?.length) {
|
||||
const group = new Transform();
|
||||
group.name = name;
|
||||
this.addLodMeshes(part.lods, group);
|
||||
return group.children.length ? group : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build a Transform hierarchy from ROI data with resolved LODs.
|
||||
*
|
||||
* @param {object} roi - ROI node with name, children, and LOD references
|
||||
* @param {Map} partsMap - Parts map for resolving LODs
|
||||
* @returns {Transform}
|
||||
*/
|
||||
buildROITree(roi, partsMap) {
|
||||
const group = new Transform();
|
||||
group.name = roi.name.toLowerCase();
|
||||
this.addLodMeshes(resolveLods(roi, partsMap), group);
|
||||
|
||||
for (const child of roi.children || []) {
|
||||
const childGroup = this.buildROITree(child, partsMap);
|
||||
if (childGroup) group.addChild(childGroup);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create meshes from the highest-detail LOD and add them to a group.
|
||||
*
|
||||
* @param {Array} lods - Array of LOD data
|
||||
* @param {Transform} group - Parent transform
|
||||
*/
|
||||
addLodMeshes(lods, group) {
|
||||
if (!lods?.length) return;
|
||||
const lod = lods[lods.length - 1];
|
||||
for (const mesh of lod.meshes) {
|
||||
const geometry = this.createGeometry(mesh, lod);
|
||||
if (!geometry) continue;
|
||||
group.addChild(new Mesh(this.gl, { geometry, program: this.createMeshProgram(mesh) }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a character index by name (case-insensitive).
|
||||
* @param {string} name - Lowercased character name
|
||||
* @returns {number} Index into ActorInfoInit, or -1 if not found
|
||||
*/
|
||||
findCharacterIndex(name) {
|
||||
for (let i = 0; i < ActorInfoInit.length; i++) {
|
||||
if (ActorInfoInit[i].name.toLowerCase() === name) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
clearModel() {
|
||||
if (this.modelGroup) {
|
||||
this.modelGroup.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry?.dispose();
|
||||
child.material?.dispose();
|
||||
}
|
||||
if (child.geometry) child.geometry.remove();
|
||||
if (child.program) child.program.remove();
|
||||
});
|
||||
this.scene.remove(this.modelGroup);
|
||||
this.scene.removeChild(this.modelGroup);
|
||||
this.modelGroup = null;
|
||||
}
|
||||
|
||||
for (const texture of this.textures.values()) {
|
||||
texture.dispose();
|
||||
this.gl.deleteTexture(texture.texture);
|
||||
}
|
||||
this.textures.clear();
|
||||
}
|
||||
@ -274,31 +683,34 @@ export class BaseRenderer {
|
||||
|
||||
this.updateAnimation();
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
/**
|
||||
* Override in subclasses for custom animation logic.
|
||||
* Called each frame before rendering.
|
||||
*/
|
||||
updateAnimation() {
|
||||
this.controls?.update();
|
||||
}
|
||||
|
||||
resize(width, height) {
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height, false);
|
||||
this.camera.perspective({ aspect: width / height });
|
||||
this.glRenderer.setSize(width, height);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.animating = false;
|
||||
if (this.controls) {
|
||||
this.controls.dispose();
|
||||
this.controls.remove();
|
||||
this.canvas.removeEventListener('pointerdown', this._onPointerDown);
|
||||
this.canvas.removeEventListener('pointermove', this._onPointerMove);
|
||||
this.canvas.removeEventListener('pointerup', this._onPointerUp);
|
||||
this.canvas.removeEventListener('click', this._onNativeClickCapture, true);
|
||||
}
|
||||
this.clearModel();
|
||||
this.renderer?.dispose();
|
||||
if (this._emptyTextureCache) {
|
||||
this.gl.deleteTexture(this._emptyTextureCache.texture);
|
||||
this._emptyTextureCache = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import * as THREE from 'three';
|
||||
import { Transform, Mesh } from 'ogl';
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { LegoColors } from '../savegame/constants.js';
|
||||
import { AnimatedRenderer } from './AnimatedRenderer.js';
|
||||
|
||||
@ -7,20 +8,15 @@ import { AnimatedRenderer } from './AnimatedRenderer.js';
|
||||
* hierarchical ROIs (potentially multi-part like policsta, jail).
|
||||
*/
|
||||
export class BuildingRenderer extends AnimatedRenderer {
|
||||
constructor(canvas) {
|
||||
super(canvas);
|
||||
constructor(canvas, rendererOptions) {
|
||||
super(canvas, rendererOptions);
|
||||
|
||||
this.camera.position.set(2.5, 2.0, 4.0);
|
||||
this.camera.lookAt(0, -0.3, 0);
|
||||
this.camera.lookAt([0, -0.3, 0]);
|
||||
|
||||
this.setupControls(new THREE.Vector3(0, -0.3, 0));
|
||||
this.setupControls(new Vec3(0, -0.3, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a building model from pre-collected ROIs.
|
||||
* @param {Array} rois - Array of { name, lods } from WDB model
|
||||
* @param {Array} textures - Texture list from the model + globals
|
||||
*/
|
||||
loadBuilding(rois, textures) {
|
||||
this.clearModel();
|
||||
|
||||
@ -28,26 +24,27 @@ export class BuildingRenderer extends AnimatedRenderer {
|
||||
|
||||
this.loadTextures(textures);
|
||||
|
||||
this.modelGroup = new THREE.Group();
|
||||
this.modelGroup = new Transform();
|
||||
|
||||
const colorEntry = LegoColors['lego white'] || { r: 255, g: 255, b: 255 };
|
||||
const fallbackColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255);
|
||||
const fallbackColor = [colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255];
|
||||
|
||||
for (const roi of rois) {
|
||||
const lods = roi.lods || [];
|
||||
if (lods.length === 0) continue;
|
||||
|
||||
const lod = lods[lods.length - 1]; // Highest quality
|
||||
const lod = lods[lods.length - 1];
|
||||
for (const mesh of lod.meshes) {
|
||||
const geometry = this.createGeometry(mesh, lod);
|
||||
if (!geometry) continue;
|
||||
this.modelGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh, fallbackColor)));
|
||||
const program = this.createMeshProgram(mesh, fallbackColor);
|
||||
this.modelGroup.addChild(new Mesh(this.gl, { geometry, program }));
|
||||
}
|
||||
}
|
||||
|
||||
this.centerAndScaleModel(2.5);
|
||||
this.scene.add(this.modelGroup);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.scene.addChild(this.modelGroup);
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
queueClickAnimation(buildingIndex, move) {
|
||||
|
||||
59
src/core/rendering/LambertShader.js
Normal file
59
src/core/rendering/LambertShader.js
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Shared Lambert shading GLSL for all renderers.
|
||||
*
|
||||
* OGL automatically provides these uniforms when rendering with a camera:
|
||||
* modelViewMatrix (mat4), projectionMatrix (mat4), normalMatrix (mat3),
|
||||
* modelMatrix (mat4), viewMatrix (mat4), cameraPosition (vec3)
|
||||
*/
|
||||
|
||||
export const LAMBERT_VERTEX = /* glsl */ `
|
||||
attribute vec3 position;
|
||||
attribute vec3 normal;
|
||||
attribute vec2 uv;
|
||||
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform mat3 normalMatrix;
|
||||
|
||||
varying vec3 vNormal;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const LAMBERT_FRAGMENT = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform vec3 uAmbientColor;
|
||||
uniform vec3 uLightDirection;
|
||||
uniform vec3 uLightColor;
|
||||
uniform sampler2D tMap;
|
||||
uniform vec3 uColor;
|
||||
uniform float uUseTexture;
|
||||
uniform float uOpacity;
|
||||
|
||||
varying vec3 vNormal;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec3 baseColor = uUseTexture > 0.5 ? texture2D(tMap, vUv).rgb : uColor;
|
||||
float diff = max(dot(normalize(vNormal), uLightDirection), 0.0);
|
||||
vec3 color = baseColor * (uAmbientColor + uLightColor * diff);
|
||||
gl_FragColor = vec4(color, uOpacity);
|
||||
}
|
||||
`;
|
||||
|
||||
// Shared lighting uniform values:
|
||||
// Ambient intensity 0.8, directional intensity 0.6, direction (1, 2, 3) normalized
|
||||
const _lightDir = [1, 2, 3];
|
||||
const _len = Math.sqrt(_lightDir[0] ** 2 + _lightDir[1] ** 2 + _lightDir[2] ** 2);
|
||||
|
||||
export const LIGHT_UNIFORMS = {
|
||||
uAmbientColor: { value: [0.8, 0.8, 0.8] },
|
||||
uLightDirection: { value: [_lightDir[0] / _len, _lightDir[1] / _len, _lightDir[2] / _len] },
|
||||
uLightColor: { value: [0.6, 0.6, 0.6] },
|
||||
};
|
||||
141
src/core/rendering/PhonemePlayer.js
Normal file
141
src/core/rendering/PhonemePlayer.js
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Phoneme (lip-sync) animation player for scene playback.
|
||||
* Decodes FLC frames and swaps the head texture.
|
||||
*/
|
||||
|
||||
import { Texture, Mesh, Program } from 'ogl';
|
||||
import { FlcDecoder } from '../formats/FlcDecoder.js';
|
||||
import { LAMBERT_VERTEX, LAMBERT_FRAGMENT, LIGHT_UNIFORMS } from './LambertShader.js';
|
||||
|
||||
export class PhonemePlayer {
|
||||
constructor() {
|
||||
this.states = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../formats/SICompositeParser.js').PhonemeTrack[]} phonemeTracks
|
||||
* @param {Map<string, Map<string, Transform>>} actorContainers
|
||||
* @param {WebGL2RenderingContext} gl
|
||||
*/
|
||||
init(phonemeTracks, actorContainers, gl) {
|
||||
this.dispose();
|
||||
|
||||
for (const track of phonemeTracks) {
|
||||
if (!track.roiName || track.frameData.length === 0) continue;
|
||||
|
||||
const targetName = track.roiName.toLowerCase();
|
||||
const partMap = actorContainers.get(targetName);
|
||||
if (!partMap) { console.warn(`[Phoneme] Actor not found: ${targetName}`); continue; }
|
||||
|
||||
const headPart = partMap.get('part_head');
|
||||
if (!headPart) { console.warn(`[Phoneme] part_head not found for ${targetName}`); continue; }
|
||||
|
||||
// Find ONLY textured meshes under head part.
|
||||
// Backend (LegoLOD::UpdateTextureInfo) only updates meshes with m_textured=TRUE.
|
||||
// The head has both textured meshes (face decal) and colored meshes (head shape).
|
||||
// We must only swap the face texture, not paint over the solid-color head shape.
|
||||
const headMeshes = [];
|
||||
headPart.traverse(n => {
|
||||
if (n instanceof Mesh) {
|
||||
const ut = n.program?.uniforms?.uUseTexture?.value;
|
||||
const hasImg = !!n.program?.uniforms?.tMap?.value?.image;
|
||||
if (ut > 0.5 && hasImg) headMeshes.push(n);
|
||||
}
|
||||
});
|
||||
if (headMeshes.length === 0) { console.warn(`[Phoneme] No textured mesh under part_head of ${targetName}`); continue; }
|
||||
|
||||
const decoder = new FlcDecoder(track.flcHeader);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = track.width;
|
||||
canvas.height = track.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const flcTexture = new Texture(gl, {
|
||||
image: canvas,
|
||||
magFilter: gl.NEAREST,
|
||||
minFilter: gl.NEAREST,
|
||||
wrapS: gl.REPEAT,
|
||||
wrapT: gl.REPEAT,
|
||||
generateMipmaps: false,
|
||||
flipY: true,
|
||||
});
|
||||
|
||||
// Create a new program for the FLC texture
|
||||
const flcProgram = new Program(gl, {
|
||||
vertex: LAMBERT_VERTEX,
|
||||
fragment: LAMBERT_FRAGMENT,
|
||||
uniforms: {
|
||||
...LIGHT_UNIFORMS,
|
||||
tMap: { value: flcTexture },
|
||||
uUseTexture: { value: 1 },
|
||||
uColor: { value: [1, 1, 1] },
|
||||
uOpacity: { value: 1 },
|
||||
},
|
||||
cullFace: false,
|
||||
});
|
||||
|
||||
// Save original programs for ALL head meshes so we can restore them
|
||||
const origPrograms = headMeshes.map(m => m.program);
|
||||
|
||||
this.states.push({
|
||||
track, decoder, headMeshes, flcTexture, flcProgram, origPrograms,
|
||||
canvas, ctx, currentFrame: -1, gl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tick(elapsedMs) {
|
||||
for (const state of this.states) {
|
||||
const { track, decoder, headMeshes, flcTexture, flcProgram, canvas, ctx } = state;
|
||||
|
||||
const trackElapsed = elapsedMs - track.timeOffset;
|
||||
if (trackElapsed < 0) continue;
|
||||
|
||||
const speed = track.flcHeader.speed || 100;
|
||||
const targetFrame = Math.min(
|
||||
Math.floor(trackElapsed / speed),
|
||||
track.frameData.length - 1
|
||||
);
|
||||
|
||||
if (targetFrame <= state.currentFrame) continue;
|
||||
|
||||
for (let f = state.currentFrame + 1; f <= targetFrame; f++) {
|
||||
if (f < track.frameData.length) decoder.decodeFrame(track.frameData[f]);
|
||||
}
|
||||
state.currentFrame = targetFrame;
|
||||
|
||||
const imageData = decoder.toImageData();
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
flcTexture.needsUpdate = true;
|
||||
|
||||
// Swap program on ALL head meshes
|
||||
for (const m of headMeshes) m.program = flcProgram;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const state of this.states) {
|
||||
for (let i = 0; i < state.headMeshes.length; i++) {
|
||||
state.headMeshes[i].program = state.origPrograms[i];
|
||||
}
|
||||
state.currentFrame = -1;
|
||||
// Reset decoder pixel buffer to prevent stale data when restarting.
|
||||
// FLC frames are deltas — frame 0 must start from a clean state.
|
||||
state.decoder.pixels.fill(0);
|
||||
state.decoder.palette.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Seek to a specific time by resetting and re-decoding from frame 0. */
|
||||
seek(elapsedMs) {
|
||||
this.stop();
|
||||
this.tick(elapsedMs);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.stop();
|
||||
this.states = [];
|
||||
}
|
||||
}
|
||||
@ -1,38 +1,26 @@
|
||||
import * as THREE from 'three';
|
||||
import { Transform, Mesh } from 'ogl';
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { PlantLodNames } from '../savegame/plantConstants.js';
|
||||
import { LegoColors } from '../savegame/constants.js';
|
||||
import { AnimatedRenderer } from './AnimatedRenderer.js';
|
||||
|
||||
// Plant color → LEGO color mapping for fallback materials
|
||||
const PLANT_COLOR_MAP = ['lego white', 'lego black', 'lego yellow', 'lego red', 'lego green'];
|
||||
|
||||
// Animation suffix per variant: flower→F, tree→T, bush→B, palm→P
|
||||
const VARIANT_ANIM_SUFFIX = ['F', 'T', 'B', 'P'];
|
||||
|
||||
// Per-variant scale factors
|
||||
const VARIANT_SCALE = [1.6, 1.8, 1.6, 2.0];
|
||||
|
||||
/**
|
||||
* Renderer for LEGO Island plants. Much simpler than ActorRenderer —
|
||||
* single model group, no multi-part assembly.
|
||||
* Renderer for LEGO Island plants.
|
||||
*/
|
||||
export class PlantRenderer extends AnimatedRenderer {
|
||||
constructor(canvas) {
|
||||
super(canvas);
|
||||
|
||||
this.camera.position.set(1.5, 1.2, 2.5);
|
||||
this.camera.lookAt(0, 0.2, 0);
|
||||
this.camera.lookAt([0, 0.2, 0]);
|
||||
|
||||
this.setupControls(new THREE.Vector3(0, 0.2, 0));
|
||||
this.setupControls(new Vec3(0, 0.2, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a plant model.
|
||||
* @param {number} variant - Plant variant (0-3)
|
||||
* @param {number} color - Plant color (0-4)
|
||||
* @param {Map} partsMap - Name→part lookup from WDB
|
||||
* @param {Array} textures - Texture list from WDB
|
||||
*/
|
||||
loadPlant(variant, color, partsMap, textures) {
|
||||
this.clearModel();
|
||||
|
||||
@ -44,25 +32,26 @@ export class PlantRenderer extends AnimatedRenderer {
|
||||
|
||||
this.loadTextures(textures);
|
||||
|
||||
this.modelGroup = new THREE.Group();
|
||||
this.modelGroup = new Transform();
|
||||
|
||||
const lods = partData.lods || [];
|
||||
if (lods.length === 0) return;
|
||||
|
||||
const colorName = PLANT_COLOR_MAP[color] || 'lego green';
|
||||
const colorEntry = LegoColors[colorName] || LegoColors['lego green'];
|
||||
const fallbackColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255);
|
||||
const fallbackColor = [colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255];
|
||||
|
||||
const lod = lods[lods.length - 1]; // Highest quality
|
||||
const lod = lods[lods.length - 1];
|
||||
for (const mesh of lod.meshes) {
|
||||
const geometry = this.createGeometry(mesh, lod);
|
||||
if (!geometry) continue;
|
||||
this.modelGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh, fallbackColor)));
|
||||
const program = this.createMeshProgram(mesh, fallbackColor);
|
||||
this.modelGroup.addChild(new Mesh(this.gl, { geometry, program }));
|
||||
}
|
||||
|
||||
this.centerAndScaleModel(VARIANT_SCALE[variant] ?? 2.0);
|
||||
this.scene.add(this.modelGroup);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.scene.addChild(this.modelGroup);
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
queueClickAnimation(variant, move) {
|
||||
|
||||
597
src/core/rendering/ScenePlayerRenderer.js
Normal file
597
src/core/rendering/ScenePlayerRenderer.js
Normal file
@ -0,0 +1,597 @@
|
||||
/**
|
||||
* Multi-actor scene renderer for scene animation playback.
|
||||
*
|
||||
* Directly applies animation transforms per-frame, matching the backend's
|
||||
* AnimUtils::ApplyTree -> LegoROI::ApplyAnimationTransformation pipeline.
|
||||
* No decompose/recompose round-trip -- matrices are set directly on OGL Transforms.
|
||||
*/
|
||||
|
||||
import { Transform, Mesh } from 'ogl';
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { Mat4 } from 'ogl/src/math/Mat4.js';
|
||||
import { BaseRenderer } from './BaseRenderer.js';
|
||||
import { buildGlobalPartsMap } from '../formats/WdbParser.js';
|
||||
import { evaluateLocalTransform, getVisibility } from '../animation/keyframeEval.js';
|
||||
import { trimLODSuffix, stripStar } from '../animation/stringUtils.js';
|
||||
|
||||
/** Lowercase name with leading '*' stripped — used as the canonical key for actors/props. */
|
||||
const canonicalize = (name) => stripStar(name).toLowerCase();
|
||||
|
||||
/**
|
||||
* Map from animation node names (lowercased) to character part group names.
|
||||
* Used to resolve animation tree nodes to the OGL Transform groups created
|
||||
* by assembleCharacterParts().
|
||||
*/
|
||||
const ANIM_NODE_TO_PART = {
|
||||
'body': 'part_body',
|
||||
'infohat': 'part_infohat',
|
||||
'infogron': 'part_infogron',
|
||||
'head': 'part_head',
|
||||
'arm-lft': 'part_arm-lft',
|
||||
'arm-rt': 'part_arm-rt',
|
||||
'claw-lft': 'part_claw-lft',
|
||||
'claw-rt': 'part_claw-rt',
|
||||
'leg-lft': 'part_leg-lft',
|
||||
'leg-rt': 'part_leg-rt',
|
||||
};
|
||||
|
||||
|
||||
export class ScenePlayerRenderer extends BaseRenderer {
|
||||
constructor(canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.floor(rect.width);
|
||||
canvas.height = Math.floor(rect.height);
|
||||
super(canvas);
|
||||
|
||||
this._lastTime = 0;
|
||||
this._elapsed = 0;
|
||||
this._playing = false;
|
||||
this._duration = 0;
|
||||
this._actorContainers = new Map();
|
||||
this._animData = null;
|
||||
}
|
||||
|
||||
/** @returns {Map<string, Map<string, Transform>>} Actor name -> part map */
|
||||
get actorContainers() {
|
||||
return this._actorContainers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and initialize the scene from parsed animation data.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Load textures and build global parts map
|
||||
* 2. Assemble character and prop actors from the animation's actor list
|
||||
* 3. Resolve animation tree nodes to OGL Transforms
|
||||
* 4. Create props found in the tree but not in the actor list
|
||||
* 5. Apply frame 0, center/scale, set up camera and controls
|
||||
*
|
||||
* @param {import('../formats/SICompositeParser.js').SceneAnimData} sceneAnimData
|
||||
* @param {Array} participants - Participant records with charIndex
|
||||
* @param {{ wdbParser, wdbData }} wdbBundle
|
||||
*/
|
||||
loadScene(sceneAnimData, participants, wdbBundle) {
|
||||
this.clearModel();
|
||||
|
||||
const { wdbParser: parser, wdbData: wdb } = wdbBundle;
|
||||
this.loadTextures(wdb.globalTextures);
|
||||
|
||||
this.modelGroup = new Transform();
|
||||
this.modelGroup.name = 'sceneRoot';
|
||||
this._actorContainers.clear();
|
||||
this._duration = sceneAnimData.duration;
|
||||
this._parser = parser;
|
||||
this._wdb = wdb;
|
||||
this._worldPartsMaps = new Map();
|
||||
|
||||
const animData = sceneAnimData.anim;
|
||||
this._animData = animData;
|
||||
this._globalPartsMap = buildGlobalPartsMap(wdb.globalParts);
|
||||
|
||||
// Phase 1: Assemble actors from the animation's actor list
|
||||
for (const actor of animData.actors) {
|
||||
if (!actor.name) continue;
|
||||
|
||||
const canonicalName = canonicalize(actor.name);
|
||||
|
||||
if (actor.actorType === 2) {
|
||||
// Character actor (e_managedLegoActor)
|
||||
this._assembleCharacter(canonicalName, this._globalPartsMap);
|
||||
} else {
|
||||
// Prop actor
|
||||
this._assembleProp(canonicalName);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Resolve animation tree nodes to OGL Transforms
|
||||
this._resolveAnimTree(animData.rootNode, this.modelGroup);
|
||||
|
||||
// Phase 3: Create props found in the tree but not in the scene
|
||||
this._createMissingTreeProps(animData.rootNode);
|
||||
|
||||
// Phase 3.5: Compute rebase rotation (face viewer) and resolve PTATCAM
|
||||
this._rebaseMatrix = this._computeRebaseMatrix();
|
||||
this._resolvePtAtCam(sceneAnimData.ptAtCamNames);
|
||||
|
||||
// Phase 4: Apply frame 0 to position everything for bounding box
|
||||
this._applyFrame(0);
|
||||
|
||||
this.centerAndScaleModel(2.5);
|
||||
this.scene.addChild(this.modelGroup);
|
||||
this.camera.position.set(3, 1.5, 5);
|
||||
this.camera.lookAt([0, 0.3, 0]);
|
||||
this.setupControls(new Vec3(0, 0.3, 0));
|
||||
if (this.controls) this.controls.autoRotate = false;
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
// ── Actor Assembly ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assemble a character actor from its parts and add to the scene.
|
||||
*/
|
||||
_assembleCharacter(canonicalName, globalPartsMap) {
|
||||
const characterIndex = this.findCharacterIndex(canonicalName);
|
||||
if (characterIndex < 0) return;
|
||||
|
||||
const parts = this.assembleCharacterParts(characterIndex, globalPartsMap);
|
||||
|
||||
// Group parts under a container Transform named after the character.
|
||||
// Mirrors the backend's ROI hierarchy where each character is a parent
|
||||
// ROI with body parts as children, preventing name collisions when
|
||||
// multiple characters share part names (body, head, arm-lft, etc.).
|
||||
const container = new Transform();
|
||||
container.name = canonicalName;
|
||||
|
||||
const partMap = new Map();
|
||||
for (const [partName, partGroup] of parts) {
|
||||
container.addChild(partGroup);
|
||||
partMap.set(partName, partGroup);
|
||||
}
|
||||
|
||||
this.modelGroup.addChild(container);
|
||||
this._actorContainers.set(canonicalName, partMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a prop actor and add to the scene.
|
||||
* Tries trimmed LOD suffix first, then the original name.
|
||||
*/
|
||||
_assembleProp(canonicalName) {
|
||||
const group = this._lookupProp(canonicalName);
|
||||
if (group) {
|
||||
group.name = canonicalName;
|
||||
this.modelGroup.addChild(group);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a prop by name, trying trimmed LOD suffix first, then the original.
|
||||
* @param {string} canonicalName - Lowercased prop name
|
||||
* @returns {Transform|null}
|
||||
*/
|
||||
_lookupProp(canonicalName) {
|
||||
const trimmedName = trimLODSuffix(canonicalName);
|
||||
let group = this.assemblePropHierarchical(
|
||||
trimmedName, this._parser, this._wdb, this._worldPartsMaps, this._globalPartsMap
|
||||
);
|
||||
if (!group && trimmedName !== canonicalName) {
|
||||
group = this.assemblePropHierarchical(
|
||||
canonicalName, this._parser, this._wdb, this._worldPartsMaps, this._globalPartsMap
|
||||
);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
// ── Animation Tree Resolution ───────────────────────────────────
|
||||
|
||||
/** Find the first direct child of `parent` whose name matches. */
|
||||
_findChildByName(parent, name, excludeMesh = false) {
|
||||
for (const child of parent.children) {
|
||||
if (child.name === name && (!excludeMesh || !(child instanceof Mesh))) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the animation tree and annotate each node.data with _transform
|
||||
* pointing to the resolved OGL Transform.
|
||||
*
|
||||
* Uses parent context to handle duplicate names (e.g. BIRDBEAK under
|
||||
* both BIRD and BIRD01). Matches the backend's FindChildROI behavior
|
||||
* where the search context is the direct parent's scope.
|
||||
*/
|
||||
_resolveAnimTree(animNode, parentOGL) {
|
||||
const rawName = animNode.data?.name;
|
||||
if (!rawName) {
|
||||
for (const child of animNode.children) {
|
||||
this._resolveAnimTree(child, parentOGL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const canonicalName = canonicalize(rawName);
|
||||
let matched = null;
|
||||
|
||||
// 1. Character body part? Scoped to parent context (character container),
|
||||
// matching the backend's FindChildROI.
|
||||
const partName = ANIM_NODE_TO_PART[canonicalName];
|
||||
if (partName) {
|
||||
matched = this._findChildByName(parentOGL, partName);
|
||||
}
|
||||
|
||||
// 2. Child of parent OGL Transform? (handles duplicate names via context)
|
||||
if (!matched) {
|
||||
matched = this._findChildByName(parentOGL, canonicalName, true);
|
||||
}
|
||||
|
||||
// 3. Direct child of modelGroup?
|
||||
if (!matched) {
|
||||
matched = this._findChildByName(this.modelGroup, canonicalName, true);
|
||||
}
|
||||
|
||||
animNode.data._transform = matched || null;
|
||||
|
||||
// Backend behavior: the search context only changes at the top level (props/characters
|
||||
// that are direct children of modelGroup). For nested sub-parts, the search context
|
||||
// stays at the top-level prop so siblings can be found.
|
||||
const nextParent = (matched && matched.parent === this.modelGroup) ? matched : parentOGL;
|
||||
for (const child of animNode.children) {
|
||||
this._resolveAnimTree(child, nextParent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create props for tree nodes that still have no _transform after resolution.
|
||||
*/
|
||||
_createMissingTreeProps(node) {
|
||||
const rawName = node.data?.name;
|
||||
if (rawName && !node.data._transform) {
|
||||
const canonicalName = canonicalize(rawName);
|
||||
const group = this._lookupProp(canonicalName);
|
||||
|
||||
if (group) {
|
||||
group.name = canonicalName;
|
||||
this.modelGroup.addChild(group);
|
||||
node.data._transform = group;
|
||||
|
||||
// Re-resolve children now that this prop exists
|
||||
for (const child of node.children) {
|
||||
this._resolveAnimTree(child, group);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
this._createMissingTreeProps(child);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rebase Matrix (Face Viewer) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute a Y-axis rotation that makes the root character face the camera.
|
||||
*
|
||||
* Walks the animation tree at t=0 to find the first character actor's world
|
||||
* matrix, extracts its forward direction (XZ plane), and returns a rotation
|
||||
* that aligns it with the direction from origin toward the default camera.
|
||||
*/
|
||||
_computeRebaseMatrix() {
|
||||
const animData = this._animData;
|
||||
|
||||
// Collect character actor names
|
||||
const characterNames = new Set();
|
||||
for (const actor of animData.actors) {
|
||||
if (actor.actorType === 2 && actor.name) {
|
||||
characterNames.add(canonicalize(actor.name));
|
||||
}
|
||||
}
|
||||
if (characterNames.size === 0) return new Mat4();
|
||||
|
||||
// Find the first character's world matrix at t=0.
|
||||
// Start from root's children to match _applyFrame's iteration.
|
||||
let animPose0 = null;
|
||||
for (const child of animData.rootNode.children) {
|
||||
animPose0 = this._findFirstCharacterPose(child, new Mat4(), characterNames);
|
||||
if (animPose0) break;
|
||||
}
|
||||
if (!animPose0) return new Mat4();
|
||||
|
||||
// Forward direction = column 2 of OGL column-major matrix
|
||||
const fwdX = animPose0[8];
|
||||
const fwdZ = animPose0[10];
|
||||
const fwdLen = Math.sqrt(fwdX * fwdX + fwdZ * fwdZ);
|
||||
if (fwdLen < 1e-6) return new Mat4();
|
||||
|
||||
// Current forward angle (from +Z axis)
|
||||
const currentAngle = Math.atan2(fwdX, fwdZ);
|
||||
|
||||
// Character models face -Z in local space (see ActorRenderer: modelGroup.rotation.y = π).
|
||||
// Column 2 points behind the character, so the visual forward is -column2.
|
||||
// To make -column2 point toward the camera, column2 must point AWAY from camera.
|
||||
const desiredAngle = Math.atan2(-3, -5);
|
||||
|
||||
const rebase = new Mat4();
|
||||
rebase.rotate(desiredAngle - currentAngle, [0, 1, 0]);
|
||||
return rebase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk the animation tree at t=0, accumulating world matrices,
|
||||
* and return the world matrix of the first node that matches a character actor.
|
||||
*/
|
||||
_findFirstCharacterPose(node, parentMat, characterNames) {
|
||||
const data = node.data;
|
||||
if (!data) return null;
|
||||
|
||||
const localMat = evaluateLocalTransform(data, 0);
|
||||
const worldMat = new Mat4().copy(parentMat).multiply(localMat);
|
||||
|
||||
if (data.name) {
|
||||
const canonicalName = canonicalize(data.name);
|
||||
if (characterNames.has(canonicalName)) {
|
||||
return worldMat;
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const result = this._findFirstCharacterPose(child, worldMat, characterNames);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── PTATCAM (Point At Camera) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve PTATCAM ROI names to OGL Transforms by searching the animation tree.
|
||||
* @param {string[]} ptAtCamNames - ROI names from the SI extra directives
|
||||
*/
|
||||
_resolvePtAtCam(ptAtCamNames) {
|
||||
this._ptAtCamTransforms = [];
|
||||
if (!ptAtCamNames || ptAtCamNames.length === 0) return;
|
||||
|
||||
const targetNames = new Set(ptAtCamNames.map(n => n.toLowerCase()));
|
||||
this._collectPtAtCamNodes(this._animData.rootNode, targetNames);
|
||||
}
|
||||
|
||||
/** Recursively collect transforms for nodes matching PTATCAM target names. */
|
||||
_collectPtAtCamNodes(node, targetNames) {
|
||||
if (node.data?.name) {
|
||||
const canonicalName = canonicalize(node.data.name);
|
||||
if (targetNames.has(canonicalName) && node.data._transform) {
|
||||
this._ptAtCamTransforms.push(node.data._transform);
|
||||
}
|
||||
}
|
||||
for (const child of node.children) {
|
||||
this._collectPtAtCamNodes(child, targetNames);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply PTATCAM post-processing: reorient target ROIs so their forward
|
||||
* direction points toward the camera.
|
||||
*
|
||||
* Mirrors LegoAnimPresenter::PutFrame / ScenePlayer::ApplyPtAtCam from the
|
||||
* original game. Keeps the up vector, recomputes right and forward based on
|
||||
* the camera-to-ROI direction.
|
||||
*/
|
||||
_applyPtAtCam() {
|
||||
if (!this._ptAtCamTransforms || this._ptAtCamTransforms.length === 0) return;
|
||||
|
||||
// Camera position in animation world space:
|
||||
// modelGroup transform is uniform scale + translation (from centerAndScaleModel)
|
||||
const s = this.modelGroup.scale.x;
|
||||
const p = this.modelGroup.position;
|
||||
const camX = (this.camera.position.x - p.x) / s;
|
||||
const camY = (this.camera.position.y - p.y) / s;
|
||||
const camZ = (this.camera.position.z - p.z) / s;
|
||||
|
||||
for (const target of this._ptAtCamTransforms) {
|
||||
const wm = this._animWorldMap.get(target);
|
||||
if (!wm) continue;
|
||||
|
||||
// Column magnitudes (preserve scale)
|
||||
const rightMag = Math.sqrt(wm[0] * wm[0] + wm[1] * wm[1] + wm[2] * wm[2]);
|
||||
const upMag = Math.sqrt(wm[4] * wm[4] + wm[5] * wm[5] + wm[6] * wm[6]);
|
||||
const fwdMag = Math.sqrt(wm[8] * wm[8] + wm[9] * wm[9] + wm[10] * wm[10]);
|
||||
if (rightMag < 1e-6 || upMag < 1e-6 || fwdMag < 1e-6) continue;
|
||||
|
||||
// ROI position (column 3)
|
||||
const posX = wm[12], posY = wm[13], posZ = wm[14];
|
||||
|
||||
// Vector from camera to ROI
|
||||
const ctX = posX - camX, ctY = posY - camY, ctZ = posZ - camZ;
|
||||
|
||||
// Normalized up (column 1)
|
||||
const nuX = wm[4] / upMag, nuY = wm[5] / upMag, nuZ = wm[6] / upMag;
|
||||
|
||||
// newRight = normalize(cross(up, camToROI))
|
||||
let nrX = nuY * ctZ - nuZ * ctY;
|
||||
let nrY = nuZ * ctX - nuX * ctZ;
|
||||
let nrZ = nuX * ctY - nuY * ctX;
|
||||
const nrLen = Math.sqrt(nrX * nrX + nrY * nrY + nrZ * nrZ);
|
||||
if (nrLen < 1e-6) continue; // camera aligned with up vector
|
||||
nrX /= nrLen; nrY /= nrLen; nrZ /= nrLen;
|
||||
|
||||
// newFwd = cross(newRight, up)
|
||||
const nfX = nrY * nuZ - nrZ * nuY;
|
||||
const nfY = nrZ * nuX - nrX * nuZ;
|
||||
const nfZ = nrX * nuY - nrY * nuX;
|
||||
|
||||
// Write columns back with restored magnitudes
|
||||
wm[0] = nrX * rightMag; wm[1] = nrY * rightMag; wm[2] = nrZ * rightMag;
|
||||
wm[4] = nuX * upMag; wm[5] = nuY * upMag; wm[6] = nuZ * upMag;
|
||||
wm[8] = nfX * fwdMag; wm[9] = nfY * fwdMag; wm[10] = nfZ * fwdMag;
|
||||
|
||||
// Apply to OGL transform
|
||||
if (target.parent === this.modelGroup) {
|
||||
for (let i = 0; i < 16; i++) target.matrix[i] = wm[i];
|
||||
} else {
|
||||
const parentAnimWorld = this._animWorldMap.get(target.parent);
|
||||
if (parentAnimWorld) {
|
||||
const relMat = new Mat4().copy(parentAnimWorld).inverse().multiply(wm);
|
||||
for (let i = 0; i < 16; i++) target.matrix[i] = relMat[i];
|
||||
} else {
|
||||
for (let i = 0; i < 16; i++) target.matrix[i] = wm[i];
|
||||
}
|
||||
}
|
||||
target.worldMatrixNeedsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback Control ────────────────────────────────────────────
|
||||
|
||||
play() {
|
||||
this._playing = true;
|
||||
this._lastTime = performance.now();
|
||||
if (!this.animating) this.start();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this._playing = false;
|
||||
}
|
||||
|
||||
/** Reset elapsed time to zero for replay. */
|
||||
resetPlayback() {
|
||||
this._elapsed = 0;
|
||||
}
|
||||
|
||||
/** Seek to a specific time in the animation. */
|
||||
seek(timeMs) {
|
||||
const clampedMs = Math.max(0, Math.min(timeMs, this._duration));
|
||||
this._elapsed = clampedMs / 1000;
|
||||
this._lastTime = performance.now();
|
||||
this._applyFrame(clampedMs);
|
||||
}
|
||||
|
||||
get playing() { return this._playing; }
|
||||
get elapsed() { return this._elapsed * 1000; }
|
||||
get duration() { return this._duration; }
|
||||
set duration(value) { this._duration = value; }
|
||||
get finished() { return this._duration > 0 && this._elapsed * 1000 >= this._duration; }
|
||||
|
||||
/**
|
||||
* Override BaseRenderer's updateAnimation for the scene playback loop.
|
||||
* Called each frame by BaseRenderer.animate().
|
||||
*/
|
||||
updateAnimation() {
|
||||
const now = performance.now();
|
||||
const dt = (now - this._lastTime) / 1000;
|
||||
this._lastTime = now;
|
||||
|
||||
if (this._playing) {
|
||||
this._elapsed += dt;
|
||||
if (this._elapsed * 1000 >= this._duration) {
|
||||
this._elapsed = this._duration / 1000;
|
||||
this._playing = false;
|
||||
}
|
||||
this._applyFrame(this._elapsed * 1000);
|
||||
}
|
||||
|
||||
this.controls?.update();
|
||||
}
|
||||
|
||||
// ── Per-Frame Animation (mirrors ApplyTree -> ApplyAnimationTransformation) ──
|
||||
|
||||
/**
|
||||
* Apply all animation transforms at the given time.
|
||||
* Mirrors AnimUtils::ApplyTree which iterates root's children
|
||||
* with the rebase matrix, then applies PTATCAM post-processing.
|
||||
*/
|
||||
_applyFrame(timeMs) {
|
||||
if (!this._animData) return;
|
||||
|
||||
const root = this._animData.rootNode;
|
||||
|
||||
// Map of OGL Transform -> its animation world matrix, for computing relative matrices
|
||||
this._animWorldMap = new Map();
|
||||
|
||||
for (const child of root.children) {
|
||||
this._applyNode(child, timeMs, this._rebaseMatrix);
|
||||
}
|
||||
|
||||
this._applyPtAtCam();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply animation transforms to a single tree node and recurse.
|
||||
* Mirrors LegoROI::ApplyAnimationTransformation.
|
||||
*
|
||||
* For OGL transforms that are direct children of modelGroup (characters, top-level props),
|
||||
* we set the full animation world matrix directly.
|
||||
*
|
||||
* For sub-parts within a hierarchical prop (e.g. jail doors under jail, helicopter blades
|
||||
* under helicopter), we compute a RELATIVE matrix: inv(parentAnimWorld) * childAnimWorld.
|
||||
* This way OGL's parent-child composition produces the correct final world transform.
|
||||
*/
|
||||
_applyNode(node, time, parentMat) {
|
||||
const data = node.data;
|
||||
if (!data) return;
|
||||
|
||||
// Build local transform: Scale -> Rotation -> Translation
|
||||
const localMat = evaluateLocalTransform(data, time);
|
||||
|
||||
// World = parent * local (matches roi->m_local2world.Product(mat, p_matrix))
|
||||
const worldMat = new Mat4().copy(parentMat).multiply(localMat);
|
||||
|
||||
const target = data._transform;
|
||||
if (target) {
|
||||
let useMat;
|
||||
if (target.parent === this.modelGroup) {
|
||||
// Direct child of modelGroup: use animation world matrix
|
||||
useMat = worldMat;
|
||||
} else {
|
||||
// Sub-part within a prop: compute matrix relative to OGL parent
|
||||
// so that OGL's parent.worldMatrix * child.matrix = child.animWorldMatrix
|
||||
const parentAnimWorld = this._animWorldMap.get(target.parent);
|
||||
if (parentAnimWorld) {
|
||||
const inv = new Mat4().copy(parentAnimWorld).inverse();
|
||||
useMat = inv.multiply(worldMat);
|
||||
} else {
|
||||
useMat = worldMat;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 16; i++) target.matrix[i] = useMat[i];
|
||||
target.matrixAutoUpdate = false;
|
||||
target.worldMatrixNeedsUpdate = true;
|
||||
|
||||
// Store this target's animation world matrix for sub-parts
|
||||
this._animWorldMap.set(target, worldMat);
|
||||
|
||||
// Visibility from morph keys
|
||||
if (data.morphKeys.length) {
|
||||
target.visible = getVisibility(data.morphKeys, time);
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse -- always pass worldMat, matching backend behavior for unresolved nodes
|
||||
for (const child of node.children) {
|
||||
this._applyNode(child, time, worldMat);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cleanup ─────────────────────────────────────────────────────
|
||||
|
||||
clearModel() {
|
||||
this._animData = null;
|
||||
this._actorContainers.clear();
|
||||
this._elapsed = 0;
|
||||
this._playing = false;
|
||||
this._parser = null;
|
||||
this._wdb = null;
|
||||
this._worldPartsMaps = null;
|
||||
this._globalPartsMap = null;
|
||||
this._rebaseMatrix = null;
|
||||
this._ptAtCamTransforms = null;
|
||||
super.clearModel();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.animating = false;
|
||||
this.clearModel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@ -1,24 +1,15 @@
|
||||
import { WdbModelRenderer } from './WdbModelRenderer.js';
|
||||
|
||||
/**
|
||||
* Specialized renderer for the LEGO Island score cube
|
||||
* Extends WdbModelRenderer with score-specific functionality
|
||||
* Specialized renderer for the LEGO Island score cube.
|
||||
*/
|
||||
export class ScoreCubeRenderer extends WdbModelRenderer {
|
||||
// Score grid layout constants (from score.cpp)
|
||||
static AREA_Y_OFFSETS = [0x2b, 0x57, 0x80, 0xab, 0xd6]; // per actor row
|
||||
static AREA_Y_OFFSETS = [0x2b, 0x57, 0x80, 0xab, 0xd6];
|
||||
static AREA_HEIGHTS = [0x2a, 0x27, 0x29, 0x29, 0x2a];
|
||||
static AREA_X_OFFSETS = [0x2f, 0x56, 0x81, 0xaa, 0xd4]; // per activity column
|
||||
static AREA_X_OFFSETS = [0x2f, 0x56, 0x81, 0xaa, 0xd4];
|
||||
static AREA_WIDTHS = [0x25, 0x29, 0x27, 0x28, 0x28];
|
||||
static COLOR_INDICES = [0x11, 0x0f, 0x08, 0x05]; // grey, yellow, blue, red
|
||||
static COLOR_INDICES = [0x11, 0x0f, 0x08, 0x05];
|
||||
|
||||
/**
|
||||
* Update score colors on texture
|
||||
* Score layout on cube (left to right, top to bottom):
|
||||
* - Activities (columns): carRace, jetskiRace, pizza, towTrack, ambulance (0-4)
|
||||
* - Actors (rows): pepper, mama, papa, nick, laura (0-4)
|
||||
* @param {Array<Array<number>>} scores - 2D array [actor][activity] with values 0-3
|
||||
*/
|
||||
updateScores(scores) {
|
||||
if (!this.textureCanvas || !this.baseImageData || !this.palette) return;
|
||||
|
||||
@ -49,23 +40,12 @@ export class ScoreCubeRenderer extends WdbModelRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raycast to find clicked score cell
|
||||
* @param {MouseEvent} event - Click event
|
||||
* @returns {{ actor: number, activity: number } | null}
|
||||
*/
|
||||
raycast(event) {
|
||||
const hit = this.raycastUV(event);
|
||||
if (!hit) return null;
|
||||
return this.uvToScoreCell(hit.x, hit.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert texture pixel coordinates to score cell
|
||||
* @param {number} x - X coordinate (0-256)
|
||||
* @param {number} y - Y coordinate (0-256)
|
||||
* @returns {{ actor: number, activity: number } | null}
|
||||
*/
|
||||
uvToScoreCell(x, y) {
|
||||
for (let activity = 0; activity < 5; activity++) {
|
||||
for (let actor = 0; actor < 5; actor++) {
|
||||
|
||||
@ -1,69 +1,55 @@
|
||||
import * as THREE from 'three';
|
||||
import { Transform, Mesh } from 'ogl';
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { LegoColors } from '../savegame/constants.js';
|
||||
import { resolveLods } from '../formats/WdbParser.js';
|
||||
import { BaseRenderer } from './BaseRenderer.js';
|
||||
|
||||
/**
|
||||
* Specialized renderer for LEGO vehicle parts
|
||||
* Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName
|
||||
* Specialized renderer for LEGO vehicle parts.
|
||||
* Renders ROI with proper textures - only colors meshes with INH prefix.
|
||||
*/
|
||||
export class VehiclePartRenderer extends BaseRenderer {
|
||||
constructor(canvas) {
|
||||
super(canvas);
|
||||
this.colorableMeshes = []; // Meshes with INH prefix
|
||||
this.colorableMeshes = [];
|
||||
|
||||
this.camera.position.set(0, 0, 3);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
this.camera.lookAt([0, 0, 0]);
|
||||
|
||||
this.setupControls(new THREE.Vector3(0, 0, 0));
|
||||
this.setupControls(new Vec3(0, 0, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mesh has INH prefix in textureName or materialName
|
||||
* This indicates the mesh should inherit color from the ROI
|
||||
*/
|
||||
hasInhPrefix(mesh) {
|
||||
const texName = mesh.properties?.textureName?.toLowerCase() || '';
|
||||
const matName = mesh.properties?.materialName?.toLowerCase() || '';
|
||||
return texName.startsWith('inh') || matName.startsWith('inh');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load part geometry with proper textures and colorable mesh detection
|
||||
* @param {object} roiData - Parsed ROI data with lods
|
||||
* @param {string} colorName - LEGO color name for colorable parts
|
||||
* @param {object[]} textureList - Array of texture data from model
|
||||
* @param {Map} partsMap - Map of part name -> part data for shared LOD resolution
|
||||
*/
|
||||
loadPartWithColor(roiData, colorName, textureList = [], partsMap = new Map()) {
|
||||
this.clearModel();
|
||||
|
||||
this.modelGroup = new THREE.Group();
|
||||
this.modelGroup = new Transform();
|
||||
this.colorableMeshes = [];
|
||||
this.partsMap = partsMap;
|
||||
|
||||
this.loadTextures(textureList);
|
||||
|
||||
const legoColor = LegoColors[colorName] || LegoColors['lego red'];
|
||||
const threeLegoColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255);
|
||||
const legoColorArr = [legoColor.r / 255, legoColor.g / 255, legoColor.b / 255];
|
||||
|
||||
this.createMeshesFromROI(roiData, threeLegoColor);
|
||||
this.createMeshesFromROI(roiData, legoColorArr);
|
||||
|
||||
this.centerAndScaleModel(1.5);
|
||||
|
||||
this.scene.add(this.modelGroup);
|
||||
this.scene.addChild(this.modelGroup);
|
||||
this.controls.autoRotate = true;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively create meshes from ROI and its children
|
||||
*/
|
||||
createMeshesFromROI(roiData, legoColor) {
|
||||
const lods = resolveLods(roiData, this.partsMap);
|
||||
|
||||
if (lods.length > 0) {
|
||||
// Use highest quality LOD (last in array has most vertices)
|
||||
const lod = lods[lods.length - 1];
|
||||
|
||||
for (const mesh of lod.meshes) {
|
||||
@ -74,106 +60,82 @@ export class VehiclePartRenderer extends BaseRenderer {
|
||||
const hasUVs = mesh.textureIndices && mesh.textureIndices.length > 0;
|
||||
const meshTextureName = mesh.properties?.textureName?.toLowerCase();
|
||||
|
||||
let material;
|
||||
|
||||
// Get alpha from mesh properties
|
||||
// In the original game: alpha = 0 means opaque, alpha > 0 means transparent
|
||||
const meshAlpha = mesh.properties?.alpha || 0;
|
||||
const isTransparent = meshAlpha > 0;
|
||||
const opacity = isTransparent ? meshAlpha : 1;
|
||||
|
||||
const transparencyOpts = {
|
||||
transparent: isTransparent,
|
||||
depthWrite: !isTransparent,
|
||||
};
|
||||
|
||||
let program;
|
||||
|
||||
if (isColorable) {
|
||||
// Mesh has INH prefix - use the LEGO color
|
||||
material = new THREE.MeshLambertMaterial({
|
||||
color: legoColor,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: isTransparent,
|
||||
opacity: opacity,
|
||||
depthWrite: !isTransparent
|
||||
});
|
||||
this.colorableMeshes.push(null); // Placeholder, will set after mesh creation
|
||||
program = this.createColoredProgram([...legoColor], opacity, transparencyOpts);
|
||||
this.colorableMeshes.push(null);
|
||||
} else if (hasUVs && meshTextureName && this.textures.has(meshTextureName)) {
|
||||
// Mesh has its own texture
|
||||
material = new THREE.MeshLambertMaterial({
|
||||
map: this.textures.get(meshTextureName),
|
||||
side: THREE.DoubleSide,
|
||||
transparent: isTransparent,
|
||||
opacity: opacity,
|
||||
depthWrite: !isTransparent
|
||||
});
|
||||
program = this.createTexturedProgram(this.textures.get(meshTextureName), opacity, transparencyOpts);
|
||||
} else {
|
||||
// Fallback to mesh's vertex color
|
||||
const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
|
||||
material = new THREE.MeshLambertMaterial({
|
||||
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
|
||||
side: THREE.DoubleSide,
|
||||
transparent: isTransparent,
|
||||
opacity: opacity,
|
||||
depthWrite: !isTransparent
|
||||
});
|
||||
program = this.createColoredProgram([meshColor.r / 255, meshColor.g / 255, meshColor.b / 255], opacity, transparencyOpts);
|
||||
}
|
||||
|
||||
const threeMesh = new THREE.Mesh(geometry, material);
|
||||
const oglMesh = new Mesh(this.gl, { geometry, program });
|
||||
if (meshTextureName) {
|
||||
threeMesh.userData.textureName = meshTextureName;
|
||||
oglMesh._textureName = meshTextureName;
|
||||
}
|
||||
this.modelGroup.add(threeMesh);
|
||||
this.modelGroup.addChild(oglMesh);
|
||||
|
||||
// Track colorable meshes
|
||||
if (isColorable) {
|
||||
this.colorableMeshes[this.colorableMeshes.length - 1] = threeMesh;
|
||||
this.colorableMeshes[this.colorableMeshes.length - 1] = oglMesh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
for (const child of roiData.children || []) {
|
||||
this.createMeshesFromROI(child, legoColor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update texture on meshes matching a given texture name
|
||||
* @param {string} textureName - Texture name to match (case-insensitive)
|
||||
* @param {{ width: number, height: number, palette: Array<{r,g,b}>, pixels: Uint8Array }} textureData
|
||||
*/
|
||||
updateTexture(textureName, textureData) {
|
||||
if (!this.modelGroup) return;
|
||||
|
||||
const newTexture = this.createTexture(textureData);
|
||||
const targetName = textureName.toLowerCase();
|
||||
const newTexture = this.createTexture(textureData);
|
||||
|
||||
// Clean up old texture if it exists
|
||||
const oldTexture = this.textures.get(targetName);
|
||||
if (oldTexture) {
|
||||
this.gl.deleteTexture(oldTexture.texture);
|
||||
}
|
||||
this.textures.set(targetName, newTexture);
|
||||
|
||||
this.modelGroup.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
if (child.userData.textureName !== targetName) return;
|
||||
if (!(child instanceof Mesh)) return;
|
||||
if (child._textureName !== targetName) return;
|
||||
|
||||
const oldMap = child.material.map;
|
||||
child.material.map = newTexture;
|
||||
// Set color to white so texture isn't tinted by the fallback color
|
||||
child.material.color.setRGB(1, 1, 1);
|
||||
child.material.needsUpdate = true;
|
||||
if (oldMap && oldMap !== newTexture) oldMap.dispose();
|
||||
child.program.uniforms.tMap.value = newTexture;
|
||||
child.program.uniforms.uUseTexture.value = 1;
|
||||
child.program.uniforms.uColor.value = [1, 1, 1];
|
||||
});
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update color of colorable meshes without reloading geometry
|
||||
*/
|
||||
updateColor(colorName) {
|
||||
if (!this.modelGroup || this.colorableMeshes.length === 0) return;
|
||||
|
||||
const legoColor = LegoColors[colorName] || LegoColors['lego red'];
|
||||
const threeColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255);
|
||||
const colorArr = [legoColor.r / 255, legoColor.g / 255, legoColor.b / 255];
|
||||
|
||||
for (const mesh of this.colorableMeshes) {
|
||||
if (mesh && mesh.material) {
|
||||
mesh.material.color = threeColor;
|
||||
if (mesh && mesh.program) {
|
||||
mesh.program.uniforms.uColor.value = colorArr;
|
||||
}
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
clearModel() {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import { Transform, Mesh, Texture } from 'ogl';
|
||||
import { Vec3 } from 'ogl/src/math/Vec3.js';
|
||||
import { Raycast } from 'ogl/src/extras/Raycast.js';
|
||||
import { BaseRenderer } from './BaseRenderer.js';
|
||||
|
||||
/**
|
||||
@ -14,32 +16,32 @@ export class WdbModelRenderer extends BaseRenderer {
|
||||
this.textureCanvas = null;
|
||||
this.baseImageData = null;
|
||||
this.palette = null;
|
||||
this._raycast = new Raycast();
|
||||
|
||||
this.camera.position.set(0, 0.2, 7);
|
||||
|
||||
this.setupControls(new THREE.Vector3(0, 0.2, 0));
|
||||
this.setupControls(new Vec3(0, 0.2, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load model geometry and texture from parsed WDB data
|
||||
* @param {object} roiData - Parsed ROI data with lods
|
||||
* @param {object} textureData - Parsed texture with palette and pixels
|
||||
*/
|
||||
loadModel(roiData, textureData) {
|
||||
this.palette = textureData.palette;
|
||||
this.modelGroup = new THREE.Group();
|
||||
this.modelGroup = new Transform();
|
||||
|
||||
if (!roiData.lods || roiData.lods.length === 0) {
|
||||
this.scene.add(this.modelGroup);
|
||||
this.scene.addChild(this.modelGroup);
|
||||
return;
|
||||
}
|
||||
|
||||
const lod = roiData.lods[0];
|
||||
|
||||
this.textureCanvas = this.createTextureCanvas(textureData);
|
||||
this.texture = new THREE.CanvasTexture(this.textureCanvas);
|
||||
this.texture.minFilter = THREE.LinearFilter;
|
||||
this.texture.magFilter = THREE.LinearFilter;
|
||||
this.texture = new Texture(this.gl, {
|
||||
image: this.textureCanvas,
|
||||
minFilter: this.gl.LINEAR,
|
||||
magFilter: this.gl.LINEAR,
|
||||
generateMipmaps: false,
|
||||
flipY: true,
|
||||
});
|
||||
|
||||
for (const mesh of lod.meshes) {
|
||||
const geometry = this.createGeometry(mesh, lod);
|
||||
@ -48,33 +50,20 @@ export class WdbModelRenderer extends BaseRenderer {
|
||||
const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0;
|
||||
|
||||
if (hasTexture) {
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
map: this.texture,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
this.texturedMesh = new THREE.Mesh(geometry, material);
|
||||
this.modelGroup.add(this.texturedMesh);
|
||||
const program = this.createTexturedProgram(this.texture);
|
||||
this.texturedMesh = new Mesh(this.gl, { geometry, program });
|
||||
this.modelGroup.addChild(this.texturedMesh);
|
||||
} else {
|
||||
const color = mesh.properties?.color || { r: 128, g: 128, b: 128 };
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255),
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
this.modelGroup.add(new THREE.Mesh(geometry, material));
|
||||
const program = this.createColoredProgram([color.r / 255, color.g / 255, color.b / 255]);
|
||||
this.modelGroup.addChild(new Mesh(this.gl, { geometry, program }));
|
||||
}
|
||||
}
|
||||
|
||||
this.scene.add(this.modelGroup);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.scene.addChild(this.modelGroup);
|
||||
this.glRenderer.render({ scene: this.scene, camera: this.camera });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create canvas texture from paletted LEGO texture data.
|
||||
* Unlike BaseRenderer.createTexture(), this keeps a reference to the
|
||||
* canvas and base image data so subclasses can paint over it (e.g. scores).
|
||||
* @param {object} textureData - { width, height, palette, pixels }
|
||||
* @returns {HTMLCanvasElement}
|
||||
*/
|
||||
createTextureCanvas(textureData) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = textureData.width;
|
||||
@ -96,35 +85,36 @@ export class WdbModelRenderer extends BaseRenderer {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raycast and return UV coordinates of hit on textured mesh
|
||||
* @param {MouseEvent} event - Mouse event
|
||||
* @returns {{ uv: THREE.Vector2, x: number, y: number } | null}
|
||||
*/
|
||||
dispose() {
|
||||
if (this.texture) {
|
||||
this.gl.deleteTexture(this.texture.texture);
|
||||
this.texture = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
raycastUV(event) {
|
||||
if (!this.texturedMesh) return null;
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
const mouse = [
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
-(((event.clientY - rect.top) / rect.height) * 2 - 1),
|
||||
];
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(mouse, this.camera);
|
||||
const intersects = raycaster.intersectObject(this.texturedMesh);
|
||||
this._raycast.castMouse(this.camera, mouse);
|
||||
const hits = this._raycast.intersectMeshes([this.texturedMesh], {
|
||||
cullFace: false,
|
||||
includeUV: true,
|
||||
});
|
||||
|
||||
if (intersects.length > 0 && intersects[0].uv) {
|
||||
const uv = intersects[0].uv;
|
||||
const x = uv.x * this.textureCanvas.width;
|
||||
const y = (1 - uv.y) * this.textureCanvas.height;
|
||||
if (hits.length > 0 && hits[0].hit && hits[0].hit.uv) {
|
||||
const uv = hits[0].hit.uv;
|
||||
const x = uv[0] * this.textureCanvas.width;
|
||||
const y = (1 - uv[1]) * this.textureCanvas.height;
|
||||
return { uv, x, y };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.texture?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
47
src/core/room-names.js
Normal file
47
src/core/room-names.js
Normal file
@ -0,0 +1,47 @@
|
||||
const adjectives = [
|
||||
'brave', 'swift', 'bold', 'clever', 'mighty',
|
||||
'wild', 'keen', 'fierce', 'noble', 'daring',
|
||||
'happy', 'lucky', 'sneaky', 'speedy', 'zany',
|
||||
'epic', 'fancy', 'jolly', 'plucky', 'witty',
|
||||
'cosmic', 'turbo', 'mega', 'ultra', 'super',
|
||||
'tiny', 'grand', 'royal', 'magic', 'hyper',
|
||||
'funky', 'radical', 'gnarly', 'stellar', 'wicked',
|
||||
'blazing', 'flying', 'roaming', 'dashing', 'rogue'
|
||||
];
|
||||
|
||||
const colors = [
|
||||
'red', 'blue', 'green', 'golden', 'silver',
|
||||
'amber', 'coral', 'jade', 'ruby', 'cobalt',
|
||||
'crimson', 'azure', 'scarlet', 'violet', 'copper',
|
||||
'ivory', 'onyx', 'pearl', 'bronze', 'chrome',
|
||||
'neon', 'rusty', 'dusty', 'sunny', 'stormy',
|
||||
'frosty', 'mossy', 'sandy', 'misty', 'smoky',
|
||||
'crystal', 'marble', 'granite', 'plastic', 'painted',
|
||||
'wooden', 'steel', 'iron', 'stone', 'glass'
|
||||
];
|
||||
|
||||
const nouns = [
|
||||
'brick', 'pizza', 'island', 'pepper', 'mama',
|
||||
'papa', 'nick', 'laura', 'brickster', 'studs',
|
||||
'rhoda', 'snap', 'infoman', 'clickitt', 'rom',
|
||||
'ding', 'legando', 'shrimp', 'hogg', 'funberg',
|
||||
'surfer', 'racer', 'cop', 'skater', 'jetski',
|
||||
'tower', 'chopper', 'minifig', 'nubby', 'maggie',
|
||||
'polly', 'brad', 'doris', 'tepid', 'bumpy',
|
||||
'trades', 'pounds', 'mail', 'greenbase', 'worse'
|
||||
];
|
||||
|
||||
function pickRandom(list) {
|
||||
return list[Math.floor(Math.random() * list.length)];
|
||||
}
|
||||
|
||||
export function generateRoomName() {
|
||||
return `${pickRandom(adjectives)}-${pickRandom(colors)}-${pickRandom(nouns)}`;
|
||||
}
|
||||
|
||||
export function validateRoomName(name) {
|
||||
if (!name) return false;
|
||||
const parts = name.split('-');
|
||||
if (parts.length !== 3) return false;
|
||||
return adjectives.includes(parts[0]) && colors.includes(parts[1]) && nouns.includes(parts[2]);
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
* isle/LEGO1/lego/legoomni/src/common/legoactors.cpp
|
||||
* isle/LEGO1/lego/legoomni/include/legoactors.h
|
||||
*/
|
||||
import actorDisplayNamesData from '../../data/actor-display-names.json';
|
||||
|
||||
// LegoActorLOD flags
|
||||
export const ActorLODFlags = Object.freeze({
|
||||
@ -596,74 +597,7 @@ export const ActorInfoInit = Object.freeze([
|
||||
* Display names for the 66 actors, from savegame.ksy doc comments.
|
||||
* Falls back to the internal name (ActorInfoInit[i].name) when not listed.
|
||||
*/
|
||||
export const ActorDisplayNames = Object.freeze([
|
||||
/* 0 */ 'Pepper Roni',
|
||||
/* 1 */ 'Mama Brickolini',
|
||||
/* 2 */ 'Papa Brickolini',
|
||||
/* 3 */ 'Nick Brick',
|
||||
/* 4 */ 'Laura Brick',
|
||||
/* 5 */ 'Infomaniac',
|
||||
/* 6 */ 'Brickster',
|
||||
/* 7 */ 'Studs Linkin',
|
||||
/* 8 */ 'Rhoda Hogg',
|
||||
/* 9 */ 'Valerie Stubbins',
|
||||
/* 10 */ 'Snap Lockitt',
|
||||
/* 11 */ 'pt',
|
||||
/* 12 */ 'Maggie Post',
|
||||
/* 13 */ 'Buck Pounds',
|
||||
/* 14 */ 'Ed Mail',
|
||||
/* 15 */ 'Nubby Stevens',
|
||||
/* 16 */ 'Nancy Nubbins',
|
||||
/* 17 */ 'Dr. Clickitt',
|
||||
/* 18 */ 'Enter',
|
||||
/* 19 */ 'Return',
|
||||
/* 20 */ 'Captain D. Rom',
|
||||
/* 21 */ 'Bill Ding (Race Car)',
|
||||
/* 22 */ 'Bill Ding (Helicopter)',
|
||||
/* 23 */ 'Bill Ding (Dune Buggy)',
|
||||
/* 24 */ 'Bill Ding (Jetski)',
|
||||
/* 25 */ 'Flying Legandos #1',
|
||||
/* 26 */ 'Flying Legandos #2',
|
||||
/* 27 */ 'Flying Legandos #3',
|
||||
/* 28 */ 'Flying Legandos #4',
|
||||
/* 29 */ 'Flying Legandos #5',
|
||||
/* 30 */ 'Flying Legandos #6',
|
||||
/* 31 */ 'Legobobs #1',
|
||||
/* 32 */ 'Legobobs #2',
|
||||
/* 33 */ 'Legobobs #3',
|
||||
/* 34 */ 'Legobobs #4',
|
||||
/* 35 */ 'Brazilian Carmen',
|
||||
/* 36 */ 'Gideon Worse',
|
||||
/* 37 */ 'Red Greenbase',
|
||||
/* 38 */ 'Polly Gone',
|
||||
/* 39 */ 'Bradford Brickford',
|
||||
/* 40 */ 'Shiney Doris',
|
||||
/* 41 */ 'Glen Funberg',
|
||||
/* 42 */ 'Dorothy Funberg',
|
||||
/* 43 */ 'Brian Shrimp',
|
||||
/* 44 */ 'Luke Tepid',
|
||||
/* 45 */ 'Shorty Tails',
|
||||
/* 46 */ 'Bumpy Kindergreen',
|
||||
/* 47 */ "Jack O'Trades",
|
||||
/* 48 */ 'Ghost #1',
|
||||
/* 49 */ 'Ghost #2',
|
||||
/* 50 */ 'Ghost #3',
|
||||
/* 51 */ 'Ghost #4',
|
||||
/* 52 */ 'Ghost #5',
|
||||
/* 53 */ 'Ghost #6',
|
||||
/* 54 */ 'hg',
|
||||
/* 55 */ 'pntgy',
|
||||
/* 56 */ 'pep',
|
||||
/* 57 */ 'cop01',
|
||||
/* 58 */ 'actor_01',
|
||||
/* 59 */ 'actor_02',
|
||||
/* 60 */ 'actor_03',
|
||||
/* 61 */ 'actor_04',
|
||||
/* 62 */ 'actor_05',
|
||||
/* 63 */ 'btmncycl',
|
||||
/* 64 */ 'cboycycl',
|
||||
/* 65 */ 'boatman'
|
||||
]);
|
||||
export const ActorDisplayNames = Object.freeze(actorDisplayNamesData);
|
||||
|
||||
/**
|
||||
* Vehicle associations for actors. Maps ActorInfoInit index -> vehicle info.
|
||||
@ -713,3 +647,15 @@ export const CharacterFieldOffsets = Object.freeze({
|
||||
|
||||
export const CHARACTER_RECORD_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Map from internal character name (g_characters[].m_name / ActorInfoInit[].name)
|
||||
* to display name (ActorDisplayNames[]). Used by animation UI to show human-readable
|
||||
* character names for slot requirements.
|
||||
*/
|
||||
export const CharacterNameMap = Object.freeze(
|
||||
ActorInfoInit.reduce((map, info, i) => {
|
||||
map[info.name] = ActorDisplayNames[i];
|
||||
return map;
|
||||
}, {})
|
||||
);
|
||||
|
||||
|
||||
@ -210,7 +210,13 @@ export async function saveSaveSlot(slotNumber, buffer, silent = false) {
|
||||
}
|
||||
|
||||
const fileName = getSaveFileName(slotNumber);
|
||||
return await writeBinaryFile(fileName, buffer, silent, 'Save updated');
|
||||
const success = await writeBinaryFile(fileName, buffer, silent, 'Save updated');
|
||||
if (success) {
|
||||
window.dispatchEvent(new CustomEvent('opfs-save-slot-written', {
|
||||
detail: { slot: slotNumber }
|
||||
}));
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -377,6 +383,9 @@ export async function updatePlayerName(playerIndex, newName) {
|
||||
if (success) {
|
||||
// Clear cache so next load gets fresh data
|
||||
clearPlayersCache();
|
||||
window.dispatchEvent(new CustomEvent('opfs-save-file-written', {
|
||||
detail: { filename: PLAYERS_FILE }
|
||||
}));
|
||||
}
|
||||
|
||||
return success;
|
||||
|
||||
193
src/core/sceneAudio.js
Normal file
193
src/core/sceneAudio.js
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Multi-track audio player for scene animations.
|
||||
* Each track has a time offset and starts at the appropriate point
|
||||
* during playback, matching AudioPlayer::Tick() from
|
||||
* isle-portable/extensions/src/multiplayer/animation/audioplayer.cpp.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a WAV file ArrayBuffer from raw PCM data and WaveFormat fields.
|
||||
* Reuses the same pattern as assetLoader.js buildWav().
|
||||
* @param {ArrayBuffer} pcmData - Raw PCM audio data
|
||||
* @param {Object} format - WaveFormat fields
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
function buildWavFromPCM(pcmData, format) {
|
||||
const dataSize = pcmData.byteLength;
|
||||
const wavSize = 44 + dataSize;
|
||||
const wav = new ArrayBuffer(wavSize);
|
||||
const view = new DataView(wav);
|
||||
const bytes = new Uint8Array(wav);
|
||||
|
||||
// RIFF header
|
||||
bytes.set([0x52, 0x49, 0x46, 0x46]); // "RIFF"
|
||||
view.setUint32(4, wavSize - 8, true);
|
||||
bytes.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE"
|
||||
|
||||
// fmt chunk
|
||||
bytes.set([0x66, 0x6d, 0x74, 0x20], 12); // "fmt "
|
||||
view.setUint32(16, 16, true); // chunk size
|
||||
view.setUint16(20, format.wFormatTag || 1, true);
|
||||
view.setUint16(22, format.nChannels || 1, true);
|
||||
view.setUint32(24, format.nSamplesPerSec || 22050, true);
|
||||
view.setUint32(28, format.nAvgBytesPerSec || 22050, true);
|
||||
view.setUint16(32, format.nBlockAlign || 1, true);
|
||||
view.setUint16(34, format.wBitsPerSample || 8, true);
|
||||
|
||||
// data chunk
|
||||
bytes.set([0x64, 0x61, 0x74, 0x61], 36); // "data"
|
||||
view.setUint32(40, dataSize, true);
|
||||
bytes.set(new Uint8Array(pcmData), 44);
|
||||
|
||||
return wav;
|
||||
}
|
||||
|
||||
export class SceneAudioPlayer {
|
||||
constructor() {
|
||||
this.audioContext = null;
|
||||
this.gainNode = null;
|
||||
this.tracks = []; // { buffer, timeOffset, source, started }
|
||||
this.volume = 1.0;
|
||||
this._muted = false;
|
||||
this.blocked = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with audio tracks from SceneAnimData.
|
||||
* @param {import('./formats/SICompositeParser.js').AudioTrack[]} audioTracks
|
||||
*/
|
||||
async init(audioTracks) {
|
||||
if (audioTracks.length === 0) return;
|
||||
|
||||
this.audioContext = new AudioContext();
|
||||
this.gainNode = this.audioContext.createGain();
|
||||
this.gainNode.gain.value = this.volume;
|
||||
this.gainNode.connect(this.audioContext.destination);
|
||||
|
||||
for (const track of audioTracks) {
|
||||
const wav = buildWavFromPCM(track.pcmData, track.format);
|
||||
try {
|
||||
const audioBuffer = await this.audioContext.decodeAudioData(wav);
|
||||
// Convert SI volume (0-79) to gain (0-1)
|
||||
const gain = Math.min(track.volume / 79, 1.0);
|
||||
this.tracks.push({
|
||||
buffer: audioBuffer,
|
||||
timeOffset: track.timeOffset,
|
||||
gain,
|
||||
source: null,
|
||||
started: false,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[SceneAudio] Failed to decode track:', track.mediaSrcPath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called each frame with elapsed time in ms.
|
||||
* Starts tracks whose timeOffset has been reached.
|
||||
*/
|
||||
tick(elapsedMs) {
|
||||
if (!this.audioContext || this.blocked) return;
|
||||
|
||||
for (const track of this.tracks) {
|
||||
if (!track.started && elapsedMs >= track.timeOffset) {
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = track.buffer;
|
||||
|
||||
// Per-track gain node
|
||||
const trackGain = this.audioContext.createGain();
|
||||
trackGain.gain.value = track.gain;
|
||||
source.connect(trackGain);
|
||||
trackGain.connect(this.gainNode);
|
||||
|
||||
source.start();
|
||||
track.source = source;
|
||||
track.started = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.audioContext?.suspend();
|
||||
}
|
||||
|
||||
async resume() {
|
||||
if (!this.audioContext) return true;
|
||||
await this.audioContext.resume();
|
||||
this.blocked = this.audioContext.state !== 'running';
|
||||
return !this.blocked;
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const track of this.tracks) {
|
||||
if (track.source) {
|
||||
try { track.source.stop(); } catch { /* already stopped */ }
|
||||
track.source = null;
|
||||
}
|
||||
track.started = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a specific time. Stops all tracks and restarts those
|
||||
* that should be mid-playback at the given time.
|
||||
* @param {number} elapsedMs - Target time in milliseconds
|
||||
*/
|
||||
seek(elapsedMs) {
|
||||
if (!this.audioContext) return;
|
||||
this.stop();
|
||||
|
||||
for (const track of this.tracks) {
|
||||
if (elapsedMs >= track.timeOffset) {
|
||||
const offsetSec = (elapsedMs - track.timeOffset) / 1000;
|
||||
if (offsetSec < track.buffer.duration) {
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = track.buffer;
|
||||
const trackGain = this.audioContext.createGain();
|
||||
trackGain.gain.value = track.gain;
|
||||
source.connect(trackGain);
|
||||
trackGain.connect(this.gainNode);
|
||||
source.start(0, offsetSec);
|
||||
track.source = source;
|
||||
track.started = true;
|
||||
} else {
|
||||
track.started = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get canAutoplay() {
|
||||
return !this.audioContext || this.audioContext.state === 'running';
|
||||
}
|
||||
|
||||
get muted() {
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
set muted(value) {
|
||||
this._muted = value;
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = value ? 0 : this.volume;
|
||||
}
|
||||
}
|
||||
|
||||
/** Maximum end time (ms) across all audio tracks. */
|
||||
get maxEndTime() {
|
||||
let max = 0;
|
||||
for (const track of this.tracks) {
|
||||
const endMs = track.timeOffset + track.buffer.duration * 1000;
|
||||
if (endMs > max) max = endMs;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.stop();
|
||||
this.audioContext?.close();
|
||||
this.audioContext = null;
|
||||
this.gainNode = null;
|
||||
this.tracks = [];
|
||||
}
|
||||
}
|
||||
@ -100,7 +100,7 @@ export async function startInstall(missingFiles, language) {
|
||||
await requestPersistentStorage();
|
||||
|
||||
if (downloaderWorker) downloaderWorker.terminate();
|
||||
downloaderWorker = new Worker('/downloader.js');
|
||||
downloaderWorker = new Worker(new URL('./downloader.worker.js', import.meta.url));
|
||||
downloaderWorker.onmessage = handleWorkerMessage;
|
||||
|
||||
installState.update(state => ({
|
||||
@ -123,13 +123,16 @@ export function startUninstall(language) {
|
||||
});
|
||||
}
|
||||
|
||||
async function requestPersistentStorage() {
|
||||
if (navigator.storage && navigator.storage.persist) {
|
||||
const isPersisted = await navigator.storage.persisted();
|
||||
if (!isPersisted) {
|
||||
const wasGranted = await navigator.storage.persist();
|
||||
console.log(wasGranted ? 'Persistent storage was granted.' : 'Persistent storage request was denied.');
|
||||
export async function requestPersistentStorage() {
|
||||
try {
|
||||
if (navigator.storage && navigator.storage.persist) {
|
||||
const isPersisted = await navigator.storage.persisted();
|
||||
if (!isPersisted) {
|
||||
await navigator.storage.persist();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to request persistent storage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
93
src/core/thumbnails.js
Normal file
93
src/core/thumbnails.js
Normal file
@ -0,0 +1,93 @@
|
||||
// Preloads 3D thumbnails for the Memories page.
|
||||
// Starts a Web Worker that fetches WORLD.WDB, parses it, and renders
|
||||
// building and actor thumbnails on a background thread using OffscreenCanvas.
|
||||
// The main thread only receives finished data URLs — zero blocking.
|
||||
//
|
||||
// Subscribes to memoryCompletions so thumbnails are (re-)generated whenever
|
||||
// the set of needed actors changes (e.g. after login, logout→login, new completions).
|
||||
import { writable } from 'svelte/store';
|
||||
import { memoryCompletions } from '../stores.js';
|
||||
|
||||
/** Maps location label (e.g. "Pizzeria") to a data URL of the rendered building. */
|
||||
export const buildingThumbnails = writable({});
|
||||
|
||||
/** Maps charIndex (0-65) to a data URL of the rendered actor. */
|
||||
export const actorThumbnails = writable({});
|
||||
|
||||
/** Collect unique charIndices from completion data. */
|
||||
function getNeededActors(completions) {
|
||||
const indices = new Set();
|
||||
for (const c of completions) {
|
||||
if (c.participants) {
|
||||
for (const p of c.participants) {
|
||||
indices.add(p.charIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...indices];
|
||||
}
|
||||
|
||||
let renderedActors = new Set();
|
||||
let buildingsRendered = false;
|
||||
let activeWorker = null;
|
||||
|
||||
export function initThumbnails() {
|
||||
memoryCompletions.subscribe(completions => {
|
||||
if (completions === null) return;
|
||||
|
||||
const needed = getNeededActors(completions);
|
||||
const missing = needed.filter(i => !renderedActors.has(i));
|
||||
if (missing.length === 0 && buildingsRendered) return;
|
||||
|
||||
spawnWorker(missing, !buildingsRendered);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnWorker(actorIndices, includeBuildings) {
|
||||
if (activeWorker) {
|
||||
activeWorker.terminate();
|
||||
activeWorker = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const worker = new Worker(
|
||||
new URL('./thumbnails.worker.js', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
activeWorker = worker;
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
if (worker !== activeWorker) return;
|
||||
|
||||
switch (e.data.type) {
|
||||
case 'buildings':
|
||||
buildingThumbnails.set(e.data.thumbnails);
|
||||
buildingsRendered = true;
|
||||
break;
|
||||
case 'actors':
|
||||
actorThumbnails.update(current => ({
|
||||
...current,
|
||||
...e.data.thumbnails
|
||||
}));
|
||||
for (const i of actorIndices) renderedActors.add(i);
|
||||
worker.terminate();
|
||||
activeWorker = null;
|
||||
break;
|
||||
case 'error':
|
||||
console.warn('[Thumbnails] Worker error:', e.data.message);
|
||||
worker.terminate();
|
||||
activeWorker = null;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (e) => {
|
||||
console.warn('[Thumbnails] Worker failed:', e.message);
|
||||
if (worker === activeWorker) activeWorker = null;
|
||||
};
|
||||
|
||||
worker.postMessage({ actorIndices, skipBuildings: !includeBuildings });
|
||||
} catch (e) {
|
||||
console.warn('[Thumbnails] Thumbnails unavailable:', e);
|
||||
}
|
||||
}
|
||||
123
src/core/thumbnails.worker.js
Normal file
123
src/core/thumbnails.worker.js
Normal file
@ -0,0 +1,123 @@
|
||||
// Web Worker that renders building and actor thumbnails off the main thread.
|
||||
// Uses OffscreenCanvas + OGL Renderer — no DOM required.
|
||||
import { BuildingRenderer } from './rendering/BuildingRenderer.js';
|
||||
import { ActorRenderer } from './rendering/ActorRenderer.js';
|
||||
import { ActorInfoInit } from './savegame/actorConstants.js';
|
||||
import { WdbParser, buildPartsMap, buildGlobalPartsMap, collectAllRois } from './formats/WdbParser.js';
|
||||
|
||||
const LOCATION_BUILDINGS = {
|
||||
'Bank': 'bank',
|
||||
'Beach': 'beach',
|
||||
'Gas Station': 'gas',
|
||||
'Hospital': 'medcntr',
|
||||
'Island': 'haus6',
|
||||
'Jail': 'jail',
|
||||
'Police Station': 'policsta',
|
||||
'Pizzeria': 'pizza',
|
||||
'Racetrack': 'races'
|
||||
};
|
||||
|
||||
async function canvasToDataURL(canvas) {
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
return new FileReaderSync().readAsDataURL(blob);
|
||||
}
|
||||
|
||||
async function renderBuildings(wdbParser, wdbData, globalTextures) {
|
||||
const neededModels = new Set(Object.values(LOCATION_BUILDINGS));
|
||||
const modelsMap = new Map();
|
||||
|
||||
for (const world of wdbData.worlds) {
|
||||
let worldPartsMap = null;
|
||||
for (const model of world.models) {
|
||||
const modelKey = model.name.toLowerCase();
|
||||
if (!neededModels.has(modelKey) || modelsMap.has(modelKey)) continue;
|
||||
|
||||
const modelData = wdbParser.parseModelData(model.dataOffset);
|
||||
if (!modelData.roi) continue;
|
||||
|
||||
if (!worldPartsMap) {
|
||||
worldPartsMap = buildPartsMap(wdbParser, world.parts);
|
||||
}
|
||||
|
||||
const rois = collectAllRois(modelData.roi, worldPartsMap);
|
||||
|
||||
if (rois.length > 0) {
|
||||
modelsMap.set(modelKey, {
|
||||
rois,
|
||||
textures: [...globalTextures, ...(modelData.textures || [])]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = new OffscreenCanvas(256, 256);
|
||||
const renderer = new BuildingRenderer(canvas, { preserveDrawingBuffer: true });
|
||||
|
||||
const result = {};
|
||||
for (const [label, modelName] of Object.entries(LOCATION_BUILDINGS)) {
|
||||
const data = modelsMap.get(modelName);
|
||||
if (data) {
|
||||
renderer.loadBuilding(data.rois, data.textures);
|
||||
result[label] = await canvasToDataURL(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
renderer.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function renderActors(wdbData, globalTextures, actorIndices) {
|
||||
if (!actorIndices || actorIndices.length === 0) return {};
|
||||
|
||||
const globalPartsMap = buildGlobalPartsMap(wdbData.globalParts);
|
||||
const defaultCharacters = new Array(ActorInfoInit.length).fill(null);
|
||||
|
||||
const canvas = new OffscreenCanvas(256, 256);
|
||||
const renderer = new ActorRenderer(canvas, { preserveDrawingBuffer: true });
|
||||
renderer.skipAnimations = true;
|
||||
|
||||
const result = {};
|
||||
for (const i of actorIndices) {
|
||||
try {
|
||||
renderer.loadActor(i, defaultCharacters, globalPartsMap, globalTextures, null, null, null);
|
||||
|
||||
// Reposition camera to frame the head for a portrait thumbnail
|
||||
renderer.camera.position.set(0.6, 0.9, 1.2);
|
||||
renderer.camera.lookAt([0, 0.7, 0]);
|
||||
renderer.glRenderer.render({ scene: renderer.scene, camera: renderer.camera });
|
||||
|
||||
result[i] = await canvasToDataURL(canvas);
|
||||
} catch (e) {
|
||||
// Skip actors that fail to render
|
||||
}
|
||||
}
|
||||
|
||||
renderer.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
try {
|
||||
const response = await fetch('/LEGO/data/WORLD.WDB');
|
||||
if (!response.ok) return;
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const wdbParser = new WdbParser(buffer);
|
||||
const wdbData = wdbParser.parse();
|
||||
|
||||
const globalTextures = [
|
||||
...(wdbData.globalTextures || []),
|
||||
...(wdbData.globalParts?.textures || [])
|
||||
];
|
||||
|
||||
if (!e.data.skipBuildings) {
|
||||
const buildings = await renderBuildings(wdbParser, wdbData, globalTextures);
|
||||
self.postMessage({ type: 'buildings', thumbnails: buildings });
|
||||
}
|
||||
|
||||
const actors = await renderActors(wdbData, globalTextures, e.data.actorIndices);
|
||||
self.postMessage({ type: 'actors', thumbnails: actors });
|
||||
} catch (e) {
|
||||
self.postMessage({ type: 'error', message: e.message });
|
||||
}
|
||||
};
|
||||
17
src/core/toast.js
Normal file
17
src/core/toast.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { configToastVisible, configToastMessage } from '../stores.js';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const toastError = writable(false);
|
||||
|
||||
let toastTimeout = null;
|
||||
|
||||
export function showToast(message, { error = false, duration = 2000 } = {}) {
|
||||
if (toastTimeout) clearTimeout(toastTimeout);
|
||||
configToastMessage.set(message);
|
||||
toastError.set(error);
|
||||
configToastVisible.set(true);
|
||||
toastTimeout = setTimeout(() => {
|
||||
configToastVisible.set(false);
|
||||
toastError.set(false);
|
||||
}, duration);
|
||||
}
|
||||
25
src/core/wdbCache.js
Normal file
25
src/core/wdbCache.js
Normal file
@ -0,0 +1,25 @@
|
||||
import { WdbParser } from './formats/WdbParser.js';
|
||||
|
||||
let pending = null;
|
||||
|
||||
/**
|
||||
* Fetch and parse WORLD.WDB once, returning cached result on subsequent calls.
|
||||
* Concurrent callers share the same in-flight request.
|
||||
* @returns {Promise<{ wdbParser: WdbParser, wdbData: object }>}
|
||||
*/
|
||||
export function getWdb() {
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const response = await fetch('/LEGO/data/WORLD.WDB');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load WORLD.WDB: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const wdbParser = new WdbParser(buffer);
|
||||
const wdbData = wdbParser.parse();
|
||||
return { wdbParser, wdbData };
|
||||
})();
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
68
src/data/actor-display-names.json
Normal file
68
src/data/actor-display-names.json
Normal file
@ -0,0 +1,68 @@
|
||||
[
|
||||
"Pepper Roni",
|
||||
"Mama Brickolini",
|
||||
"Papa Brickolini",
|
||||
"Nick Brick",
|
||||
"Laura Brick",
|
||||
"Infomaniac",
|
||||
"Brickster",
|
||||
"Studs Linkin",
|
||||
"Rhoda Hogg",
|
||||
"Valerie Stubbins",
|
||||
"Snap Lockitt",
|
||||
"Pitt Stop",
|
||||
"Maggie Post",
|
||||
"Buck Pounds",
|
||||
"Ed Mail",
|
||||
"Nubby Stevens",
|
||||
"Nancy Nubbins",
|
||||
"Dr. Clickitt",
|
||||
"Enter",
|
||||
"Return",
|
||||
"Captain D. Rom",
|
||||
"Bill Ding (Race Car)",
|
||||
"Bill Ding (Helicopter)",
|
||||
"Bill Ding (Dune Buggy)",
|
||||
"Bill Ding (Jetski)",
|
||||
"Flying Legandos #1",
|
||||
"Flying Legandos #2",
|
||||
"Flying Legandos #3",
|
||||
"Flying Legandos #4",
|
||||
"Flying Legandos #5",
|
||||
"Flying Legandos #6",
|
||||
"Legobobs #1",
|
||||
"Legobobs #2",
|
||||
"Legobobs #3",
|
||||
"Legobobs #4",
|
||||
"Brazilian Carmen",
|
||||
"Gideon Worse",
|
||||
"Red Greenbase",
|
||||
"Polly Gone",
|
||||
"Bradford Brickford",
|
||||
"Shiney Doris",
|
||||
"Glen Funberg",
|
||||
"Dorothy Funberg",
|
||||
"Brian Shrimp",
|
||||
"Luke Tepid",
|
||||
"Shorty Tails",
|
||||
"Bumpy Kindergreen",
|
||||
"Jack O'Trades",
|
||||
"Ghost #1",
|
||||
"Ghost #2",
|
||||
"Ghost #3",
|
||||
"Ghost #4",
|
||||
"Ghost #5",
|
||||
"Ghost #6",
|
||||
"hg",
|
||||
"pntgy",
|
||||
"pep",
|
||||
"cop01",
|
||||
"actor_01",
|
||||
"actor_02",
|
||||
"actor_03",
|
||||
"actor_04",
|
||||
"actor_05",
|
||||
"btmncycl",
|
||||
"cboycycl",
|
||||
"boatman"
|
||||
]
|
||||
401
src/data/animation-titles.json
Normal file
401
src/data/animation-titles.json
Normal file
@ -0,0 +1,401 @@
|
||||
{
|
||||
"0": "Bank Closed for Remodeling",
|
||||
"1": "Counting Visitors",
|
||||
"2": "Laughing All the Way to the Bank",
|
||||
"3": "Lockstep Parade",
|
||||
"4": "Walking in Line",
|
||||
"5": "The Light's About to Change",
|
||||
"6": "Leapfrog",
|
||||
"7": "Piggyback Leapfrog",
|
||||
"8": "Welcome to Nubby's",
|
||||
"9": "Red Car vs. Blue Car",
|
||||
"10": "Build a Dune Buggy",
|
||||
"11": "Why Don't We Have Elbows?",
|
||||
"12": "Why Don't More of Us Ride Bicycles?",
|
||||
"13": "Snappy Automobile",
|
||||
"14": "Beautiful Day",
|
||||
"15": "Lost My Memory",
|
||||
"16": "You First! No You First!",
|
||||
"17": "No Clapping in the Hospital",
|
||||
"18": "A Leg Up or a Foot Down",
|
||||
"19": "Your Head's a Little Loose",
|
||||
"20": "Late for Plastic Surgery",
|
||||
"21": "I Can't Place the Name",
|
||||
"22": "Pizza Ambulance",
|
||||
"23": "Disassembly Required",
|
||||
"24": "Car Compressor",
|
||||
"25": "Can't Spell Pepper",
|
||||
"26": "Build Schools, Not Prisons",
|
||||
"27": "The Condiment Family",
|
||||
"28": "Laura Can't Add",
|
||||
"29": "Nothing Adds Up",
|
||||
"30": "What Comes After Two?",
|
||||
"31": "Chip Off the Old Block",
|
||||
"32": "The Man With No Nose",
|
||||
"33": "I'm Innocent!",
|
||||
"34": "Papa's Shoes Are Untied",
|
||||
"35": "Now That's Art",
|
||||
"36": "Good Dancers",
|
||||
"37": "How Many Claws?",
|
||||
"38": "Play Me a Jailbreak Tune",
|
||||
"39": "It's Me, the Tree",
|
||||
"40": "Let Me Out",
|
||||
"41": "Breaking and Entering",
|
||||
"42": "I Demand a Mistrial",
|
||||
"43": "Build a Jet Ski",
|
||||
"44": "Throw Me to the Refreshment Stand",
|
||||
"45": "It's Hot",
|
||||
"46": "Perfect for Motos",
|
||||
"47": "Even the Sharks Come Here",
|
||||
"48": "Last One In Is a Dirty Brick",
|
||||
"49": "Stoked and Ready to Smoke",
|
||||
"50": "That Was Fast",
|
||||
"51": "Shark Attack on Jetski",
|
||||
"52": "Pull Up to the Buoy",
|
||||
"53": "Twisted Course on the North Side",
|
||||
"54": "Shark Spotting a Dog",
|
||||
"55": "Keep the Buoys to Your Right",
|
||||
"56": "Heads Up, Glue Your Bow",
|
||||
"57": "Reminds Me of Me",
|
||||
"58": "Falling Apart on the Hill",
|
||||
"59": "The Pyramid Stunt",
|
||||
"60": "Hi There!",
|
||||
"61": "My Bricks Are Killing Me",
|
||||
"62": "Lockstep Moonwalk",
|
||||
"63": "Build a Helicopter!",
|
||||
"64": "All's Quiet on This Side",
|
||||
"65": "Not Very Quiet at All",
|
||||
"66": "Move It, Booking",
|
||||
"67": "Best Pizza on the Island",
|
||||
"68": "Mozart of Meatballs",
|
||||
"69": "Faster Than a Loose Brick on Ice",
|
||||
"70": "Where Is My Piano?",
|
||||
"71": "Learn to Read, Not Skateboard",
|
||||
"72": "Too Cool for Words",
|
||||
"73": "Unusual Bricks Out There",
|
||||
"74": "P-I-Z-Z-A",
|
||||
"75": "Watch Me Dance",
|
||||
"76": "Sir Circumference",
|
||||
"77": "You Look Thin, Officer Nick",
|
||||
"78": "I'll Smell for You",
|
||||
"79": "Looking Snappy",
|
||||
"80": "Let's Dance, Officer Nick",
|
||||
"81": "I Remember the White Apron",
|
||||
"82": "Picasso of Pizza",
|
||||
"83": "Music Makes the Flowers Sing",
|
||||
"84": "Tickle the Ivories",
|
||||
"85": "Songs Fill My Bricks With Joy",
|
||||
"86": "That's My Pepper",
|
||||
"87": "Your Smile Reminds Me of a Song",
|
||||
"88": "You Look So Lovely I Could Dance",
|
||||
"89": "Good Friend Song",
|
||||
"90": "We Adore a Lola",
|
||||
"91": "Come Dance With Me",
|
||||
"92": "That Pizza Smells So Good",
|
||||
"93": "Concert of a Kitchen",
|
||||
"94": "Pizza Symphony",
|
||||
"95": "Dance With the Pizza",
|
||||
"96": "You're Seeing Someone Else",
|
||||
"97": "Biggest Slice, No Elbows",
|
||||
"98": "Sauce on the Dough",
|
||||
"99": "Pizza Masterpiece",
|
||||
"100": "Pizza Surfing",
|
||||
"101": "Mug Stacking",
|
||||
"102": "Big Dog Wants Pizza",
|
||||
"103": "Click on the Pizzeria",
|
||||
"104": "Gorilla Flip",
|
||||
"105": "Bad Smeller",
|
||||
"106": "Airborne!",
|
||||
"107": "Mama Wrote Me a Theme Song",
|
||||
"108": "Pizza Delivery Dude",
|
||||
"109": "Pizza Delivery!",
|
||||
"110": "Something's Not Put Together Right",
|
||||
"111": "Use Your Brain",
|
||||
"112": "Build a Race Car",
|
||||
"113": "Race to the Pit Stop",
|
||||
"114": "You're Lost",
|
||||
"115": "Ask for Directions",
|
||||
"116": "Going in Circles",
|
||||
"117": "50 Is Legal",
|
||||
"118": "I Grind That Fast",
|
||||
"119": "Go! Go! Go!",
|
||||
"120": "Goal! Whoa! Wow!",
|
||||
"121": "Store Closed for Remodeling",
|
||||
"122": "Shark Warning",
|
||||
"123": "Things Can Change",
|
||||
"124": "Don't Cut Corners",
|
||||
"125": "I Remember Everything",
|
||||
"126": "Go Low and Slow",
|
||||
"127": "You Can Count on Me",
|
||||
"128": "Very Police-Like",
|
||||
"129": "Lost Count on Patrol",
|
||||
"130": "Parrot Stuck in a Car Again",
|
||||
"131": "Register Those Songs",
|
||||
"132": "Drive as Well as You Play Piano",
|
||||
"133": "An Anthem for Lego Island",
|
||||
"134": "I Ought to Get Me One of Those",
|
||||
"135": "Humming Your Tune",
|
||||
"136": "Faster Than a Car",
|
||||
"137": "Make the Birds and Trees Sing",
|
||||
"138": "I Can Count on You",
|
||||
"139": "504 Pizzas a Week",
|
||||
"140": "Are You Going to Sing?",
|
||||
"141": "If I Wasn't on Duty",
|
||||
"142": "They Look Like They Smell Delicious",
|
||||
"143": "Papa, You're the Greatest",
|
||||
"144": "Twisted a Brick Dancing",
|
||||
"145": "Slow Down Your Dance Steps",
|
||||
"146": "You Move Way Too Fast",
|
||||
"147": "Snapping Pepper Together",
|
||||
"148": "Lucky to Have You, Sis",
|
||||
"149": "Cessquapavillion",
|
||||
"150": "Sir Comference, Knight",
|
||||
"151": "Papa Can't Sing",
|
||||
"152": "Consider This a Warning",
|
||||
"153": "Crime Doesn't Pay",
|
||||
"154": "Don't Block the Road",
|
||||
"155": "Just a Matter of Time",
|
||||
"156": "Don't Run Into Anybody",
|
||||
"157": "Try the Helicopter",
|
||||
"158": "One Pretty Island",
|
||||
"159": "Head Spinning",
|
||||
"160": "You Must Have My Mail",
|
||||
"161": "My Parrot Got Loose",
|
||||
"162": "Rain, Snow, Sleet, and Hail",
|
||||
"163": "Rad Race Car With a Skull Decal",
|
||||
"164": "Lovely Day for a Bike Ride",
|
||||
"165": "Perfect Day for Gardening",
|
||||
"166": "Cool Hat",
|
||||
"167": "Check Out the Tunes",
|
||||
"168": "Protect and Serve",
|
||||
"169": "You Look Lost",
|
||||
"170": "Eyes Straight Ahead",
|
||||
"171": "Brickster's Locked Behind Closed Doors",
|
||||
"172": "A Lovely Day to You",
|
||||
"173": "Save It for the Racetrack",
|
||||
"174": "A Gracious Howdy Do",
|
||||
"175": "Ed Mail Drops His Mail",
|
||||
"176": "My Goodness Gracious!",
|
||||
"177": "Hey, What's Up!",
|
||||
"178": "Laura's Motorcycle Tumble",
|
||||
"179": "Nick's Motorcycle Tumble",
|
||||
"180": "Race Begins at the Buoy",
|
||||
"181": "Ain't She a Beaut!",
|
||||
"182": "Let's See What It Can Do",
|
||||
"183": "The Helicopter Maniac",
|
||||
"184": "A Regular Studs Lincoln",
|
||||
"185": "You Show Real Potential",
|
||||
"186": "Thanks for Your Help",
|
||||
"187": "What Do We Do If It's Hot?",
|
||||
"188": "Parrot Dive-Bombing",
|
||||
"189": "Look Out!",
|
||||
"190": "I'm Already Here!",
|
||||
"191": "Look Before You Leap!",
|
||||
"192": "Stand Up, Sit Down, Fight!",
|
||||
"193": "Where have you been?",
|
||||
"194": "Some dude is choking in there",
|
||||
"195": "You'd better get in there.",
|
||||
"196": "Quick do something.",
|
||||
"197": "Won't Somebody Help That Poor Man?",
|
||||
"198": "I kept telling him chew!",
|
||||
"199": "That's not on the menu.",
|
||||
"200": "Oh won't somebody help that poor shark!",
|
||||
"201": "Er er er er er.",
|
||||
"202": "Ah ah ah ah ah.",
|
||||
"203": "Oh ha horror.",
|
||||
"204": "Go key. Oh oh.",
|
||||
"205": "Choking #1",
|
||||
"206": "Choking #2",
|
||||
"207": "Choking #3",
|
||||
"208": "Choking #4",
|
||||
"209": "Paperplane Launch #1",
|
||||
"210": "Paperplane Launch #2",
|
||||
"211": "Paperplane Launch #3",
|
||||
"212": "Pizza So Hot",
|
||||
"213": "Still Warm",
|
||||
"214": "Cold Pizza",
|
||||
"215": "360 Pieces of Pepperoni",
|
||||
"216": "Must Be Pizza Day",
|
||||
"217": "Fat as a Brick House",
|
||||
"218": "Skateboard Tips (Papa)",
|
||||
"219": "Go Vert! (Papa)",
|
||||
"220": "You Go, We'll Think of Something",
|
||||
"221": "Extreme Skateboard Experience (Pepper)",
|
||||
"222": "Don't Be Afraid to Go Vert (Pepper)",
|
||||
"223": "You're Looking Fat, Mama",
|
||||
"224": "Nix Polizoria #1",
|
||||
"225": "Nix Polizoria #2",
|
||||
"226": "Nix Polizoria #3",
|
||||
"227": "Deliver Pizza to Nubby",
|
||||
"228": "Another One for Nubby",
|
||||
"229": "Calling Him Stubby",
|
||||
"230": "Take Pepper's Skateboard",
|
||||
"231": "Pepper Should Be Back Soon",
|
||||
"232": "Rush Order for Studs Lincoln",
|
||||
"233": "Sad Pizza Song",
|
||||
"234": "Another One for the Track",
|
||||
"235": "Skateboarding Is Like Dancing",
|
||||
"236": "Pizza's Still on the Ceiling",
|
||||
"237": "Maybe Pepper Will Come Back",
|
||||
"238": "Just Don't Fall!",
|
||||
"239": "Lucky Pizza",
|
||||
"240": "Reheat It on the Engine",
|
||||
"241": "Cold Pizza in Reverse",
|
||||
"242": "The pizzas nice and hot.",
|
||||
"243": "Pizza's Still Warm, Quick Operating!",
|
||||
"244": "Pizza's DOA!",
|
||||
"245": "Laura, Deliver to the Hospital",
|
||||
"246": "Hospital Needs Another Pizza",
|
||||
"247": "One More Pizza to Go",
|
||||
"248": "Grind It or Smith It? (Mama)",
|
||||
"249": "Twice as Much Topping",
|
||||
"250": "Figuring Something Out",
|
||||
"251": "Grind It or Smith It? (Pepper)",
|
||||
"252": "A True Betty Now",
|
||||
"253": "Rip It Up!",
|
||||
"254": "Cat Chases Parrot (Unfinished)",
|
||||
"255": "Bring the Pizza Over Here",
|
||||
"256": "Helicopters Are Fun",
|
||||
"257": "Pepperoni Again?!",
|
||||
"258": "Build That Helicopter",
|
||||
"259": "Wrong Pizza",
|
||||
"260": "Papa's Famous Garlic Pizza",
|
||||
"261": "Send Pizza to the Jail (Mama)",
|
||||
"262": "Send Pizza to the Jail (Papa)",
|
||||
"263": "Papa Wants the Mud Stacked",
|
||||
"264": "Party at the County Jail",
|
||||
"265": "Papa's Got the Sixth Sense",
|
||||
"266": "Something About That Call",
|
||||
"267": "Back Already?",
|
||||
"268": "First Tow's on Me",
|
||||
"269": "Oil in Your Blood, Mama",
|
||||
"270": "Good Reflexes, Nick",
|
||||
"271": "Let Me Throw Pizzas",
|
||||
"272": "You're the Best, Laura!",
|
||||
"273": "Heard You Had Trouble",
|
||||
"274": "By the Time You Can Drive",
|
||||
"275": "Practice Makes Perfect",
|
||||
"276": "Not a High-Speed Chase Vehicle",
|
||||
"277": "I'm Not Blaming You, Papa",
|
||||
"278": "Come Back and Do Better",
|
||||
"279": "Thanks for Helping!",
|
||||
"280": "That Took a Long Time!",
|
||||
"281": "Smashing So Much Stuff",
|
||||
"282": "Nancy Prefers I Don't Sing",
|
||||
"283": "Good Thing You Don't Catch Crooks That Way",
|
||||
"284": "He Who Does Something",
|
||||
"285": "A Little More Practice, Laura",
|
||||
"286": "Try Again Sometime!",
|
||||
"287": "Tow Truck Recovery",
|
||||
"288": "I've Got to Finish the Race!",
|
||||
"289": "Red Car Is Blocking the Track!",
|
||||
"290": "Didn't You See the Red Car?",
|
||||
"291": "Nothing to See Here",
|
||||
"292": "I Said We'd Be Back!",
|
||||
"293": "Dance #1",
|
||||
"294": "Dance #2",
|
||||
"295": "Dance #3",
|
||||
"296": "Dance #4",
|
||||
"297": "Dance #5",
|
||||
"298": "Dance #6",
|
||||
"299": "Dance #7",
|
||||
"356": "I Love Jet Skis!",
|
||||
"357": "Move Your Hand Off the Wheel",
|
||||
"358": "I Love Two Wheelers!",
|
||||
"359": "Have a Gas on That Skateboard!",
|
||||
"360": "Jet Ski First Place!",
|
||||
"361": "Jet Ski Second Place!",
|
||||
"362": "Jet Ski Third Place",
|
||||
"363": "Jet Ski Better Luck Next Time",
|
||||
"364": "Race First Place!",
|
||||
"365": "Race Second Place!",
|
||||
"366": "Race Third Place!",
|
||||
"367": "Race Better Luck Next Time",
|
||||
"368": "Start Your Engines!",
|
||||
"16384": "Can't Keep Count",
|
||||
"16385": "The Brickster Must Be Stopped",
|
||||
"16386": "My Beautiful Garage Is Sinking",
|
||||
"16387": "I Think Too Much",
|
||||
"16388": "Keep It Together",
|
||||
"16389": "He's Kind of Cute",
|
||||
"16390": "Pizza Escape",
|
||||
"16391": "Self-Pardon",
|
||||
"16392": "I'm Free!",
|
||||
"16393": "Biggest Mistake of Your Life",
|
||||
"16394": "The Brickster Has Escaped",
|
||||
"16395": "Lord of the Bricks",
|
||||
"16396": "The Brickster Rules",
|
||||
"16397": "Tee Brick Sir",
|
||||
"16398": "Our Town Is Disappearing",
|
||||
"16399": "Usually I'm Yellow",
|
||||
"16400": "Save Our Island",
|
||||
"16401": "Reminds Me of Me",
|
||||
"16402": "Power's Out",
|
||||
"16403": "We're Counting on You",
|
||||
"16404": "It Was a Lot Cooler",
|
||||
"16405": "Losing Power",
|
||||
"16406": "Suspicious Character Spotted",
|
||||
"16407": "I Remember His Every Move",
|
||||
"16408": "Brickster's Laugh",
|
||||
"16409": "All I Can Say Is",
|
||||
"16410": "The Mambo Shuffle",
|
||||
"16411": "The Cave Blockade",
|
||||
"16412": "Clearing the Blockades",
|
||||
"16413": "Track Is Down",
|
||||
"16414": "More Fun Before This Mess",
|
||||
"16415": "How Can I Win?",
|
||||
"16416": "De-Bricking Blast",
|
||||
"16417": "Twenty Bricks to the South",
|
||||
"16418": "Polar Bear or Brickster?",
|
||||
"16419": "Country Western Reggae Ditty",
|
||||
"16420": "Snap to It",
|
||||
"16421": "Good Luck Friend",
|
||||
"16422": "The Scent of Jasmine",
|
||||
"16423": "Do Not Panic!",
|
||||
"16424": "Don't Want to Remember This",
|
||||
"16425": "Two Hats or Three?",
|
||||
"16426": "The Town Needs Your Help",
|
||||
"16427": "Keep a Stiff Upper Brick",
|
||||
"16428": "Good Catch!",
|
||||
"16429": "Deliver This!",
|
||||
"16430": "Mean Pizza Machine",
|
||||
"16431": "He Shoots He Scores!",
|
||||
"16432": "Can't Catch Me",
|
||||
"16433": "Pepper in the Air",
|
||||
"16434": "Maniacal Laughter",
|
||||
"16435": "Big Deal!",
|
||||
"16436": "I Got a Bunch More",
|
||||
"16437": "Useless One",
|
||||
"16438": "One Less Brick",
|
||||
"16439": "I'll Hide the Rest",
|
||||
"16440": "I'll Put His Head on Backwards",
|
||||
"16441": "Situation Red",
|
||||
"16442": "Close Your Doors",
|
||||
"16443": "Technical Difficulties",
|
||||
"16444": "Simply Dreadful!",
|
||||
"16445": "Cancel All Appointments",
|
||||
"16446": "Prescription for Capture",
|
||||
"16447": "No Mail for the Brickster",
|
||||
"16448": "Every Flower Disappears",
|
||||
"16449": "Get It Together!",
|
||||
"16450": "Have You Seen My Parrot?",
|
||||
"16451": "Just Not Right",
|
||||
"16452": "Shine Is Off the Brick",
|
||||
"16453": "Go Get Him Pepper!",
|
||||
"16454": "He Went That Away!",
|
||||
"16455": "The Good Guys' Side",
|
||||
"16456": "Hikes!",
|
||||
"16457": "Everything's Sinking!",
|
||||
"16458": "Help!",
|
||||
"16459": "Run! Hide!",
|
||||
"16460": "Brick Found",
|
||||
"16461": "Build the Helicopter",
|
||||
"16462": "Helicopter Pieces Found",
|
||||
"16463": "Brickster's Loose! (BD)",
|
||||
"16464": "Brickster's Loose! (PG)",
|
||||
"16465": "Brickster's Loose! (RD)",
|
||||
"16466": "Brickster's Loose! (SY)",
|
||||
"32768": "The Pizza Turbo Chucker Plan",
|
||||
"32769": "Good Luck Partner",
|
||||
"32770": "Shoot Pizzas at the Brickster"
|
||||
}
|
||||
@ -18,3 +18,84 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
/* Accordion (slide-animated details replacement) */
|
||||
.accordion-item {
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
font-size: 1.1em;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.accordion-header::after {
|
||||
content: '+';
|
||||
font-size: 1.5em;
|
||||
color: var(--color-primary);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.accordion-item:has(.accordion-content.open) .accordion-header::after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease;
|
||||
}
|
||||
|
||||
.accordion-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Slotted content — :global() needed because p/ul/li are rendered by the parent */
|
||||
.accordion-content :global(p) {
|
||||
padding: 0 20px 20px 20px;
|
||||
margin: 0;
|
||||
color: #b0b0b0;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.accordion-content :global(ul) {
|
||||
padding: 0 20px 20px 40px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.accordion-content :global(li) {
|
||||
color: #b0b0b0;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.accordion-content :global(li:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.accordion-content :global(li strong) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
495
src/lib/AccountIndicator.svelte
Normal file
495
src/lib/AccountIndicator.svelte
Normal file
@ -0,0 +1,495 @@
|
||||
<script>
|
||||
import { tick } from 'svelte';
|
||||
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
|
||||
import { authSession, signInWithDiscord, signOut } from '../core/auth.js';
|
||||
import { clearLocalMemories } from '../core/memories.js';
|
||||
import { API_URL } from '../core/config.js';
|
||||
import { showToast } from '../core/toast.js';
|
||||
import { currentPage } from '../stores.js';
|
||||
import { navigateTo, navigateToMultiplayer } from '../core/navigation.js';
|
||||
import SignInModal from './SignInModal.svelte';
|
||||
import DiscordIcon from './icons/DiscordIcon.svelte';
|
||||
|
||||
let openMenu = null; // null | 'nav' | 'account'
|
||||
let showSignInModal = false;
|
||||
let showDeleteDialog = false;
|
||||
let deleteConfirmText = '';
|
||||
let deleting = false;
|
||||
|
||||
let navButtonEl, navDropdownEl;
|
||||
let accountButtonEl, accountDropdownEl;
|
||||
|
||||
const menuConfig = {
|
||||
nav: () => [navButtonEl, navDropdownEl],
|
||||
account: () => [accountButtonEl, accountDropdownEl],
|
||||
};
|
||||
|
||||
async function positionDropdown(reference, floating) {
|
||||
// Keep invisible during measurement to prevent jerk on open
|
||||
floating.style.visibility = 'hidden';
|
||||
const { x, y } = await computePosition(reference, floating, {
|
||||
placement: 'bottom-end',
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })]
|
||||
});
|
||||
Object.assign(floating.style, { left: `${x}px`, top: `${y}px`, visibility: '' });
|
||||
}
|
||||
|
||||
async function toggleMenu(name) {
|
||||
openMenu = openMenu === name ? null : name;
|
||||
if (openMenu) {
|
||||
await tick();
|
||||
const [button, dropdown] = menuConfig[name]();
|
||||
if (button && dropdown) positionDropdown(button, dropdown);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenus() {
|
||||
openMenu = null;
|
||||
}
|
||||
|
||||
function handleAuthClick() {
|
||||
if ($authSession === undefined) return;
|
||||
if ($authSession === null) {
|
||||
showSignInModal = true;
|
||||
} else {
|
||||
toggleMenu('account');
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
if (!openMenu) return;
|
||||
const wrapperClass = openMenu === 'nav' ? '.nav-menu-wrapper' : '.account-menu-wrapper';
|
||||
if (!event.target.closest(wrapperClass)) closeMenus();
|
||||
}
|
||||
|
||||
function navAction(fn) {
|
||||
closeMenus();
|
||||
fn();
|
||||
}
|
||||
|
||||
function handleDeleteKeydown(e) {
|
||||
if (showDeleteDialog && e.key === 'Escape') {
|
||||
closeDeleteDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteBackdropClick(e) {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeDeleteDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteDialog() {
|
||||
closeMenus();
|
||||
showDeleteDialog = true;
|
||||
deleteConfirmText = '';
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
showDeleteDialog = false;
|
||||
deleteConfirmText = '';
|
||||
}
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (deleteConfirmText !== 'DELETE') return;
|
||||
deleting = true;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/account`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
await clearLocalMemories();
|
||||
await signOut();
|
||||
showToast('Account deleted');
|
||||
} else {
|
||||
showToast('Failed to delete account', { error: true });
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to delete account', { error: true });
|
||||
} finally {
|
||||
deleting = false;
|
||||
closeDeleteDialog();
|
||||
}
|
||||
}
|
||||
|
||||
$: $currentPage, closeMenus();
|
||||
$: $authSession, closeMenus();
|
||||
|
||||
$: if ($authSession && showSignInModal) {
|
||||
showSignInModal = false;
|
||||
}
|
||||
|
||||
$: displayName = $authSession?.user?.isAnonymous
|
||||
? 'Guest'
|
||||
: ($authSession?.user?.name || 'Player');
|
||||
$: userImage = $authSession?.user?.image || null;
|
||||
$: isGuest = $authSession?.user?.isAnonymous === true;
|
||||
|
||||
let imgLoaded = false;
|
||||
let imgFailed = false;
|
||||
$: { userImage; imgLoaded = false; imgFailed = false; }
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleDeleteKeydown} />
|
||||
|
||||
<div class="nav-bar">
|
||||
<div class="nav-menu-wrapper">
|
||||
<button class="nav-circle" bind:this={navButtonEl} onclick={() => toggleMenu('nav')} title="Menu">
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="4" y1="6" x2="16" y2="6"/>
|
||||
<line x1="4" y1="10" x2="16" y2="10"/>
|
||||
<line x1="4" y1="14" x2="16" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if openMenu === 'nav'}
|
||||
<div class="dropdown" bind:this={navDropdownEl}>
|
||||
<button class="dropdown-item" onclick={() => navAction(() => navigateTo('save-editor'))}>Save Editor</button>
|
||||
<button class="dropdown-item" onclick={() => navAction(navigateToMultiplayer)}>Multiplayer</button>
|
||||
<button class="dropdown-item" onclick={() => navAction(() => navigateTo('memories'))}>Nick Brick's Memories</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="account-menu-wrapper">
|
||||
<button
|
||||
class="nav-circle"
|
||||
bind:this={accountButtonEl}
|
||||
onclick={handleAuthClick}
|
||||
disabled={$authSession === undefined}
|
||||
title={$authSession ? displayName : ($authSession === null ? 'Sign in' : '')}
|
||||
aria-label={$authSession ? displayName : 'Sign in'}
|
||||
>
|
||||
<svg class="avatar-placeholder" class:hidden={imgLoaded} class:signed-out={!$authSession} viewBox="0 0 32 32" fill="none">
|
||||
<circle cx="16" cy="12" r="5" fill="currentColor"/>
|
||||
<path d="M6 28c0-5.523 4.477-10 10-10s10 4.477 10 10" fill="currentColor"/>
|
||||
</svg>
|
||||
{#if $authSession && userImage && !imgFailed}
|
||||
<img class="avatar-img" class:loaded={imgLoaded} src={userImage} alt="" crossorigin="anonymous"
|
||||
onload={() => imgLoaded = true} onerror={() => imgFailed = true} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if openMenu === 'account'}
|
||||
<div class="dropdown" bind:this={accountDropdownEl}>
|
||||
<div class="dropdown-header">
|
||||
{isGuest ? 'Guest' : displayName}
|
||||
</div>
|
||||
|
||||
{#if isGuest}
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="dropdown-sublabel">Link an account to save across devices</div>
|
||||
<button class="dropdown-item link-discord" onclick={() => navAction(signInWithDiscord)}>
|
||||
<DiscordIcon size={16} />
|
||||
Link with Discord
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item delete-account" onclick={openDeleteDialog}>Delete Account</button>
|
||||
<button class="dropdown-item signout" onclick={() => navAction(signOut)}>Sign out</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SignInModal open={showSignInModal} onClose={() => showSignInModal = false} />
|
||||
|
||||
{#if showDeleteDialog}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleDeleteBackdropClick}>
|
||||
<div class="modal-panel delete-panel">
|
||||
<button class="modal-close" onclick={closeDeleteDialog} aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 class="delete-title">Delete Account</h2>
|
||||
<p class="delete-body">This will permanently delete your account and all associated data (saves, settings, memories). Crash reports will be anonymized. This cannot be undone.</p>
|
||||
<p class="delete-body">Type <strong>DELETE</strong> to confirm:</p>
|
||||
<input
|
||||
type="text"
|
||||
class="delete-input"
|
||||
bind:value={deleteConfirmText}
|
||||
placeholder="Type DELETE"
|
||||
/>
|
||||
<div class="delete-actions">
|
||||
<button class="delete-cancel-btn" onclick={closeDeleteDialog}>Cancel</button>
|
||||
<button
|
||||
class="delete-confirm-btn"
|
||||
disabled={deleteConfirmText !== 'DELETE' || deleting}
|
||||
onclick={handleDeleteAccount}
|
||||
>{deleting ? 'Deleting...' : 'Delete my account'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-menu-wrapper,
|
||||
.account-menu-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.15);
|
||||
background: var(--color-surface-hover);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.nav-circle:hover {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.nav-circle:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.avatar-img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: -2px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.avatar-placeholder.signed-out {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.avatar-placeholder.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Dropdowns — positioned dynamically by floating-ui */
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
width: max-content;
|
||||
min-width: 220px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.dropdown-sublabel {
|
||||
padding: 2px 14px 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.dropdown-item.link-discord {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-item.delete-account {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.dropdown-item.signout {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--color-surface-hover);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* Delete account dialog — mirrors SignInModal structure */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-dark);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 32px 28px;
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 48px);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-panel.delete-panel {
|
||||
border-color: rgba(255, 80, 80, 0.2);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.delete-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #ff6b6b;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.delete-body {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.delete-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-input);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 11px 16px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.delete-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 80, 80, 0.4);
|
||||
}
|
||||
|
||||
.delete-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.delete-cancel-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 11px 16px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.delete-cancel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.delete-cancel-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.delete-confirm-btn {
|
||||
background: rgba(255, 60, 60, 0.15);
|
||||
border: 1px solid rgba(255, 60, 60, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ff6b6b;
|
||||
padding: 11px 16px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.delete-confirm-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 60, 60, 0.25);
|
||||
}
|
||||
|
||||
.delete-confirm-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.delete-confirm-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
221
src/lib/ActorPicker.svelte
Normal file
221
src/lib/ActorPicker.svelte
Normal file
@ -0,0 +1,221 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { ActorRenderer } from '../core/rendering/ActorRenderer.js';
|
||||
import { buildGlobalPartsMap } from '../core/formats/WdbParser.js';
|
||||
import { getWdb } from '../core/wdbCache.js';
|
||||
import { ActorInfoInit, ActorDisplayNames } from '../core/savegame/actorConstants.js';
|
||||
import Carousel from './Carousel.svelte';
|
||||
|
||||
export let selectedIndex = 0;
|
||||
export let onSelect = () => {};
|
||||
|
||||
let canvas;
|
||||
let renderer = null;
|
||||
let loading = true;
|
||||
let error = null;
|
||||
let carousel;
|
||||
|
||||
let globalPartsMap = null;
|
||||
let globalTextures = null;
|
||||
|
||||
const nullCharacters = new Array(66).fill(null);
|
||||
|
||||
function loadActor(index = selectedIndex) {
|
||||
if (!renderer || !globalPartsMap) return;
|
||||
renderer.loadActor(index, nullCharacters, globalPartsMap, globalTextures, null, null, null);
|
||||
}
|
||||
|
||||
function handleChipClick(index) {
|
||||
if (index === selectedIndex) return;
|
||||
onSelect(index);
|
||||
loadActor(index);
|
||||
carousel?.scrollToIndex(index);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const { wdbData } = await getWdb();
|
||||
|
||||
if (!wdbData.globalParts) throw new Error('No global parts found in WORLD.WDB');
|
||||
|
||||
globalPartsMap = buildGlobalPartsMap(wdbData.globalParts);
|
||||
globalTextures = [
|
||||
...(wdbData.globalTextures || []),
|
||||
...(wdbData.globalParts.textures || [])
|
||||
];
|
||||
|
||||
renderer = new ActorRenderer(canvas);
|
||||
loadActor();
|
||||
renderer.start();
|
||||
loading = false;
|
||||
carousel?.scrollToIndex(selectedIndex);
|
||||
} catch (e) {
|
||||
console.error('ActorPicker initialization error:', e);
|
||||
error = e.message;
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
renderer?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="actor-picker">
|
||||
<div class="actor-heading">
|
||||
<span class="actor-heading-label">Choose your character</span>
|
||||
<span class="tooltip-trigger">?
|
||||
<span class="tooltip-content">This is the visual model you'll use in multiplayer, regardless of which actor is selected in the Infocenter.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="actor-preview">
|
||||
<div class="actor-preview-container">
|
||||
<canvas bind:this={canvas} class:hidden={loading || error} width="180" height="180"></canvas>
|
||||
{#if loading}
|
||||
<div class="actor-preview-overlay">
|
||||
<div class="actor-spinner"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="actor-preview-overlay error">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="actor-name">{ActorDisplayNames[selectedIndex] || ActorInfoInit[selectedIndex].name}</span>
|
||||
</div>
|
||||
|
||||
<div class="actor-carousel-wrap">
|
||||
<Carousel bind:this={carousel} gap={4}>
|
||||
{#each ActorInfoInit as actor, i}
|
||||
<button
|
||||
class="actor-chip"
|
||||
class:selected={i === selectedIndex}
|
||||
onclick={() => handleChipClick(i)}
|
||||
>
|
||||
{ActorDisplayNames[i] || actor.name}
|
||||
</button>
|
||||
{/each}
|
||||
</Carousel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.actor-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actor-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.actor-heading-label {
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.actor-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.actor-carousel-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actor-preview-container {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.actor-preview-container canvas {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.actor-preview-container canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.actor-preview-container canvas:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.actor-preview-container canvas.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.actor-preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-input);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.actor-preview-overlay.error {
|
||||
color: var(--color-error, #e74c3c);
|
||||
font-size: 0.7em;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actor-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(transparent 55%, transparent 56%),
|
||||
conic-gradient(var(--color-primary, #FFD700) 0deg 90deg, var(--color-border-dark, #333) 90deg 360deg);
|
||||
animation: actor-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes actor-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.actor-chip {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.7em;
|
||||
font-family: inherit;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border-medium);
|
||||
background: var(--gradient-panel);
|
||||
color: var(--color-text-medium);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.actor-chip:hover {
|
||||
border-color: var(--color-text-light);
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.actor-chip.selected {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,15 @@
|
||||
<script>
|
||||
import { currentPage } from '../stores.js';
|
||||
|
||||
function goBack() {
|
||||
history.back();
|
||||
// If there's a prior app page in history, go back to it.
|
||||
// Otherwise (direct landing on a deep link), navigate home.
|
||||
if (history.state?.fromApp) {
|
||||
history.back();
|
||||
} else {
|
||||
currentPage.set('main');
|
||||
history.pushState({ page: 'main', fromApp: true }, '', '/');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -21,3 +21,86 @@
|
||||
</div>
|
||||
<canvas id="canvas" oncontextmenu={(e) => e.preventDefault()} tabindex="-1"></canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.quote-block {
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.quote-block .quote-text {
|
||||
font-size: 0.5rem;
|
||||
color: var(--color-text-light);
|
||||
margin-bottom: 10px;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.quote-block .quote-attribution {
|
||||
font-size: 0.4rem;
|
||||
color: var(--color-text-medium);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.loading-info-text {
|
||||
margin-top: 15px;
|
||||
padding: 10px 15px;
|
||||
max-width: 280px;
|
||||
width: 80%;
|
||||
font-size: 0.8em;
|
||||
color: #b0b0b0;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
border-top: 1px dashed var(--color-border-medium);
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.loading-info-text p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.loading-info-text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status-message-bar {
|
||||
margin-top: 20px;
|
||||
padding: 8px 12px;
|
||||
width: 85%;
|
||||
max-width: 340px;
|
||||
background-color: var(--color-bg-input);
|
||||
color: var(--color-text-medium);
|
||||
font-family: 'Consolas', 'Menlo', 'Courier New', Courier, monospace;
|
||||
font-size: 0.75em;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
border: 1px solid #303030;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.status-message-bar :global(code) {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-bg-panel);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.loading-info-text {
|
||||
max-width: 90%;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.loading-info-text {
|
||||
max-width: 95%;
|
||||
font-size: 0.7em;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,7 +1,41 @@
|
||||
<script>
|
||||
import { configToastVisible, configToastMessage } from '../stores.js';
|
||||
import { toastError as configToastError } from '../core/toast.js';
|
||||
import { keepVisible } from '../core/keep-visible.js';
|
||||
</script>
|
||||
|
||||
<div id="config-toast" class="config-toast" class:show={$configToastVisible}>
|
||||
<div id="config-toast" class="config-toast" class:show={$configToastVisible} class:error={$configToastError} use:keepVisible>
|
||||
{$configToastMessage}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.config-toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
background: var(--gradient-panel);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 8px;
|
||||
padding: 12px 24px;
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.config-toast.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.config-toast.error {
|
||||
border-color: #ff6b6b;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
import BackButton from './BackButton.svelte';
|
||||
import OpfsDisabledBanner from './OpfsDisabledBanner.svelte';
|
||||
import DisplayTab from './config/DisplayTab.svelte';
|
||||
import ControlsTab from './config/ControlsTab.svelte';
|
||||
import AudioTab from './config/AudioTab.svelte';
|
||||
import ExtrasTab from './config/ExtrasTab.svelte';
|
||||
import { installState, currentPage } from '../stores.js';
|
||||
import { installState, currentPage, opfsDisabled, configVersion } from '../stores.js';
|
||||
import { loadConfig, saveConfig, getFileHandle } from '../core/opfs.js';
|
||||
import { checkCacheStatus, startInstall, startUninstall, getSiFilesForCache } from '../core/service-worker.js';
|
||||
import { getMsaaSamples, getMaxAnisotropy, populateMsaaSelect, populateAfSelect } from '../core/webgl.js';
|
||||
@ -20,8 +21,18 @@
|
||||
openSection = 'game';
|
||||
}
|
||||
|
||||
// Reload config from OPFS when navigating to this page
|
||||
$: if ($currentPage === 'configure' && configForm && !$opfsDisabled) {
|
||||
loadConfig(configForm);
|
||||
}
|
||||
|
||||
// Reload config from OPFS after cloud sync (even if not on config page),
|
||||
// so that saveConfigFromDOM() before game launch won't overwrite it with stale form values
|
||||
$: if ($configVersion && configForm && !$opfsDisabled) {
|
||||
loadConfig(configForm);
|
||||
}
|
||||
|
||||
let configForm;
|
||||
let opfsDisabled = false;
|
||||
let msaaSupported = false;
|
||||
let afSupported = false;
|
||||
let isTouchDevice = false;
|
||||
@ -61,12 +72,17 @@
|
||||
// Load config from OPFS
|
||||
const handle = await getFileHandle();
|
||||
if (!handle) {
|
||||
opfsDisabled = true;
|
||||
opfsDisabled.set(true);
|
||||
} else {
|
||||
const config = await loadConfig(configForm);
|
||||
if (!config) {
|
||||
// Save defaults silently (no toast on initial creation)
|
||||
await saveConfig(configForm, getSiFiles, true);
|
||||
try {
|
||||
const config = await loadConfig(configForm);
|
||||
if (!config) {
|
||||
// Save defaults silently (no toast on initial creation)
|
||||
await saveConfig(configForm, getSiFiles, true);
|
||||
}
|
||||
} catch (e) {
|
||||
// Read error — do NOT overwrite config with defaults
|
||||
console.error('Config read failed, keeping existing data:', e);
|
||||
}
|
||||
showOrHideGraphicsOptions();
|
||||
}
|
||||
@ -111,7 +127,7 @@
|
||||
}
|
||||
|
||||
function handleFormChange() {
|
||||
if (!opfsDisabled) {
|
||||
if (!$opfsDisabled) {
|
||||
saveConfig(configForm, getSiFiles);
|
||||
}
|
||||
showOrHideGraphicsOptions();
|
||||
@ -142,11 +158,7 @@
|
||||
document.getElementById('tex-high').checked = true;
|
||||
document.getElementById('max-lod').value = '3.6';
|
||||
document.getElementById('max-allowed-extras').value = '20';
|
||||
document.getElementById('check-hd-textures').checked = false;
|
||||
document.getElementById('check-hd-music').checked = false;
|
||||
document.getElementById('check-widescreen-bgs').checked = false;
|
||||
document.getElementById('check-outro').checked = false;
|
||||
document.getElementById('check-ending').checked = false;
|
||||
document.querySelectorAll('#config-tab-extras .toggle-group input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||
} else if (preset === 'modern') {
|
||||
document.getElementById('aspect-wide').checked = true;
|
||||
document.getElementById('resolution-wide').checked = true;
|
||||
@ -188,20 +200,15 @@
|
||||
|
||||
<div id="configure-page" class="page-content">
|
||||
<BackButton />
|
||||
{#if opfsDisabled}
|
||||
<blockquote id="opfs-disabled" class="error-box">
|
||||
<p>OPFS is disabled in this browser. Default configuration will apply. If you are using a Private/Incognito
|
||||
window, please change to a regular window instead to change configuration.</p>
|
||||
</blockquote>
|
||||
{/if}
|
||||
<OpfsDisabledBanner />
|
||||
<div class="page-inner-content config-layout">
|
||||
<div class="config-art-panel">
|
||||
<img src="images/shark.webp" alt="LEGO Island Shark and Brickster">
|
||||
</div>
|
||||
<div class="config-main">
|
||||
<div class="config-presets">
|
||||
<button type="button" class="preset-btn" disabled={opfsDisabled} onclick={() => applyPreset('classic')}>Classic Mode</button>
|
||||
<button type="button" class="preset-btn" disabled={opfsDisabled} onclick={() => applyPreset('modern')}>Modern Mode</button>
|
||||
<button type="button" class="preset-btn" disabled={$opfsDisabled} onclick={() => applyPreset('classic')}>Classic Mode</button>
|
||||
<button type="button" class="preset-btn" disabled={$opfsDisabled} onclick={() => applyPreset('modern')}>Modern Mode</button>
|
||||
</div>
|
||||
<div class="config-tabs">
|
||||
<div class="config-tab-buttons">
|
||||
@ -214,7 +221,7 @@
|
||||
<form id="config-form" class="config-form" bind:this={configForm} onchange={handleFormChange}>
|
||||
<div class:hidden={activeTab !== 'display'}>
|
||||
<DisplayTab
|
||||
{opfsDisabled}
|
||||
opfsDisabled={$opfsDisabled}
|
||||
{openSection}
|
||||
{toggleSection}
|
||||
{fullscreenSupported}
|
||||
@ -226,7 +233,7 @@
|
||||
</div>
|
||||
<div class:hidden={activeTab !== 'controls'}>
|
||||
<ControlsTab
|
||||
{opfsDisabled}
|
||||
opfsDisabled={$opfsDisabled}
|
||||
{openSection}
|
||||
{toggleSection}
|
||||
{isTouchDevice}
|
||||
@ -234,14 +241,14 @@
|
||||
</div>
|
||||
<div class:hidden={activeTab !== 'audio'}>
|
||||
<AudioTab
|
||||
{opfsDisabled}
|
||||
opfsDisabled={$opfsDisabled}
|
||||
{openSection}
|
||||
{toggleSection}
|
||||
/>
|
||||
</div>
|
||||
<div class:hidden={activeTab !== 'extras'}>
|
||||
<ExtrasTab
|
||||
{opfsDisabled}
|
||||
opfsDisabled={$opfsDisabled}
|
||||
{openSection}
|
||||
{toggleSection}
|
||||
{handleExtensionChange}
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
<script>
|
||||
import { showGoodbyePopup } from '../stores.js';
|
||||
import { startGame } from '../core/emscripten.js';
|
||||
import { pauseInstallAudio } from '../core/audio.js';
|
||||
import { launchGame } from '../core/emscripten.js';
|
||||
import { navigateTo } from '../core/navigation.js';
|
||||
import { saveConfigFromDOM } from '../core/opfs.js';
|
||||
import ImageButton from './ImageButton.svelte';
|
||||
|
||||
let rendererValue = "0 0x682656f3 0x0 0x0 0x4000000"; // WebGL default
|
||||
|
||||
const buttons = [
|
||||
{ id: 'run-game-btn', off: 'images/run_game_off.webp', on: 'images/run_game_on.webp', alt: 'Run Game', width: 135, height: 164, action: handleRunGame },
|
||||
{ id: 'configure-btn', off: 'images/configure_off.webp', on: 'images/configure_on.webp', alt: 'Configure', width: 130, height: 147, action: () => navigateTo('configure') },
|
||||
@ -15,16 +13,9 @@
|
||||
{ id: 'cancel-btn', off: 'images/cancel_off.webp', on: 'images/cancel_on.webp', alt: 'Cancel', width: 93, height: 145, action: () => showGoodbyePopup.set(true) }
|
||||
];
|
||||
|
||||
function handleRunGame() {
|
||||
pauseInstallAudio();
|
||||
|
||||
// Get current renderer value from select
|
||||
const rendererSelect = document.getElementById('renderer-select');
|
||||
if (rendererSelect) {
|
||||
rendererValue = rendererSelect.value;
|
||||
}
|
||||
|
||||
startGame(rendererValue);
|
||||
async function handleRunGame() {
|
||||
await saveConfigFromDOM();
|
||||
launchGame();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
151
src/lib/CrashOverlay.svelte
Normal file
151
src/lib/CrashOverlay.svelte
Normal file
@ -0,0 +1,151 @@
|
||||
<script>
|
||||
import { gameCrashed } from '../stores.js';
|
||||
|
||||
function reload() {
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $gameCrashed}
|
||||
<div class="crash-overlay">
|
||||
<div class="crash-card">
|
||||
<img src="images/callfail.webp" alt="Crash" class="crash-image" width="150" height="187">
|
||||
<div class="crash-body">
|
||||
<h2 class="crash-title">Uh oh! The game crashed.</h2>
|
||||
<p class="crash-message">Sorry about that! Something went wrong and LEGO Island had to stop. You can try reloading the page to get back to the action.</p>
|
||||
<button class="crash-reload-btn" onclick={reload}>Reload Page</button>
|
||||
<p class="crash-report">If this keeps happening, please <a href="https://github.com/isledecomp/isle-portable/issues" target="_blank" rel="noopener noreferrer">report the issue</a> so we can fix it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.crash-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
animation: crash-fade-in 0.4s ease-out;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@keyframes crash-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.crash-card {
|
||||
background: var(--gradient-panel);
|
||||
border: 1px solid var(--color-border-medium);
|
||||
border-radius: 16px;
|
||||
padding: 32px 28px;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.crash-image {
|
||||
width: 110px;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--color-border-medium);
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.crash-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.crash-title {
|
||||
color: #ff6b6b;
|
||||
font-size: 1.15em;
|
||||
font-weight: bold;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.crash-message {
|
||||
color: var(--color-text-medium);
|
||||
font-size: 0.9em;
|
||||
margin: 0 0 20px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crash-reload-btn {
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
background-color: var(--color-primary);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
|
||||
.crash-reload-btn:hover {
|
||||
background-color: #fff;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.crash-reload-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.crash-report {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8em;
|
||||
margin: 16px 0 0 0;
|
||||
line-height: 1.4;
|
||||
border-top: 1px solid var(--color-border-dark);
|
||||
padding-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crash-report a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.crash-report a:hover {
|
||||
text-decoration: underline;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.crash-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.crash-image {
|
||||
width: 90px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.crash-title {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.crash-message {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,28 +1,8 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { debugUIVisible } from '../stores.js';
|
||||
import { keepVisible } from '../core/keep-visible.js';
|
||||
|
||||
let debugPanelOpen = false;
|
||||
let debugUIElement;
|
||||
let observer = null;
|
||||
|
||||
// Set up MutationObserver when the element becomes available
|
||||
$: if (debugUIElement && !observer) {
|
||||
observer = new MutationObserver(() => {
|
||||
if (debugUIElement && debugUIElement.style.display === 'none') {
|
||||
debugUIElement.style.setProperty('display', 'block', 'important');
|
||||
}
|
||||
});
|
||||
observer.observe(debugUIElement, { attributes: true, attributeFilter: ['style'] });
|
||||
}
|
||||
|
||||
// Clean up observer when component is destroyed
|
||||
onDestroy(() => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
});
|
||||
|
||||
let debugModeActive = false;
|
||||
let selectedLocation = '';
|
||||
@ -305,7 +285,7 @@
|
||||
</script>
|
||||
|
||||
{#if $debugUIVisible}
|
||||
<div id="debug-ui" bind:this={debugUIElement}>
|
||||
<div id="debug-ui" use:keepVisible>
|
||||
<button id="debug-toggle" title="Debug Options" class:active={debugPanelOpen} onclick={() => debugPanelOpen = !debugPanelOpen}>⚙</button>
|
||||
|
||||
{#if debugPanelOpen}
|
||||
@ -384,5 +364,195 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Styles moved to app.css for #debug-ui */
|
||||
/* Debug UI Panel */
|
||||
#debug-ui {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
font-family: Arial, sans-serif;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
#debug-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border: 2px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#debug-toggle:hover {
|
||||
background-color: rgba(255, 215, 0, 0.2);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
#debug-toggle.active {
|
||||
background-color: var(--color-primary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#debug-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
right: 0;
|
||||
width: 280px;
|
||||
max-height: calc(100dvh - 70px);
|
||||
overflow-y: auto;
|
||||
background-color: rgba(24, 24, 24, 0.95);
|
||||
border: 1px solid var(--color-border-medium);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#debug-panel.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.debug-header {
|
||||
padding: 12px 15px;
|
||||
background-color: var(--color-primary);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
border-radius: 7px 7px 0 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.debug-section {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border-dark);
|
||||
}
|
||||
|
||||
.debug-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.debug-section-title {
|
||||
color: var(--color-primary);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#debug-panel button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--color-bg-panel);
|
||||
border: 1px solid var(--color-border-medium);
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
#debug-panel button:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#debug-panel button:hover {
|
||||
background-color: #3a3a3a;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
#debug-panel button:active {
|
||||
background-color: var(--color-primary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#debug-panel button.debug-password {
|
||||
background-color: #3d2a00;
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
#debug-panel button.debug-password:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#debug-panel button.debug-password.active {
|
||||
background-color: #00aa00;
|
||||
border-color: #00ff00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#debug-panel button.requires-debug {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#debug-panel button.requires-debug.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for debug panel */
|
||||
#debug-panel::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
#debug-panel::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-dark);
|
||||
}
|
||||
|
||||
#debug-panel::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#debug-panel::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
#debug-animation-select,
|
||||
#debug-location-select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--color-bg-panel);
|
||||
border: 1px solid var(--color-border-medium);
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#debug-animation-select:hover,
|
||||
#debug-location-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
#debug-animation-select:focus,
|
||||
#debug-location-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
#debug-animation-select option,
|
||||
#debug-location-select option {
|
||||
background-color: var(--color-bg-panel);
|
||||
color: #e0e0e0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -55,7 +55,6 @@
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Expand touch target on mobile */
|
||||
|
||||
@ -44,3 +44,100 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.resource-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-item {
|
||||
display: block;
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.resource-item:hover {
|
||||
background-color: #252525;
|
||||
border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.resource-item h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--color-primary);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.resource-item p {
|
||||
margin: 0;
|
||||
color: #b0b0b0;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Quote panel with side art */
|
||||
.quote-panel {
|
||||
display: flex;
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.quote-panel-art {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background-color: #111;
|
||||
}
|
||||
|
||||
.quote-panel-art img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 180px;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quote-panel-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quote-panel-content p {
|
||||
color: var(--color-text-medium);
|
||||
font-size: 0.95em;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.quote-panel-content footer {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.quote-panel-content footer::before {
|
||||
content: '— ';
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quote-panel-art {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -57,3 +57,23 @@
|
||||
<img src="images/later.webp" alt="Goodbye" class="update-character" width="150" height="187">
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Goodbye popup progress bar */
|
||||
.goodbye-progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-border-dark);
|
||||
border-radius: 2px;
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goodbye-progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
</style>
|
||||
|
||||
1063
src/lib/MemoriesPage.svelte
Normal file
1063
src/lib/MemoriesPage.svelte
Normal file
File diff suppressed because it is too large
Load Diff
728
src/lib/MultiplayerPage.svelte
Normal file
728
src/lib/MultiplayerPage.svelte
Normal file
@ -0,0 +1,728 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import BackButton from './BackButton.svelte';
|
||||
import OpfsDisabledBanner from './OpfsDisabledBanner.svelte';
|
||||
import PanningImage from './PanningImage.svelte';
|
||||
import ActorPicker from './ActorPicker.svelte';
|
||||
import { multiplayerRoom, currentPage, gameRunning, opfsDisabled } from '../stores.js';
|
||||
import { navigateToRoom } from '../core/navigation.js';
|
||||
import { generateRoomName } from '../core/room-names.js';
|
||||
import { launchGame } from '../core/emscripten.js';
|
||||
import { saveConfigFromDOM, getOpfsRoot } from '../core/opfs.js';
|
||||
import { showToast } from '../core/toast.js';
|
||||
import { ActorInfoInit } from '../core/savegame/actorConstants.js';
|
||||
import ShareLinkButton from './ShareLinkButton.svelte';
|
||||
|
||||
const RELAY_URL = __RELAY_URL__;
|
||||
const RELAY_HTTP = RELAY_URL.replace('wss://', 'https://').replace('ws://', 'http://');
|
||||
|
||||
const maxPlayers = 16;
|
||||
let creating = false;
|
||||
let isPrivate = false;
|
||||
|
||||
let selectedActorIndex = Number(sessionStorage.getItem('mp-actor')) || 0;
|
||||
|
||||
// Room lobby state
|
||||
let playerCount = 0;
|
||||
let roomMaxPlayers = 0;
|
||||
let roomFull = false;
|
||||
let previewLoading = false;
|
||||
let previewError = null;
|
||||
let pollInterval = null;
|
||||
|
||||
let fetching = false;
|
||||
let lastPolledRoom = null;
|
||||
|
||||
// Server browser state
|
||||
const BROWSER_ROWS = 5;
|
||||
const placeholderMessages = [
|
||||
'It\'s one pretty island.',
|
||||
'It\'s a lovely day for a bike ride.',
|
||||
'I\'m about to pop a brick!',
|
||||
'The fun is in the doing, not the winning.',
|
||||
'Come on back sometime and build on that.',
|
||||
];
|
||||
let publicRooms = [];
|
||||
let browserFetching = false;
|
||||
let browserPollInterval = null;
|
||||
let browserInitialized = false;
|
||||
let showBrowserRows = false;
|
||||
let browserGraceTimeout = setTimeout(() => { showBrowserRows = true; }, 300);
|
||||
|
||||
// Check if we were reloaded after a room-full rejection
|
||||
{
|
||||
if (sessionStorage.getItem('mp-rejected')) {
|
||||
sessionStorage.removeItem('mp-rejected');
|
||||
setTimeout(() => showToast('Island is full', { error: true, duration: 3000 }), 0);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const root = await getOpfsRoot();
|
||||
if (!root) {
|
||||
opfsDisabled.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
$: roomName = $multiplayerRoom;
|
||||
$: hasRoom = roomName !== null && roomName !== '';
|
||||
$: browserRows = (() => {
|
||||
const rows = publicRooms.map(r => ({ ...r, id: r.roomId, placeholder: false }));
|
||||
for (let i = rows.length; i < BROWSER_ROWS; i++) {
|
||||
rows.push({ id: `_empty_${i}`, placeholder: true, message: placeholderMessages[i] });
|
||||
}
|
||||
return rows;
|
||||
})();
|
||||
|
||||
// Poll room preview when room is active
|
||||
$: {
|
||||
const shouldPoll = hasRoom && $currentPage === 'multiplayer' && !$gameRunning;
|
||||
const roomChanged = roomName !== lastPolledRoom;
|
||||
|
||||
if (shouldPoll && (roomChanged || !pollInterval)) {
|
||||
lastPolledRoom = roomName;
|
||||
startPolling();
|
||||
} else if (!shouldPoll) {
|
||||
lastPolledRoom = null;
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
fetchPreview();
|
||||
pollInterval = setInterval(fetchPreview, 5000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll public rooms when no room is active
|
||||
$: {
|
||||
const shouldPollBrowser = !hasRoom && $currentPage === 'multiplayer' && !$gameRunning;
|
||||
if (shouldPollBrowser && !browserPollInterval) {
|
||||
fetchPublicRooms();
|
||||
browserPollInterval = setInterval(fetchPublicRooms, 3000);
|
||||
} else if (!shouldPollBrowser && browserPollInterval) {
|
||||
clearInterval(browserPollInterval);
|
||||
browserPollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPublicRooms() {
|
||||
if (browserFetching) return;
|
||||
browserFetching = true;
|
||||
try {
|
||||
const res = await fetch(`${RELAY_HTTP}/rooms`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
publicRooms = data.rooms;
|
||||
} catch {
|
||||
// Silently fail — browser is optional
|
||||
} finally {
|
||||
browserInitialized = true;
|
||||
clearTimeout(browserGraceTimeout);
|
||||
showBrowserRows = true;
|
||||
browserFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPreview() {
|
||||
if (!roomName || fetching) return;
|
||||
fetching = true;
|
||||
previewLoading = playerCount === 0 && roomMaxPlayers === 0;
|
||||
previewError = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${RELAY_HTTP}/room/${roomName}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
playerCount = data.players;
|
||||
roomMaxPlayers = data.maxPlayers;
|
||||
roomFull = playerCount >= roomMaxPlayers;
|
||||
} catch {
|
||||
previewError = 'Could not reach relay server';
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRoom() {
|
||||
creating = true;
|
||||
const name = generateRoomName();
|
||||
|
||||
try {
|
||||
await fetch(`${RELAY_HTTP}/room/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ maxPlayers, isPublic: !isPrivate })
|
||||
});
|
||||
|
||||
navigateToRoom(name);
|
||||
} catch {
|
||||
previewError = 'Could not reach relay server';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunGame() {
|
||||
if (!roomName) return;
|
||||
const actorName = ActorInfoInit[selectedActorIndex].name;
|
||||
await saveConfigFromDOM({ room: roomName, relayUrl: RELAY_URL, actor: actorName });
|
||||
launchGame();
|
||||
}
|
||||
|
||||
$: roomShareUrl = `${window.location.origin}${window.location.pathname}#r/${roomName}`;
|
||||
|
||||
onDestroy(() => {
|
||||
stopPolling();
|
||||
clearTimeout(browserGraceTimeout);
|
||||
if (browserPollInterval) {
|
||||
clearInterval(browserPollInterval);
|
||||
browserPollInterval = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="multiplayer-page" class="page-content">
|
||||
<BackButton />
|
||||
<OpfsDisabledBanner />
|
||||
<div class="page-inner-content config-layout">
|
||||
<div class="config-art-panel">
|
||||
<PanningImage src="images/multi.webp" alt="LEGO Island Multiplayer" duration={45} />
|
||||
</div>
|
||||
<div class="config-main">
|
||||
<h2 class="mp-title">Multiplayer
|
||||
<span class="mp-badge">
|
||||
<svg class="mp-badge-icon" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 3L12 14L15 3"/><path d="M6 3H18"/><path d="M5 21H19L17 8H7L5 21Z"/>
|
||||
</svg>
|
||||
Experimental
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p class="mp-description">Explore LEGO Island together with other players. Create an island and share the link to get started.</p>
|
||||
|
||||
{#if !hasRoom}
|
||||
<div class="mp-panel">
|
||||
<div class="mp-action-bar">
|
||||
<button class="preset-btn mp-create-main" onclick={handleCreateRoom} disabled={creating || $opfsDisabled}>
|
||||
{#if isPrivate}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{creating ? 'Creating...' : isPrivate ? 'Create Private Island' : 'Create Public Island'}
|
||||
</button>
|
||||
<button class="preset-btn mp-create-toggle" onclick={() => { isPrivate = !isPrivate; }} disabled={creating || $opfsDisabled} title={isPrivate ? 'Switch to public (listed in browser)' : 'Switch to private (link only)'}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="mp-chevron" class:mp-chevron-up={isPrivate}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mp-browser-section">
|
||||
<div class="mp-browser-header">
|
||||
<span class="mp-browser-title">
|
||||
{#if !browserInitialized}
|
||||
<span class="mp-browser-spinner"></span>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
Public Islands
|
||||
</span>
|
||||
{#if publicRooms.length > 0}
|
||||
<span class="mp-browser-count">{publicRooms.length >= BROWSER_ROWS ? `${BROWSER_ROWS}+` : publicRooms.length} available</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mp-browser-list" class:mp-browser-hidden={!showBrowserRows}>
|
||||
{#each browserRows as row (row.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="mp-browser-room" class:mp-browser-placeholder={row.placeholder} animate:flip={{ duration: 250 }} onclick={() => !row.placeholder && navigateToRoom(row.roomId)}>
|
||||
{#if row.placeholder}
|
||||
<span class="mp-placeholder-text">{row.message}</span>
|
||||
{:else}
|
||||
<span class="mp-browser-room-name">{row.roomId}</span>
|
||||
<span class="mp-browser-room-players">
|
||||
<span class="mp-player-bar-wrap">
|
||||
<span class="mp-player-bar" style="width: {row.maxPlayers > 0 ? (row.players / row.maxPlayers * 100) : 0}%"></span>
|
||||
</span>
|
||||
<span class="mp-player-count"><span class="mp-count-num">{row.players}</span>/<span class="mp-count-num">{row.maxPlayers}</span></span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-feature-grid">
|
||||
<div class="mp-feature">
|
||||
<div class="mp-feature-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mp-feature-text">
|
||||
<strong>Third-person camera</strong>
|
||||
<span>Toggle a camera behind your character to see yourself walking and performing emotes.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-feature">
|
||||
<div class="mp-feature-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mp-feature-text">
|
||||
<strong><a href="#memories" class="mp-feature-link">Nick Brick's Memories</a></strong>
|
||||
<span>Trigger 300+ original animations together. Fill actor roles and watch scenes play out.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-feature">
|
||||
<div class="mp-feature-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mp-feature-text">
|
||||
<strong>Emotes and interactions</strong>
|
||||
<span>Wave, tip your hat, cycle colors and moods, and pick different walk and idle styles.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-feature">
|
||||
<div class="mp-feature-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mp-feature-text">
|
||||
<strong>Shared world*</strong>
|
||||
<span>See other players on the island. Plants, buildings, and their states are synchronized.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mp-footer-note">*Missions, vehicles, and other game systems are not shared between players.</p>
|
||||
{:else}
|
||||
<div class="mp-section">
|
||||
<div class="mp-room-bar">
|
||||
<span class="mp-room-bar-info">
|
||||
<span class="mp-room-name">{roomName}</span>
|
||||
<span class="mp-room-sep">·</span>
|
||||
<span class="mp-room-players">
|
||||
{#if previewLoading}
|
||||
...
|
||||
{:else if previewError}
|
||||
<span class="mp-error">{previewError}</span>
|
||||
{:else}
|
||||
<span class="mp-player-bar-wrap">
|
||||
<span class="mp-player-bar" style="width: {roomMaxPlayers > 0 ? (playerCount / roomMaxPlayers * 100) : 0}%"></span>
|
||||
</span>
|
||||
<span class="mp-player-count">{playerCount}/{roomMaxPlayers}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<ShareLinkButton url={roomShareUrl} shareText="Let's play LEGO Island together!" compact />
|
||||
</div>
|
||||
|
||||
{#if !$gameRunning}
|
||||
<ActorPicker
|
||||
selectedIndex={selectedActorIndex}
|
||||
onSelect={(idx) => { selectedActorIndex = idx; sessionStorage.setItem('mp-actor', idx); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if roomFull}
|
||||
<p class="mp-full-msg">Island is full. Wait for a player to leave or create a new island.</p>
|
||||
{:else}
|
||||
<button class="preset-btn mp-run-btn" onclick={handleRunGame} disabled={$opfsDisabled}>Run Game</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mp-title {
|
||||
color: var(--color-text-light);
|
||||
font-size: 1.05em;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.mp-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.55em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary);
|
||||
color: #000;
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.mp-badge-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-description {
|
||||
color: var(--color-text-medium);
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
/* Unified panel for no-room state */
|
||||
.mp-panel {
|
||||
background: var(--gradient-panel);
|
||||
border: 1px solid var(--color-bg-panel);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mp-action-bar {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border-dark);
|
||||
}
|
||||
|
||||
.mp-create-main,
|
||||
.mp-create-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.mp-create-main {
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
border-radius: 6px 0 0 6px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.mp-create-toggle {
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
border-left: 1px solid var(--color-border-dark);
|
||||
}
|
||||
|
||||
.mp-chevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mp-chevron-up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Shared section card for lobby */
|
||||
.mp-section {
|
||||
background: var(--gradient-panel);
|
||||
border: 1px solid var(--color-bg-panel);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Room info bar */
|
||||
.mp-room-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.mp-room-bar-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mp-room-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mp-room-sep {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.mp-room-players {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.75em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mp-player-bar-wrap {
|
||||
width: 36px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-border-medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mp-player-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.mp-player-count {
|
||||
color: var(--color-text-muted);
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 38px;
|
||||
}
|
||||
|
||||
.mp-count-num {
|
||||
display: inline-block;
|
||||
min-width: 14px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mp-error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.mp-run-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-full-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75em;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Features grid */
|
||||
.mp-feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 7px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.mp-feature {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 9px;
|
||||
background: var(--gradient-panel);
|
||||
border: 1px solid var(--color-bg-panel);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mp-feature-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.mp-feature-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mp-feature-text strong {
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
.mp-feature-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mp-feature-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mp-feature-text span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.65em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.mp-footer-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.65em;
|
||||
text-align: center;
|
||||
margin: 6px 0 0 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Server browser */
|
||||
.mp-browser-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mp-browser-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-border-dark);
|
||||
border-top-color: var(--color-primary);
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.mp-browser-room:not(.mp-browser-placeholder) {
|
||||
animation: mp-room-enter 0.25s ease both;
|
||||
}
|
||||
|
||||
@keyframes mp-room-enter {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.mp-browser-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border-dark);
|
||||
}
|
||||
|
||||
.mp-browser-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mp-browser-count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
.mp-browser-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border-medium) transparent;
|
||||
}
|
||||
|
||||
.mp-browser-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.mp-browser-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.mp-browser-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mp-browser-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-medium);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mp-browser-room {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--color-surface-subtle);
|
||||
color: var(--color-text-medium);
|
||||
cursor: pointer;
|
||||
font-size: 0.75em;
|
||||
transition: background 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mp-browser-room:not(.mp-browser-placeholder):hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.mp-browser-room:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mp-browser-room-name {
|
||||
color: var(--color-primary);
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mp-browser-room-players {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-browser-placeholder {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mp-placeholder-text {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.4;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.mp-feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mp-browser-room {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
src/lib/OpfsDisabledBanner.svelte
Normal file
27
src/lib/OpfsDisabledBanner.svelte
Normal file
@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { opfsDisabled } from '../stores.js';
|
||||
</script>
|
||||
|
||||
{#if $opfsDisabled}
|
||||
<blockquote class="error-box">
|
||||
<p>OPFS is disabled in this browser. This feature is unavailable. If you are using a
|
||||
Private/Incognito window, please change to a regular window instead.</p>
|
||||
</blockquote>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.error-box {
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 25px;
|
||||
border-left: 3px solid #ff0011;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.error-box p {
|
||||
font-style: italic;
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
</style>
|
||||
47
src/lib/PanningImage.svelte
Normal file
47
src/lib/PanningImage.svelte
Normal file
@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
export let src;
|
||||
export let alt = '';
|
||||
/** Full cycle duration in seconds (left-to-right-to-left) */
|
||||
export let duration = 45;
|
||||
|
||||
let imgEl;
|
||||
let animationId;
|
||||
let startTime = null;
|
||||
|
||||
function animate(timestamp) {
|
||||
if (!imgEl) return;
|
||||
if (startTime === null) startTime = timestamp;
|
||||
|
||||
const elapsed = (timestamp - startTime) / 1000;
|
||||
// Ping-pong: 0→1→0 over `duration` seconds
|
||||
const phase = (elapsed % duration) / duration;
|
||||
const pct = phase < 0.5
|
||||
? phase * 2
|
||||
: 2 - phase * 2;
|
||||
|
||||
imgEl.style.objectPosition = `${pct * 100}% 50%`;
|
||||
animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
animationId = requestAnimationFrame(animate);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<img bind:this={imgEl} {src} {alt} class="panning-image" />
|
||||
|
||||
<style>
|
||||
.panning-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 0% 50%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@ -21,23 +21,30 @@
|
||||
];
|
||||
|
||||
const faqItems = [
|
||||
{ id: 'faq1', question: 'Is this the full, original game?', answer: `<p>This is a complete port of the original 1997 PC game. You can select from multiple languages, including both the 1.0 and 1.1 versions of English, from the "Configure" menu before starting.</p>` },
|
||||
{ id: 'faq2', question: 'How does this differ from the original 1997 CD-ROM game?', answer: `<p>The core gameplay is identical, but this version has some great advantages! It runs in your browser with no installation needed and works on modern devices. It also includes enhancements like widescreen support, improved controls, many bug fixes from the decompilation project, and the ability to run at your display's maximum resolution (even 4K!).</p><p>Check out the "Configure" page to see what's possible.</p>` },
|
||||
{ id: 'faq3', question: 'Can I save my progress?', answer: `<p>Yes! The game automatically saves your progress. To ensure your game is saved, return to the Infocenter and use the exit door. This will bring you back to the main menu and lock in your save state. A "best effort" save is also attempted if you close the tab directly, but this method isn't always guaranteed.</p>` },
|
||||
{ id: 'faq4', question: 'Does this run on mobile?', answer: `<p>Yes! The game is designed to work on a wide range of devices, including desktops, laptops, tablets, and phones. It has even been seen running on <a href="https://github.com/isledecomp/isle-portable/issues/418#issuecomment-3003572219" target="_blank" rel="noopener noreferrer">Tesla in-car browsers</a>!</p>` },
|
||||
{ id: 'faq5', question: 'Which browsers are supported?', answer: `<p>This port runs best on recent versions of modern browsers, including Chrome, Firefox, and Safari. For an optimal experience on iOS devices, please ensure you are running iOS 18 or newer.</p>` },
|
||||
{ id: 'faq6', question: 'What are the controls?', answer: `<p>You can play using a keyboard and mouse, a gamepad, or a touch screen. Gamepad support can vary depending on your browser. On mobile, you can select your preferred touch control scheme in the "Configure" menu.</p>` },
|
||||
{ id: 'faq7', question: 'Can I play offline?', answer: `<p>You bet! In the "Configure" menu, scroll to the "Offline Play" section. You'll find an option there to install all necessary game files (about 550MB) for offline access.</p>` },
|
||||
{ id: 'faq8', question: "I don't hear any sound or music. How do I fix it?", answer: `<p>Most modern browsers block audio until you interact with the page. Click the mute icon on the animated intro to enable sound.</p>` },
|
||||
{ id: 'faq9', question: 'I think I found a bug! Where do I report it?', answer: `<p>As an active development project, some bugs are expected. If you find one, we'd be grateful if you'd report it on the isle-portable <a href="https://github.com/isledecomp/isle-portable/issues" target="_blank" rel="noopener noreferrer">GitHub Issues page</a>. Please include details about your browser, device, and what you were doing when the bug occurred.</p>` },
|
||||
{ id: 'faq10', question: 'Is this project open-source?', answer: `<p>Yes, absolutely! This web port is built upon the incredible open-source <a href="https://github.com/isledecomp/isle-portable" target="_blank" rel="noopener noreferrer">LEGO Island (portable)</a> project, and the code for this website is also <a href="https://github.com/isledecomp/isle.pizza" target="_blank" rel="noopener noreferrer">available here</a>.</p>` }
|
||||
{ id: 'faq1', question: 'Is this the same game as the original?', answer: `<p>This is a complete port of the original 1997 PC game — the core gameplay is identical. You can select from multiple languages, including both the 1.0 and 1.1 versions of English, from the Configure page before starting.</p><p>On top of that, this version includes enhancements like widescreen support, improved controls, many bug fixes from the decompilation project, and the ability to run at your display's maximum resolution (even 4K!). Check out the <a href="#configure">Configure</a> page to see what's possible.</p>` },
|
||||
{ id: 'faq2', question: 'Can I save my progress?', answer: `<p>Yes! The game automatically saves your progress. To ensure your game is saved, return to the Infocenter and use the exit door. This will bring you back to the main menu and lock in your save state. A "best effort" save is also attempted if you close the tab directly, but this method isn't always guaranteed.</p>` },
|
||||
{ id: 'faq3', question: 'Does this run on mobile?', answer: `<p>Yes! The game is designed to work on a wide range of devices, including desktops, laptops, tablets, and phones. It has even been seen running on <a href="https://github.com/isledecomp/isle-portable/issues/418#issuecomment-3003572219" target="_blank" rel="noopener noreferrer">Tesla in-car browsers</a>!</p>` },
|
||||
{ id: 'faq4', question: 'Which browsers are supported?', answer: `<p>See the System tab for a full list of supported browsers and minimum versions. For the best experience on iOS, make sure you're running iOS 18 or newer.</p>` },
|
||||
{ id: 'faq5', question: 'What are the controls?', answer: `<p>You can play using a keyboard and mouse, a gamepad, or a touch screen. Gamepad support can vary depending on your browser. On mobile, you can select your preferred touch control scheme in the <a href="#configure">Configure</a> menu.</p>` },
|
||||
{ id: 'faq6', question: 'Can I play offline?', answer: `<p>You bet! On the <a href="#configure">Configure</a> page, open the "Extras" tab and expand the "Offline Play" section. From there you can install all necessary game files (about 550MB) for offline access.</p>` },
|
||||
{ id: 'faq7', question: "I don't hear any sound or music. How do I fix it?", answer: `<p>Most modern browsers block audio until you interact with the page. Click the mute icon on the animated intro to enable sound.</p>` },
|
||||
{ id: 'faq8', question: 'I think I found a bug! Where do I report it?', answer: `<p>As an active development project, some bugs are expected. If you find one, we'd be grateful if you'd report it on the isle-portable <a href="https://github.com/isledecomp/isle-portable/issues" target="_blank" rel="noopener noreferrer">GitHub Issues page</a>. Please include details about your browser, device, and what you were doing when the bug occurred.</p>` },
|
||||
{ id: 'faq9', question: 'Is this project open-source?', answer: `<p>Yes, absolutely! This web port is built upon the incredible open-source <a href="https://github.com/isledecomp/isle-portable" target="_blank" rel="noopener noreferrer">LEGO Island (portable)</a> project, and the code for this website is also <a href="https://github.com/isledecomp/isle.pizza" target="_blank" rel="noopener noreferrer">available here</a>.</p>` }
|
||||
];
|
||||
|
||||
const changelogItems = [
|
||||
{ id: 'cl0', title: 'March 2026', items: [
|
||||
{ id: 'cl0', title: 'April 2026', items: [
|
||||
{ type: 'New', text: 'Multiplayer mode — create public or private islands and explore LEGO Island together with up to 16 players in real time' },
|
||||
{ type: 'New', text: 'Scene Player lets you watch over 300 original LEGO Island animations with playback controls and shareable links' },
|
||||
{ type: 'New', text: 'Memories page — reenact original in-game animations with other players in multiplayer and collect them as memories' },
|
||||
{ type: 'New', text: 'Cloud Sync automatically backs up your save files and config across devices when signed in' },
|
||||
{ type: 'New', text: 'Sign in with Discord to enable cloud sync, memories, and multiplayer features' },
|
||||
{ type: 'New', text: 'Crash reporting overlay captures diagnostics and lets you submit reports when something goes wrong' }
|
||||
]},
|
||||
{ id: 'cl1', title: 'March 2026', items: [
|
||||
{ type: 'New', text: 'Voices tab on the Read Me page showcases reactions from the original LEGO Island development team' }
|
||||
]},
|
||||
{ id: 'cl1', title: 'February 2026', items: [
|
||||
{ id: 'cl2', title: 'February 2026', items: [
|
||||
{ type: 'New', text: 'Save Editor lets you view and modify save files — change your player name, character, and high scores directly from the browser' },
|
||||
{ type: 'New', text: 'Sky Color Editor allows customizing the island sky gradient colors in your save file' },
|
||||
{ type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' },
|
||||
@ -52,14 +59,14 @@
|
||||
{ type: 'Improved', text: 'Save Editor tabs now use a carousel with arrow navigation for easier browsing on small screens' },
|
||||
{ type: 'Fixed', text: 'Sticky hover highlights on touch devices for editor buttons' }
|
||||
]},
|
||||
{ id: 'cl2', title: 'January 2026', items: [
|
||||
{ id: 'cl3', title: 'January 2026', items: [
|
||||
{ type: 'New', text: 'Debug menu for developers and power users. Tap the LEGO Island logo 5 times to unlock OGEL mode and access debug features like teleporting to locations, switching acts, and playing animations' },
|
||||
{ type: 'Improved', text: 'Configure page redesigned with tabbed navigation, collapsible sections, quick presets (Classic/Modern Mode), and modern toggle switches' },
|
||||
{ type: 'Improved', text: 'Read Me page reorganized into tabs (About, System, FAQ, Changelog, Manual) with the original instruction manual now viewable in-browser' },
|
||||
{ type: 'Fixed', text: 'Safari audio not playing on first toggle' },
|
||||
{ type: 'Fixed', text: 'Tooltips not working correctly on mobile devices' }
|
||||
]},
|
||||
{ id: 'cl3', title: 'December 2025', items: [
|
||||
{ id: 'cl4', title: 'December 2025', items: [
|
||||
{ type: 'New', text: '"Active in Background" option keeps the game running when the tab loses focus' },
|
||||
{ type: 'New', text: 'WASD navigation controls as an alternative to arrow keys' },
|
||||
{ type: 'Fixed', text: 'Act 3 helicopter ammo now correctly sticks to targets and finishes animations' },
|
||||
@ -68,17 +75,17 @@
|
||||
{ type: 'Fixed', text: 'Touch controls now properly support widescreen aspect ratios' },
|
||||
{ type: 'Improved', text: 'Default anisotropic filtering increased to 16x for sharper textures' }
|
||||
]},
|
||||
{ id: 'cl4', title: 'November 2025', items: [
|
||||
{ id: 'cl5', title: 'November 2025', items: [
|
||||
{ type: 'Fixed', text: 'Dictionary loading failure no longer causes crashes' },
|
||||
{ type: 'Fixed', text: 'INI configuration now properly applies defaults when values are missing' }
|
||||
]},
|
||||
{ id: 'cl5', title: 'September 2025', items: [
|
||||
{ id: 'cl6', title: 'September 2025', items: [
|
||||
{ type: 'New', text: 'Additional widescreen background images' },
|
||||
{ type: 'Fixed', text: 'Jukebox state now correctly restored when using HD Music extension' },
|
||||
{ type: 'Fixed', text: 'Background audio no longer gets stuck when starting audio fails' },
|
||||
{ type: 'Improved', text: 'SI Loader actions now start at the correct time during world loading' }
|
||||
]},
|
||||
{ id: 'cl6', title: 'August 2025', items: [
|
||||
{ id: 'cl7', title: 'August 2025', items: [
|
||||
{ type: 'New', text: 'Extended Bad Ending FMV extension shows the uncut beta animation' },
|
||||
{ type: 'New', text: 'HD Music extension with high-quality audio' },
|
||||
{ type: 'New', text: 'Widescreen backgrounds extension eliminates 3D edges on wide displays' },
|
||||
@ -87,7 +94,7 @@
|
||||
{ type: 'Fixed', text: 'Purple edges no longer appear on scaled transparent 2D elements' },
|
||||
{ type: 'Fixed', text: 'Transparent pixels now render correctly with alpha channel support' }
|
||||
]},
|
||||
{ id: 'cl7', title: 'July 2025', items: [
|
||||
{ id: 'cl8', title: 'July 2025', items: [
|
||||
{ type: 'New', text: 'HD Textures extension with enhanced visuals' },
|
||||
{ type: 'New', text: 'MSAA anti-aliasing support for smoother edges' },
|
||||
{ type: 'New', text: 'Anisotropic filtering for sharper textures at angles' },
|
||||
@ -109,7 +116,7 @@
|
||||
{ type: 'Improved', text: 'Mosaic transition animation is faster and cleaner' },
|
||||
{ type: 'Improved', text: 'Loading UX for HD Textures with progress indicators' }
|
||||
]},
|
||||
{ id: 'cl8', title: 'June 2025 — Initial Release', items: [
|
||||
{ id: 'cl9', title: 'June 2025 — Initial Release', items: [
|
||||
{ type: 'New', text: 'Emscripten web port — play LEGO Island directly in your browser!' },
|
||||
{ type: 'New', text: 'WebGL rendering for hardware-accelerated 3D graphics' },
|
||||
{ type: 'New', text: 'Software renderer fallback for devices without WebGL' },
|
||||
@ -161,26 +168,21 @@
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" class:active={activeTab === 'about'} id="tab-about">
|
||||
<p>Welcome to the LEGO Island web port project! This is a recreation of the classic 1997 PC game,
|
||||
rebuilt to run in modern web browsers using Emscripten and WebAssembly.</p>
|
||||
<p>This incredible project stands on the shoulders of giants. It was made possible by the original <a
|
||||
href="https://github.com/isledecomp/isle" target="_blank"
|
||||
rel="noopener noreferrer">decompilation project</a>, which achieved 100% decompilation of the
|
||||
original game. This was then adapted into a <a
|
||||
href="https://github.com/isledecomp/isle-portable" target="_blank"
|
||||
rel="noopener noreferrer">portable version</a> that eliminated all Windows dependencies and
|
||||
replaced them with modern, cross-platform alternatives.</p>
|
||||
<p>The technical work involved replacing Windows-specific systems with SDL for window management and input,
|
||||
migrating audio from DirectSound to the miniaudio library, converting Windows Registry configuration
|
||||
to INI files, and creating a modular graphics layer supporting multiple rendering backends including
|
||||
WebGL. This represents years of effort from many awesome contributors dedicated to preserving this
|
||||
piece of gaming history.</p>
|
||||
<p>Thanks to this work, LEGO Island now runs on over 10 platforms including Windows, Linux, macOS, iOS,
|
||||
Android, Nintendo Switch, PlayStation Vita, and of course, web browsers. The web version uses the
|
||||
original, unmodified Interleaf streaming code, enabling progressive content loading just like the
|
||||
original CD-ROM.</p>
|
||||
<p>Our goal is to make this classic accessible to everyone. The project is still in development, so you
|
||||
may encounter bugs. Your patience and feedback are greatly appreciated!</p>
|
||||
<p>Play the classic 1997 LEGO Island — right in your browser. This is a faithful recreation of the
|
||||
original PC game, rebuilt with Emscripten and WebAssembly to run on modern devices without any
|
||||
installation.</p>
|
||||
<p>This project was made possible by the <a href="https://github.com/isledecomp/isle" target="_blank"
|
||||
rel="noopener noreferrer">LEGO Island decompilation</a>, which achieved a complete,
|
||||
byte-accurate reconstruction of the original source code. That work was then transformed into a
|
||||
<a href="https://github.com/isledecomp/isle-portable" target="_blank"
|
||||
rel="noopener noreferrer">portable version</a> that replaced every Windows dependency with
|
||||
modern, cross-platform alternatives — from graphics and audio to input and configuration.</p>
|
||||
<p>Thanks to years of effort from many dedicated contributors, LEGO Island now runs on over 10 platforms
|
||||
including Windows, Linux, macOS, iOS, Android, Nintendo Switch, PlayStation Vita, and the web. The
|
||||
browser version even uses the original Interleaf streaming code, progressively loading content just
|
||||
like the 1997 CD-ROM.</p>
|
||||
<p>Our goal is to make this classic accessible to everyone. The project is still in active development,
|
||||
so you may encounter the occasional bug — your patience and feedback are greatly appreciated!</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" class:active={activeTab === 'system'} id="tab-system">
|
||||
@ -198,7 +200,7 @@
|
||||
|
||||
<div class="requirements-section">
|
||||
<h3>Input Methods</h3>
|
||||
<p>The game supports multiple ways to play. Visit the Configure page to adjust your control preferences.</p>
|
||||
<p>The game supports multiple ways to play. Visit the <a href="#configure">Configure</a> page to adjust your control preferences.</p>
|
||||
<ul class="requirements-list">
|
||||
<li><strong>Keyboard & Mouse</strong> — Traditional desktop controls using arrow keys or WASD</li>
|
||||
<li><strong>Gamepad</strong> — Controller support with analog sticks and D-pad</li>
|
||||
@ -208,14 +210,14 @@
|
||||
|
||||
<div class="requirements-section">
|
||||
<h3>Audio</h3>
|
||||
<p>Audio hardware is recommended for the full experience. If the game is silent, click the mute icon
|
||||
on the animated intro to enable sound. Modern browsers require user interaction before playing audio.</p>
|
||||
<p>If the game is silent, click the mute icon on the animated intro to enable sound —
|
||||
browsers require a user interaction before playing audio.</p>
|
||||
</div>
|
||||
|
||||
<div class="requirements-section">
|
||||
<h3>Storage & Network</h3>
|
||||
<p>The game streams approximately <strong>25MB</strong> of data on first load (more with extensions enabled).
|
||||
For offline play, you can install the full game (about <strong>550MB</strong>) via the Configure menu.
|
||||
For offline play, you can install the full game (about <strong>550MB</strong>) via the <a href="#configure">Configure</a> menu.
|
||||
A stable internet connection is recommended for initial loading.</p>
|
||||
</div>
|
||||
|
||||
@ -225,7 +227,7 @@
|
||||
<li>Close other browser tabs to free up memory</li>
|
||||
<li>Use hardware acceleration (enabled by default in most browsers)</li>
|
||||
<li>On mobile, ensure your device isn't in low-power mode</li>
|
||||
<li>If experiencing lag, try reducing the resolution in Configure</li>
|
||||
<li>If experiencing lag, try reducing the resolution in <a href="#configure">Configure</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -252,7 +254,7 @@
|
||||
|
||||
<div class="tab-panel" class:active={activeTab === 'manual'} id="tab-manual">
|
||||
<div class="manual-container">
|
||||
<p class="manual-description">The original 15-page instruction manual from the 1997 CD-ROM release.</p>
|
||||
<p class="manual-description">The original comic-style instruction manual from the 1997 CD-ROM release.</p>
|
||||
<a href="pdf/comic.pdf" target="_blank" rel="noopener" class="manual-open-btn">Open Manual in New Tab</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -326,3 +328,296 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Read Me Tabs */
|
||||
.readme-tabs {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border-medium);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
flex: 1 1 calc(33.333% - 10px);
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background-color: var(--color-bg-card);
|
||||
border: 2px solid var(--color-border-dark);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background-color: #252525;
|
||||
border-color: var(--color-border-light);
|
||||
color: var(--color-text-medium);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background-color: #2a2a00;
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border-medium);
|
||||
}
|
||||
|
||||
.tab-btn.active .tab-icon {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tab-panel > p {
|
||||
color: var(--color-text-medium);
|
||||
line-height: 1.6;
|
||||
font-size: 1em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tab-panel > p a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tab-panel > p a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Voices Section */
|
||||
.voices-intro {
|
||||
color: var(--color-text-medium);
|
||||
font-size: 1em;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.voices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.voice-card {
|
||||
background: linear-gradient(135deg, var(--color-bg-card) 0%, var(--color-bg-elevated) 100%);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: 12px;
|
||||
padding: 24px 24px 20px;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
transition: border-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.voice-card::before {
|
||||
content: '\201C';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
font-size: 3em;
|
||||
line-height: 1;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.25;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
.voice-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(255, 215, 0, 0.08);
|
||||
}
|
||||
|
||||
.voice-card p {
|
||||
color: var(--color-text-medium);
|
||||
font-size: 0.95em;
|
||||
line-height: 1.7;
|
||||
margin: 8px 0 16px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.voice-card footer {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-dark);
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.voice-name {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.95em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.voice-name::before {
|
||||
content: '— ';
|
||||
}
|
||||
|
||||
.voice-role {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.voice-tagline {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75em;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Manual Section */
|
||||
.manual-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.manual-description {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manual-open-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: var(--gradient-panel);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 8px;
|
||||
color: var(--color-primary);
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.manual-open-btn:hover {
|
||||
background: var(--gradient-hover);
|
||||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Requirements Section */
|
||||
.requirements-section {
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.requirements-section h3 {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.1em;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.requirements-section p {
|
||||
color: var(--color-text-medium);
|
||||
font-size: 0.95em;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.requirements-section p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.requirements-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: var(--color-text-medium);
|
||||
}
|
||||
|
||||
.requirements-list li {
|
||||
font-size: 0.95em;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.requirements-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.requirements-list li strong {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.requirements-note {
|
||||
font-size: 0.85em !important;
|
||||
color: var(--color-text-muted) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-btn {
|
||||
padding: 10px 18px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1 1 calc(50% - 5px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voices-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tab-buttons {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.75em;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import BackButton from './BackButton.svelte';
|
||||
import OpfsDisabledBanner from './OpfsDisabledBanner.svelte';
|
||||
import Carousel from './Carousel.svelte';
|
||||
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
|
||||
import SkyColorEditor from './save-editor/SkyColorEditor.svelte';
|
||||
@ -10,7 +11,8 @@
|
||||
import PlantEditor from './save-editor/PlantEditor.svelte';
|
||||
import BuildingEditor from './save-editor/BuildingEditor.svelte';
|
||||
import { fetchBitmapAsURL } from '../core/assetLoader.js';
|
||||
import { saveEditorState, currentPage } from '../stores.js';
|
||||
import { saveEditorState, currentPage, opfsDisabled, savesVersion } from '../stores.js';
|
||||
import { getOpfsRoot } from '../core/opfs.js';
|
||||
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
||||
import { Actor, ActorNames } from '../core/savegame/constants.js';
|
||||
|
||||
@ -40,6 +42,12 @@
|
||||
openSection = 'name';
|
||||
}
|
||||
|
||||
// Reload saves from OPFS when navigating to this page or after cloud sync
|
||||
$: if ($currentPage === 'save-editor' && !$opfsDisabled) {
|
||||
$savesVersion;
|
||||
loadSlots();
|
||||
}
|
||||
|
||||
// Name editing state (7 characters)
|
||||
let nameSlots = ['', '', '', '', '', '', ''];
|
||||
let slotRefs = [];
|
||||
@ -97,6 +105,13 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const root = await getOpfsRoot();
|
||||
if (!root) {
|
||||
opfsDisabled.set(true);
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await loadSlots();
|
||||
|
||||
// Load character icons from SI file in background
|
||||
@ -314,6 +329,7 @@
|
||||
|
||||
<div id="save-editor" class="page-content">
|
||||
<BackButton />
|
||||
<OpfsDisabledBanner />
|
||||
<div class="page-inner-content config-layout">
|
||||
<div class="config-art-panel">
|
||||
<img src="images/save.webp" alt="LEGO Island Save Editor">
|
||||
@ -529,7 +545,6 @@
|
||||
.no-saves-image {
|
||||
width: 100px;
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@ -576,7 +591,6 @@
|
||||
.slot-character-icon {
|
||||
width: 32px;
|
||||
height: 37px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.slot-name {
|
||||
@ -655,7 +669,6 @@
|
||||
width: 40px;
|
||||
height: 46px;
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.tab-carousel-wrapper {
|
||||
|
||||
702
src/lib/ScenePlayerPage.svelte
Normal file
702
src/lib/ScenePlayerPage.svelte
Normal file
@ -0,0 +1,702 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { currentPage, scenePlayerEventId, scenePlayerData, memoryCompletions } from '../stores.js';
|
||||
import { AnimationTitles, AnimationObjectIds } from './multiplayer/animationCatalog.js';
|
||||
import { ActorDisplayNames } from '../core/savegame/actorConstants.js';
|
||||
import { navigateTo, encodeSceneData, formatDateTime } from '../core/navigation.js';
|
||||
import { API_URL } from '../core/config.js';
|
||||
import { getWdb } from '../core/wdbCache.js';
|
||||
import { getSIReader, decodeAnimIndex } from '../core/formats/SIParser.js';
|
||||
import { parseComposite } from '../core/formats/SICompositeParser.js';
|
||||
import { ScenePlayerRenderer } from '../core/rendering/ScenePlayerRenderer.js';
|
||||
import { SceneAudioPlayer } from '../core/sceneAudio.js';
|
||||
import { PhonemePlayer } from '../core/rendering/PhonemePlayer.js';
|
||||
import BackButton from './BackButton.svelte';
|
||||
import ShareLinkButton from './ShareLinkButton.svelte';
|
||||
|
||||
let loading = true;
|
||||
let error = null;
|
||||
let animIndex = null;
|
||||
let participants = [];
|
||||
let title = '';
|
||||
let sceneLanguage = 'en';
|
||||
let sceneTimestamp = null;
|
||||
let loadedFromServer = false;
|
||||
|
||||
// Playback state
|
||||
let renderer = null;
|
||||
let audioPlayer = null;
|
||||
let phonemePlayer = null;
|
||||
let playing = false;
|
||||
let elapsed = 0;
|
||||
let duration = 0;
|
||||
let muted = false;
|
||||
let ready = false;
|
||||
let canvasEl;
|
||||
let progressBarEl;
|
||||
let tickRaf = null;
|
||||
let loadGeneration = 0; // guard against stale async callbacks
|
||||
let seeking = false;
|
||||
let wasPlayingBeforeSeek = false;
|
||||
let audioBlocked = false;
|
||||
|
||||
$: if ($currentPage === 'scene-player') {
|
||||
startLoad();
|
||||
}
|
||||
|
||||
$: if ($currentPage !== 'scene-player') {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
onDestroy(cleanup);
|
||||
|
||||
function updateMetaUrls(url) {
|
||||
document.querySelector('link[rel="canonical"]')?.setAttribute('href', url);
|
||||
document.querySelector('meta[property="og:url"]')?.setAttribute('content', url);
|
||||
document.querySelector('meta[name="twitter:url"]')?.setAttribute('content', url);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
loadGeneration++;
|
||||
if (tickRaf) { cancelAnimationFrame(tickRaf); tickRaf = null; }
|
||||
phonemePlayer?.dispose();
|
||||
audioPlayer?.dispose();
|
||||
if (renderer) {
|
||||
renderer.animating = false;
|
||||
renderer.dispose();
|
||||
}
|
||||
renderer = null;
|
||||
audioPlayer = null;
|
||||
phonemePlayer = null;
|
||||
playing = false;
|
||||
ready = false;
|
||||
elapsed = 0;
|
||||
duration = 0;
|
||||
sceneLanguage = 'en';
|
||||
sceneTimestamp = null;
|
||||
loadedFromServer = false;
|
||||
audioBlocked = false;
|
||||
updateMetaUrls(window.location.origin + '/');
|
||||
}
|
||||
|
||||
function startLoad() {
|
||||
// Guard: don't load if we have no scene data yet (stores may not be set)
|
||||
if (!$scenePlayerEventId && !$scenePlayerData) return;
|
||||
loadScene();
|
||||
}
|
||||
|
||||
async function loadScene() {
|
||||
cleanup();
|
||||
const gen = ++loadGeneration;
|
||||
loading = true;
|
||||
error = null;
|
||||
animIndex = null;
|
||||
participants = [];
|
||||
title = '';
|
||||
|
||||
try {
|
||||
// Step 1: Resolve the completion record
|
||||
let record = null;
|
||||
|
||||
loadedFromServer = false;
|
||||
if ($scenePlayerData) {
|
||||
// Support both long keys (legacy) and short keys (new compact format)
|
||||
const d = $scenePlayerData;
|
||||
const rawParts = d.participants ?? d.p ?? [];
|
||||
record = {
|
||||
animIndex: d.animIndex ?? d.a,
|
||||
participants: rawParts.map(p => ({
|
||||
displayName: p.displayName ?? p.n,
|
||||
charIndex: p.charIndex ?? p.c
|
||||
})),
|
||||
language: d.language ?? d.l,
|
||||
t: d.t
|
||||
};
|
||||
} else if ($scenePlayerEventId) {
|
||||
const local = ($memoryCompletions || []).find(c => c.eventId === $scenePlayerEventId);
|
||||
if (local) {
|
||||
record = { animIndex: local.animIndex, participants: local.participants, language: local.language, t: local.t };
|
||||
} else {
|
||||
const res = await fetch(`${API_URL}/api/memory/${encodeURIComponent($scenePlayerEventId)}`);
|
||||
if (gen !== loadGeneration) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
record = { animIndex: data.animIndex, participants: data.participants, language: data.language, t: data.completedAt };
|
||||
loadedFromServer = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!record || record.animIndex == null) {
|
||||
error = 'Memory not found';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
animIndex = record.animIndex;
|
||||
participants = record.participants || [];
|
||||
sceneLanguage = record.language || record.l || 'en';
|
||||
sceneTimestamp = record.t || null;
|
||||
const language = sceneLanguage;
|
||||
title = AnimationTitles[animIndex] || `Animation #${animIndex}`;
|
||||
|
||||
// Step 2: Derive world slot and objectId
|
||||
const { worldSlot } = decodeAnimIndex(animIndex);
|
||||
const objectId = AnimationObjectIds[animIndex];
|
||||
if (objectId == null) {
|
||||
error = 'Unknown animation';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Load SI and WDB in parallel
|
||||
const [siReader, wdbData] = await Promise.all([
|
||||
getSIReader(worldSlot, language),
|
||||
getWdb(),
|
||||
]);
|
||||
if (gen !== loadGeneration) return;
|
||||
|
||||
// Step 4: Read the composite object from SI
|
||||
const siObject = await siReader.readObjectWithData(objectId);
|
||||
if (gen !== loadGeneration) return;
|
||||
if (!siObject) {
|
||||
error = 'Animation data not found in SI file';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Parse into SceneAnimData
|
||||
const sceneData = parseComposite(siObject);
|
||||
if (!sceneData) {
|
||||
error = 'Failed to parse animation data';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
duration = sceneData.duration;
|
||||
|
||||
// Step 6: Wait for canvas layout (loading stays true to keep overlay visible)
|
||||
ready = false;
|
||||
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
if (gen !== loadGeneration || !canvasEl) return;
|
||||
|
||||
// Step 7: Initialize renderer
|
||||
renderer = new ScenePlayerRenderer(canvasEl);
|
||||
renderer.loadScene(sceneData, participants, wdbData);
|
||||
|
||||
// Step 8: Initialize audio
|
||||
audioPlayer = new SceneAudioPlayer();
|
||||
await audioPlayer.init(sceneData.audioTracks);
|
||||
if (gen !== loadGeneration) return;
|
||||
|
||||
// Extend duration if audio extends beyond animation
|
||||
const audioEnd = audioPlayer.maxEndTime;
|
||||
if (audioEnd > duration) {
|
||||
duration = audioEnd;
|
||||
renderer.duration = duration;
|
||||
}
|
||||
|
||||
// Step 9: Initialize phoneme player
|
||||
phonemePlayer = new PhonemePlayer();
|
||||
phonemePlayer.init(sceneData.phonemeTracks, renderer.actorContainers, renderer.gl);
|
||||
|
||||
ready = true;
|
||||
|
||||
// Step 10: Check if audio is allowed, then auto-play or show overlay
|
||||
// Don't call resume() here — without a user gesture the promise hangs.
|
||||
// AudioContext.state is 'running' when autoplay is allowed, 'suspended' when blocked.
|
||||
loading = false;
|
||||
if (audioPlayer.canAutoplay) {
|
||||
doPlay();
|
||||
} else {
|
||||
audioPlayer.blocked = true;
|
||||
audioBlocked = true;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('[ScenePlayer] Load failed:', e);
|
||||
if (gen === loadGeneration) {
|
||||
error = e.message || 'Failed to load scene';
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function doPlay() {
|
||||
if (!renderer) return;
|
||||
playing = true;
|
||||
elapsed = 0;
|
||||
renderer.resetPlayback();
|
||||
renderer.play();
|
||||
await audioPlayer?.resume();
|
||||
startTick();
|
||||
}
|
||||
|
||||
function handleOverlayClick() {
|
||||
audioBlocked = false;
|
||||
doPlay();
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!renderer) return;
|
||||
|
||||
if (playing) {
|
||||
playing = false;
|
||||
renderer.pause();
|
||||
audioPlayer?.pause();
|
||||
} else {
|
||||
// If finished, restart from beginning
|
||||
if (renderer.finished) {
|
||||
renderer.resetPlayback();
|
||||
elapsed = 0;
|
||||
audioPlayer?.stop();
|
||||
phonemePlayer?.stop();
|
||||
}
|
||||
playing = true;
|
||||
renderer.play();
|
||||
audioPlayer?.resume();
|
||||
startTick();
|
||||
}
|
||||
}
|
||||
|
||||
function startTick() {
|
||||
if (tickRaf) return;
|
||||
const tick = () => {
|
||||
if (!renderer) { tickRaf = null; return; }
|
||||
|
||||
elapsed = renderer.elapsed;
|
||||
|
||||
if (playing) {
|
||||
audioPlayer?.tick(elapsed);
|
||||
phonemePlayer?.tick(elapsed);
|
||||
|
||||
if (renderer.finished) {
|
||||
playing = false;
|
||||
phonemePlayer?.stop();
|
||||
audioPlayer?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
tickRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
tickRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function onProgressPointerDown(e) {
|
||||
if (!renderer || !ready) return;
|
||||
e.preventDefault();
|
||||
|
||||
seeking = true;
|
||||
wasPlayingBeforeSeek = playing;
|
||||
|
||||
if (playing) {
|
||||
playing = false;
|
||||
renderer.pause();
|
||||
}
|
||||
audioPlayer?.pause();
|
||||
|
||||
seekToPosition(e);
|
||||
progressBarEl.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onProgressPointerMove(e) {
|
||||
if (!seeking) return;
|
||||
seekToPosition(e);
|
||||
}
|
||||
|
||||
function onProgressPointerUp(e) {
|
||||
if (!seeking) return;
|
||||
seeking = false;
|
||||
|
||||
seekToPosition(e);
|
||||
audioPlayer?.seek(elapsed);
|
||||
|
||||
if (wasPlayingBeforeSeek && !renderer.finished) {
|
||||
playing = true;
|
||||
renderer.play();
|
||||
audioPlayer?.resume();
|
||||
startTick();
|
||||
}
|
||||
}
|
||||
|
||||
function seekToPosition(e) {
|
||||
const rect = progressBarEl.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const seekTime = fraction * duration;
|
||||
|
||||
renderer.seek(seekTime);
|
||||
phonemePlayer?.seek(seekTime);
|
||||
elapsed = seekTime;
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted = !muted;
|
||||
if (audioPlayer) audioPlayer.muted = muted;
|
||||
}
|
||||
|
||||
function formatTime(ms) {
|
||||
const s = Math.floor(Math.max(0, ms) / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Is this memory confirmed available on the server?
|
||||
$: serverAvailable = loadedFromServer ||
|
||||
(!!$scenePlayerEventId && ($memoryCompletions || []).some(
|
||||
c => c.eventId === $scenePlayerEventId && c.synced
|
||||
));
|
||||
|
||||
$: shareUrl = animIndex != null
|
||||
? (serverAvailable && $scenePlayerEventId
|
||||
? `${window.location.origin}/memory/${$scenePlayerEventId}`
|
||||
: `${window.location.origin}/scene/${encodeSceneData(animIndex, participants, sceneLanguage, sceneTimestamp)}`)
|
||||
: null;
|
||||
|
||||
// Keep URL bar in sync with the shareable URL
|
||||
$: if ($currentPage === 'scene-player' && shareUrl && !loading) {
|
||||
const targetPath = new URL(shareUrl).pathname;
|
||||
if (window.location.pathname !== targetPath) {
|
||||
const newState = { page: 'scene-player', fromApp: history.state?.fromApp };
|
||||
if (serverAvailable && $scenePlayerEventId) {
|
||||
newState.eventId = $scenePlayerEventId;
|
||||
} else {
|
||||
newState.sceneData = encodeSceneData(animIndex, participants, sceneLanguage, sceneTimestamp);
|
||||
}
|
||||
history.replaceState(newState, '', targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep canonical / OG meta in sync so native share uses the correct URL
|
||||
$: if (shareUrl) {
|
||||
updateMetaUrls(shareUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page-content">
|
||||
<BackButton />
|
||||
<div class="page-inner-content scene-player-inner">
|
||||
{#if !error}
|
||||
<div class="scene-title-area">
|
||||
<h2 class="scene-title">{title || '\u00A0'}</h2>
|
||||
<div class="scene-participants">
|
||||
{#if participants.length > 0}
|
||||
{#each participants as p, idx}
|
||||
{#if idx > 0}<span class="sep">·</span>{/if}
|
||||
<span class="participant">{p.displayName}
|
||||
<span class="char-name">as {ActorDisplayNames[p.charIndex] || `#${p.charIndex}`}</span>
|
||||
</span>
|
||||
{/each}
|
||||
{#if sceneTimestamp}
|
||||
<span class="sep">·</span>
|
||||
<span class="scene-timestamp">{formatDateTime(sceneTimestamp)}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="scene-canvas-area" class:has-error={error}>
|
||||
{#if error}
|
||||
<div class="scene-error">
|
||||
<img src="images/callfail.webp" alt="" class="scene-error-image" />
|
||||
<p class="scene-error-title">{error}</p>
|
||||
<p class="scene-error-message">This memory may have been deleted or the link could be invalid. Try browsing existing memories or create new ones by playing with others!</p>
|
||||
<a href="#memories" class="scene-error-back" onclick={e => { e.preventDefault(); navigateTo('memories'); }}>Back to Memories</a>
|
||||
</div>
|
||||
{:else if $currentPage === 'scene-player'}
|
||||
<canvas bind:this={canvasEl} class="scene-canvas" class:dimmed={loading || audioBlocked}></canvas>
|
||||
{#if loading || audioBlocked}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="scene-overlay" onclick={audioBlocked ? handleOverlayClick : undefined}
|
||||
class:clickable={audioBlocked}>
|
||||
{#if audioBlocked}
|
||||
<div class="play-overlay-btn">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="currentColor" style="margin-left: 3px"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="spinner"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !error}
|
||||
<div class="scene-controls" class:disabled={!ready || audioBlocked}>
|
||||
<button class="ctrl-btn" onclick={togglePlay} title={playing ? 'Pause' : 'Play'}>
|
||||
{#if playing}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||||
{:else}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="progress-bar" class:seeking bind:this={progressBarEl}
|
||||
onpointerdown={onProgressPointerDown}
|
||||
onpointermove={onProgressPointerMove}
|
||||
onpointerup={onProgressPointerUp}>
|
||||
<div class="progress-fill" style="width: {duration > 0 ? Math.min(elapsed / duration * 100, 100) : 0}%"></div>
|
||||
</div>
|
||||
|
||||
<span class="time-display">{formatTime(elapsed)} / {formatTime(duration)}</span>
|
||||
|
||||
<button class="ctrl-btn" onclick={toggleMute} title={muted ? 'Unmute' : 'Mute'}>
|
||||
{#if muted}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11,5 6,9 2,9 2,15 6,15 11,19" fill="currentColor"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
|
||||
{:else}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11,5 6,9 2,9 2,15 6,15 11,19" fill="currentColor"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if shareUrl}
|
||||
<span class="controls-spacer"></span>
|
||||
<ShareLinkButton url={shareUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scene-player-inner {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scene-title-area {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.scene-participants {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.scene-participants .sep {
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
|
||||
.scene-participants .char-name {
|
||||
color: #777;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.scene-timestamp {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.scene-canvas-area {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scene-canvas-area.has-error {
|
||||
aspect-ratio: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.scene-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scene-canvas.dimmed {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.scene-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scene-overlay.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.play-overlay-btn {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.scene-overlay.clickable:hover .play-overlay-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #fff;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.scene-overlay.clickable:active .play-overlay-btn {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.scene-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.scene-error-image {
|
||||
width: 80px;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--color-border-medium);
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.scene-error-title {
|
||||
color: #ff6b6b;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.scene-error-message {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 20px 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.scene-error-back {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background-color: var(--color-primary);
|
||||
color: #000;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
|
||||
.scene-error-back:hover {
|
||||
background-color: #fff;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scene-error-back:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(transparent 55%, transparent 56%),
|
||||
conic-gradient(var(--color-primary, #FFD700) 0deg 90deg, var(--color-border-dark, #333) 90deg 360deg);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.scene-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.scene-controls.disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
background-clip: content-box;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #6af;
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.progress-bar.seeking .progress-fill {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
min-width: 5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.controls-spacer {
|
||||
flex: 0 0 1px;
|
||||
height: 16px;
|
||||
background: #333;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
</style>
|
||||
114
src/lib/ShareLinkButton.svelte
Normal file
114
src/lib/ShareLinkButton.svelte
Normal file
@ -0,0 +1,114 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let url;
|
||||
export let shareText = '';
|
||||
export let compact = false;
|
||||
|
||||
let feedback = '';
|
||||
let feedbackTimeout = null;
|
||||
|
||||
function showFeedback(msg) {
|
||||
feedback = msg;
|
||||
clearTimeout(feedbackTimeout);
|
||||
feedbackTimeout = setTimeout(() => { feedback = ''; }, 2000);
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
if (navigator.share && matchMedia('(pointer: coarse)').matches) {
|
||||
try {
|
||||
const data = { url };
|
||||
if (shareText) data.text = shareText;
|
||||
await navigator.share(data);
|
||||
showFeedback('Shared!');
|
||||
} catch { /* user cancelled */ }
|
||||
} else {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showFeedback('Copied!');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => clearTimeout(feedbackTimeout));
|
||||
</script>
|
||||
|
||||
<button class="share-link-btn" class:compact class:copied={feedback} onclick={handleShare} title="Share link">
|
||||
<!-- Ghost: always in flow, sets minimum width to widest state -->
|
||||
<span class="share-ghost" aria-hidden="true">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Copied!
|
||||
</span>
|
||||
<!-- Visible content overlaid -->
|
||||
<span class="share-visible">
|
||||
{#if feedback}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{feedback}
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
Share
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.share-link-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-medium);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.share-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.share-visible {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.share-link-btn:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.share-link-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.share-link-btn.copied {
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
border-color: rgba(74, 222, 128, 0.4);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.share-link-btn.compact {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
</style>
|
||||
261
src/lib/SignInModal.svelte
Normal file
261
src/lib/SignInModal.svelte
Normal file
@ -0,0 +1,261 @@
|
||||
<script>
|
||||
import { signInWithDiscord, signInAnonymously } from '../core/auth.js';
|
||||
import DiscordIcon from './icons/DiscordIcon.svelte';
|
||||
|
||||
export let open = false;
|
||||
export let onClose = () => {};
|
||||
|
||||
function handleBackdropClick(e) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (open && e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscord() {
|
||||
onClose();
|
||||
signInWithDiscord();
|
||||
}
|
||||
|
||||
async function handleGuest() {
|
||||
await signInAnonymously();
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="modal-panel">
|
||||
<button class="modal-close" onclick={onClose} aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="modal-header">
|
||||
<img class="character-avatar" src="images/register.webp" alt="Infomaniac" />
|
||||
<h2>Sign in to LEGO Island</h2>
|
||||
<p>Keep everything synced across devices</p>
|
||||
<div class="benefits-chips">
|
||||
<span class="benefit-chip">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 13V3h8v10H4z"/><path d="M7 3V1h2v2"/><path d="M6 6h4M6 8.5h4"/>
|
||||
</svg>
|
||||
Saves
|
||||
</span>
|
||||
<span class="benefit-chip">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
Settings
|
||||
</span>
|
||||
<span class="benefit-chip">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="6.5" r="3"/><circle cx="10.5" cy="9" r="3"/>
|
||||
</svg>
|
||||
Multiplayer
|
||||
</span>
|
||||
<span class="benefit-chip">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 1.5l1.8 3.7 4 .6-2.9 2.8.7 4L8 10.8l-3.6 1.8.7-4-2.9-2.8 4-.6z"/>
|
||||
</svg>
|
||||
Memories
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<button class="provider-button discord" onclick={handleDiscord}>
|
||||
<DiscordIcon fill="#fff" />
|
||||
Continue with Discord
|
||||
</button>
|
||||
|
||||
<div class="divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<button class="guest-button" onclick={handleGuest}>
|
||||
Continue as Guest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-dark);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 32px 28px;
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 48px);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.modal-header p {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.character-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
margin: 0 auto 14px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
|
||||
.benefits-chips {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.benefit-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
border: 1px solid rgba(255, 215, 0, 0.15);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 215, 0, 0.7);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.benefit-chip svg {
|
||||
flex-shrink: 0;
|
||||
stroke: rgba(255, 215, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.provider-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.provider-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.provider-button.discord {
|
||||
background: #5865F2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.guest-button {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 11px 16px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.guest-button:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
</style>
|
||||
@ -90,3 +90,25 @@
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Pizza celebration animation for OGEL mode */
|
||||
:global(.pizza-slice) {
|
||||
position: fixed;
|
||||
font-size: 32px;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
animation: pizza-fly 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes pizza-fly {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(0deg) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
138
src/lib/WhatsNewBanner.svelte
Normal file
138
src/lib/WhatsNewBanner.svelte
Normal file
@ -0,0 +1,138 @@
|
||||
<script>
|
||||
import { navigateTo } from '../core/navigation.js';
|
||||
import { currentPage, gameRunning } from '../stores.js';
|
||||
|
||||
const WHATS_NEW_MESSAGE = 'Multiplayer is here!';
|
||||
const STORAGE_KEY = 'whats-new-dismissed';
|
||||
|
||||
function djb2(str) {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
const currentHash = djb2(WHATS_NEW_MESSAGE);
|
||||
|
||||
let visible = true;
|
||||
try {
|
||||
visible = localStorage.getItem(STORAGE_KEY) !== currentHash;
|
||||
} catch {}
|
||||
|
||||
let dismissing = false;
|
||||
|
||||
function persistDismiss() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, currentHash);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
persistDismiss();
|
||||
dismissing = true;
|
||||
}
|
||||
|
||||
function dismissImmediate() {
|
||||
persistDismiss();
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function handleAnimationEnd() {
|
||||
if (dismissing) {
|
||||
visible = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible && $currentPage === 'main' && !$gameRunning}
|
||||
<div class="whats-new-banner" class:dismissing onanimationend={handleAnimationEnd}>
|
||||
<span class="banner-label">New</span>
|
||||
<span class="banner-message">{WHATS_NEW_MESSAGE} <a href="#multiplayer" class="banner-link" onclick={(e) => { e.preventDefault(); dismissImmediate(); navigateTo('multiplayer'); }}>Create an island and play with friends.</a></span>
|
||||
<button class="banner-dismiss" aria-label="Dismiss" onclick={dismiss}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.whats-new-banner {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-panel);
|
||||
border: 1px solid rgba(255, 215, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.whats-new-banner.dismissing {
|
||||
animation: fade-out 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.banner-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #1a1a1a;
|
||||
background: var(--color-primary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.banner-message {
|
||||
color: var(--color-text-medium);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.banner-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.banner-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.banner-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.banner-dismiss:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.whats-new-banner {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
transform: none;
|
||||
padding: 5px 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.banner-message {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import ImageButton from '../ImageButton.svelte';
|
||||
import { installState, swRegistration, currentPage } from '../../stores.js';
|
||||
import { installState, swRegistration } from '../../stores.js';
|
||||
|
||||
export let opfsDisabled;
|
||||
export let openSection;
|
||||
@ -10,12 +10,6 @@
|
||||
export let handleUninstall;
|
||||
|
||||
$: progressAngle = ($installState.progress / 100) * 360;
|
||||
|
||||
function navigateToSaveEditor(e) {
|
||||
e.preventDefault();
|
||||
history.pushState({ page: 'save-editor' }, '', '#save-editor');
|
||||
currentPage.set('save-editor');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="config-tab-panel active" id="config-tab-extras">
|
||||
@ -43,6 +37,10 @@
|
||||
<label><input type="checkbox" id="check-ending" name="Extended Bad Ending FMV" data-not-ini="true" disabled={opfsDisabled} onchange={handleExtensionChange}><span class="toggle-slider"></span><span class="toggle-label">Extended Bad Ending FMV <span class="toggle-badge">+20MB</span></span></label>
|
||||
<span class="tooltip-trigger">?<span class="tooltip-content">Plays the extended / "uncut" Bad Ending animation as found in beta versions of the game upon failing to catch the Brickster.</span></span>
|
||||
</div>
|
||||
<div class="toggle-switch">
|
||||
<label><input type="checkbox" id="check-third-person-camera" name="Third Person Camera" data-not-ini="true" disabled={opfsDisabled} onchange={handleExtensionChange}><span class="toggle-slider"></span><span class="toggle-label">Third-person Camera <span class="toggle-badge-experimental"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3L12 14L15 3"/><path d="M6 3H18"/><path d="M5 21H19L17 8H7L5 21Z"/></svg> Experimental</span></span></label>
|
||||
<span class="tooltip-trigger">?<span class="tooltip-content">Switches to a third-person camera that follows your character around LEGO Island.</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -82,7 +80,66 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-section-card">
|
||||
<a href="#save-editor" class="config-card-header nav-link" onclick={navigateToSaveEditor}>Save Editor</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.offline-note {
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.offline-play-controls .offline-error {
|
||||
color: var(--color-primary);
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.offline-play-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.offline-play-text p {
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.offline-play-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.progress-circular {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(var(--color-bg-input) 60%, transparent 61%),
|
||||
conic-gradient(var(--color-primary) 0deg, var(--color-border-dark) 0deg);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-light);
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
font-family: 'Consolas', 'Menlo', monospace;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.offline-play-grid {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.offline-play-text p {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
8
src/lib/icons/DiscordIcon.svelte
Normal file
8
src/lib/icons/DiscordIcon.svelte
Normal file
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
export let size = 20;
|
||||
export let fill = '#5865F2';
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03z" {fill}/>
|
||||
</svg>
|
||||
208
src/lib/multiplayer/AnimationLegend.svelte
Normal file
208
src/lib/multiplayer/AnimationLegend.svelte
Normal file
@ -0,0 +1,208 @@
|
||||
<script>
|
||||
export let onDismiss = () => {};
|
||||
</script>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-body">
|
||||
<div class="legend-header">
|
||||
<img class="legend-avatar" src="images/infoface.webp" alt="Infomaniac" />
|
||||
<span class="legend-intro">Animations are scenes you perform with other players. Completed ones are saved to <a href="#memories" target="_blank" class="legend-link">Nick Brick's Memories</a> — the progress bar tracks your area completion.</span>
|
||||
</div>
|
||||
|
||||
<div class="legend-section desktop-only">
|
||||
<div class="legend-row">
|
||||
<span class="row-desc">Tabs: <strong>area</strong> = location scenes, <strong>act</strong> = character interactions.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-section">
|
||||
<div class="legend-row eligible-ex">
|
||||
<span class="row-bar eligible-bar"></span>
|
||||
<span class="row-desc"><strong>Green</strong> — You can start this</span>
|
||||
</div>
|
||||
<div class="legend-row joinable-ex">
|
||||
<span class="row-bar joinable-bar"></span>
|
||||
<span class="row-desc"><strong>Blue pulse</strong> — Someone started it, join in</span>
|
||||
</div>
|
||||
<div class="legend-row gathering-ex">
|
||||
<span class="row-bar gathering-bar"></span>
|
||||
<span class="row-desc"><strong>Yellow</strong> — You joined, waiting for others</span>
|
||||
</div>
|
||||
<div class="legend-divider"></div>
|
||||
<div class="legend-row">
|
||||
<span class="dots-example">
|
||||
<span class="dot"></span>
|
||||
<span class="dot filled"></span>
|
||||
</span>
|
||||
<span class="row-desc">Dots = roles. <span class="dot-filled-text">Green</span> = a player can fill it</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<span class="checkmark">✓</span>
|
||||
<span class="row-desc">You've completed this before</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="dismiss-btn" onclick={onDismiss}>Got it!</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.legend {
|
||||
padding: 8px 10px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.legend-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.legend-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.legend-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1.5px solid rgba(255, 215, 0, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-intro {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.legend-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.legend-section {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.legend {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-divider {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.row-bar {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.eligible-bar { background: rgba(76, 175, 80, 0.7); }
|
||||
.joinable-bar { background: rgba(100, 181, 246, 0.85); }
|
||||
.gathering-bar { background: rgba(255, 193, 7, 0.75); }
|
||||
|
||||
.joinable-ex {
|
||||
animation: legend-joinable-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes legend-joinable-pulse {
|
||||
0%, 100% { background: rgba(100, 181, 246, 0.04); }
|
||||
50% { background: rgba(100, 181, 246, 0.1); }
|
||||
}
|
||||
|
||||
.row-desc {
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.row-desc strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dots-example {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.dot.filled {
|
||||
background: rgba(76, 175, 80, 0.7);
|
||||
border-color: rgba(76, 175, 80, 0.85);
|
||||
}
|
||||
|
||||
.dot-filled-text {
|
||||
color: rgba(76, 175, 80, 0.85);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
color: rgba(76, 175, 80, 0.7);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: var(--color-primary-surface);
|
||||
color: var(--color-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dismiss-btn:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
354
src/lib/multiplayer/AnimationPanel.svelte
Normal file
354
src/lib/multiplayer/AnimationPanel.svelte
Normal file
@ -0,0 +1,354 @@
|
||||
<script>
|
||||
import { flip } from 'svelte/animate';
|
||||
import { CharacterNameMap } from '../../core/savegame/actorConstants.js';
|
||||
import { AnimationTitles } from './animationCatalog.js';
|
||||
import { memoryUnlocks } from '../../stores.js';
|
||||
|
||||
export let animations = [];
|
||||
export let currentInterest = null;
|
||||
export let pendingInterest = -1;
|
||||
export let onToggleInterest = () => {};
|
||||
export let isMobile = false;
|
||||
export let scrollContainer = null;
|
||||
|
||||
// Build sort order from CharacterNameMap keys (same order as g_characters[])
|
||||
const charSortOrder = Object.fromEntries(Object.keys(CharacterNameMap).map((name, i) => [name, i]));
|
||||
|
||||
function missingCount(anim) {
|
||||
return anim.slots.filter(s => !s.filled).length;
|
||||
}
|
||||
|
||||
// Lowest g_characters index across all slot names (for grouping by character)
|
||||
function charOrder(anim) {
|
||||
let best = 9999;
|
||||
for (const slot of anim.slots) {
|
||||
for (const name of slot.names) {
|
||||
const order = charSortOrder[name];
|
||||
if (order !== undefined && order < best) best = order;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function hasActiveSession(anim) {
|
||||
return anim.sessionState > 0; // gathering, countdown, or playing
|
||||
}
|
||||
|
||||
$: sorted = [...animations].sort((a, b) => {
|
||||
// Active sessions (gathering/countdown/playing) always on top
|
||||
const aActive = hasActiveSession(a), bActive = hasActiveSession(b);
|
||||
if (aActive !== bActive) return aActive ? -1 : 1;
|
||||
if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
|
||||
const ma = missingCount(a), mb = missingCount(b);
|
||||
if (ma !== mb) return ma - mb;
|
||||
if (a.slots.length !== b.slots.length) return a.slots.length - b.slots.length;
|
||||
return charOrder(a) - charOrder(b);
|
||||
});
|
||||
|
||||
function formatNeeds(slots) {
|
||||
const named = [];
|
||||
let anyCount = 0;
|
||||
for (const s of slots) {
|
||||
if (s.filled) continue;
|
||||
if (s.names.length === 1 && s.names[0] === 'any') {
|
||||
anyCount++;
|
||||
} else {
|
||||
named.push(s.names.map(n => CharacterNameMap[n] || n).join(' or '));
|
||||
}
|
||||
}
|
||||
if (anyCount) named.push(anyCount === 1 ? '+1 player' : `+${anyCount} players`);
|
||||
return named.join(', ');
|
||||
}
|
||||
|
||||
function formatWaiting(anim) {
|
||||
const unfilled = anim.slots.filter(s => !s.filled);
|
||||
const parts = [];
|
||||
let anyCount = 0;
|
||||
for (const slot of unfilled) {
|
||||
if (slot.names.length === 1 && slot.names[0] === 'any') {
|
||||
anyCount++;
|
||||
} else {
|
||||
parts.push(slot.names.map(n => CharacterNameMap[n] || n).join(' or '));
|
||||
}
|
||||
}
|
||||
if (anyCount) parts.push(anyCount === 1 ? '1 more player' : `${anyCount} more players`);
|
||||
return `Waiting for ${parts.join(', ')}...`;
|
||||
}
|
||||
|
||||
function isInterested(anim) {
|
||||
return currentInterest === anim.animIndex || pendingInterest === anim.animIndex;
|
||||
}
|
||||
|
||||
function isClickDisabled(anim) {
|
||||
if (anim.sessionState === 3) return true; // playing
|
||||
if (anim.localInSession) return false; // can always cancel own interest
|
||||
if (anim.sessionState >= 1 && !anim.canJoin) return true; // no available slot
|
||||
return false;
|
||||
}
|
||||
|
||||
let listEl;
|
||||
|
||||
function handleClick(anim) {
|
||||
const joining = !isInterested(anim);
|
||||
onToggleInterest(anim.animIndex);
|
||||
// Scroll to top when joining so the user follows the item as it moves up
|
||||
const el = scrollContainer || listEl;
|
||||
if (joining && el) {
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
// Refocus the game canvas so arrow keys don't scroll the list
|
||||
document.getElementById('canvas')?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="anim-panel" onfocusin={() => document.getElementById('canvas')?.focus()}>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="anim-list" tabindex="-1" bind:this={listEl}>
|
||||
{#each sorted as anim (anim.animIndex)}
|
||||
<button class="anim-row" tabindex="-1" animate:flip={{ duration: 250 }}
|
||||
class:eligible={anim.eligible && anim.sessionState === 0}
|
||||
class:interested={isInterested(anim)}
|
||||
class:dimmed={!anim.eligible && !anim.atLocation && anim.sessionState === 0}
|
||||
class:gathering={anim.sessionState === 1}
|
||||
class:countdown={anim.sessionState === 2}
|
||||
class:playing={anim.sessionState === 3}
|
||||
class:joinable={anim.sessionState >= 1 && anim.canJoin && !anim.localInSession}
|
||||
disabled={isClickDisabled(anim)}
|
||||
onclick={() => handleClick(anim)}>
|
||||
<div class="row-left">
|
||||
<span class="anim-name-row">
|
||||
<span class="anim-name">{AnimationTitles[anim.animIndex] || anim.name}</span>
|
||||
{#if $memoryUnlocks.has(anim.animIndex)}<span class="unlocked-mark" title="Memory unlocked">✓</span>{/if}
|
||||
</span>
|
||||
{#if anim.sessionState === 3 && anim.localInSession}
|
||||
<span class="anim-sub playing-text">Playing...</span>
|
||||
{:else if anim.sessionState === 2 && anim.localInSession}
|
||||
<span class="anim-sub countdown-text">Starting...</span>
|
||||
{:else if anim.sessionState === 1 && anim.localInSession}
|
||||
<span class="anim-sub gathering-text">{formatWaiting(anim)}</span>
|
||||
{:else if anim.sessionState >= 1 && !anim.canJoin}
|
||||
<span class="anim-sub full-text">Roles filled</span>
|
||||
{:else if anim.sessionState >= 1 && anim.canJoin}
|
||||
<span class="anim-sub join-text">Join!</span>
|
||||
{:else if anim.eligible}
|
||||
<span class="anim-sub ready-text">{isMobile ? 'Tap to start' : 'Click to start'}</span>
|
||||
{:else if anim.atLocation}
|
||||
<span class="anim-sub needs-text">{formatNeeds(anim.slots)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="slot-dots">
|
||||
{#each [...anim.slots].sort((a, b) => (b.filled ? 1 : 0) - (a.filled ? 1 : 0)) as slot}
|
||||
<span class="dot" class:filled={slot.filled}></span>
|
||||
{/each}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="empty">Explore the island to discover scenes</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.anim-panel {
|
||||
width: 300px;
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.anim-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
|
||||
}
|
||||
|
||||
.anim-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.anim-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.anim-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.anim-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-left-color 0.2s ease, opacity 0.2s ease;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.anim-row + .anim-row {
|
||||
border-top: 1px solid var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.anim-row:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.anim-row.eligible {
|
||||
border-left-color: rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.anim-row.interested {
|
||||
border-left-color: var(--color-primary);
|
||||
background: rgba(255, 215, 0, 0.06);
|
||||
}
|
||||
|
||||
.anim-row.dimmed {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.anim-row.gathering {
|
||||
border-left-color: rgba(255, 193, 7, 0.6);
|
||||
}
|
||||
|
||||
.anim-row.countdown {
|
||||
border-left-color: rgba(255, 152, 0, 0.7);
|
||||
}
|
||||
|
||||
.anim-row.playing {
|
||||
border-left-color: rgba(0, 188, 212, 0.7);
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.anim-row.joinable {
|
||||
border-left-color: rgba(100, 181, 246, 0.7);
|
||||
background: rgba(100, 181, 246, 0.06);
|
||||
animation: joinable-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.anim-row:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.anim-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anim-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.unlocked-mark {
|
||||
color: rgba(76, 175, 80, 0.7);
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.anim-sub {
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ready-text {
|
||||
color: rgba(76, 175, 80, 0.85);
|
||||
}
|
||||
|
||||
.needs-text {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.gathering-text {
|
||||
color: rgba(255, 193, 7, 0.85);
|
||||
}
|
||||
|
||||
.countdown-text {
|
||||
color: rgba(255, 152, 0, 0.9);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.join-text {
|
||||
color: rgba(100, 181, 246, 0.95);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.full-text {
|
||||
color: rgba(255, 107, 107, 0.6);
|
||||
}
|
||||
|
||||
.playing-text {
|
||||
color: rgba(0, 188, 212, 0.85);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes joinable-pulse {
|
||||
0%, 100% { background: rgba(100, 181, 246, 0.04); }
|
||||
50% { background: rgba(100, 181, 246, 0.1); }
|
||||
}
|
||||
|
||||
.slot-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.dot.filled {
|
||||
background: rgba(76, 175, 80, 0.7);
|
||||
border-color: rgba(76, 175, 80, 0.85);
|
||||
}
|
||||
|
||||
.interested .dot.filled {
|
||||
background: rgba(255, 215, 0, 0.7);
|
||||
border-color: rgba(255, 215, 0, 0.85);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px; text-align: center;
|
||||
font-size: 11px; color: var(--color-text-muted); opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
184
src/lib/multiplayer/AnimationTabs.svelte
Normal file
184
src/lib/multiplayer/AnimationTabs.svelte
Normal file
@ -0,0 +1,184 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { bestAnimTab } from './constants.js';
|
||||
import AnimationLegend from './AnimationLegend.svelte';
|
||||
|
||||
export let animations = [];
|
||||
export let animTab = 'scene';
|
||||
export let onTabChange = (tab) => { animTab = tab; };
|
||||
export let onFilteredChange = () => {};
|
||||
export let showLegend = false;
|
||||
export let onToggleLegend = () => {};
|
||||
export let clusterProgress = null;
|
||||
|
||||
$: activeProgress = clusterProgress ? clusterProgress[animTab] : null;
|
||||
$: progressPct = activeProgress
|
||||
? (activeProgress.total > 0 ? (activeProgress.unlocked / activeProgress.total * 100) : 0)
|
||||
: 0;
|
||||
|
||||
$: sceneAnims = animations.filter(a => a.category === 1);
|
||||
$: npcAnims = animations.filter(a => a.category === 0);
|
||||
$: filteredAnims = animTab === 'scene' ? sceneAnims : npcAnims;
|
||||
$: onFilteredChange(filteredAnims);
|
||||
|
||||
function selectBestTab() {
|
||||
const best = bestAnimTab(sceneAnims, npcAnims, animTab);
|
||||
if (best !== animTab) onTabChange(best);
|
||||
}
|
||||
|
||||
onMount(() => selectBestTab());
|
||||
</script>
|
||||
|
||||
<div class="anim-tabs-wrapper">
|
||||
<div class="anim-tabs">
|
||||
<button class="anim-tab" class:active={animTab === 'scene' && !showLegend}
|
||||
onclick={() => { if (showLegend) onToggleLegend(); onTabChange('scene'); }}>
|
||||
{clusterProgress ? clusterProgress.scene.label : 'Scene'}{#if sceneAnims.length} ({sceneAnims.length}){/if}
|
||||
</button>
|
||||
<button class="anim-tab" class:active={animTab === 'act' && !showLegend}
|
||||
onclick={() => { if (showLegend) onToggleLegend(); onTabChange('act'); }}>
|
||||
Act{#if npcAnims.length} ({npcAnims.length}){/if}
|
||||
</button>
|
||||
<button class="legend-btn" class:active={showLegend}
|
||||
onclick={onToggleLegend} title="Help">
|
||||
<img class="legend-btn-img" src="images/infosign.webp" alt="?" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="legend-panel" class:hidden={!showLegend}>
|
||||
<AnimationLegend onDismiss={onToggleLegend} />
|
||||
</div>
|
||||
<div class="content-panel" class:hidden={showLegend}>
|
||||
{#if activeProgress}
|
||||
<div class="cluster-progress-row"
|
||||
title="{activeProgress.unlocked}/{activeProgress.total} memories unlocked">
|
||||
<div class="cluster-progress">
|
||||
<div class="cluster-progress-fill" style="width: {progressPct}%"></div>
|
||||
</div>
|
||||
<span class="cluster-count">{activeProgress.unlocked}/{activeProgress.total}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<slot {filteredAnims} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.anim-tabs-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.legend-panel, .content-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.anim-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cluster-progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-top: -2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cluster-progress {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--color-border-dark);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cluster-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.cluster-count {
|
||||
font-family: 'Consolas', 'Menlo', monospace;
|
||||
font-size: 9px;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anim-tab {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: var(--color-surface-subtle);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.anim-tab:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.anim-tab.active {
|
||||
background: var(--color-primary-surface);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.legend-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-surface-subtle);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
align-self: center;
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.legend-btn-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.legend-btn:hover {
|
||||
border-color: var(--color-primary-border);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-btn.active {
|
||||
border-color: var(--color-primary-border);
|
||||
box-shadow: 0 0 0 1px var(--color-primary-border);
|
||||
}
|
||||
</style>
|
||||
105
src/lib/multiplayer/CountdownOverlay.svelte
Normal file
105
src/lib/multiplayer/CountdownOverlay.svelte
Normal file
@ -0,0 +1,105 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { AnimationTitles } from './animationCatalog.js';
|
||||
|
||||
export let animations = [];
|
||||
|
||||
// Find the animation the local player is counting down for
|
||||
$: countdownAnim = animations.find(a => a.sessionState === 2 && a.localInSession) || null;
|
||||
|
||||
// Local countdown end-time, synced from server
|
||||
let endTime = 0;
|
||||
let displayNumber = 0;
|
||||
let animKey = 0; // toggles to retrigger CSS animation
|
||||
|
||||
$: if (countdownAnim) {
|
||||
const serverEnd = Date.now() + countdownAnim.countdownMs;
|
||||
if (!endTime || Math.abs(endTime - serverEnd) > 500) {
|
||||
endTime = serverEnd;
|
||||
}
|
||||
} else {
|
||||
endTime = 0;
|
||||
displayNumber = 0;
|
||||
}
|
||||
|
||||
let tickInterval;
|
||||
function tick() {
|
||||
if (!endTime) return;
|
||||
const remaining = Math.max(0, endTime - Date.now());
|
||||
const num = Math.ceil(remaining / 1000);
|
||||
if (num !== displayNumber) {
|
||||
displayNumber = num;
|
||||
animKey = 1 - animKey; // toggle to retrigger animation
|
||||
}
|
||||
}
|
||||
|
||||
tickInterval = setInterval(tick, 100);
|
||||
onDestroy(() => clearInterval(tickInterval));
|
||||
|
||||
$: animName = countdownAnim
|
||||
? (AnimationTitles[countdownAnim.animIndex] || countdownAnim.name || '')
|
||||
: '';
|
||||
</script>
|
||||
|
||||
{#if countdownAnim && displayNumber > 0}
|
||||
<div class="countdown-overlay" out:fade={{ duration: 200 }}>
|
||||
{#key animKey}
|
||||
<span class="countdown-number">{displayNumber}</span>
|
||||
{/key}
|
||||
{#if animName}
|
||||
<span class="countdown-label" in:fade={{ duration: 300 }}>{animName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.countdown-overlay {
|
||||
position: fixed;
|
||||
top: 25%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1001;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 16px;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
font-family: Arial, sans-serif;
|
||||
color: var(--color-primary);
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 215, 0, 0.5),
|
||||
0 0 40px rgba(255, 215, 0, 0.2),
|
||||
0 2px 8px rgba(0, 0, 0, 0.6);
|
||||
line-height: 1;
|
||||
animation: countdown-pulse 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: Arial, sans-serif;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes countdown-pulse {
|
||||
0% {
|
||||
transform: scale(1.4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user