From 397b428a4484f3b4741482a9364944c338741375 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Fri, 6 Mar 2026 00:00:37 +0200 Subject: [PATCH 1/7] Fix Fibery branch mapping for commit export --- .github/workflows/fibery-commit-export.yml | 55 +++ scripts/fibery/export-commits-to-fibery.mjs | 402 ++++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 .github/workflows/fibery-commit-export.yml create mode 100644 scripts/fibery/export-commits-to-fibery.mjs diff --git a/.github/workflows/fibery-commit-export.yml b/.github/workflows/fibery-commit-export.yml new file mode 100644 index 00000000..ac118e9b --- /dev/null +++ b/.github/workflows/fibery-commit-export.yml @@ -0,0 +1,55 @@ +name: Fibery Commit Export + +on: + push: + branches: + - dev + - main + +jobs: + export-commits: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Export commits to Fibery + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FIBERY_TOKEN: ${{ secrets.FIBERY_TOKEN }} + FIBERY_HOST: vsoft.fibery.io + + # Type names for Blue Signal Game space + FIBERY_COMMIT_TYPE: Blue Signal Game/Commit + FIBERY_BRANCH_TYPE: Blue Signal Game/Branch_Blue Signal Game/Commit + FIBERY_REPO_TYPE: Blue Signal Game/Repository_Blue Signal Game/Commit + + # Commit fields + FIBERY_FIELD_COMMIT_SHA: Blue Signal Game/SHA + FIBERY_FIELD_COMMIT_MESSAGE: Blue Signal Game/Message + FIBERY_FIELD_AUTHOR_NAME: Blue Signal Game/Author Name + FIBERY_FIELD_AUTHOR_EMAIL: Blue Signal Game/Author Email + FIBERY_FIELD_COMMIT_DATE: Blue Signal Game/Commit Date + FIBERY_FIELD_GITHUB_LINK: Blue Signal Game/GitHub Link + FIBERY_FIELD_PARENT_SHAS: Blue Signal Game/Parent SHAs + FIBERY_FIELD_COMMIT_NAME: Blue Signal Game/Name + FIBERY_FIELD_BRANCH_REL: Blue Signal Game/Branch + FIBERY_FIELD_REPO_REL: Blue Signal Game/Repository + + # Related enum-like relation values + FIBERY_FIELD_RELATION_NAME: enum/name + FIBERY_REPO_VALUE: Main Repository + + # Robustness + FIBERY_BATCH_SIZE: 25 + FIBERY_RETRY_MAX: 3 + FIBERY_RETRY_BASE_MS: 250 + run: node scripts/fibery/export-commits-to-fibery.mjs diff --git a/scripts/fibery/export-commits-to-fibery.mjs b/scripts/fibery/export-commits-to-fibery.mjs new file mode 100644 index 00000000..f523136a --- /dev/null +++ b/scripts/fibery/export-commits-to-fibery.mjs @@ -0,0 +1,402 @@ +#!/usr/bin/env node + +/** + * Export commits to Fibery with robust error handling. + * + * Features: + * - Upsert by SHA + * - Batch create/update commands (default batch size: 25) + * - Retry only failed commands with exponential backoff + * - Detailed per-command error logging + * - Source SHAs from push event, COMMIT_SHAS env, or local git log backfill + */ + +import fs from 'node:fs'; +import { execSync } from 'node:child_process'; + +const fiberyHost = process.env.FIBERY_HOST || 'vsoft.fibery.io'; +const fiberyToken = process.env.FIBERY_TOKEN; +if (!fiberyToken) throw new Error('Missing required env: FIBERY_TOKEN'); +const fiberyApi = `https://${fiberyHost}/api/commands`; + +const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''; +const githubRepo = process.env.GITHUB_REPOSITORY || 'z-hunter/Quest'; +const githubRef = process.env.GITHUB_REF || ''; +const gitBranch = githubRef.startsWith('refs/heads/') + ? githubRef.replace('refs/heads/', '') + : safeExec('git rev-parse --abbrev-ref HEAD') || 'dev'; + +const commitType = process.env.FIBERY_COMMIT_TYPE || 'Blue Signal Game/Commit'; +const branchType = + process.env.FIBERY_BRANCH_TYPE || 'Blue Signal Game/Branch_Blue Signal Game/Commit'; +const repoType = + process.env.FIBERY_REPO_TYPE || 'Blue Signal Game/Repository_Blue Signal Game/Commit'; + +const fields = { + commitSha: process.env.FIBERY_FIELD_COMMIT_SHA || 'Blue Signal Game/SHA', + commitMessage: process.env.FIBERY_FIELD_COMMIT_MESSAGE || 'Blue Signal Game/Message', + authorName: process.env.FIBERY_FIELD_AUTHOR_NAME || 'Blue Signal Game/Author Name', + authorEmail: process.env.FIBERY_FIELD_AUTHOR_EMAIL || 'Blue Signal Game/Author Email', + commitDate: process.env.FIBERY_FIELD_COMMIT_DATE || 'Blue Signal Game/Commit Date', + githubLink: process.env.FIBERY_FIELD_GITHUB_LINK || 'Blue Signal Game/GitHub Link', + parentShas: process.env.FIBERY_FIELD_PARENT_SHAS || 'Blue Signal Game/Parent SHAs', + commitName: process.env.FIBERY_FIELD_COMMIT_NAME || 'Blue Signal Game/Name', + branchRel: process.env.FIBERY_FIELD_BRANCH_REL || 'Blue Signal Game/Branch', + repoRel: process.env.FIBERY_FIELD_REPO_REL || 'Blue Signal Game/Repository', + relationName: process.env.FIBERY_FIELD_RELATION_NAME || 'enum/name', +}; + +const branchValueOverride = process.env.FIBERY_BRANCH_VALUE || ''; +const repoValue = process.env.FIBERY_REPO_VALUE || 'Main Repository'; + +const batchSize = parseInt(process.env.FIBERY_BATCH_SIZE || '25', 10); +const retryMax = parseInt(process.env.FIBERY_RETRY_MAX || '3', 10); +const retryBaseMs = parseInt(process.env.FIBERY_RETRY_BASE_MS || '250', 10); +const backfillCount = parseInt(process.env.BACKFILL_COUNT || '0', 10); + +function mapBranchToFibery(branch) { + if (branch === 'dev' || branch === 'develop') return 'develop'; + if (branch === 'main' || branch === 'master') return 'main'; + return 'feature'; +} + +function safeExec(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeFiberyResponse(data) { + if (Array.isArray(data)) return data; + return [data]; +} + +async function postFibery(commands) { + const res = await fetch(fiberyApi, { + method: 'POST', + headers: { + Authorization: `Token ${fiberyToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(commands), + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`Fibery HTTP ${res.status}: ${text}`); + } + + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`Fibery response is not JSON: ${text}`); + } + return normalizeFiberyResponse(parsed); +} + +async function runFiberySingle(commandObj, label) { + const out = await postFibery([commandObj]); + const row = out[0]; + if (!row?.success) { + const errText = row?.error ? JSON.stringify(row.error) : JSON.stringify(row); + throw new Error(`${label}: ${errText}`); + } + return row.result; +} + +async function runCommandsInBatches(commandItems) { + let success = 0; + let failed = 0; + + for (let i = 0; i < commandItems.length; i += batchSize) { + const chunk = commandItems.slice(i, i + batchSize); + const payload = chunk.map((x) => x.command); + + let responseRows; + try { + responseRows = await postFibery(payload); + } catch (e) { + console.warn(`WARN: batch ${i}-${i + chunk.length - 1} failed: ${e.message}`); + responseRows = new Array(chunk.length).fill(null).map(() => ({ + success: false, + error: { message: e.message }, + })); + } + + for (let idx = 0; idx < chunk.length; idx++) { + const item = chunk[idx]; + const row = responseRows[idx]; + if (row?.success) { + success++; + continue; + } + console.warn(`WARN: initial failure for ${item.label}: ${JSON.stringify(row)}`); + + let attempt = 0; + let ok = false; + while (attempt < retryMax && !ok) { + attempt++; + const delayMs = retryBaseMs * Math.pow(2, attempt - 1); + await sleep(delayMs); + try { + await runFiberySingle(item.command, item.label); + ok = true; + success++; + } catch (err) { + if (attempt >= retryMax) { + failed++; + console.warn(`WARN: ${item.label} failed after ${attempt} attempts: ${err.message}`); + } + } + } + } + } + + return { success, failed }; +} + +async function fiberyQueryOne(typeName, whereField, value) { + const out = await postFibery([ + { + command: 'fibery.entity/query', + args: { + query: { + 'q/from': typeName, + 'q/select': ['fibery/id', whereField], + 'q/where': ['=', [whereField], '$v'], + 'q/limit': 1, + }, + params: { $v: value }, + }, + }, + ]); + const row = out[0]; + if (!row?.success) { + const errText = row?.error ? JSON.stringify(row.error) : 'Unknown Fibery query error'; + throw new Error(`Query failed (${typeName}, ${whereField}=${value}): ${errText}`); + } + const rows = row.result || []; + return rows.length ? rows[0] : null; +} + +async function resolveRelationId(typeName, relationValue) { + const item = await fiberyQueryOne(typeName, fields.relationName, relationValue); + if (!item?.['fibery/id']) { + throw new Error(`Relation value not found in ${typeName}: ${relationValue}`); + } + return item['fibery/id']; +} + +function detectBranchValueForCommit(sha) { + if (branchValueOverride) return branchValueOverride; + + // For normal GitHub push flow we trust the pushed ref. + if (process.env.GITHUB_EVENT_PATH && fs.existsSync(process.env.GITHUB_EVENT_PATH)) { + return mapBranchToFibery(gitBranch); + } + + // Backfill/local mode: try to infer from remote branches containing commit. + const containsRaw = safeExec(`git branch -r --contains ${sha}`); + const contains = containsRaw + .split('\n') + .map((x) => x.trim()) + .filter(Boolean); + + // Priority: main/master -> develop/dev -> hotfix/* -> feature/* + if (contains.some((x) => x.includes('origin/main') || x.includes('origin/master'))) return 'main'; + if (contains.some((x) => x.includes('origin/develop') || x.includes('origin/dev'))) + return 'develop'; + if (contains.some((x) => x.includes('origin/hotfix'))) return 'hotfix'; + if (contains.some((x) => x.includes('origin/feature'))) return 'feature'; + + return mapBranchToFibery(gitBranch); +} + +function readShasFromEvent() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath || !fs.existsSync(eventPath)) return []; + const raw = fs.readFileSync(eventPath, 'utf8'); + const payload = JSON.parse(raw); + if (!Array.isArray(payload.commits)) return []; + return payload.commits.map((c) => c.id).filter(Boolean); +} + +function readShasFromEnv() { + const raw = (process.env.COMMIT_SHAS || '').trim(); + if (!raw) return []; + return raw + .split(',') + .map((x) => x.trim()) + .filter(Boolean); +} + +function readShasFromGitLog(limit) { + const out = safeExec(`git log -n ${limit} --pretty=format:%H`); + if (!out) return []; + return out + .split('\n') + .map((x) => x.trim()) + .filter(Boolean); +} + +function getCommitDetailsFromLocalGit(sha) { + const sep = String.fromCharCode(31); + const fmt = `%H${sep}%P${sep}%an${sep}%ae${sep}%aI${sep}%s`; + const out = safeExec(`git show -s --date=iso-strict --format=${fmt} ${sha}`); + if (!out) return null; + const parts = out.split(sep); + if (parts.length < 6) return null; + const parentShas = parts[1] + ? parts[1] + .split(' ') + .map((x) => x.trim()) + .filter(Boolean) + : []; + return { + sha: parts[0], + message: parts[5] || '', + authorName: parts[2] || '', + authorEmail: parts[3] || '', + commitDate: parts[4] || '', + githubLink: `https://github.com/${githubRepo}/commit/${parts[0]}`, + parentShas, + }; +} + +function normalizeCommitDate(value) { + if (!value) return null; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} + +async function fetchCommitDetailsFromGitHub(sha) { + const headers = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'quest-fibery-export', + }; + if (githubToken) headers.Authorization = `Bearer ${githubToken}`; + const url = `https://api.github.com/repos/${githubRepo}/commits/${sha}`; + const res = await fetch(url, { headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`GitHub fetch failed for ${sha}: HTTP ${res.status} ${text}`); + } + const details = await res.json(); + const commit = details.commit || {}; + const commitAuthor = commit.author || {}; + return { + sha, + message: commit.message || '', + authorName: commitAuthor.name || details.author?.login || '', + authorEmail: commitAuthor.email || '', + commitDate: commitAuthor.date || commit.committer?.date || '', + githubLink: details.html_url || `https://github.com/${githubRepo}/commit/${sha}`, + parentShas: Array.isArray(details.parents) ? details.parents.map((p) => p.sha) : [], + }; +} + +async function getCommitDetails(sha) { + try { + return await fetchCommitDetailsFromGitHub(sha); + } catch { + const local = getCommitDetailsFromLocalGit(sha); + if (!local) throw new Error(`Cannot resolve commit details for ${sha} from GitHub or local git`); + return local; + } +} + +function buildCommitEntity(details, branchId, repoId) { + const shortSha = details.sha.slice(0, 8); + const firstLineMessage = (details.message || '').split('\n')[0]; + const title = `${shortSha} ${firstLineMessage}`.trim(); + + return { + [fields.commitSha]: details.sha, + [fields.commitMessage]: details.message || '', + [fields.authorName]: details.authorName || '', + [fields.authorEmail]: details.authorEmail || '', + [fields.commitDate]: normalizeCommitDate(details.commitDate), + [fields.githubLink]: details.githubLink || `https://github.com/${githubRepo}/commit/${details.sha}`, + [fields.parentShas]: (details.parentShas || []).join(','), + [fields.commitName]: title, + [fields.branchRel]: { 'fibery/id': branchId }, + [fields.repoRel]: { 'fibery/id': repoId }, + }; +} + +async function main() { + let shas = readShasFromEnv(); + if (!shas.length) shas = readShasFromEvent(); + if (!shas.length && backfillCount > 0) shas = readShasFromGitLog(backfillCount); + if (!shas.length) { + console.log('No commits to export. Provide push event commits, COMMIT_SHAS, or BACKFILL_COUNT.'); + return; + } + + shas = [...new Set(shas)]; + console.log(`Preparing export for ${shas.length} commit(s)...`); + + const repoId = await resolveRelationId(repoType, repoValue); + const branchIdCache = new Map(); + + const createOrUpdateItems = []; + for (const sha of shas) { + try { + const details = await getCommitDetails(sha); + const branchValue = detectBranchValueForCommit(sha); + let branchId = branchIdCache.get(branchValue); + if (!branchId) { + branchId = await resolveRelationId(branchType, branchValue); + branchIdCache.set(branchValue, branchId); + } + const existing = await fiberyQueryOne(commitType, fields.commitSha, sha); + const entity = buildCommitEntity(details, branchId, repoId); + if (existing?.['fibery/id']) { + createOrUpdateItems.push({ + label: `update ${sha}`, + command: { + command: 'fibery.entity/update', + args: { + type: commitType, + entity: { + 'fibery/id': existing['fibery/id'], + ...entity, + }, + }, + }, + }); + } else { + createOrUpdateItems.push({ + label: `create ${sha}`, + command: { + command: 'fibery.entity/create', + args: { + type: commitType, + entity, + }, + }, + }); + } + } catch (e) { + console.warn(`WARN: skip ${sha}: ${e.message}`); + } + } + + const result = await runCommandsInBatches(createOrUpdateItems); + console.log( + `Done. commands=${createOrUpdateItems.length} success=${result.success} failed=${result.failed} batchSize=${batchSize}` + ); +} + +main().catch((err) => { + console.warn(`WARN: export failed: ${err.message}`); + process.exit(0); +}); From 3dc5ebae46e0bef16dbb58600596474a522c561b Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sun, 8 Mar 2026 19:17:15 +0200 Subject: [PATCH 2/7] Feature: add text asset workflow and runtime integration Implemented a first-pass text asset system for scenes and objects with runtime resolution and editor tooling. Added support for text asset files under public/text for scenes and objects, plus a new TextAssetManager responsible for loading assets, resolving title/description fields, applying runtime text redirects, and duplicating/deleting/opening TA files. Updated the scene editor properties panel so Title is read-only and sourced from the linked text asset. Added Create/Open/Sync/Delete TA actions, including backend file operations for creating, opening, reading, and deleting text asset files. Integrated text assets into parser and runtime behavior: - LOOK now resolves scene descriptions from scene TA - LOOK object title resolves objects by TA title and prints TA description - LOOK AROUND / LOOK HERE / LOOK SCENE resolve to the current scene description - clicking visible scene objects with a TA title prints "You see " instead of walking - Subtrigger clicks resolve title from the forwarded target object Adjusted click hit-testing so layer ordering is respected between entities and triggerboxes, and restored correct close-on-click-outside behavior for subscenes while preserving title detection inside subscenes. Also unified command handling between closed and popup console modes so gameplay commands in the expanded console follow the parser path instead of being treated as console-only commands. Included current scene file updates and added TextAssets.md documentation describing the chosen asset model and redirect rules. --- TextAssets.md | 71 ++++++ public/scenes/home/room.json | 94 ++++++-- public/scenes/home/room_backup.json | 45 +++- public/text/objects/boombox.json | 5 + public/text/objects/logo_1.json | 4 + public/text/objects/miles_id.json | 4 + public/text/objects/miles_id_1.json | 4 + public/text/scenes/home/room.json | 4 + public/text/scenes/home/room_backup.json | 4 + public/text/scenes/new_scene.json | 4 + src/components/ConsoleOverlay.tsx | 24 -- src/components/editor/PropertiesPanel.tsx | 177 +++++++++++++-- src/core/Game.ts | 3 + src/core/IGame.ts | 2 + src/core/TextAssetManager.ts | 227 +++++++++++++++++++ src/entities/SceneObject.ts | 3 + src/mechanics/Parser.ts | 19 +- src/scene/Scene.ts | 21 +- src/scene/SceneInteraction.ts | 188 ++++++++++++++- src/scene/SceneManager.ts | 7 +- src/tools/SceneEditor.ts | 3 + src/tools/editor/EditorPersistenceManager.ts | 1 + src/tools/editor/EditorSelectionManager.ts | 13 +- vite.config.ts | 109 +++++++++ 24 files changed, 943 insertions(+), 93 deletions(-) create mode 100644 TextAssets.md create mode 100644 public/text/objects/boombox.json create mode 100644 public/text/objects/logo_1.json create mode 100644 public/text/objects/miles_id.json create mode 100644 public/text/objects/miles_id_1.json create mode 100644 public/text/scenes/home/room.json create mode 100644 public/text/scenes/home/room_backup.json create mode 100644 public/text/scenes/new_scene.json create mode 100644 src/core/TextAssetManager.ts diff --git a/TextAssets.md b/TextAssets.md new file mode 100644 index 00000000..31c78179 --- /dev/null +++ b/TextAssets.md @@ -0,0 +1,71 @@ +# Text Assets + +## V1 decision + +We start with a minimal text asset system for scene and object descriptions. + +Text assets are stored separately from scene and prefab JSON files: + +- `public/text/scenes/<scene-id>.json` +- `public/text/objects/<object-id>.json` + +Since scene/object IDs according to GDD can contain paths like "building\room", which means that the 'room.json scene' is located in the 'building' folder, there may be subfolders inside these folders. + +## Main rules + +- Scene text asset is created automatically when a scene is created or first saved, if it does not exist yet. +- Object text asset is stored independently from scenes and prefabs, because objects may exist outside a scene or move between scenes. +- Missing text asset files are not errors; runtime falls back to existing built-in fields. +- Text assets contain only data, not code. +- Dynamic text changes are controlled by scripts through runtime properties of scenes and objects. + +## Minimal fields + +Scene asset: + +- `title` +- `description` + +Object asset: + +- `title` +- `description` + +## Custom text variants + +Text assets may also contain custom named fields in the same JSON file, for example: + +- `description_morning` +- `description_evening` +- `title_locked` + +These are alternative text values that can be activated at runtime. + +## Runtime redirection + +The redirection table does not live inside text asset JSON files. + +Instead, each scene and object may have a runtime property such as `textRedirects` that remaps standard text fields to custom fields from the same text asset. + +Example: + +```json +{ + "description": "description_evening" +} +``` + +Meaning: + +- when runtime asks for `description`, it should use `description_evening` from the text asset; +- if no redirect is set, the default `description` field is used; +- if redirect points to a missing field, runtime should fall back to the standard field. + +Scripts do not generate text themselves. They only change which named text field is currently active. + +## Runtime integration + +- `title` maps to the user-facing object or scene name. +- `description` maps to the basic text used by parser/runtime for `look` or `look around`. +- Existing runtime fields remain as fallback and for backward compatibility. +- Parser and UI should read only the resolved standard fields, not custom variant names directly. diff --git a/public/scenes/home/room.json b/public/scenes/home/room.json index 7585f5c1..086ae220 100644 --- a/public/scenes/home/room.json +++ b/public/scenes/home/room.json @@ -1,6 +1,8 @@ { "id": "home\\room", "name": "Test Room", + "description": "You are in Test Room.", + "textRedirects": {}, "filename": "home/room", "walkbox": [ { @@ -10,6 +12,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -41,6 +44,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -106,6 +110,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -159,6 +164,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -171,7 +177,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -200,6 +206,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -214,6 +221,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -226,7 +234,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -264,6 +272,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -2, @@ -294,6 +303,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -1, @@ -324,6 +334,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -354,23 +365,24 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 249.76314957424822, - "y": 295.45481146309464, - "width": 119.88, - "height": 289.34, - "baseWidth": 162, - "baseHeight": 391, + "x": 241.58710837405346, + "y": 236.58147331007746, + "width": 119.33276583023533, + "height": 278.4431202705491, + "baseWidth": 168, + "baseHeight": 392, "colliderWidth": 88, "colliderHeight": 4, - "spriteName": "miles_ds-idle-down.json", + "spriteName": "miles_ds-idle-up.json", "color": "#00ffff", - "scale": 0.74, + "scale": 0.7103140823228293, "modelScale": 0.74, - "parallax": 1.0715390924595392, + "parallax": 1.033097301991213, "ignoreScaling": false, "animationSpeed": 30, "opacity": 1, @@ -378,7 +390,7 @@ "blur": 0, "isPlayer": true, "speed": 0.24, - "direction": "down", + "direction": "up", "animSets": { "idle": { "id": "idle", @@ -403,6 +415,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -433,9 +446,10 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 3, + "layer": 0, "visible": true, "x": 135, "y": 310, @@ -463,8 +477,14 @@ "disabled": true, "groupID": "#D2", "customName": "", + "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d2" + } + ], "layer": 4, "visible": true, "x": 134, @@ -493,9 +513,10 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 5, + "layer": 4, "visible": true, "x": 136, "y": -4, @@ -523,6 +544,7 @@ "disabled": true, "groupID": "#D1", "customName": "your ID card", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -558,6 +580,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 5, @@ -588,6 +611,7 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 6, @@ -618,6 +642,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -653,6 +678,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -706,12 +732,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 222.90433731748038, - "y": 306.92845155295413, + "x": 222.90433731748033, + "y": 306.928451552954, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -722,7 +749,7 @@ "color": "#36d87fff", "scale": 0.8, "modelScale": 0.8, - "parallax": 1.079038203629382, + "parallax": 1.0790382036293817, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -732,6 +759,37 @@ "speed": 0.1, "direction": "down", "animSets": {} + }, + { + "name": "boombox", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -152, + "y": -11, + "width": 112, + "height": 47, + "baseWidth": 123.07692307692307, + "baseHeight": 51.64835164835165, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 0, + "blendMode": "source-over", + "blur": 0 } ], "camera": { diff --git a/public/scenes/home/room_backup.json b/public/scenes/home/room_backup.json index 7b52df5e..a37a3dcb 100644 --- a/public/scenes/home/room_backup.json +++ b/public/scenes/home/room_backup.json @@ -1,6 +1,8 @@ { "id": "home\\room_backup", "name": "Test Room", + "description": "You are in Test Room.", + "textRedirects": {}, "filename": "home/room_backup", "walkbox": [ { @@ -10,6 +12,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -41,6 +44,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -106,6 +110,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -159,6 +164,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -171,7 +177,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -200,6 +206,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -214,6 +221,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -226,7 +234,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -264,6 +272,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -2, @@ -294,6 +303,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -1, @@ -324,6 +334,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -354,12 +365,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, "x": 249.76314957424822, - "y": 295.45481146309464, + "y": 295.45481146309453, "width": 119.88, "height": 289.34, "baseWidth": 162, @@ -403,6 +415,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -433,9 +446,10 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 3, + "layer": 0, "visible": true, "x": 135, "y": 310, @@ -463,8 +477,14 @@ "disabled": true, "groupID": "#D2", "customName": "", + "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d2" + } + ], "layer": 4, "visible": true, "x": 134, @@ -493,9 +513,10 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 5, + "layer": 4, "visible": true, "x": 136, "y": -4, @@ -523,6 +544,7 @@ "disabled": true, "groupID": "#D1", "customName": "your ID card", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -558,6 +580,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 5, @@ -588,6 +611,7 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 6, @@ -618,6 +642,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -653,6 +678,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -706,12 +732,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 222.90433731748038, - "y": 306.92845155295413, + "x": 222.90433731748033, + "y": 306.92845155295396, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -722,7 +749,7 @@ "color": "#36d87fff", "scale": 0.8, "modelScale": 0.8, - "parallax": 1.079038203629382, + "parallax": 1.0790382036293817, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json new file mode 100644 index 00000000..a405403b --- /dev/null +++ b/public/text/objects/boombox.json @@ -0,0 +1,5 @@ +{ + "title": "Boombox", + "description": "An old cheap tape recorder with a radio.", + "details": "A very basic tape recorder is connected to the computer. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape." +} diff --git a/public/text/objects/logo_1.json b/public/text/objects/logo_1.json new file mode 100644 index 00000000..86973794 --- /dev/null +++ b/public/text/objects/logo_1.json @@ -0,0 +1,4 @@ +{ + "title": "logo", + "description": "You see nothing special." +} diff --git a/public/text/objects/miles_id.json b/public/text/objects/miles_id.json new file mode 100644 index 00000000..442b3058 --- /dev/null +++ b/public/text/objects/miles_id.json @@ -0,0 +1,4 @@ +{ + "title": "your ID card", + "description": "You see nothing special." +} diff --git a/public/text/objects/miles_id_1.json b/public/text/objects/miles_id_1.json new file mode 100644 index 00000000..442b3058 --- /dev/null +++ b/public/text/objects/miles_id_1.json @@ -0,0 +1,4 @@ +{ + "title": "your ID card", + "description": "You see nothing special." +} diff --git a/public/text/scenes/home/room.json b/public/text/scenes/home/room.json new file mode 100644 index 00000000..03435560 --- /dev/null +++ b/public/text/scenes/home/room.json @@ -0,0 +1,4 @@ +{ + "title": "Test Room", + "description": "You are in Test Room." +} diff --git a/public/text/scenes/home/room_backup.json b/public/text/scenes/home/room_backup.json new file mode 100644 index 00000000..03435560 --- /dev/null +++ b/public/text/scenes/home/room_backup.json @@ -0,0 +1,4 @@ +{ + "title": "Test Room", + "description": "You are in Test Room." +} diff --git a/public/text/scenes/new_scene.json b/public/text/scenes/new_scene.json new file mode 100644 index 00000000..faa8b8b7 --- /dev/null +++ b/public/text/scenes/new_scene.json @@ -0,0 +1,4 @@ +{ + "title": "New Scene", + "description": "You are in New Scene." +} diff --git a/src/components/ConsoleOverlay.tsx b/src/components/ConsoleOverlay.tsx index 89ceb693..988bfabb 100644 --- a/src/components/ConsoleOverlay.tsx +++ b/src/components/ConsoleOverlay.tsx @@ -135,28 +135,6 @@ const InputMirror: React.FC<{ game: Game }> = ({ game }) => { const input = game.getCommandInput(); if (!input) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - const command = input.value; - if (command.trim()) { - // Send to Game Console Processing - if (game.console) { - game.console.processCommand(command); - } else { - // Fallback purely for parser if console not active? - // Actually, if we are in ConsoleOverlay, we want Console logic. - // The original game parser logic might still listen to 'Enter' globally? - // Let's ensure we don't double submit. - // Game.ts -> onKeyDown usually handles parser. - // We might need to coordinate who consumes the input. - // For now, let's assume this is the Console input. - } - input.value = ''; - setVal(''); - } - } - }; - const update = () => { if (input && input.value !== val) { setVal(input.value); @@ -164,11 +142,9 @@ const InputMirror: React.FC<{ game: Game }> = ({ game }) => { requestAnimationFrame(update); }; - input.addEventListener('keydown', handleKeyDown); const rAF = requestAnimationFrame(update); return () => { - input.removeEventListener('keydown', handleKeyDown); cancelAnimationFrame(rAF); }; }, [game, val]); diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 7d0b6d25..de79f864 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -16,6 +16,10 @@ export const PropertiesPanel: React.FC = () => { selectedVertexIndex, } = useEditorStore(); const [groupIdDraft, setGroupIdDraft] = React.useState(''); + const [resolvedTitle, setResolvedTitle] = React.useState(''); + const [textAssetPath, setTextAssetPath] = React.useState(''); + const [isReadingTA, setIsReadingTA] = React.useState(false); + const [hasTextAsset, setHasTextAsset] = React.useState(false); // Derived Object Binding (Source of Truth) // We re-render whenever objectVersion changes (subscribed via store hook) @@ -59,6 +63,125 @@ export const PropertiesPanel: React.FC = () => { incrementHierarchyVersion(); }; + const loadResolvedTitle = React.useCallback( + async (forceReload: boolean = false) => { + if (!game || !obj || selectedObjectType === 'MULTI' || selectedObjectType === 'SETTINGS') { + setResolvedTitle(''); + setTextAssetPath(''); + return; + } + + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + const asset = forceReload + ? await game.textAssets.readSceneAsset(scene, true) + : await game.textAssets.readSceneAsset(scene, false); + setHasTextAsset(!!asset); + setResolvedTitle(game.textAssets.getResolvedSceneField(scene, 'title') || ''); + setTextAssetPath(game.textAssets.getSceneAssetProjectPath(scene.id)); + return; + } + + if (game.editor?.selectedObject) { + const selected = game.editor.selectedObject; + const asset = forceReload + ? await game.textAssets.readObjectAsset(selected, true) + : await game.textAssets.readObjectAsset(selected, false); + setHasTextAsset(!!asset); + setResolvedTitle(game.textAssets.getResolvedObjectField(selected, 'title') || ''); + setTextAssetPath(game.textAssets.getObjectAssetProjectPath(selected.name)); + } + }, + [game, obj, selectedObjectType] + ); + + React.useEffect(() => { + loadResolvedTitle(false).catch((err) => { + console.error('Failed to load text asset title:', err); + }); + }, [loadResolvedTitle, selectedObjectId, selectedObjectType]); + + const handleOpenTA = async () => { + if (!game || !obj) return; + try { + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + await game.textAssets.openSceneAsset(scene); + } else if (game.editor?.selectedObject) { + await game.textAssets.openObjectAsset(game.editor.selectedObject); + } + await loadResolvedTitle(true); + } catch (err) { + console.error('Failed to open text asset:', err); + game.showNotification?.(`Failed to open TA: ${err}`); + } + }; + + const handleReadTA = async () => { + if (!game || !obj) return; + setIsReadingTA(true); + try { + const path = + selectedObjectType === 'SCENE' + ? game.textAssets.getSceneAssetProjectPath(game.sceneManager.currentScene?.id || '') + : game.editor?.selectedObject + ? game.textAssets.getObjectAssetProjectPath(game.editor.selectedObject.name) + : ''; + const defaultContent = + selectedObjectType === 'SCENE' + ? JSON.stringify( + game.textAssets.buildDefaultSceneAsset(game.sceneManager.currentScene as any), + null, + 2 + ) + : game.editor?.selectedObject + ? JSON.stringify( + game.textAssets.buildDefaultObjectAsset(game.editor.selectedObject), + null, + 2 + ) + : '{}'; + + await fetch('/api/read-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, content: defaultContent }), + }); + await loadResolvedTitle(true); + incrementObjectVersion(); + game.showNotification?.('Text asset reloaded'); + } catch (err) { + console.error('Failed to read text asset:', err); + game.showNotification?.(`Failed to read TA: ${err}`); + } finally { + setIsReadingTA(false); + } + }; + + const handleDeleteTA = async () => { + if (!game || !obj || !hasTextAsset) return; + const confirmed = window.confirm(`Delete text asset?\n${textAssetPath}`); + if (!confirmed) return; + + try { + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + await game.textAssets.deleteSceneAsset(scene); + } else if (game.editor?.selectedObject) { + await game.textAssets.deleteObjectAsset(game.editor.selectedObject); + } + await loadResolvedTitle(true); + incrementObjectVersion(); + game.showNotification?.('Text asset deleted'); + } catch (err) { + console.error('Failed to delete text asset:', err); + game.showNotification?.(`Failed to delete TA: ${err}`); + } + }; + React.useEffect(() => { if (selectedObjectType !== 'MULTI') { setGroupIdDraft(''); @@ -751,6 +874,38 @@ export const PropertiesPanel: React.FC = () => { }} /> </div> + <div className="e-row"> + <label className="e-label">Title</label> + <input + type="text" + className="e-input" + value={resolvedTitle} + readOnly + tabIndex={-1} + onFocus={(e) => e.currentTarget.blur()} + style={{ pointerEvents: 'none', color: '#888' }} + /> + {textAssetPath && ( + <> + <div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}> + <button className="e-btn" onClick={handleOpenTA}> + {hasTextAsset ? 'Open TA' : 'Create TA'} + </button> + <button className="e-btn" onClick={handleReadTA} disabled={isReadingTA}> + {isReadingTA ? 'Syncing...' : 'Sync TA'} + </button> + {hasTextAsset && ( + <button className="e-btn" onClick={handleDeleteTA}> + Delete TA + </button> + )} + </div> + <div className="e-label" style={{ color: '#888', fontSize: '0.8em' }}> + {textAssetPath} + </div> + </> + )} + </div> </> )} @@ -792,18 +947,6 @@ export const PropertiesPanel: React.FC = () => { selectedObjectType === 'Actor' || selectedObjectType === 'Static') && ( <> - {/* Display Name */} - <div className="e-row"> - <label className="e-label">Display Name</label> - <input - type="text" - className="e-input" - placeholder="e.g. Pillar (for Parser)" - value={obj.customName || ''} - onChange={(e) => handleChange('customName', e.target.value)} - /> - </div> - {/* Transform: X, Y, W, H */} <div className="e-row" @@ -2611,16 +2754,6 @@ export const PropertiesPanel: React.FC = () => { {/* SCENE Properties */} {selectedObjectType === 'SCENE' && ( <> - <div className="e-row"> - <label className="e-label">Title</label> - <input - type="text" - className="e-input" - value={obj.name || ''} - onChange={(e) => handleChange('name', e.target.value)} - /> - </div> - {/* Camera properties */} {(obj.camera || obj.defaultCamera) && ( <div className="e-row" style={{ borderTop: '1px solid #444', paddingTop: '5px' }}> diff --git a/src/core/Game.ts b/src/core/Game.ts index 2b8b8473..4636f537 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -9,6 +9,7 @@ import { Entity } from '../entities/Entity'; import { registerDemoScripts } from '../scripts/DemoScripts'; import { registerUserScripts } from '../scripts/main'; import { AudioManager } from './AudioManager'; +import { TextAssetManager } from './TextAssetManager'; import { Console } from './Console'; @@ -41,6 +42,7 @@ export class Game implements IGame { sceneManager: SceneManager; assets: AssetLoader; audio: AudioManager; + textAssets: TextAssetManager; editor: SceneEditor; spriteEditor: SpriteEditor; console: Console; // Virtual Console @@ -149,6 +151,7 @@ export class Game implements IGame { this.parser = new Parser(this); this.assets = new AssetLoader(); this.audio = new AudioManager(); + this.textAssets = new TextAssetManager(); this.sceneManager = new SceneManager(this); this.editor = new SceneEditor(this); this.spriteEditor = new SpriteEditor(this); diff --git a/src/core/IGame.ts b/src/core/IGame.ts index c0f30ecd..7e61d3bb 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -3,10 +3,12 @@ import { AudioManager } from './AudioManager'; import { SceneManager } from '../scene/SceneManager'; import { SceneEditor } from '../tools/SceneEditor'; import { Entity } from '../entities/Entity'; +import { TextAssetManager } from './TextAssetManager'; export interface IGame { assets: AssetLoader; audio: AudioManager; + textAssets: TextAssetManager; sceneManager: SceneManager; editor: SceneEditor; inventory: Entity[]; diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts new file mode 100644 index 00000000..3b04b75c --- /dev/null +++ b/src/core/TextAssetManager.ts @@ -0,0 +1,227 @@ +import type { Scene } from '../scene/Scene'; +import type { SceneObject } from '../entities/SceneObject'; + +type TextAssetData = Record<string, string>; + +export class TextAssetManager { + private sceneCache = new Map<string, TextAssetData | null>(); + private objectCache = new Map<string, TextAssetData | null>(); + + private normalizeId(id: string): string { + return String(id || '') + .replace(/\//g, '\\') + .trim(); + } + + private idToRelativePath(id: string): string { + return this.normalizeId(id).replace(/\\/g, '/'); + } + + getSceneAssetProjectPath(sceneId: string): string { + return `public/text/scenes/${this.idToRelativePath(sceneId)}.json`; + } + + getObjectAssetProjectPath(objectId: string): string { + return `public/text/objects/${this.idToRelativePath(objectId)}.json`; + } + + private getSceneAssetUrl(sceneId: string): string { + return `/text/scenes/${this.idToRelativePath(sceneId)}.json`; + } + + private getObjectAssetUrl(objectId: string): string { + return `/text/objects/${this.idToRelativePath(objectId)}.json`; + } + + buildDefaultSceneAsset(scene: Scene): TextAssetData { + return { + title: scene.name || scene.id || 'Untitled Scene', + description: + scene.description || `You are in ${scene.name || scene.id || 'an unnamed scene'}.`, + }; + } + + buildDefaultObjectAsset(obj: SceneObject): TextAssetData { + const fallbackTitle = (obj as any).customName || obj.name || obj.type || 'Object'; + const fallbackDescription = (obj as any).description || 'You see nothing special.'; + return { + title: fallbackTitle, + description: fallbackDescription, + }; + } + + async ensureSceneAssetFile(scene: Scene): Promise<void> { + if (!scene?.id) return; + const assetPath = this.getSceneAssetProjectPath(scene.id); + const content = JSON.stringify(this.buildDefaultSceneAsset(scene), null, 2); + await this.ensureFile(assetPath, content); + } + + async ensureObjectAssetFile(obj: SceneObject): Promise<void> { + if (!obj?.name) return; + const assetPath = this.getObjectAssetProjectPath(obj.name); + const content = JSON.stringify(this.buildDefaultObjectAsset(obj), null, 2); + await this.ensureFile(assetPath, content); + } + + async openSceneAsset(scene: Scene): Promise<void> { + const assetPath = this.getSceneAssetProjectPath(scene.id); + const content = JSON.stringify(this.buildDefaultSceneAsset(scene), null, 2); + await this.openFile(assetPath, content); + } + + async openObjectAsset(obj: SceneObject): Promise<void> { + const assetPath = this.getObjectAssetProjectPath(obj.name); + const content = JSON.stringify(this.buildDefaultObjectAsset(obj), null, 2); + await this.openFile(assetPath, content); + } + + async deleteSceneAsset(scene: Scene): Promise<void> { + await this.deleteFile(this.getSceneAssetProjectPath(scene.id)); + this.sceneCache.delete(this.normalizeId(scene.id)); + } + + async deleteObjectAsset(obj: SceneObject): Promise<void> { + await this.deleteFile(this.getObjectAssetProjectPath(obj.name)); + this.objectCache.delete(this.normalizeId(obj.name)); + } + + async readSceneAsset(scene: Scene, forceReload: boolean = false): Promise<TextAssetData | null> { + const sceneId = this.normalizeId(scene?.id || ''); + if (!sceneId) return null; + if (!forceReload && this.sceneCache.has(sceneId)) { + return this.sceneCache.get(sceneId) || null; + } + const data = await this.fetchJson(this.getSceneAssetUrl(sceneId)); + this.sceneCache.set(sceneId, data); + return data; + } + + async readObjectAsset( + obj: SceneObject, + forceReload: boolean = false + ): Promise<TextAssetData | null> { + const objectId = this.normalizeId(obj?.name || ''); + if (!objectId) return null; + if (!forceReload && this.objectCache.has(objectId)) { + return this.objectCache.get(objectId) || null; + } + const data = await this.fetchJson(this.getObjectAssetUrl(objectId)); + this.objectCache.set(objectId, data); + return data; + } + + async preloadScene(scene: Scene): Promise<void> { + await this.readSceneAsset(scene, true); + await Promise.all( + (scene.entities || []).map((entity: SceneObject) => this.readObjectAsset(entity, true)) + ); + } + + clearCaches(): void { + this.sceneCache.clear(); + this.objectCache.clear(); + } + + getResolvedSceneField(scene: Scene, field: string): string | null { + const sceneId = this.normalizeId(scene?.id || ''); + const asset = sceneId ? this.sceneCache.get(sceneId) : null; + const fallback = field === 'description' ? scene?.description || null : null; + return this.resolveField(asset, scene?.textRedirects || null, field, fallback); + } + + getResolvedObjectField(obj: SceneObject, field: string): string | null { + const objectId = this.normalizeId(obj?.name || ''); + const asset = objectId ? this.objectCache.get(objectId) : null; + const fallback = field === 'description' ? (obj as any).description || null : null; + return this.resolveField(asset, obj?.textRedirects || null, field, fallback); + } + + private resolveField( + asset: TextAssetData | null | undefined, + redirects: Record<string, string> | null | undefined, + field: string, + fallback: string | null + ): string | null { + if (!asset) return fallback; + const redirectTarget = redirects && redirects[field]; + if (redirectTarget) { + const redirected = asset[redirectTarget]; + if (typeof redirected === 'string') return redirected; + console.warn( + `[TextAssetManager] Missing redirected field '${redirectTarget}' for '${field}'.` + ); + } + const direct = asset[field]; + if (typeof direct === 'string') return direct; + return fallback; + } + + private async fetchJson(url: string): Promise<TextAssetData | null> { + try { + const response = await fetch(`${url}?t=${Date.now()}`); + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(await response.text()); + } + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + return null; + } + return (await response.json()) as TextAssetData; + } catch (error) { + console.error('[TextAssetManager] Failed to fetch text asset:', error); + return null; + } + } + + private async ensureFile(filePath: string, content: string): Promise<void> { + await fetch('/api/ensure-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }); + } + + private async openFile(filePath: string, content: string): Promise<void> { + const response = await fetch('/api/open-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + } + + async duplicateObjectAssetIfExists( + sourceObjectId: string, + targetObjectId: string + ): Promise<void> { + const sourceUrl = this.getObjectAssetUrl(sourceObjectId); + const sourceData = await this.fetchJson(sourceUrl); + if (!sourceData) return; + + const targetPath = this.getObjectAssetProjectPath(targetObjectId); + const response = await fetch('/api/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: targetPath, content: JSON.stringify(sourceData, null, 2) }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + this.objectCache.set(this.normalizeId(targetObjectId), sourceData); + } + + private async deleteFile(filePath: string): Promise<void> { + const response = await fetch('/api/delete-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + } +} diff --git a/src/entities/SceneObject.ts b/src/entities/SceneObject.ts index 85ee6b19..d79aafd2 100644 --- a/src/entities/SceneObject.ts +++ b/src/entities/SceneObject.ts @@ -9,6 +9,7 @@ export class SceneObject { // User-facing name for parser (e.g. "Pillar" instead of "Pillar_01") customName: string = ''; + textRedirects: Record<string, string> = {}; // Script bindings for verbs: { "LOOK": "script.id", "USE": "script.id" } interactions: Record<string, string> = {}; @@ -30,6 +31,7 @@ export class SceneObject { 'disabled', 'groupID', 'customName', + 'textRedirects', 'interactions', 'components', 'layer', @@ -44,6 +46,7 @@ export class SceneObject { this.layer = 0; this.visible = true; this.customName = ''; + this.textRedirects = {}; this.interactions = {}; this.components = []; } diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index b0b32051..7b8f6dab 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -40,14 +40,24 @@ export class Parser { execute(verb: string, noun: string): void { const scene = this.game.sceneManager.currentScene; if (!scene) return; + const normalizedNoun = noun.trim().toUpperCase(); + const isSceneLook = + !normalizedNoun || + normalizedNoun === 'AROUND' || + normalizedNoun === 'HERE' || + normalizedNoun === 'SCENE'; // Basic command handling switch (verb) { case 'LOOK': case 'EXAMINE': case 'X': // Common shortcut - if (!noun) { - this.game.log(`You are in ${scene.name}.`); + if (isSceneLook) { + const sceneDescription = + this.game.textAssets.getResolvedSceneField(scene, 'description') || + scene.description || + `You are in ${scene.name}.`; + this.game.log(sceneDescription); } else { const entity = scene.findEntity(noun); if (entity) { @@ -58,7 +68,10 @@ export class Parser { ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); } else { // Fallback to description - this.game.log(entity.description || `You see nothing special about the ${noun}.`); + const description = + this.game.textAssets.getResolvedObjectField(entity, 'description') || + entity.description; + this.game.log(description || `You see nothing special about the ${noun}.`); } } else { this.game.log(`You don't see any ${noun} here.`); diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 50a8dadf..73724cd9 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -24,6 +24,8 @@ export interface SceneScaling { export interface SceneData { id: string; name: string; + description?: string; + textRedirects?: Record<string, string>; filename?: string; walkbox: { poly: { x: number; y: number }[]; @@ -50,6 +52,7 @@ export class Scene { id: string; name: string; + description: string; filename: string; background: HTMLImageElement | null; entities: Entity[]; @@ -77,6 +80,7 @@ export class Scene { // Default Camera (saved to scene file, restored on load/reset) defaultCamera: { x: number; y: number; zoom: number }; + textRedirects: Record<string, string> = {}; // Subscene State private _activeSubscene: string | null = null; @@ -104,6 +108,7 @@ export class Scene { this.game = game; this.id = id; this.name = name; + this.description = `You are in ${name}.`; this.filename = ''; // Default empty this.background = null; // Image object this.entities = []; @@ -149,11 +154,15 @@ export class Scene { } findEntity(name: string): Entity | undefined { - return this.entities.find( - (e) => - e.name.toUpperCase() === name.toUpperCase() || - (e.customName && e.customName.toUpperCase() === name.toUpperCase()) - ); + const normalized = name.toUpperCase(); + return this.entities.find((e) => { + const resolvedTitle = this.game.textAssets.getResolvedObjectField(e, 'title'); + return ( + e.name.toUpperCase() === normalized || + (e.customName && e.customName.toUpperCase() === normalized) || + (resolvedTitle && resolvedTitle.toUpperCase() === normalized) + ); + }); } getScaling(y: number): number { @@ -486,6 +495,8 @@ export class Scene { return { id: this.id, name: this.name, + description: this.description, + textRedirects: this.textRedirects, filename: this.filename, walkbox: this.walkbox.map((wb) => wb.toJSON()), triggerboxes: this.triggerboxes.map((tb) => tb.toJSON()), diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index aad36b31..63b0614e 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -2,6 +2,7 @@ import type { Scene } from './Scene'; import { SceneObject } from '../entities/SceneObject'; import { Triggerbox } from '../entities/Triggerbox'; import { ComponentSystem } from '../systems/ComponentSystem'; +import { Geometry } from '../utils/Geometry'; function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { const screenW = 420; @@ -14,6 +15,150 @@ function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { }; } +function findVisibleHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + const camX = scene.camera.x; + const camY = scene.camera.y; + const zoom = scene.camera.zoom; + + const entities = scene.entities || []; + for (let i = entities.length - 1; i >= 0; i--) { + const entity = entities[i]; + if (entity.disabled || !entity.visible) continue; + + const p = entity.parallax !== undefined ? entity.parallax : 1.0; + const vOx = (entity as any).visualOffset ? (entity as any).visualOffset.x : 0; + const vOy = (entity as any).visualOffset ? (entity as any).visualOffset.y : 0; + const worldX = (screenX - halfW) / zoom + camX * p - vOx; + const worldY = (screenY - halfH) / zoom + camY * p - vOy; + + if (entity.hitTest(worldX, worldY)) return entity; + } + + const worldPos = { + x: (screenX - halfW) / zoom + camX, + y: (screenY - halfH) / zoom + camY, + }; + + if (scene.triggerboxes) { + for (const tb of scene.triggerboxes) { + if (tb.disabled || !tb.visible) continue; + if (Geometry.isPointInPolygon(worldPos, tb.poly)) return tb; + } + } + + if (scene.walkbox) { + for (const wb of scene.walkbox) { + if (wb.disabled || !wb.visible) continue; + if (Geometry.isPointInPolygon(worldPos, wb.poly)) return wb; + } + } + + return null; +} + +function isHitAtScreenPoint( + scene: Scene, + obj: SceneObject, + screenX: number, + screenY: number +): boolean { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + const camX = scene.camera.x; + const camY = scene.camera.y; + const zoom = scene.camera.zoom; + + if ('x' in obj && 'y' in obj) { + const entity = obj as any; + const p = entity.parallax !== undefined ? entity.parallax : 1.0; + const vOx = entity.visualOffset ? entity.visualOffset.x : 0; + const vOy = entity.visualOffset ? entity.visualOffset.y : 0; + const worldX = (screenX - halfW) / zoom + camX * p - vOx; + const worldY = (screenY - halfH) / zoom + camY * p - vOy; + return obj.hitTest(worldX, worldY); + } + + const worldPos = { + x: (screenX - halfW) / zoom + camX, + y: (screenY - halfH) / zoom + camY, + }; + return obj.hitTest(worldPos.x, worldPos.y); +} + +function sortClickableCandidates(candidates: SceneObject[]): SceneObject[] { + const sorted = [...candidates]; + sorted.sort((a, b) => { + const layerA = a.layer || 0; + const layerB = b.layer || 0; + if (layerA !== layerB) return layerB - layerA; + + const hasXYA = 'x' in (a as any) && 'y' in (a as any); + const hasXYB = 'x' in (b as any) && 'y' in (b as any); + if (hasXYA && !hasXYB) return -1; + if (!hasXYA && hasXYB) return 1; + return 0; + }); + + return sorted; +} + +function getSortedClickableCandidates(scene: Scene): SceneObject[] { + return sortClickableCandidates([ + ...scene.entities.filter((e) => !e.disabled && e.visible), + ...(scene.triggerboxes?.filter((t) => !t.disabled && t.visible) || []), + ...(scene.walkbox?.filter((w) => !w.disabled && w.visible) || []), + ]); +} + +function findTopHitInCandidates( + scene: Scene, + candidates: SceneObject[], + screenX: number, + screenY: number +): SceneObject | null { + for (const candidate of sortClickableCandidates(candidates)) { + if (isHitAtScreenPoint(scene, candidate, screenX, screenY)) { + return candidate; + } + } + return null; +} + +function findTopHitInWorldCandidates( + candidates: SceneObject[], + worldX: number, + worldY: number +): SceneObject | null { + for (const candidate of sortClickableCandidates(candidates)) { + if (candidate.hitTest(worldX, worldY)) { + return candidate; + } + } + return null; +} + +function findTopHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { + return findTopHitInCandidates(scene, getSortedClickableCandidates(scene), screenX, screenY); +} + +function resolveSubtriggerTarget(scene: Scene, obj: SceneObject): SceneObject { + const subtrigger = obj.components?.find((c: any) => c?.type === 'Subtrigger') as + | { target?: string } + | undefined; + if (!subtrigger?.target) return obj; + + const target = + scene.triggerboxes.find((t) => t.name === subtrigger.target) || + scene.entities.find((e) => e.name === subtrigger.target); + return target || obj; +} + export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): void { if (depth > 5) { console.warn('[Scene] Recursion limit reached.'); @@ -31,9 +176,36 @@ export function activateSceneObject(scene: Scene, obj: SceneObject, depth: numbe export function handleSceneClick(scene: Scene, x: number, y: number): void { const world = toWorld(scene, x, y); - const hitObj = scene.getHitObject(world.x, world.y); + + if (scene.activeSubscene) { + const subsceneHit = findTopHitInWorldCandidates( + Array.from(scene.subsceneEntities).filter((obj) => !obj.disabled && obj.visible), + world.x, + world.y + ); + + if (subsceneHit) { + const titleOwner = resolveSubtriggerTarget(scene, subsceneHit); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + if (title && title.trim()) { + scene.game.log(`You see ${title}`); + } + activateSceneObject(scene, subsceneHit); + return; + } + + scene.activeSubscene = null; + return; + } + + const hitObj = findTopHitObject(scene, x, y); if (hitObj) { + const title = scene.game.textAssets.getResolvedObjectField(hitObj, 'title'); + if (title) { + scene.game.log(`You see ${title}`); + } + const isWalkBox = hitObj.components && hitObj.components.some((c) => c.type === 'WalkBox'); const isMechanism = hitObj.components && @@ -46,14 +218,14 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { } } - if (scene.activeSubscene) { - for (const obj of scene.subsceneEntities) { - if (obj.hitTest(world.x, world.y)) { - return; - } + const visibleHitObj = findTopHitObject(scene, x, y) || findVisibleHitObject(scene, x, y); + if (visibleHitObj) { + const titleOwner = resolveSubtriggerTarget(scene, visibleHitObj); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + if (title && title.trim()) { + scene.game.log(`You see ${title}`); + return; } - scene.activeSubscene = null; - return; } if (scene.player) { diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index ca619eb2..02078ce1 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -73,14 +73,14 @@ export class SceneManager { const data = await response.json(); // Pass the derived ID to loadSceneData - this.loadSceneData(data, idFromPath); + await this.loadSceneData(data, idFromPath); } catch (e) { console.error(e); this.game.showNotification?.('Failed to load scene'); } } - loadSceneData(data: any, filename?: string): void { + async loadSceneData(data: any, filename?: string): Promise<void> { try { // Priority: // 1. filename argument (derived from path: "sub\scene") @@ -96,6 +96,8 @@ export class SceneManager { // If ID was missing in File but provided by filename, ensure consistency newScene.id = sceneId; + if (data.description !== undefined) newScene.description = data.description; + if (data.textRedirects) newScene.textRedirects = { ...data.textRedirects }; // Restore Camera if (data.camera) { @@ -162,6 +164,7 @@ export class SceneManager { this.addScene(newScene); this.switchTo(newScene.id); + await this.game.textAssets.preloadScene(newScene); // If Editor is active, it needs to know if (this.game.editor) { diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index 71304674..d772e820 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -584,6 +584,9 @@ export class SceneEditor { newScene.scaling.enabled = true; this.game.sceneManager.addScene(newScene); this.game.sceneManager.switchTo(newScene.id); + this.game.textAssets.ensureSceneAssetFile(newScene).catch((err: unknown) => { + console.error('Failed to create default scene text asset:', err); + }); this.syncUI(); this.refreshHierarchy(); this.selectObject('SCENE'); diff --git a/src/tools/editor/EditorPersistenceManager.ts b/src/tools/editor/EditorPersistenceManager.ts index c5f9b1d5..4b414bcd 100644 --- a/src/tools/editor/EditorPersistenceManager.ts +++ b/src/tools/editor/EditorPersistenceManager.ts @@ -63,6 +63,7 @@ export class EditorPersistenceManager { }); if (response.ok) { + await this.editor.game.textAssets.ensureSceneAssetFile(scene); // Use Toast Message this.editor.game.showNotification(`Scene saved as ${normalizedPath}.json`); } else { diff --git a/src/tools/editor/EditorSelectionManager.ts b/src/tools/editor/EditorSelectionManager.ts index bd891f73..4a5d9679 100644 --- a/src/tools/editor/EditorSelectionManager.ts +++ b/src/tools/editor/EditorSelectionManager.ts @@ -558,7 +558,9 @@ export class EditorSelectionManager { const created: SceneObject[] = []; const preserveQuadBindings = payload.items.length > 1; - orderedItems.forEach((item) => { + orderedItems.forEach((item, index) => { + const originalName = + typeof payload.items[index]?.name === 'string' ? payload.items[index].name : item.name; const sourcePoint = this.getReferencePointFromSerializedData(item); const overrideX = insertionPoint.x + (sourcePoint.x - anchorSourcePoint.x); const overrideY = insertionPoint.y + (sourcePoint.y - anchorSourcePoint.y); @@ -571,7 +573,14 @@ export class EditorSelectionManager { const newObj = this.editor.createObjectFromData(item, overrideX, overrideY, { preserveBindings: preserveQuadBindings && item?.type === 'Quad', }); - if (newObj) created.push(newObj); + if (newObj) { + created.push(newObj); + if (originalName && originalName !== newObj.name) { + this.editor.game.textAssets + .duplicateObjectAssetIfExists(originalName, newObj.name) + .catch((err: unknown) => console.error('Failed to duplicate text asset:', err)); + } + } }); return created; diff --git a/vite.config.ts b/vite.config.ts index 41bf373c..1cdf2ac3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,16 @@ import fs from 'fs'; import path from 'path'; import { exec } from 'child_process'; +function ensureFile(targetPath: string, content: string) { + const dir = path.dirname(targetPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + if (!fs.existsSync(targetPath)) { + fs.writeFileSync(targetPath, content); + } +} + // https://vite.dev/config/ export default defineConfig({ plugins: [ @@ -45,6 +55,30 @@ export default defineConfig({ } }); + server.middlewares.use('/api/ensure-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Ensure file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); + // LIST FILES ENDPOINT server.middlewares.use('/api/list', (req, res, next) => { if (req.method === 'POST') { @@ -90,6 +124,30 @@ export default defineConfig({ next(); } }); + server.middlewares.use('/api/read-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + const fileContent = fs.readFileSync(targetPath, 'utf-8'); + res.statusCode = 200; + res.end(JSON.stringify({ success: true, content: fileContent })); + } catch (err) { + console.error('[Vite] Read file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); // OPEN FOLDER ENDPOINT server.middlewares.use('/api/open-folder', (req, res, next) => { if (req.method === 'POST') { @@ -122,6 +180,57 @@ export default defineConfig({ next(); } }); + server.middlewares.use('/api/open-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + + exec(`start "" "${targetPath}"`); + + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Open file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); + server.middlewares.use('/api/delete-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + if (fs.existsSync(targetPath)) { + fs.unlinkSync(targetPath); + } + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Delete file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); }, }, ], From 126f90d0a103faa39a07778ad68acb10e742eeae Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 00:38:58 +0200 Subject: [PATCH 3/7] Feature: extend TA API and script documentation Add a separate service text-asset layer under public/text/system for parser, engine and script strings, with dotted key lookup and placeholder interpolation via TextAssetManager. Expose game.text(key, params?) as the base runtime access point for service TA and mirror it in ScriptAPI as api.text(key, params?) for script-side convenience. Keep api.log() as output-only, and document the distinction between lookup and output. Implement runtime text redirect mutators on Scene and SceneObject so scripts can switch standard TA fields like description to alternate fields at runtime via scene.setTextRedirect()/clearTextRedirect() and entity.setTextRedirect()/clearTextRedirect(). Migrate the first set of hardcoded strings in Parser, ComponentSystem, SceneInteraction and DemoScripts to service TA lookups, including parser feedback, click titles, distance/lock messages and demo script narration. Expand GDD scripting/API documentation into a coherent guide covering ScriptContext, game/api responsibilities, currentScene access, object lookup helpers, text redirects, entity and actor operations, and the distinction between script context and browser-console globals. --- GDD.md | 308 +++++++++++++++++++++++----- public/text/objects/boombox.json | 4 +- public/text/objects/miles_id_1.json | 4 - public/text/system/engine.json | 6 + public/text/system/parser.json | 16 ++ public/text/system/scripts.json | 6 + src/core/Game.ts | 5 + src/core/IGame.ts | 1 + src/core/ScriptAPI.ts | 4 + src/core/TextAssetManager.ts | 105 ++++++++++ src/entities/SceneObject.ts | 23 +++ src/mechanics/Parser.ts | 45 ++-- src/scene/Scene.ts | 21 ++ src/scene/SceneInteraction.ts | 6 +- src/scripts/DemoScripts.ts | 8 +- src/systems/ComponentSystem.ts | 12 +- 16 files changed, 486 insertions(+), 88 deletions(-) delete mode 100644 public/text/objects/miles_id_1.json create mode 100644 public/text/system/engine.json create mode 100644 public/text/system/parser.json create mode 100644 public/text/system/scripts.json diff --git a/GDD.md b/GDD.md index 44c68070..f42e8101 100644 --- a/GDD.md +++ b/GDD.md @@ -74,6 +74,8 @@ Parser обрабатывает пользовательский ввод кас - _Static_: прямоугольник с координатами X/Y, размерами X/Y, цветом заполнения и опционально спрайтом/анимацией, отображающимся вместо прямоугольника. Спрайт можно переключать на лету. В основном Static это фоны, декоративные элементы и предметы, которые не перемещаются. - _Actor_: объект, который помимо свойств Static имеет направление, в котором он повёрнут и, опционально, спрайты/анимации состояний (idle, walk, talk, etc), причём для каждого направления свой набор. Обычно Actor это NPC и анимированные объекты. Персонаж игрока также является разновидностью Actor. +### ID + Каждая сцена и каждый объект имеют свой уникальный _ID_, который используется для ссылок на них. При этом: 1. id (содержимое поля id/file) для сцен, спрайтов, и также префабов (т.е. сохранённых объектов) может включать один или несколько обратных слешей "\". При сохранении такого объекта слеши работают как маркеры подпапок (относительно дефолтной папки для данного типа объектов), например "home\room1" сохранится как файл room.json в папке home. @@ -81,6 +83,8 @@ Parser обрабатывает пользовательский ввод кас 3. При завершении загрузки объекта сформированный id дополнительно проверяется на предмет совпадения с уже имеющимися в сцене. Если это не уникальный id, то он дополняется до уникального. 4. API при создании объекта или загрузки сцены получает id, трактует его как имя файла с возможным учётом подпапок и загружает его оттуда. +### Свойства сцены + Сцена может поддерживать _Depth-scaling_ -- масштабирование объектов, имитирующее 3d перспективу, когда объекты, находящиеся "дальше от камеры" (то есть, выше по оси Y), становятся меньше. Настройки масштабирования для каждой сцены свои. Кроме того, объекты типа Static и Actor имеют свойство, запрещающее их Depth-scaling. Если Depth-scaling объекта запрещен, то он не изменяет свой размер при изменении Y, даже если Depth-scaling включен для сцены. Это полезно, например, для сцен, где персонаж лезет вертикально вверх по пожарной лестнице, и не должен уменьшаться по мере подъёма, поскольку не удаляется от камеры. @@ -89,7 +93,9 @@ Parser обрабатывает пользовательский ввод кас Важно отметить, что все свойства сцены и всех объектов должны быть доступны для изменения не только в редакторе, но и динамически прямо во время игры, со стороны игровой логики (скриптов). Примерно как свойства в Unity или Unreal Engine. -## Структура классов +## Объекты сцены + +### Структура классов С точки зрения кода класс _SceneObject_ является прародительским для всех объектов сцены в игре, включая Static, Actor, а также полигональные объекты TriggerBox и WalkBox. @@ -101,7 +107,7 @@ SceneObject └── Entity (≈ Static) └── Actor -## Свойства объектов SceneObject +### Свойства объектов SceneObject Эти свойства наследуются всеми объектами в игре: @@ -119,9 +125,9 @@ _Disabled_ : (boolean) _Locked_ : (boolean) Объект может быть заблокирован (Locked) для редактирования в редакторе сцены. Заблокированные объекты нельзя выбрать или переместить кликом мыши на экране (они становятся "прозрачными" для кликов), но их всё ещё можно выбрать в списке объектов. В режиме игры это свойство игнорируется. -## Коллайдеры (Collision Box) +#### Коллайдеры (Collision Box) -Объекты типа Static и Actor имеют свойства `Collider Width` и `Collider Height`, задающие размер прямоугольной области столкновения, которая по X центрирована по объекту, а по Y нижняя граница прямоугольника коллайдера приходится на нижнюю границу спрайта/прямоугольника объекта. То есть, при увеличении высоты коллайдера он растёт вверх, а при увеличении ширины он растёт в обе стороны от центра объекта. +Объекты Entity (Static и Actor имеют свойства `Collider Width` и `Collider Height`, задающие размер прямоугольной области столкновения, которая по X центрирована по объекту, а по Y нижняя граница прямоугольника коллайдера приходится на нижнюю границу спрайта/прямоугольника объекта. То есть, при увеличении высоты коллайдера он растёт вверх, а при увеличении ширины он растёт в обе стороны от центра объекта. - Если размеры коллайдера больше 0, этот объект является препятствием для других объектов (Actor), имеющих коллайдер. - Коллайдер взаимодействует с WalkBox: @@ -129,7 +135,7 @@ _Locked_ : (boolean) - В режиме _Invert_: коллайдер объекта должен полностью находиться внутри разрешенной зоны. - Если размеры коллайдера равны 0, объект считается проходимым, не сталкивается с другими и игнорирует WalkBox. -## Свойства Static +### Свойства Static _Parallax_ Управляет их перемещением при движении камеры. При значении 1 они движутся так же, как другие объекты, при 0.5 движутся вдвое медленней, при 0 остаются вообще неподвижными, а при значениях >1, соответственно, движутся быстрее чем обычные объекты. Это позволяет делать параллаксные фоны с эффектом глубины. Например, спрайт с небом не движется при скроллинге сцены, спрайт с отдалёнными домами движется вдвое медленней, чем остальная сцена, а деревья на переднем плане -- чуть быстрее. @@ -144,7 +150,7 @@ _Visual Effects_ - **Blend Mode**: Режим наложения цвета (Normal, Multiply, Screen, Overlay, etc). - **Blur**: Эффект размытия (в пикселях). -## Свойства Actor +### Свойства Actor Actor это расширение Static. Помимо текущего спрайта, как у Static, имеет направление и (опционально) визуальное состояние. @@ -157,7 +163,7 @@ Actor может иметь сколько угодно групп анимац Для скриптов есть возможность через API переключать состояние. Например, если переключить на группу "talk", то персонаж будет воспроизводить анимации разговора в зависимости от того, куда он повёрнут. Он будет сохранять эту анимацию до тех пор, пока ему не придёт команда перемещаться, тогда он переключится на walk а после остановки автоматически на idle. -## Свойства Quad +### Свойства Quad _Quad_ это примитив, определяемый четырьмя вершинами. В отличие от Static, это не прямоугольник, а произвольный четырёхугольник. Основное назначение -- создание поверхностей и стен с учётом 2.5D перспективы, а также эффектов тени и освещения. @@ -172,8 +178,7 @@ _Sort Mode_ (v0, v1, v2, v3, ignore) ## Компонентная система -Кроме простых свойств, которые есть всегда, объекты сцены могут содержать компоненты (структуры данных), которые могут быть добавлены и удалены в редакторе. Каждый объект может иметь один или несколько компонентов разных типов. Но не любой объект может содержать любой компонент. -Каждый компонент имеет уникальный id компонента. +Кроме простых свойств, которые есть всегда, объекты сцены способны содержать _компоненты_ (структуры данных), которые могут быть добавлены и удалены в редакторе. Каждый объект может иметь один или несколько компонентов разных типов. Но не любой объект может содержать любой компонент. #### Компоненты групп анимаций @@ -266,68 +271,266 @@ Static и Actor могут содержать скриптовые событи > Примечание: События _Always_ и _OnCollide_ зарезервированы в дизайне, но на текущий момент технически не реализованы в движке. +## Текстовые ассеты (TA) + +Наша игра в значительной степени текстовая. Каждый объект или сцена имеет название, а также описания, выдаваемые при различных действиях с объектами, например по методу look ("You see _a desk_"). Есть также тексты, предназначенные не для пользователя, а для SLM/LLM: описывающие возможные действия c предметами, промпты для задания нужной атмосферы и тп. Всё это удобно хранить в виде json файлов. Когда игра загружает сцену и объекты, она читает и текстовые ассеты с ними связанные. + +Текстовые ассеты хранятся в Public\text\ в виде json файлов с именами, совпадающими с id сцен и объектов. + +- `public\text\scenes\<scene-id>.json` +- `public\text\objects\<object-id>.json` + +Поскольку ID могут быть составными, ссылаясь на файлы в подпапках, соответствующие TA тоже могут находиться в подпапках. Например: 'public\text\scenes\home\room.json' для сцены 'home\room' + +Текстовый asset содержит стандартные (используемые движком) поля, а также может содержать дополнительные (кастомные). + +> сейчас стандартными текстовыми полями считаются `title` и `description`. + +Кроме текстовых ассетов сцен и объектов, в проекте есть и **служебные TA** для строк самого движка, парсера, UI и скриптов. Они хранятся отдельно, в `public\text\system\`, разбиваются по доменам (`parser.json`, `engine.json`, `scripts.json`, etc) и адресуются по строковым ключам вида `parser.take_prompt` или `engine.click_you_see`. +Служебные TA не имеют таблицы переадресации. Это просто словари строк, доступных по ключу. +В строках служебных TA допускаются именованные плейсхолдеры, например `{item}` или `{title}`, которые заполняются вызывающим кодом. + # Игровая логика (API & scripting system) Поскольку у нас игра, основанная на сюжетной логике, то нам нужно реагировать на события, такие как: -- столкновения Actor между собой, попадание их в TriggerBox, -- условия, такие как наличие у игрока предмета в инветаре, текущей сцены, присутствия в ней NPC, состояния какой-то внутренней переменной -- команды игрока -- реплики игрока в диалоге NPC и ответы NPC на них +- столкновения Actor между собой, попадание их в TriggerBox; +- условия, такие как наличие у игрока предмета в инветаре, текущей сцены, присутствия в ней NPC, состояния какой-то внутренней переменной; +- команды игрока; +- реплики игрока в диалоге NPC и ответы NPC на них; +- и тд. При этом в качестве реакции на это может потребоваться: -- изменение свойств сцены/объекта (например, скрыть/показать объект, приблизить или отдалить камеру) -- изменение состояния игры (например, изменить внутреннюю переменную timeOfDay на 'night') -- работа с инвентарём игрока (например, взять/забрать предмет) -- перенос игрока или NPC в другую сцену -- работа с анимациями объектов (например, персонаж садится на стул) +- изменение свойств сцены/объекта (например, скрыть/показать объект, приблизить или отдалить камеру); +- изменение состояния игры (например, изменить внутреннюю переменную timeOfDay на 'night'); +- работа с инвентарём игрока (например, взять/забрать предмет); +- перенос игрока или NPC в другую сцену; +- работа с анимациями объектов (например, персонаж садится на стул); - и тд. Комплексный пример: игрок подходит к стене, на которой есть кнопка. Если игрок находится рядом с кнопкой и отдаёт команду нажать на неё, сверху спускается лестница, после чего становится доступна новая команда: "лезь по лестнице". Когда игрок переходит в режим лазания по лестнице, то обычный WalkBox отключается, а включается WalkBox для лестницы, который позволяет персонажу перемещаться лишь вверх и вниз. Кроме того, у персонажа игрока заменяются анимации walk для ходьбы вверх и вниз на анимации лазания вверх и вниз по лестнице, а ещё устанавливается запрет на Depth-scaling, чтобы поднимаясь по лестнице персонаж не уменьшался в размере. Когда игрок долазит до TriggerBox вверху лестницы, он оказывается в другой сцене, при этом свойства его персонажа сбрасываются на дефолтные, то есть он вновь масштабируется и ходит, а не лазит. Очевидно, что это требует какой-то системы скриптов. Для этого мы используем тот же язык, на котором написан движок, то есть Typescript с паттерном Script Registry и API для взаимодействия с игрой. -> Парсер и UI используют этот же API. Например, если пользователь ввёл команду Look <объект> или кликнул наэтот объект, вызовется game.look(object_id) +> Парсер и UI используют этот же API. Например, если пользователь ввёл команду Look <объект> или кликнул на этот объект, вызовется game.look(target_id) ## API -Все скрипты регистрируются в `ScriptRegistry` и получают объект контекста `ScriptContext` со следующими аргументами: +Все скрипты регистрируются в `ScriptRegistry`. При выполнении скрипт получает объект `ScriptContext` со следующими полями: + +- `game`: основной экземпляр игры (`Game.instance`); +- `entity`: объект, на котором сработал скрипт, если он есть; +- `api`: экземпляр `ScriptAPI`, то есть компактная script-oriented обёртка над частью runtime API; +- `args`: опциональные дополнительные аргументы. + +Базовый шаблон скрипта: + +```typescript +ScriptRegistry.register('demo.test', ({ game, entity, api, args }) => { + game.showMessage('Script started'); +}); +``` + +### Видимость и модель доступа + +Важно различать **контекст скрипта** и **контекст браузерной консоли**. + +Штатный игровой скрипт работает только с тем, что передано в `ScriptContext` или доступно через эти ссылки. Нормальный способ доступа к сцене и объектам из скрипта: + +- `game` +- `entity` +- `api` +- `game.sceneManager.currentScene` +- `api.getEntity(name)` +- `api.getActor(name)` +- `api.getQuad(name)` +- `game.sceneManager.currentScene?.findEntity(name)` -- `game`: Ссылка на основной экземпляр игры (`Game.instance`). -- `entity`: Ссылка на объект, на котором сработал скрипт (Entity/Actor/Triggerbox). -- `args`: Опциональные дополнительные аргументы. +Вызовы вида: -### Основные методы +```typescript +Hero.walkTo(100, 100); +``` + +не являются нормальным способом использования API. Такой синтаксис относится к debug-видимости объектов в `window` для браузерной консоли. Он может быть полезен для отладки, но не должен использоваться как опора для игровых скриптов. + +### Доступ через `game` + +`game` — это базовый runtime API. Им пользуются не только скрипты, но и parser, компонентные системы и сам движок. + +Основные методы и свойства, полезные в скриптах: + +- `game.showMessage(text: string)`: выводит сообщение в игровую консоль; +- `game.log(text: string)`: выводит сообщение напрямую в буфер консоли; +- `game.text(key: string, params?: Record<string, string | number>)`: получает строку из служебного TA по ключу; +- `game.playSound(filename: string)`: проигрывает звук из `public/sounds`; +- `game.sceneManager.currentScene`: ссылка на текущую сцену; +- `game.sceneManager.switchTo(sceneId: string)`: переключает игру на другую сцену; +- `game.inventory`: массив предметов в инвентаре игрока. + +Пример: + +```typescript +ScriptRegistry.register('door.locked', ({ game }) => { + game.showMessage(game.text('engine.locked_needs', { item: 'keycard' })); +}); +``` + +### Доступ через `api` + +`api` — это удобная script-side обёртка. Она не заменяет `game`, а сокращает наиболее частые операции. + +Методы `ScriptAPI`: + +- `api.log(text: string)`: выводит текст в игровую консоль; +- `api.text(key: string, params?: Record<string, string | number>)`: получает строку из служебного TA; +- `api.getEntity(name: string)`: возвращает объект сцены по имени; +- `api.getActor(name: string)`: возвращает `Actor` по имени; +- `api.getQuad(name: string)`: возвращает `QuadObject` по имени; +- `api.setTimeout(...)`, `api.clearTimeout(...)`: таймеры; +- `api.setInterval(...)`, `api.clearInterval(...)`: интервалы; +- `api.saveCheckpoint()`: сохраняет текущее состояние сцены в undo history редактора. + +`api.text(...)` и `game.text(...)` по сути делают одно и то же. Разница только в форме доступа: -#### Game +- `game.text(...)` — базовый runtime метод; +- `api.text(...)` — его сокращённая обёртка для скриптов. -- `game.showMessage(text: string)`: Выводит сообщение в игровую консоль/UI. -- `game.playSound(filename: string)`: Проигрывает звуковой файл из папки `public/sounds`. -- `game.sceneManager.switchTo(sceneId: string)`: Загружает и переключает на указанную сцену. +Ни `game.text(...)`, ни `api.text(...)` не выводят текст сами по себе. Они только возвращают строку. + +Примеры: + +```typescript +api.log(api.text('scripts.puzzle_solved')); + +const lamp = api.getEntity('lamp'); +const hero = api.getActor('Hero'); +const floor = api.getQuad('floor_main'); +``` -- 'game.look()' +api.getQuad(name) по сути делает: -#### Entity / Actor +1. берёт game.sceneManager.currentScene +2. ищет объект через scene.findEntity(name) +3. проверяет obj.type === 'Quad' +4. возвращает объект или null -- `entity.setSprite(filename: string)`: Меняет спрайт объекта. -- `entity.description = "..."`: Меняет описание объекта (для команды look). -- `actor.setDirection(dir: 'up'|'down'|'left'|'right')`: Поворачивает персонажа. -- `actor.playAnimSet(id: string)`: Переключает набор анимаций (например, на 'talk'). -- `actor.resetAnimSet()`: Возвращает набор анимаций к дефолтному ('idle'/'walk'). -- `actor.walkTo(x, y)`: Заставляет персонажа идти в указанную точку (с учетом Walkbox). -- `actor.stop()`: Останавливает движение. +Упрощённый эквивалент: + +```typescript +function getQuad(game, name) { + const scene = game.sceneManager.currentScene; + if (!scene) return null; + const obj = scene.findEntity(name); + if (obj && obj.type === 'Quad') { + return obj; + } +} +``` + +### Работа с текущей сценой + +Текущая сцена доступна как: + +```typescript +const scene = game.sceneManager.currentScene; +``` + +Основные полезные методы и свойства сцены: + +- `scene.findEntity(name)`: ищет объект по `id`, `customName` или `title` из TA; +- `scene.resolveTarget(targetStr)`: разрешает цель по `id`, `#group` или смешанному списку целей; +- `scene.setTextRedirect(field, targetField)`: устанавливает runtime-переадресацию стандартного текстового поля сцены на кастомное поле из её TA; +- `scene.clearTextRedirect(field)`: сбрасывает переадресацию; +- `scene.activeSubscene`: текущее состояние Subscene; +- `scene.player`: ссылка на персонажа игрока, если он есть. + +#### Text Redirects + +Каждая сцена и объект _в рантайме_ имеют _таблицу переадресации полей_ TA, позволяющую стандартным полям динамически ссылаться на кастомные поля из того же TA. Если переадресации нет, используется стандартное поле. Если целевое поле отсутствует, движок делает fallback на стандартное поле. + +Например, если мы хотим, чтобы описание сцены зависело от времени суток, можно хранить в TA поля `description`, `description_morning`, `description_evening` и переключать `description` скриптом: + +```typescript +const scene = game.sceneManager.currentScene; +scene?.setTextRedirect('description', 'description_evening'); +``` + +Сброс: + +```typescript +scene?.clearTextRedirect('description'); +``` + +Таблица переадресации, как и другие runtime-изменения сцены, сохраняется вместе с сохранённой игрой. + +### Работа с `entity` + +Если скрипт вызван событием конкретного объекта, он получает его в `entity`. + +Основные операции, доступные на уровне `SceneObject`: + +- `entity.setTextRedirect(field, targetField)` +- `entity.clearTextRedirect(field)` +- `entity.description = '...'` +- `entity.customName = '...'` +- `entity.disabled = true/false` +- `entity.visible = true/false` +- `entity.groupID = '#tag'` +- `entity.layer = number` +- `entity.locked = true/false` + +Если `entity` является `Entity` или `Actor`, также доступны типичные визуальные и пространственные свойства: + +- `entity.x`, `entity.y` +- `entity.scale` +- `entity.parallax` +- `entity.opacity` +- `entity.blur` +- `entity.blendMode` +- `entity.setSprite(filename: string, keepSize?: boolean)` + +Пример: + +```typescript +ScriptRegistry.register('interaction.lamp.use', ({ entity }) => { + entity.visible = false; + entity.setTextRedirect('description', 'description_broken'); +}); +``` + +### Работа с `Actor` + +Если объект является `Actor`, для него доступны методы управления движением и анимацией: + +- `actor.setDirection(dir: 'up' | 'down' | 'left' | 'right')` +- `actor.walkTo(x, y)` +- `actor.moveTo(x, y)` +- `actor.stop()` +- `actor.setState(state)` +- `actor.playAnimSet(id: string)` +- `actor.resetAnimSet()` + +Пример: + +```typescript +ScriptRegistry.register('npc.go_to_door', ({ api }) => { + const hero = api.getActor('Hero'); + hero?.walkTo(180, 140); +}); +``` ### Пример скрипта ```typescript ScriptRegistry.register('interaction.pillar.key', ({ game, entity }) => { - game.showMessage('You insert the key into a hidden slot in the pillar.'); + game.showMessage(game.text('scripts.pillar_key_inserted')); game.playSound('secret_reveal.wav'); // Change pillar appearance entity.setSprite('pillar_open'); - entity.description = 'The pillar is open.'; + entity.description = game.text('scripts.pillar_open_description'); }); ``` @@ -353,10 +556,6 @@ export function registerUserScripts() { } ``` -## Текстовые ресурсы - -Наша игра в значительной степени текстовая. Каждый объект или сцена имеет название, а также описания, выдаваемые при различных дейсвиях с объектами, например по методу look ("You see _a desk_"). Есть также тексты, предназначенные не для пользователя, а для SLM/LLM: описывающие возможные действия c предметами, промпты для задания нужной атмосферы и тп. Всё это удобно хранить в виде текстового файла, или файлов. Когда игра загружает сцену и объекты, она читает и текстовые ассеты с ними связанные. - # Редактор cцены Используется для создания/редактирования cцен и объектов. Включается по нажатию клавиши F1. @@ -462,17 +661,18 @@ Prefab можно загрузить в текущую сцену из файл ### 1. Общие (General) -| Сочетание | Действие | Описание | -+-----------+-------------------+---------- | -| **F1** | Toggle Editor | Открыть/Закрыть редактор сцены | -| **F5** | Sprite Editor | Открыть/Закрыть редактор спрайтов | -| **F9** | Settings | Открыть/Закрыть настройки игры | -| **F2** | Smart Save | Быстрое сохранение сцены (по текущему пути) | -| **Shift+F2** | Save As... | Сохранить сцену как (открывает диалог) | -| **F3** | Load Scene | Загрузить сцену | -| **F4** | New Scene | Создать новую сцену | -| **Alt + L** | Lock Object | Заблокировать/разблокировать объект | -| **Ctrl+Z** | Undo/Redo | Отменить последнее действие | +| Сочетание | Действие | Описание | +| ------------ | ------------- | ----------------------------------------- | +| **F1** | Toggle Editor | Открыть/Закрыть редактор сцены | +| **F5** | Sprite Editor | Открыть/Закрыть редактор спрайтов | +| **F9** | Settings | Открыть/Закрыть настройки игры | +| **F2** | Smart Save | Быстрое сохранение сцены (ID = имя файла) | +| **Shift+F2** | Save As... | Сохранить сцену как... (открывает диалог) | +| **F3** | Load Scene | Загрузить сцену | +| **F4** | New Scene | Создать новую сцену | +| **Alt+L** | Lock Object | Заблокировать/разблокировать объект | +| **Ctrl+Z** | Undo | Отменить последнее действие | +| **Ctrl+R** | Redo | Вернуть отменённое действие | ### 2. Работа с объектами (Object Manipulation) diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json index a405403b..072159a2 100644 --- a/public/text/objects/boombox.json +++ b/public/text/objects/boombox.json @@ -1,5 +1,5 @@ { "title": "Boombox", - "description": "An old cheap tape recorder with a radio.", - "details": "A very basic tape recorder is connected to the computer. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape." + "description": "A compact tape recorder with a radio.", + "details": "The Sharp GF-7 boombox is connected to the computer. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape." } diff --git a/public/text/objects/miles_id_1.json b/public/text/objects/miles_id_1.json deleted file mode 100644 index 442b3058..00000000 --- a/public/text/objects/miles_id_1.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "your ID card", - "description": "You see nothing special." -} diff --git a/public/text/system/engine.json b/public/text/system/engine.json new file mode 100644 index 00000000..b7ff50ca --- /dev/null +++ b/public/text/system/engine.json @@ -0,0 +1,6 @@ +{ + "click_you_see": "You see {title}", + "too_far_generic": "You are too far away.", + "too_far_from_entity": "You are too far away from the {target}.", + "locked_needs": "Locked. Needs {item}" +} diff --git a/public/text/system/parser.json b/public/text/system/parser.json new file mode 100644 index 00000000..8f8e12c1 --- /dev/null +++ b/public/text/system/parser.json @@ -0,0 +1,16 @@ +{ + "look_default_scene": "You are in {scene}.", + "look_default_object": "You see nothing special about the {target}.", + "look_not_found": "You don't see any {target} here.", + "take_prompt": "Take what?", + "take_pickup_success": "You picked up the {item}.", + "take_cannot": "You cannot take that.", + "inventory_empty": "You are not carrying anything.", + "inventory_items": "You are carrying: {items}", + "use_prompt": "Use what?", + "use_format_prompt": "Use what on what? (Format: USE ITEM ON TARGET)", + "use_missing_item": "You don't have the {item}.", + "use_no_effect_pair": "Using the {item} on the {target} does nothing.", + "use_no_effect_single": "You try to use the {target}, but nothing happens.", + "parse_unknown": "I don't understand." +} diff --git a/public/text/system/scripts.json b/public/text/system/scripts.json new file mode 100644 index 00000000..4fd9d570 --- /dev/null +++ b/public/text/system/scripts.json @@ -0,0 +1,6 @@ +{ + "pillar_key_inserted": "You insert the key into a hidden slot in the pillar.", + "pillar_compartment_opened": "Click! A secret compartment opens!", + "pillar_open_description": "The pillar is open, revealing a secret compartment.", + "test_audio_playing": "Playing test sound..." +} diff --git a/src/core/Game.ts b/src/core/Game.ts index 4636f537..932e7ffc 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -152,6 +152,7 @@ export class Game implements IGame { this.assets = new AssetLoader(); this.audio = new AudioManager(); this.textAssets = new TextAssetManager(); + void this.textAssets.preloadServiceAssets(); this.sceneManager = new SceneManager(this); this.editor = new SceneEditor(this); this.spriteEditor = new SpriteEditor(this); @@ -415,6 +416,10 @@ export class Game implements IGame { this.console.log(text); } + text(key: string, params?: Record<string, string | number>): string { + return this.textAssets.getServiceText(key, params); + } + showNotification(text: string): void { if (this.onMessage) { this.onMessage(text); diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 7e61d3bb..752bda81 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -15,6 +15,7 @@ export interface IGame { showMessage(text: string): void; log(text: string): void; + text(key: string, params?: Record<string, string | number>): string; showNotification?(text: string): void; // Optional onSceneChange?(sceneName: string): void; playSound(name: string): void; diff --git a/src/core/ScriptAPI.ts b/src/core/ScriptAPI.ts index 814d76c7..23eb3cf8 100644 --- a/src/core/ScriptAPI.ts +++ b/src/core/ScriptAPI.ts @@ -14,6 +14,10 @@ export class ScriptAPI { this.game.log(message); } + text(key: string, params?: Record<string, string | number>): string { + return this.game.text(key, params); + } + setInterval(handler: TimerHandler, timeout?: number, ...args: any[]): number { const id = setInterval(handler, timeout, ...args); this.intervals.push(id); diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 3b04b75c..9f1df12a 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -3,9 +3,41 @@ import type { SceneObject } from '../entities/SceneObject'; type TextAssetData = Record<string, string>; +const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { + parser: { + look_default_scene: 'You are in {scene}.', + look_default_object: 'You see nothing special about the {target}.', + look_not_found: "You don't see any {target} here.", + take_prompt: 'Take what?', + take_pickup_success: 'You picked up the {item}.', + take_cannot: 'You cannot take that.', + inventory_empty: 'You are not carrying anything.', + inventory_items: 'You are carrying: {items}', + use_prompt: 'Use what?', + use_format_prompt: 'Use what on what? (Format: USE ITEM ON TARGET)', + use_missing_item: "You don't have the {item}.", + use_no_effect_pair: 'Using the {item} on the {target} does nothing.', + use_no_effect_single: 'You try to use the {target}, but nothing happens.', + parse_unknown: "I don't understand.", + }, + engine: { + click_you_see: 'You see {title}', + too_far_generic: 'You are too far away.', + too_far_from_entity: 'You are too far away from the {target}.', + locked_needs: 'Locked. Needs {item}', + }, + scripts: { + pillar_key_inserted: 'You insert the key into a hidden slot in the pillar.', + pillar_compartment_opened: 'Click! A secret compartment opens!', + pillar_open_description: 'The pillar is open, revealing a secret compartment.', + test_audio_playing: 'Playing test sound...', + }, +}; + export class TextAssetManager { private sceneCache = new Map<string, TextAssetData | null>(); private objectCache = new Map<string, TextAssetData | null>(); + private serviceCache = new Map<string, TextAssetData>(); private normalizeId(id: string): string { return String(id || '') @@ -33,6 +65,14 @@ export class TextAssetManager { return `/text/objects/${this.idToRelativePath(objectId)}.json`; } + private getServiceAssetUrl(domain: string): string { + return `/text/system/${domain}.json`; + } + + private getDefaultServiceDomain(domain: string): TextAssetData { + return { ...(DEFAULT_SERVICE_ASSETS[domain] || {}) }; + } + buildDefaultSceneAsset(scene: Scene): TextAssetData { return { title: scene.name || scene.id || 'Untitled Scene', @@ -118,9 +158,31 @@ export class TextAssetManager { ); } + async preloadServiceAssets(domains?: string[]): Promise<void> { + const targetDomains = domains?.length ? domains : Object.keys(DEFAULT_SERVICE_ASSETS); + await Promise.all(targetDomains.map((domain) => this.readServiceAsset(domain, true))); + } + clearCaches(): void { this.sceneCache.clear(); this.objectCache.clear(); + this.serviceCache.clear(); + } + + async readServiceAsset(domain: string, forceReload: boolean = false): Promise<TextAssetData> { + const normalizedDomain = String(domain || '') + .trim() + .toLowerCase(); + if (!normalizedDomain) return {}; + if (!forceReload && this.serviceCache.has(normalizedDomain)) { + return this.serviceCache.get(normalizedDomain) || {}; + } + + const defaults = this.getDefaultServiceDomain(normalizedDomain); + const loaded = await this.fetchJson(this.getServiceAssetUrl(normalizedDomain)); + const merged = { ...defaults, ...(loaded || {}) }; + this.serviceCache.set(normalizedDomain, merged); + return merged; } getResolvedSceneField(scene: Scene, field: string): string | null { @@ -137,6 +199,38 @@ export class TextAssetManager { return this.resolveField(asset, obj?.textRedirects || null, field, fallback); } + getServiceText(key: string, params?: Record<string, string | number>, fallback?: string): string { + const rawKey = String(key || '').trim(); + if (!rawKey) return fallback || ''; + + const dotIndex = rawKey.indexOf('.'); + if (dotIndex === -1) { + console.warn(`[TextAssetManager] Invalid service text key '${rawKey}'.`); + return fallback || rawKey; + } + + const domain = rawKey.slice(0, dotIndex).toLowerCase(); + const entryKey = rawKey.slice(dotIndex + 1); + if (!entryKey) { + console.warn(`[TextAssetManager] Invalid service text key '${rawKey}'.`); + return fallback || rawKey; + } + + if (!this.serviceCache.has(domain)) { + this.serviceCache.set(domain, this.getDefaultServiceDomain(domain)); + void this.readServiceAsset(domain, true); + } + + const domainAsset = this.serviceCache.get(domain) || {}; + const template = domainAsset[entryKey]; + if (typeof template !== 'string') { + console.warn(`[TextAssetManager] Missing service text '${rawKey}'.`); + return fallback || rawKey; + } + + return this.interpolate(template, params); + } + private resolveField( asset: TextAssetData | null | undefined, redirects: Record<string, string> | null | undefined, @@ -175,6 +269,17 @@ export class TextAssetManager { } } + private interpolate( + template: string, + params?: Record<string, string | number> | null | undefined + ): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match, token: string) => { + const value = params[token]; + return value === undefined || value === null ? `{${token}}` : String(value); + }); + } + private async ensureFile(filePath: string, content: string): Promise<void> { await fetch('/api/ensure-file', { method: 'POST', diff --git a/src/entities/SceneObject.ts b/src/entities/SceneObject.ts index d79aafd2..a7bdb6a1 100644 --- a/src/entities/SceneObject.ts +++ b/src/entities/SceneObject.ts @@ -88,6 +88,29 @@ export class SceneObject { }); } + setTextRedirect(field: string, targetField: string): void { + const source = String(field || '').trim(); + const target = String(targetField || '').trim(); + if (!source || !target) return; + this.textRedirects[source] = target; + this.notifyTextRedirectChanged(); + } + + clearTextRedirect(field: string): void { + const source = String(field || '').trim(); + if (!source) return; + if (this.textRedirects[source] === undefined) return; + delete this.textRedirects[source]; + this.notifyTextRedirectChanged(); + } + + private notifyTextRedirectChanged(): void { + const game = (this as any).game; + if (game?.editor?.selectionManager) { + game.editor.selectionManager.notifyObjectChanged(this); + } + } + /** * Checks if a World Coordinate point hits this object. * Base implementation returns false. Subclasses should override. diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 7b8f6dab..67e5ece8 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -56,7 +56,7 @@ export class Parser { const sceneDescription = this.game.textAssets.getResolvedSceneField(scene, 'description') || scene.description || - `You are in ${scene.name}.`; + this.game.text('parser.look_default_scene', { scene: scene.name }); this.game.log(sceneDescription); } else { const entity = scene.findEntity(noun); @@ -71,10 +71,12 @@ export class Parser { const description = this.game.textAssets.getResolvedObjectField(entity, 'description') || entity.description; - this.game.log(description || `You see nothing special about the ${noun}.`); + this.game.log( + description || this.game.text('parser.look_default_object', { target: noun }) + ); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } break; @@ -82,7 +84,7 @@ export class Parser { case 'GET': case 'PICKUP': if (!noun) { - this.game.log('Take what?'); + this.game.log(this.game.text('parser.take_prompt')); } else { const entity = scene.findEntity(noun); if (entity) { @@ -115,12 +117,16 @@ export class Parser { if (isItem || entity.isTakeable) { scene.removeEntity(entity); this.game.inventory.push(entity); - this.game.log(`You picked up the ${entity.customName || entity.name}.`); + this.game.log( + this.game.text('parser.take_pickup_success', { + item: entity.customName || entity.name, + }) + ); } else { - this.game.log('You cannot take that.'); + this.game.log(this.game.text('parser.take_cannot')); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } break; @@ -128,22 +134,22 @@ export class Parser { case 'INVENTORY': case 'I': if (this.game.inventory.length === 0) { - this.game.log('You are not carrying anything.'); + this.game.log(this.game.text('parser.inventory_empty')); } else { const items = this.game.inventory.map((e: any) => e.customName || e.name).join(', '); - this.game.log(`You are carrying: ${items}`); + this.game.log(this.game.text('parser.inventory_items', { items })); } break; case 'USE': if (!noun) { - this.game.log('Use what?'); + this.game.log(this.game.text('parser.use_prompt')); } else { // Check if it's "USE [ID] ON [ID]" vs "USE [ID]" if (noun.includes(' ON ')) { // Parse "USE X ON Y" const parts = noun.split(' ON '); if (parts.length !== 2) { - this.game.log('Use what on what? (Format: USE ITEM ON TARGET)'); + this.game.log(this.game.text('parser.use_format_prompt')); } else { const itemName = parts[0].trim(); const targetName = parts[1].trim(); @@ -153,7 +159,7 @@ export class Parser { (i: any) => (i.customName || i.name).toUpperCase() === itemName.toUpperCase() ); if (!item) { - this.game.log(`You don't have the ${itemName}.`); + this.game.log(this.game.text('parser.use_missing_item', { item: itemName })); } else { // Check if target is in the scene const target = scene.findEntity(targetName); @@ -167,10 +173,15 @@ export class Parser { if (interactionId) { ScriptRegistry.execute(interactionId, { game: this.game, entity: target }); } else { - this.game.log(`Using the ${itemName} on the ${targetName} does nothing.`); + this.game.log( + this.game.text('parser.use_no_effect_pair', { + item: itemName, + target: targetName, + }) + ); } } else { - this.game.log(`You don't see any ${targetName} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: targetName })); } } } @@ -183,16 +194,16 @@ export class Parser { if (interactionId) { ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); } else { - this.game.log(`You try to use the ${noun}, but nothing happens.`); + this.game.log(this.game.text('parser.use_no_effect_single', { target: noun })); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } } break; default: - this.game.log("I don't understand."); + this.game.log(this.game.text('parser.parse_unknown')); } } } diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 73724cd9..c9f7a276 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -12,6 +12,7 @@ import { toVisualPosition } from '../utils/Parallax'; import { updateSceneCamera } from './SceneCamera'; import { resolveSceneTargets, cleanupClosingSubscene } from './SceneSubscene'; import { handleSceneClick, activateSceneObject } from './SceneInteraction'; +import { useEditorStore } from '../store/editorStore'; export interface SceneScaling { enabled: boolean; @@ -165,6 +166,26 @@ export class Scene { }); } + setTextRedirect(field: string, targetField: string): void { + const source = String(field || '').trim(); + const target = String(targetField || '').trim(); + if (!source || !target) return; + this.textRedirects[source] = target; + this.notifyTextRedirectChanged(); + } + + clearTextRedirect(field: string): void { + const source = String(field || '').trim(); + if (!source) return; + if (this.textRedirects[source] === undefined) return; + delete this.textRedirects[source]; + this.notifyTextRedirectChanged(); + } + + private notifyTextRedirectChanged(): void { + useEditorStore.getState().incrementObjectVersion(); + } + getScaling(y: number): number { if (!this.scaling.enabled) return 1.0; diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 63b0614e..49d50145 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -188,7 +188,7 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { const titleOwner = resolveSubtriggerTarget(scene, subsceneHit); const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); if (title && title.trim()) { - scene.game.log(`You see ${title}`); + scene.game.log(scene.game.text('engine.click_you_see', { title })); } activateSceneObject(scene, subsceneHit); return; @@ -203,7 +203,7 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { if (hitObj) { const title = scene.game.textAssets.getResolvedObjectField(hitObj, 'title'); if (title) { - scene.game.log(`You see ${title}`); + scene.game.log(scene.game.text('engine.click_you_see', { title })); } const isWalkBox = hitObj.components && hitObj.components.some((c) => c.type === 'WalkBox'); @@ -223,7 +223,7 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { const titleOwner = resolveSubtriggerTarget(scene, visibleHitObj); const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); if (title && title.trim()) { - scene.game.log(`You see ${title}`); + scene.game.log(scene.game.text('engine.click_you_see', { title })); return; } } diff --git a/src/scripts/DemoScripts.ts b/src/scripts/DemoScripts.ts index 89097c84..a56127ff 100644 --- a/src/scripts/DemoScripts.ts +++ b/src/scripts/DemoScripts.ts @@ -3,11 +3,11 @@ import { ScriptRegistry } from '../core/ScriptRegistry'; // We can improve types later to avoid 'any' export function registerDemoScripts() { ScriptRegistry.register('interaction.pillar.key', ({ game, entity }) => { - game.showMessage('You insert the key into a hidden slot in the pillar.'); - game.showMessage('Click! A secret compartment opens!'); + game.showMessage(game.text('scripts.pillar_key_inserted')); + game.showMessage(game.text('scripts.pillar_compartment_opened')); // Update entity state - entity.description = 'The pillar is open, revealing a secret compartment.'; + entity.description = game.text('scripts.pillar_open_description'); // Example of a permanent state change (we'll adding a real state system later) // game.state.set('pillar_opened', true); @@ -18,7 +18,7 @@ export function registerDemoScripts() { }); ScriptRegistry.register('test.audio', ({ game }) => { - game.showMessage('Playing test sound...'); + game.showMessage(game.text('scripts.test_audio_playing')); game.playSound('drawer_open.wav'); // Ensure it exists in public/sounds }); } diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 4609372f..46e065dd 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -95,7 +95,8 @@ export class ComponentSystem { // Called when trying to TAKE an item // Returns string (error message) or null (success) static canTakeItem(entity: SceneObject, player: Actor | null): string | null { - if (!entity.components) return 'You cannot take that.'; + const game = (entity as any).game as IGame | undefined; + if (!entity.components) return game?.text('parser.take_cannot') || 'You cannot take that.'; const itemComp = entity.components.find((c: any) => c.type === 'Item') as | ItemComponent @@ -112,7 +113,10 @@ export class ComponentSystem { const allowedDist = (player.width || 30) * 4; // Tolerance if (dist > allowedDist) { - return `You are too far away from the ${entity.name}.`; + return ( + game?.text('engine.too_far_from_entity', { target: entity.name }) || + `You are too far away from the ${entity.name}.` + ); } } @@ -178,7 +182,7 @@ export class ComponentSystem { if (dist > allowedDist) { const game = scene.game as unknown as IGame; if (game && typeof game.showMessage === 'function') { - game.showMessage('You are too far away.'); + game.showMessage(game.text('engine.too_far_generic')); } return true; // Blocked } @@ -208,7 +212,7 @@ export class ComponentSystem { (i) => i.name === sw.idKey || (i as unknown as { id?: string }).id === sw.idKey ); if (!hasKey) { - game.showMessage(`Locked. Needs ${sw.idKey}`); + game.showMessage(game.text('engine.locked_needs', { item: sw.idKey })); return true; // Handled (Blocked) } } From 0d9d643b344fc7177454d0efd89ad3736d6b538a Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 00:59:18 +0200 Subject: [PATCH 4/7] Chore: exclude Markdown from pre-commit formatting Remove .md from the lint-staged prettier rule so Husky no longer rewrites Markdown files during pre-commit. Keep JSON/CSS/SCSS formatting and TypeScript checks unchanged. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 34ea177b..16351385 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,6 @@ "prettier --write", "eslint --max-warnings=0 --fix" ], - "*.{json,md,css,scss}": "prettier --write" + "*.{json,css,scss}": "prettier --write" } } From 9f41fca859a4deeb34298fe2e03291983c53cdbe Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 02:17:24 +0200 Subject: [PATCH 5/7] Fix: restore click-to-walk through passive scene objects Only block movement when a clicked object actually handles activation or has a resolved title to show. Passive Static/Quad hits without TA titles no longer swallow mouse movement commands. --- src/scene/SceneInteraction.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 49d50145..662f03bc 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -159,19 +159,22 @@ function resolveSubtriggerTarget(scene: Scene, obj: SceneObject): SceneObject { return target || obj; } -export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): void { +export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): boolean { if (depth > 5) { console.warn('[Scene] Recursion limit reached.'); - return; + return false; } if (ComponentSystem.handleActivation(obj, scene, depth)) { - return; + return true; } if (obj instanceof Triggerbox && obj.script) { // Intentionally silent: triggering handled by systems/scripts + return true; } + + return false; } export function handleSceneClick(scene: Scene, x: number, y: number): void { @@ -201,19 +204,16 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { const hitObj = findTopHitObject(scene, x, y); if (hitObj) { - const title = scene.game.textAssets.getResolvedObjectField(hitObj, 'title'); + const titleOwner = resolveSubtriggerTarget(scene, hitObj); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + const activated = activateSceneObject(scene, hitObj); + if (title) { scene.game.log(scene.game.text('engine.click_you_see', { title })); + return; } - const isWalkBox = hitObj.components && hitObj.components.some((c) => c.type === 'WalkBox'); - const isMechanism = - hitObj.components && - hitObj.components.some((c) => ['Switch', 'Subscene', 'Subtrigger'].includes(c.type)); - const hasScript = hitObj instanceof Triggerbox && hitObj.script && hitObj.script.length > 0; - - if (!(isWalkBox && !isMechanism && !hasScript)) { - activateSceneObject(scene, hitObj); + if (activated) { return; } } From ddde5ad9b2c8a1552077e123b942f3c33331ab00 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 03:05:21 +0200 Subject: [PATCH 6/7] Fix: stabilize click movement across parallax changes Resolve mouse-walk targeting for actors on non-1.0 parallax layers and keep click movement stable when 3d-parallax updates actor parallax during motion. Mouse clicks now drive actors through a visual-space target that is re-projected each frame using the current parallax, while script-driven walkTo/moveTo remain world-space and backward compatible. --- src/entities/Actor.ts | 37 +++++++++++++++++++++++++++++------ src/scene/SceneInteraction.ts | 29 ++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/entities/Actor.ts b/src/entities/Actor.ts index 78d9866c..f4dde7d2 100644 --- a/src/entities/Actor.ts +++ b/src/entities/Actor.ts @@ -2,6 +2,7 @@ import { Entity, type EntityData } from './Entity'; import { Animator } from '../core/Animator'; import { useEditorStore } from '../store/editorStore'; import type { IGame } from '../core/IGame'; +import { toWorldPosition } from '../utils/Parallax'; export type ActorState = 'idle' | 'walk' | 'talk' | 'interact' | string; export type ActorDirection = 'up' | 'down' | 'left' | 'right'; @@ -34,6 +35,7 @@ export class Actor extends Entity { speed: number; target: { x: number; y: number } | null; + visualTarget: { x: number; y: number } | null; readonly type: string = 'Actor'; isPlayer: boolean = false; @@ -63,6 +65,7 @@ export class Actor extends Entity { this.state = 'idle'; this.speed = 0.1; this.target = null; + this.visualTarget = null; this.isPlayer = false; this.animSets = {}; @@ -130,12 +133,21 @@ export class Actor extends Entity { moveTo(x: number, y: number): void { this.target = { x, y }; + this.visualTarget = null; + this.setState('walk'); + this.overrideAnimSet = null; + } + + moveToVisual(x: number, y: number): void { + this.visualTarget = { x, y }; + this.target = null; this.setState('walk'); this.overrideAnimSet = null; } stop(): void { this.target = null; + this.visualTarget = null; this.setState('idle'); } @@ -159,9 +171,22 @@ export class Actor extends Entity { this.handlePlayerInput(deltaTime, isWalkable); } - if (this.state === 'walk' && this.target) { - const dx = this.target.x - this.x; - const dy = this.target.y - this.y; + if (this.state === 'walk' && (this.target || this.visualTarget)) { + const currentTarget = this.visualTarget + ? toWorldPosition( + this.visualTarget, + this.scene?.camera || { x: 0, y: 0 }, + this.parallax !== undefined ? this.parallax : 1.0 + ) + : this.target; + + if (!currentTarget) { + this.stop(); + return; + } + + const dx = currentTarget.x - this.x; + const dy = currentTarget.y - this.y; const dist = Math.sqrt(dx * dx + dy * dy); const p = this.parallax !== undefined ? this.parallax : 1.0; @@ -170,8 +195,8 @@ export class Actor extends Entity { const step = this.speed * speedScale * deltaTime; if (dist <= step) { - this.x = this.target.x; - this.y = this.target.y; + this.x = currentTarget.x; + this.y = currentTarget.y; this.stop(); } else { const moveX = (dx / dist) * step; @@ -258,7 +283,7 @@ export class Actor extends Entity { this.y = nextY; } } - } else if (!this.target) { + } else if (!this.target && !this.visualTarget) { this.setState('idle'); } } diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 662f03bc..56802f9c 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -15,6 +15,22 @@ function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { }; } +function toWorldForParallax( + scene: Scene, + x: number, + y: number, + parallax: number = 1.0 +): { x: number; y: number } { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + return { + x: (x - halfW) / scene.camera.zoom + scene.camera.x * parallax, + y: (y - halfH) / scene.camera.zoom + scene.camera.y * parallax, + }; +} + function findVisibleHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { const screenW = 420; const screenH = 300; @@ -229,10 +245,17 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { } if (scene.player) { - if (typeof scene.player.walkTo === 'function') { - scene.player.walkTo(world.x, world.y); + const visualTarget = toWorld(scene, x, y); + if (typeof (scene.player as any).moveToVisual === 'function') { + (scene.player as any).moveToVisual(visualTarget.x, visualTarget.y); + } else if (typeof scene.player.walkTo === 'function') { + const playerParallax = scene.player.parallax !== undefined ? scene.player.parallax : 1.0; + const playerTarget = toWorldForParallax(scene, x, y, playerParallax); + scene.player.walkTo(playerTarget.x, playerTarget.y); } else if (typeof scene.player.moveTo === 'function') { - scene.player.moveTo(world.x, world.y); + const playerParallax = scene.player.parallax !== undefined ? scene.player.parallax : 1.0; + const playerTarget = toWorldForParallax(scene, x, y, playerParallax); + scene.player.moveTo(playerTarget.x, playerTarget.y); } } } From fc7d59b6900b8e2084117b9cf3a31d0d996628a7 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 03:07:14 +0200 Subject: [PATCH 7/7] Documentation update --- GDD.md | 50 +++++++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/GDD.md b/GDD.md index f42e8101..c05d21b2 100644 --- a/GDD.md +++ b/GDD.md @@ -8,7 +8,7 @@ # Текстовый интерфейс -Наша игра продолжает традиции классических Adventure, которые когда-то были полностью текстовыми. Кроме того, нарратив связан с темой компьютеров. Поэтому у нас есть классическая "консоль терминала" со строкой ввода команд и областью вывода сообщений над ней (буфер консоли). В этот буфер выводятся все введенные пользователем команды и все игровые сообщения (за исключением неигровых, служебных уведомлений движка и редактора сцены, которые выводятся через toast notifications). +Наша игра продолжает традиции классических Adventure, которые изначально когда-то были полностью текстовыми. Кроме того, нарратив связан с темой компьютеров. Поэтому у нас есть классическая "консоль терминала" со строкой ввода команд и областью вывода сообщений над ней (буфер консоли). В этот буфер выводятся все введенные пользователем команды и все игровые сообщения (за исключением неигровых, служебных уведомлений движка и редактора сцены, которые выводятся через toast notifications). Есть два основных формата пользовательского ввода: - **Команда**: указание что нужно сделать, напр. "открой дверь ключом"; @@ -23,8 +23,8 @@ - **закрытое модальное**; - **открытое**. В _закрытом_ состоянии пользователь видит только последние две строки буфера консоли в нижней части экрана, и под ними строку ввода команды. - При нажатии на специальную клавишу на клавиатуре (тильда ~) консоль _открывается_ поверх картинки, почти на весь игровой экран, накладываясь на него с небольшой полупрозрачностью. При этом, в закрытом виде консоль и строка ввода интегрированы в игровую картинку, то есть рисуются на low-res 2d канвасе и поверх накладывается CRT фильтр. В открытом же виде консоль рисуется поверх игровой картинки, в том же слое, что UI редактора, в высоком разрешении, без CRT фильтра,чтобы пользователям было комфортно читать текст. Строка ввода работает и в открытом состоянии, так что пользователи могут вводить команды в консоль не закрывая её. - Для показа важных сообщений, которые не влазят в 2 строки закрытой консоли, она может переходить в _модальный_ режим, когда командная строка убирается, а если текст сообщения не помещается и в три строки, то высота области буфера увеличивается на нужное число строк, чтобы текст сообщения выводился поверх картинки. В модальном режиме после текста сообщения всегда идёт надпись "[Continue]" и ожидается нажатие любой клавиши или клик мыши, после чего происходит переход в обычный режим. + При нажатии на специальную клавишу на клавиатуре (тильда ~) консоль _открывается_ поверх картинки, почти на весь игровой экран, накладываясь на него с небольшой полупрозрачностью. При этом, в закрытом виде консоль и строка ввода интегрированы в игровую картинку, то есть рисуются на low-res канвасе и поверх накладывается CRT фильтр. В открытом же виде консоль рисуется поверх игровой картинки, в том же слое, что UI редактора, в высоком разрешении, без CRT фильтра,чтобы пользователям было комфортно читать текст. Строка ввода работает и в открытом состоянии, так что пользователи могут вводить команды в консоль не закрывая её. + Для показа важных сообщений, которые не влазят в 2 строки закрытой консоли, она может переходить в _модальный_ режим, когда командная строка убирается, а если текст сообщения не помещается и в три строки, то высота области буфера увеличивается на нужное число строк, чтобы текст сообщения выводился поверх картинки. В модальном режиме после текста сообщения всегда идёт мигающая надпись "[Continue]" и ожидается нажатие любой клавиши или клик мыши, после чего происходит переход в обычный режим. Открытая консоль не переходит в модальный режим. Текст открытой консоли можно прокручивать колесом мыши или клавишами Page Up/Down чтобы увидеть более ранние сообщения. Буфер должен быть достаточно большим, порядка 150 Kb. При сохранении игры в файл буфер сохраняется вместе с игрой. @@ -36,12 +36,14 @@ ## Парсер - посредник -Парсер играет роль **посредника** между движком игры и игроком, своеобразного гейм-мастера. Он принимает пользовательский ввод, наряду с контекстом (информацией о сцене, находящихся в ней предметах и NPC, доступны действиях и состояниях). Затем парсер обрабатывает это и даёт команды игровому движку через API, опционально получает возвращаемые API значения и составляет сообщения для пользователя. +Парсер играет роль **посредника** между движком игры и игроком, своеобразного гейм-мастера. Он принимает пользовательский ввод, наряду с контекстом (информацией о сцене, находящихся в ней предметах и NPC, доступныx действиях и состояниях). Затем парсер обрабатывает это и даёт команды игровому движку через API, опционально получает возвращаемые API значения и составляет сообщения для пользователя. + +<Context> ---json---> | | | | +| | ---text--> | <Parser> | ---json--> | <API> | +| <User> | <--text--- | | <--------- | | +| | + -<Context> ---json--> | | | | -| | ---text--> | <Parser> | ---json--> | <API> | -| <User> | <--text--- | | <--------- | | -| | Parser обрабатывает пользовательский ввод каскадно, если каскад не смог обработать команду, она передаётся следующему: @@ -69,11 +71,16 @@ Parser обрабатывает пользовательский ввод кас Сцена это отдельная локация, в которой находится персонаж игрока, и другие объекты. Может занимать один физический экран, либо быть больше его. Сцена может содержать _объекты_ следующих типов: -- _WalkBox_: замкнутый многоугольник, определяющий область, в которой можно перемещаться персонажем игрока (или NPC). Несколько WalkBox могут быть на одной сцене и взаимодействовать друг с другом, в зависимости от их типа: add, substract, invert; -- _TriggerBox_: замкнутый многоугольник, определяющий область, активирующую какие-то события и сюжетную логику, например коллайдер, попав в который персонаж игрока проваливается в люк, переносится в другую сцену, запускает диалог с NPC и т.п; +- _WalkBox_: замкнутый многоугольник, определяющий служебную область, в которой можно перемещаться персонажем игрока (или NPC). Несколько WalkBox могут быть на одной сцене и взаимодействовать друг с другом, в зависимости от их типа: add, substract, invert; + +- _TriggerBox_: замкнутый многоугольник, определяющий служебную область, активирующую какие-то события и сюжетную логику, например коллайдер, попав в который персонаж игрока проваливается в люк, переносится в другую сцену, запускает диалог с NPC и т.п; + - _Static_: прямоугольник с координатами X/Y, размерами X/Y, цветом заполнения и опционально спрайтом/анимацией, отображающимся вместо прямоугольника. Спрайт можно переключать на лету. В основном Static это фоны, декоративные элементы и предметы, которые не перемещаются. + - _Actor_: объект, который помимо свойств Static имеет направление, в котором он повёрнут и, опционально, спрайты/анимации состояний (idle, walk, talk, etc), причём для каждого направления свой набор. Обычно Actor это NPC и анимированные объекты. Персонаж игрока также является разновидностью Actor. +- _Quad_ : четырёхугольный объект, каждая вершина которого обладает отдельным параллаксом. Используется для создания псевдо 3d поверхностей и эффектов типа лучей света и теней. + ### ID Каждая сцена и каждый объект имеют свой уникальный _ID_, который используется для ссылок на них. При этом: @@ -81,7 +88,7 @@ Parser обрабатывает пользовательский ввод кас 1. id (содержимое поля id/file) для сцен, спрайтов, и также префабов (т.е. сохранённых объектов) может включать один или несколько обратных слешей "\". При сохранении такого объекта слеши работают как маркеры подпапок (относительно дефолтной папки для данного типа объектов), например "home\room1" сохранится как файл room.json в папке home. 2. При загрузке такого объекта его id не читается из файла, а формируется с учётом пути относительно дефолтной папки и имени файла, таким образом этот объект загрузится c id "home\room1" а не "room1". Соответственно, если пользователь нажмёт "Save", объект сохранится не в дефолтной папке а в подпапке home как room1. 3. При завершении загрузки объекта сформированный id дополнительно проверяется на предмет совпадения с уже имеющимися в сцене. Если это не уникальный id, то он дополняется до уникального. -4. API при создании объекта или загрузки сцены получает id, трактует его как имя файла с возможным учётом подпапок и загружает его оттуда. +4. Игровой движок при создании объекта или загрузки сцены получает id, трактует его как имя файла с возможным учётом подпапок и загружает его оттуда. ### Свойства сцены @@ -91,21 +98,21 @@ Parser обрабатывает пользовательский ввод кас Сцена имеет свойство, определяющее _положение "камеры"_ (viewport), т.е. задаёт какая область сцены будет отображаться на экране и с каким зумом. Например, при приближении персонажа игрока к краю экрана сцена скроллится. По умолчанию камера позиционируется на персонаже игрока, но позиционированием можно управлять и динамически, например если игрок выходит из дома на улицу, то масштаб изображения может уменьшиться кастомной логикой (скриптом) этой сцены, отдалив камеру чтобы передать ощущение большого открытого пространства. Чтобы облегчить манипуляции с камерой, сцена имеет два значения параметра zoom: дефолтный и текущий. Дефолтный zoom задаётся при создании сцены и применяется при её загрузке, а текущий -- изменяется динамически во время игры и при редактировании. -Важно отметить, что все свойства сцены и всех объектов должны быть доступны для изменения не только в редакторе, но и динамически прямо во время игры, со стороны игровой логики (скриптов). Примерно как свойства в Unity или Unreal Engine. +Важно отметить, что все свойства сцены и всех объектов доступны для изменения не только в редакторе, но и динамически прямо во время игры, со стороны игровой логики (скриптов). Примерно как свойства в Unity или Unreal Engine. ## Объекты сцены ### Структура классов -С точки зрения кода класс _SceneObject_ является прародительским для всех объектов сцены в игре, включая Static, Actor, а также полигональные объекты TriggerBox и WalkBox. +С точки зрения ООП класс _SceneObject_ является прародителем для всех объектов сцены в игре, включая Static, Actor, Quad, а также полигональные служебные области TriggerBox и WalkBox, которые в игре не видны. SceneObject ├── PolygonObject -│ ├── Walkbox -│ └── Triggerbox +│ ├── Walkbox +│ └── Triggerbox ├── QuadObject └── Entity (≈ Static) -└── Actor + └── Actor ### Свойства объектов SceneObject @@ -171,7 +178,8 @@ _Vertices_ Quad имеет 4 вершины. У каждой вершины свои координаты X, Y и свой коэффициент Parallax (P). Это позволяет создавать объекты, которые корректно деформируются при движении камеры, имитируя 3D перспективу. Например, "пол" будет иметь вершины с разным параллаксом: ближние к камере P > 1, дальние P < 1. _Retro Grid Mode_ -Quad может отображаться как "сетка" (Retro-Grid), что соответствует стилистике ретро-футуризма 80х. Настраивается цвет линий, толщина и количество ячеек сетки. Этот режим не отменяет заливку цветом и может использоваться одновременно с ней. +Quad может отображаться как сетка линий (Retro-Grid) в стиле компьютерной графики 80х. Настраивается цвет линий, толщина и количество ячеек сетки. Этот режим не отменяет заливку цветом и может использоваться одновременно с ней. +Помимо эстетической, Retro-Grid несёт и функциональную роль, играя роль сетки для выравнивания объектов относительно друг-друга. Её узлы могут служить точками привязки (когда объект перетаскивается с зажатым <Alt>) наряду с вершинами Quad и Entity. _Sort Mode_ (v0, v1, v2, v3, ignore) Определяет точку сортировки (Z-Sort) для объекта. Поскольку Quad может быть сильно вытянут в глубину (наподобие пола), его центр может быть некорректной точкой для сортировки относительно других объектов (например, персонажа стоящего на этом полу). Режим сортировки позволяет привязать Z-индекс к конкретной вершине (например, самой дальней). @@ -309,7 +317,7 @@ Static и Actor могут содержать скриптовые событи - работа с анимациями объектов (например, персонаж садится на стул); - и тд. -Комплексный пример: игрок подходит к стене, на которой есть кнопка. Если игрок находится рядом с кнопкой и отдаёт команду нажать на неё, сверху спускается лестница, после чего становится доступна новая команда: "лезь по лестнице". Когда игрок переходит в режим лазания по лестнице, то обычный WalkBox отключается, а включается WalkBox для лестницы, который позволяет персонажу перемещаться лишь вверх и вниз. Кроме того, у персонажа игрока заменяются анимации walk для ходьбы вверх и вниз на анимации лазания вверх и вниз по лестнице, а ещё устанавливается запрет на Depth-scaling, чтобы поднимаясь по лестнице персонаж не уменьшался в размере. Когда игрок долазит до TriggerBox вверху лестницы, он оказывается в другой сцене, при этом свойства его персонажа сбрасываются на дефолтные, то есть он вновь масштабируется и ходит, а не лазит. +Комплексный пример: игрок подходит к стене, на которой есть кнопка. Если игрок отдаёт команду "push the button", сверху спускается лестница, после чего становится доступна новая команда: "climb the ladder". Когда игрок переходит в режим лазания по лестнице, то обычный WalkBox отключается, а включается WalkBox для лестницы, который позволяет персонажу перемещаться лишь вверх и вниз. Кроме того, у персонажа игрока заменяются анимации walk для ходьбы вверх и вниз на анимации лазания вверх и вниз по лестнице, а ещё устанавливается запрет на Depth-scaling, чтобы поднимаясь по лестнице персонаж не уменьшался в размере. Когда игрок долазит до TriggerBox вверху лестницы, он оказывается в другой сцене, при этом свойства его персонажа сбрасываются на дефолтные, то есть он вновь масштабируется и ходит, а не лазит. Очевидно, что это требует какой-то системы скриптов. Для этого мы используем тот же язык, на котором написан движок, то есть Typescript с паттерном Script Registry и API для взаимодействия с игрой. @@ -556,7 +564,11 @@ export function registerUserScripts() { } ``` -# Редактор cцены + + + + +# Редактор cцены ################################# Используется для создания/редактирования cцен и объектов. Включается по нажатию клавиши F1. Визуально отображается как набор UI элементов за пределами пользовательского игрового экрана: