diff --git a/.gitignore b/.gitignore index 1dbba20..17513c8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,3 @@ backups/ # OS/editor noise Thumbs.db .DS_Store -.vscode/ diff --git a/docs/kb/request-system-flowchart.md b/docs/kb/request-system-flowchart.md deleted file mode 100644 index 25db960..0000000 --- a/docs/kb/request-system-flowchart.md +++ /dev/null @@ -1,52 +0,0 @@ -# Request System Flowchart - -This flow shows the current Worldshaper request pipeline from public submission through promotion into the public Active request list. - -![Worldshaper Request System Flowchart](./request-system-flowchart.svg) - -```mermaid -flowchart TD - A[User submits request from Launcher Requests tab] --> B[POST /api/launcher-requests] - B --> C[Server normalizes request
status = pending
stores sourceText + fallback title] - C --> D[Request saved in launcher request store] - - D --> E{How does analysis start?} - E -->|Auto / queued worker| F[Request analysis worker selects pending unprocessed requests] - E -->|Admin clicks Run Pending Queue| F - E -->|Admin manual resubmission| F - - F --> G[Mark request analysis.state = processing] - G --> H[Routing pass] - H --> H1[Map slang / loose wording to Worldshaper terminology] - H1 --> H2[Suggest standardized tags, likely systems, likely modules] - H2 --> I[Load only relevant KB docs] - I --> J[Deep analysis pass] - J --> K[Split submission into one or more atomic request items] - K --> L[Generate title, category, standardized tags,
parsed interpretation, implementation approach,
review rationale, and confidence] - - L --> M{Can it auto-promote?} - M -->|Yes| N[Requirements:
all items statusRecommendation = active
every item confidence >= promote threshold
routing ambiguity is not high] - N --> O[POST /api/launcher-requests/:id/process-analysis
action = promote] - O --> P[Server replaces pending submission with one or more active request rows] - P --> Q[Public Requests tab shows those rows as Active] - - M -->|No| R[POST /api/launcher-requests/:id/process-analysis
action = review] - R --> S[Original request stays pending] - S --> T[analysis.state = needs_review or error
routing + analysis metadata saved on the request] - T --> U[Admin reviews request in Admin window] - U --> V{Admin outcome} - V -->|Edit + approve| O - V -->|Edit + resubmit| F - V -->|Leave pending| S -``` - -## Notes - -- Public submission starts as a single `pending` request record, even if the worker later splits it into multiple active items. -- The original `sourceText` is preserved through the workflow. -- Auto-promotion is intentionally strict: - - every analyzed item must recommend `active` - - every item must meet the confidence threshold - - routing ambiguity cannot be `high` -- If the analyzer is unsure, the request is still interpreted and stored with review guidance instead of being dropped. -- Promotion replaces the pending submission with one or more normalized active request rows that appear on the public board. diff --git a/docs/kb/request-system-flowchart.png b/docs/kb/request-system-flowchart.png deleted file mode 100644 index 6ea0f2d..0000000 Binary files a/docs/kb/request-system-flowchart.png and /dev/null differ diff --git a/docs/kb/request-system-flowchart.svg b/docs/kb/request-system-flowchart.svg deleted file mode 100644 index 9452241..0000000 --- a/docs/kb/request-system-flowchart.svg +++ /dev/null @@ -1,148 +0,0 @@ - - Worldshaper Request System Flowchart - Flowchart showing the Worldshaper request system from public submission through automated analysis, review, and promotion into active requests. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Worldshaper Request System - From public submission through review and promotion into the Active request list - - - PUBLIC ENTRY - 1. User submits request - Launcher Requests tab posts raw submission text. - - - - - API WRITE - 2. Server stores pending request - `POST /api/launcher-requests` normalizes the record, - sets `status = pending`, and preserves `sourceText`. - - - - - QUEUE STATE - 3. Request sits in the pending queue - It keeps fallback title, timestamps, and raw source text - until automated or manual analysis begins. - - - - - 4. What starts analysis? - Autorun, admin queue trigger, - or manual resubmission - - - - - WORKER PICKUP - 5. Worker selects eligible pending requests - It targets pending records whose analysis state is still - unprocessed, then marks them as `processing`. - - - - - ROUTING PASS - 6. Route the request into Worldshaper terms - Map loose wording onto standardized tags, likely systems, - and likely modules before the deeper analysis call. - - - - - KB RETRIEVAL - 7. Load only relevant KB sections - The worker pulls matching systems, focused modules, - terminology hints, and standardized tag definitions. - - - - - ANALYSIS PASS - 8. Produce structured request items - Split the submission into atomic items, then generate - title, category, tags, interpretation, implementation path, - review rationale, options, and confidence. - - - - - 9. Can it auto-promote? - Every item must be `active`, - meet confidence threshold, - and avoid high ambiguity - - NO - YES - - - - PROMOTION - 10A. Promote analyzed items - `process-analysis` runs with `action = promote` - and replaces the pending request with active rows. - - - - 11A. Public board lists them as Active - - - - REVIEW HOLD - 10B. Save review metadata on the pending request - The original request stays pending with routing, - analysis items, rationale, and possible options attached. - - - - 11B. Admin reviews, edits, or resubmits - - - Approve or rerun - diff --git a/docs/kb/systems/request-board.md b/docs/kb/systems/request-board.md index a119af9..f18406b 100644 --- a/docs/kb/systems/request-board.md +++ b/docs/kb/systems/request-board.md @@ -41,7 +41,6 @@ Request records can now contain: - Depends on `Launcher Home` for the public site presentation. - Depends on the KB under `docs/kb/` for model-grounded request parsing. - Depends on the request-analysis worker for automated triage. -- Flow reference: `docs/kb/request-system-flowchart.md` ## Triage Hints diff --git a/src/App.tsx b/src/App.tsx index 3339225..39781d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import ConfigSection from "./components/ConfigSection"; import EditorToolbar from "./components/EditorToolbar"; import StatusFooter from "./components/StatusFooter"; import TopNavTabs from "./components/TopNavTabs"; -import { openWorldshaperStudioWindow } from "./shared/windowing"; +import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing"; import { CONFIG_TAB_TO_KEY, DIALOGUE_NODE_FIELD_ORDER, diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index 893a47b..f2a2c02 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -1 +1,2308 @@ -export { default } from "./launcher/WorldshaperLauncher"; +import type { CSSProperties } from "react"; +import { useEffect, useState } from "react"; +import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing"; +import { + CHANGELOG_SECTIONS, + CHANGELOG_SPLASH_FOOTNOTE, + CHANGELOG_SPLASH_KICKER, + CHANGELOG_SPLASH_TITLE, + CHANGELOG_SPLASH_VERSION, +} from "./worldshaperStudio/changelogData"; +import type { ChangelogItem } from "./worldshaperStudio/changelogData"; +import launcherBackground from "../background.png"; + +declare const __APP_BUILD__: string; + +type WorldDefaultPayload = { + worldId?: string; + world?: { + id?: string; + }; +}; + +type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error"; +type BoardTab = "news" | "requests"; +type LauncherWindowMode = "public" | "admin"; +type LauncherRequestStatus = "pending" | "active" | "implemented"; +type AdminDetailTab = "routing" | "analysis"; + +type LauncherRequestAnalysisRouting = { + summary?: string; + ambiguity?: "low" | "medium" | "high"; + matchedTerms?: string[]; + suggestedTags?: string[]; + suggestedSystems?: string[]; + suggestedModules?: string[]; + rationale?: string; + possibleDirections?: string[]; + kbSections?: string[]; +}; + +type LauncherRequest = { + id: string; + sourceSubmissionId?: string; + title: string; + status: LauncherRequestStatus; + category: string; + tags: string[]; + sourceText: string; + summary: string; + implementationNotes: string; + analysis?: { + state?: "unprocessed" | "processing" | "processed" | "needs_review" | "error"; + confidence?: number | null; + model?: string; + createdAt?: string; + updatedAt?: string; + error?: string; + submissionId?: string; + sourceTextSnapshot?: string; + routing?: LauncherRequestAnalysisRouting; + itemCount?: number; + items?: Array<{ + title?: string; + primaryCategory?: string; + tags?: string[]; + statusRecommendation?: string; + parsedInterpretation?: string; + implementationApproach?: string; + affectedSystems?: string[]; + affectedFiles?: string[]; + problemType?: string; + rawExcerpt?: string; + confidence?: number | null; + reviewRationale?: string; + reviewOptions?: string[]; + notes?: string; + }>; + }; + createdAt: string; + updatedAt: string; +}; + +type LauncherRequestAnalysisItem = NonNullable["items"]>[number]; + +type LauncherRequestsPayload = { + requests?: LauncherRequest[]; +}; + +type RecentSaveEvent = { + at?: string; + type?: string; + requestId?: string; + textPreview?: string; + status?: string; + category?: string; + itemCount?: number; + model?: string; + reason?: string; + provider?: string; + pid?: number; + code?: number | null; + signal?: string; + error?: string; +}; + +type RecentSaveEventsPayload = { + saves?: RecentSaveEvent[]; +}; + +type ProcessPendingPayload = { + ok?: boolean; + launched?: boolean; + reason?: string; + autorunEnabled?: boolean; + configured?: boolean; + queuedPendingCount?: number; + pid?: number; +}; + +type RequeueAnalysisPayload = { + ok?: boolean; + launched?: boolean; + reason?: string; + request?: LauncherRequest; + requests?: LauncherRequest[]; + requestId?: string; + queuedPendingCount?: number; + pid?: number; +}; + +type LauncherRequestMetaPayload = { + allowedTags?: string[]; +}; + +type AdminAuthPayload = { + ok?: boolean; + accessGranted?: boolean; + adminConfigured?: boolean; + error?: string; +}; + +const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld"; + +function readLauncherWindowMode(): LauncherWindowMode { + if (typeof window === "undefined") { + return "public"; + } + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get("admin") === "requests" ? "admin" : "public"; +} + +function normalizeStringList(values: string[]): string[] { + return Array.from(new Set( + values + .map((entry) => String(entry || "").trim()) + .filter(Boolean), + )).sort((left, right) => left.localeCompare(right)); +} + +function appendUniqueString(values: string[], value: string): string[] { + const normalizedValue = String(value || "").trim(); + if (!normalizedValue) { + return normalizeStringList(values); + } + return normalizeStringList([...values, normalizedValue]); +} + +function removeStringValue(values: string[], value: string): string[] { + const normalizedValue = String(value || "").trim().toLowerCase(); + return normalizeStringList(values.filter((entry) => entry.trim().toLowerCase() !== normalizedValue)); +} + +function toggleStringSelection(current: string[], value: string): string[] { + return current.includes(value) + ? current.filter((entry) => entry !== value) + : [...current, value].sort((left, right) => left.localeCompare(right)); +} + +function normalizeSearchText(value: string): string { + return String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); +} + +function extractRoutingTerms(requestEntry: LauncherRequest): string[] { + const tagTerms = requestEntry.tags + .map((entry) => String(entry || "").trim()) + .filter(Boolean); + if (tagTerms.length > 0) { + return tagTerms.slice(0, 6); + } + const seen = new Set(); + const stopWords = new Set([ + "the", "and", "for", "with", "that", "this", "from", "into", "have", "need", + "want", "make", "more", "just", "like", "does", "dont", "cannot", "should", + "would", "could", "about", "because", "there", "their", "they", "them", "then", + "than", "over", "under", "your", "while", "where", + ]); + const matches = `${requestEntry.title} ${requestEntry.sourceText}`.match(/[A-Za-z][A-Za-z0-9/-]{2,}/g) || []; + return matches + .map((entry) => entry.trim()) + .filter((entry) => { + const normalized = entry.toLowerCase(); + if (stopWords.has(normalized) || seen.has(normalized)) { + return false; + } + seen.add(normalized); + return true; + }) + .slice(0, 6); +} + +function buildRoutingSummaryFallback(requestEntry: LauncherRequest, firstItem: LauncherRequestAnalysisItem | null): string { + const normalizedSummary = String(requestEntry.summary || "").trim(); + if (normalizedSummary && normalizedSummary !== "Awaiting parsing and categorization.") { + return normalizedSummary; + } + if (firstItem?.parsedInterpretation) { + return String(firstItem.parsedInterpretation).trim(); + } + const normalizedSource = String(requestEntry.sourceText || "").replace(/\s+/g, " ").trim(); + if (normalizedSource) { + return normalizedSource.length > 220 + ? `${normalizedSource.slice(0, 217).trim()}...` + : normalizedSource; + } + return "No routing summary has been stored yet."; +} + +function buildFallbackRouting(requestEntry: LauncherRequest): LauncherRequestAnalysisRouting { + const firstItem = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items[0] : null; + const routedTags = Array.isArray(firstItem?.tags) && firstItem.tags.length > 0 + ? firstItem.tags + : requestEntry.tags.length > 0 + ? requestEntry.tags + : (requestEntry.category && requestEntry.category !== "Unsorted" ? [requestEntry.category] : []); + const likelySystems = Array.isArray(firstItem?.affectedSystems) && firstItem.affectedSystems.length > 0 + ? firstItem.affectedSystems + : (routedTags.length > 0 ? routedTags : []); + const possibleDirections = Array.isArray(firstItem?.reviewOptions) && firstItem.reviewOptions.length > 0 + ? firstItem.reviewOptions + : (requestEntry.implementationNotes.trim() ? [requestEntry.implementationNotes.trim()] : []); + return { + summary: buildRoutingSummaryFallback(requestEntry, firstItem), + ambiguity: requestEntry.status === "pending" ? "medium" : "low", + matchedTerms: extractRoutingTerms(requestEntry), + suggestedTags: Array.isArray(routedTags) ? routedTags : [], + suggestedSystems: likelySystems, + suggestedModules: [], + rationale: String( + firstItem?.reviewRationale + || requestEntry.implementationNotes + || `Routing was reconstructed from the saved request title, tags, and submission text for "${requestEntry.title}".` + ).trim(), + possibleDirections, + kbSections: [], + }; +} + +function mergeRoutingWithFallback( + requestEntry: LauncherRequest, + existingRouting: LauncherRequestAnalysisRouting | undefined, +): LauncherRequestAnalysisRouting { + const fallbackRouting = buildFallbackRouting(requestEntry); + const normalizedRouting = existingRouting || {}; + return { + summary: String(normalizedRouting.summary || "").trim() || fallbackRouting.summary, + ambiguity: normalizedRouting.ambiguity || fallbackRouting.ambiguity, + matchedTerms: Array.isArray(normalizedRouting.matchedTerms) && normalizedRouting.matchedTerms.length > 0 + ? normalizedRouting.matchedTerms + : fallbackRouting.matchedTerms, + suggestedTags: Array.isArray(normalizedRouting.suggestedTags) && normalizedRouting.suggestedTags.length > 0 + ? normalizedRouting.suggestedTags + : fallbackRouting.suggestedTags, + suggestedSystems: Array.isArray(normalizedRouting.suggestedSystems) && normalizedRouting.suggestedSystems.length > 0 + ? normalizedRouting.suggestedSystems + : fallbackRouting.suggestedSystems, + suggestedModules: Array.isArray(normalizedRouting.suggestedModules) && normalizedRouting.suggestedModules.length > 0 + ? normalizedRouting.suggestedModules + : fallbackRouting.suggestedModules, + rationale: String(normalizedRouting.rationale || "").trim() || fallbackRouting.rationale, + possibleDirections: Array.isArray(normalizedRouting.possibleDirections) && normalizedRouting.possibleDirections.length > 0 + ? normalizedRouting.possibleDirections + : fallbackRouting.possibleDirections, + kbSections: Array.isArray(normalizedRouting.kbSections) && normalizedRouting.kbSections.length > 0 + ? normalizedRouting.kbSections + : fallbackRouting.kbSections, + }; +} + +function hydrateLauncherRequestForUi(requestEntry: LauncherRequest): LauncherRequest { + const nextRequest = cloneLauncherRequest(requestEntry); + nextRequest.analysis = { + ...(nextRequest.analysis || {}), + createdAt: nextRequest.analysis?.createdAt || nextRequest.createdAt, + updatedAt: nextRequest.analysis?.updatedAt || nextRequest.updatedAt, + itemCount: nextRequest.analysis?.itemCount ?? (Array.isArray(nextRequest.analysis?.items) ? nextRequest.analysis?.items.length : 0), + items: Array.isArray(nextRequest.analysis?.items) ? nextRequest.analysis.items : [], + routing: mergeRoutingWithFallback(nextRequest, nextRequest.analysis?.routing), + }; + return nextRequest; +} + +function buildRequestSearchCorpus(requestEntry: LauncherRequest): string { + return normalizeSearchText([ + requestEntry.title, + requestEntry.category, + requestEntry.tags.join(" "), + requestEntry.sourceText, + requestEntry.summary, + requestEntry.implementationNotes, + requestEntry.analysis?.routing?.summary, + requestEntry.analysis?.routing?.rationale, + ...(Array.isArray(requestEntry.analysis?.routing?.matchedTerms) ? requestEntry.analysis?.routing?.matchedTerms : []), + ...(Array.isArray(requestEntry.analysis?.items) + ? requestEntry.analysis.items.flatMap((item) => [ + item.title, + item.primaryCategory, + item.parsedInterpretation, + item.implementationApproach, + ...(Array.isArray(item.tags) ? item.tags : []), + ]) + : []), + ].filter(Boolean).join(" ")); +} + +function matchesRequestFilterToken(requestEntry: LauncherRequest, token: string): boolean { + if (token === "pending") { + return requestEntry.status === "pending"; + } + if (token === "queued") { + return isQueuedPendingRequest(requestEntry); + } + if (token === "review") { + return isNeedsReviewRequest(requestEntry); + } + if (token === "active") { + return requestEntry.status === "active"; + } + if (token === "implemented") { + return requestEntry.status === "implemented"; + } + return false; +} + +function requestMatchesFilters( + requestEntry: LauncherRequest, + searchText: string, + statusSelections: string[], + tagSelections: string[], +): boolean { + const normalizedSearchText = normalizeSearchText(searchText); + if (normalizedSearchText && !buildRequestSearchCorpus(requestEntry).includes(normalizedSearchText)) { + return false; + } + if (statusSelections.length > 0 && !statusSelections.some((token) => matchesRequestFilterToken(requestEntry, token))) { + return false; + } + if (tagSelections.length > 0 && !tagSelections.some((tag) => requestEntry.tags.includes(tag))) { + return false; + } + return true; +} + +function FilterIcon() { + return ( + + ); +} + +function LogsIcon() { + return ( + + ); +} + +function SaveIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +function PlayIcon() { + return ( + + ); +} + +type LauncherChipSelectorProps = { + label: string; + values: string[]; + options: string[]; + placeholder: string; + emptyLabel?: string; + onAdd: (value: string) => void; + onRemove: (value: string) => void; +}; + +function LauncherChipSelector({ + label, + values, + options, + placeholder, + emptyLabel = "No tags selected yet.", + onAdd, + onRemove, +}: LauncherChipSelectorProps) { + const availableOptions = options.filter((option) => !values.includes(option)); + return ( +
+
+ {label} + +
+
+ {values.length > 0 ? values.map((value) => ( + + {value} + + + )) : ( + {emptyLabel} + )} +
+
+ ); +} + +async function resolveDefaultWorldId(): Promise { + const response = await fetch("/api/world-default"); + if (!response.ok) { + throw new Error(`Failed to load default world (${response.status}).`); + } + const payload = await response.json() as WorldDefaultPayload; + const resolvedWorldId = String(payload.worldId || payload.world?.id || "").trim(); + return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK; +} + +async function fetchJsonOrThrow(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init); + if (!response.ok) { + let detail = `Request failed (${response.status}).`; + try { + const payload = await response.json() as { error?: string }; + detail = String(payload?.error || detail); + } catch { + // Ignore JSON parse failures and fall back to status text. + } + throw new Error(detail); + } + return response.json() as Promise; +} + +function buildAdminHeaders(password: string, headers?: HeadersInit): HeadersInit { + const normalizedPassword = String(password || "").trim(); + if (!normalizedPassword) { + return { + ...(headers || {}), + }; + } + return { + ...(headers || {}), + "x-worldshaper-admin-password": normalizedPassword, + }; +} + +function isAdminAccessError(error: unknown): boolean { + const text = String(error || "").toLowerCase(); + return text.includes("admin access denied") + || text.includes("admin access is not configured"); +} + +function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest { + return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest; +} + +function formatRequestTimestamp(value: string): string { + const parsed = Date.parse(String(value || "")); + if (!Number.isFinite(parsed)) { + return "Saved recently"; + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(parsed); +} + +function formatRequestSubmittedDate(value: string): string { + const parsed = Date.parse(String(value || "")); + if (!Number.isFinite(parsed)) { + return "Recently"; + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(parsed); +} + +function normalizeAnalysisState(value: string | undefined): string { + return String(value || "").trim().toLowerCase(); +} + +function formatAnalysisStateLabel(value: string | undefined): string { + const normalized = normalizeAnalysisState(value); + if (normalized === "processing") { + return "Processing"; + } + if (normalized === "processed") { + return "Processed"; + } + if (normalized === "needs_review") { + return "Needs Review"; + } + if (normalized === "error") { + return "Error"; + } + return "Unprocessed"; +} + +function getRequestDisplayStateLabel(requestEntry: LauncherRequest): string { + if (requestEntry.status === "implemented") { + return "Implemented"; + } + if (requestEntry.status === "active") { + return "Active"; + } + const analysisState = normalizeAnalysisState(requestEntry.analysis?.state); + if (!analysisState || analysisState === "unprocessed") { + return "Queued"; + } + if (analysisState === "needs_review") { + return "Needs Review"; + } + if (analysisState === "error") { + return "Analysis Error"; + } + if (analysisState === "processed") { + return "Reviewed"; + } + return formatAnalysisStateLabel(analysisState); +} + +function getRequestDisplayStateClassName(requestEntry: LauncherRequest): string { + if (requestEntry.status === "implemented") { + return "implemented"; + } + if (requestEntry.status === "active") { + return "active"; + } + const analysisState = normalizeAnalysisState(requestEntry.analysis?.state); + if (!analysisState || analysisState === "unprocessed") { + return "queued"; + } + if (analysisState === "needs_review") { + return "needs-review"; + } + if (analysisState === "error") { + return "error"; + } + if (analysisState === "processed") { + return "processed"; + } + if (analysisState === "processing") { + return "processing"; + } + return "pending"; +} + +function isQueuedPendingRequest(requestEntry: LauncherRequest): boolean { + const analysisState = normalizeAnalysisState(requestEntry.analysis?.state); + return requestEntry.status === "pending" && (!analysisState || analysisState === "unprocessed" || analysisState === "processing"); +} + +function isNeedsReviewRequest(requestEntry: LauncherRequest): boolean { + const analysisState = normalizeAnalysisState(requestEntry.analysis?.state); + return requestEntry.status === "pending" && (analysisState === "needs_review" || analysisState === "error"); +} + +function formatEventLabel(event: RecentSaveEvent): string { + switch (String(event.type || "").trim()) { + case "launcher-request-add": + return "Request submitted"; + case "launcher-request-delete": + return "Request deleted"; + case "launcher-request-update": + return "Request updated"; + case "launcher-request-review": + return "Analysis saved for review"; + case "launcher-request-promote": + return "Pending request promoted"; + case "launcher-request-analysis-error": + return "Analysis failed"; + case "launcher-request-analysis-launch": + return "Queue worker launched"; + case "launcher-request-analysis-finish": + return "Queue worker finished"; + case "launcher-request-analysis-launch-error": + return "Queue worker launch error"; + case "launcher-request-analysis-requeue": + return "Request requeued for review"; + default: + return String(event.type || "Event"); + } +} + +function formatEventDetail(event: RecentSaveEvent): string { + const parts = [ + event.requestId ? `Request ${event.requestId}` : "", + event.category ? `Category ${event.category}` : "", + event.status ? `Status ${event.status}` : "", + event.itemCount ? `${event.itemCount} item${event.itemCount === 1 ? "" : "s"}` : "", + event.provider ? `Provider ${event.provider}` : "", + event.model ? `Model ${event.model}` : "", + event.reason ? `Reason ${event.reason}` : "", + event.pid ? `PID ${event.pid}` : "", + Number.isFinite(Number(event.code)) ? `Exit ${event.code}` : "", + event.signal ? `Signal ${event.signal}` : "", + event.error ? String(event.error) : "", + event.textPreview ? `Preview: ${event.textPreview}` : "", + ].filter(Boolean); + return parts.join(" • "); +} + +function openStudioPopup(worldId: string): boolean { + const popup = openWorldshaperStudioWindow(worldId, window, { worldId }); + return Boolean(popup); +} + +function openRepo(): void { + window.location.assign("https://repo.andraxion.net/"); +} + +function openAdminPanelWindow(): boolean { + const nextUrl = new URL(window.location.href); + nextUrl.searchParams.set("admin", "requests"); + nextUrl.searchParams.set("tab", "requests"); + const popup = window.open(nextUrl.toString(), "worldshaper-admin-panel", "popup=yes,width=1620,height=980,resizable=yes,scrollbars=yes"); + if (popup) { + popup.focus(); + } + return Boolean(popup); +} + +function WorldshaperLauncher() { + const launcherWindowMode = readLauncherWindowMode(); + const adminWindowMode = launcherWindowMode === "admin"; + const [launchState, setLaunchState] = useState("ready"); + const [error, setError] = useState(""); + const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK); + const [activeBoardTab, setActiveBoardTab] = useState(adminWindowMode ? "requests" : "news"); + const [requests, setRequests] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(true); + const [requestsError, setRequestsError] = useState(""); + const [requestDraftOpen, setRequestDraftOpen] = useState(false); + const [requestDraft, setRequestDraft] = useState(""); + const [requestSubmitting, setRequestSubmitting] = useState(false); + const [requestMutatingId, setRequestMutatingId] = useState(""); + const [requestSearchText, setRequestSearchText] = useState(""); + const [requestFilterMenuOpen, setRequestFilterMenuOpen] = useState(false); + const [requestStatusFilters, setRequestStatusFilters] = useState([]); + const [requestTagFilters, setRequestTagFilters] = useState([]); + const [allowedRequestTags, setAllowedRequestTags] = useState([]); + const [expandedRequestIds, setExpandedRequestIds] = useState([]); + const [adminAccessGranted, setAdminAccessGranted] = useState(false); + const [adminPassword, setAdminPassword] = useState(""); + const [adminPasswordDraft, setAdminPasswordDraft] = useState(""); + const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false); + const [adminPasswordError, setAdminPasswordError] = useState(""); + const [selectedAdminRequestId, setSelectedAdminRequestId] = useState(""); + const [selectedAdminAnalysisIndex, setSelectedAdminAnalysisIndex] = useState(0); + const [adminSearchText, setAdminSearchText] = useState(""); + const [adminFilterMenuOpen, setAdminFilterMenuOpen] = useState(false); + const [adminStatusFilters, setAdminStatusFilters] = useState([]); + const [adminTagFilters, setAdminTagFilters] = useState([]); + const [adminEditorDraft, setAdminEditorDraft] = useState(null); + const [adminDetailTab, setAdminDetailTab] = useState("routing"); + const [adminSaving, setAdminSaving] = useState(false); + const [recentSaveEvents, setRecentSaveEvents] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + const [logsModalOpen, setLogsModalOpen] = useState(false); + const [logsError, setLogsError] = useState(""); + const [queueTriggering, setQueueTriggering] = useState(false); + const [requeueingMode, setRequeueingMode] = useState<"" | "saved" | "draft">(""); + const [adminNotice, setAdminNotice] = useState(""); + const adminPanelOpen = adminWindowMode; + + useEffect(() => { + let cancelled = false; + void resolveDefaultWorldId() + .then((resolvedWorldId) => { + if (cancelled) { + return; + } + setWorldId(resolvedWorldId); + }) + .catch(() => { + if (cancelled) { + return; + } + setWorldId(DEFAULT_EDITOR_WORLD_ID_FALLBACK); + }); + + return () => { + cancelled = true; + }; + }, []); + + async function loadRequests(options?: { silent?: boolean }): Promise { + const silent = options?.silent === true; + if (!silent) { + setRequestsLoading(true); + } + try { + const payload = await fetchJsonOrThrow("/api/launcher-requests"); + setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []); + setRequestsError(""); + } catch (nextError: unknown) { + setRequestsError(String(nextError || "Failed to load requests.")); + } finally { + if (!silent) { + setRequestsLoading(false); + } + } + } + + async function loadRequestMeta(): Promise { + try { + const payload = await fetchJsonOrThrow("/api/launcher-request-meta"); + setAllowedRequestTags(Array.isArray(payload.allowedTags) ? payload.allowedTags : []); + } catch { + setAllowedRequestTags([]); + } + } + + async function loadRecentSaveEvents(): Promise { + setLogsLoading(true); + try { + const payload = await fetchJsonOrThrow("/api/debug/recent-saves", { + headers: buildAdminHeaders(adminPassword), + }); + setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []); + setLogsError(""); + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + } + setLogsError(String(nextError || "Failed to load admin logs.")); + } finally { + setLogsLoading(false); + } + } + + async function verifyAdminPassword(password: string): Promise { + const payload = await fetchJsonOrThrow("/api/admin/auth-check", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ password }), + }); + if (!payload.accessGranted) { + throw new Error(String(payload.error || "Admin access denied.")); + } + } + + async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise { + await loadRequests({ silent: options?.silentRequests === true }); + if (options?.includeLogs && adminAccessGranted && adminPassword) { + await loadRecentSaveEvents(); + } + } + + useEffect(() => { + void loadRequests(); + void loadRequestMeta(); + }, []); + + useEffect(() => { + if (!adminPanelOpen || !adminAccessGranted || !adminPassword) { + return; + } + void loadRecentSaveEvents(); + }, [adminPanelOpen, adminAccessGranted, adminPassword]); + + useEffect(() => { + if (activeBoardTab !== "requests") { + return; + } + let cancelled = false; + const refreshBoard = async (): Promise => { + if (!adminPanelOpen) { + try { + const payload = await fetchJsonOrThrow("/api/launcher-requests"); + if (!cancelled) { + setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []); + } + } catch { + // Keep the current list visible during background refresh failures. + } + } + if (!adminPanelOpen || !adminAccessGranted || !adminPassword) { + return; + } + try { + const payload = await fetchJsonOrThrow("/api/debug/recent-saves", { + headers: buildAdminHeaders(adminPassword), + }); + if (!cancelled) { + setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []); + } + } catch { + // Avoid surfacing noisy polling failures in the admin panel. + } + }; + const intervalId = window.setInterval(() => { + void refreshBoard(); + }, 15000); + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]); + + useEffect(() => { + if (!adminPanelOpen || !adminAccessGranted) { + return; + } + if (requests.length === 0) { + setSelectedAdminRequestId(""); + setAdminEditorDraft(null); + return; + } + const selectedRequest = requests.find((entry) => entry.id === selectedAdminRequestId); + if (selectedRequest) { + if (!adminEditorDraft || adminEditorDraft.id !== selectedRequest.id) { + setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(selectedRequest))); + } + return; + } + setSelectedAdminRequestId(requests[0].id); + setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(requests[0]))); + }, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]); + + useEffect(() => { + setSelectedAdminAnalysisIndex(0); + }, [selectedAdminRequestId]); + + useEffect(() => { + setAdminDetailTab("routing"); + }, [selectedAdminRequestId]); + + async function handleLaunch(): Promise { + setError(""); + const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK; + setLaunchState("opening"); + try { + const resolvedWorldId = nextWorldId || await resolveDefaultWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK); + setWorldId(resolvedWorldId); + if (openStudioPopup(resolvedWorldId)) { + setLaunchState("opened"); + return; + } + setLaunchState("blocked"); + } catch (nextError: unknown) { + const nextErrorText = String(nextError || "Failed to prepare Worldshaper Studio."); + setLaunchState("error"); + setError(nextErrorText); + } + } + + async function handleAddRequest(): Promise { + const text = requestDraft.trim(); + if (!text) { + setRequestsError("Write a request before saving it."); + return; + } + setRequestSubmitting(true); + try { + const payload = await fetchJsonOrThrow("/api/launcher-requests", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ text }), + }); + setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []); + setRequestDraft(""); + setRequestDraftOpen(false); + setRequestsError(""); + setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled."); + if (adminPanelOpen && adminAccessGranted) { + void loadRecentSaveEvents(); + } + window.setTimeout(() => { + void refreshAdminData({ includeLogs: adminPanelOpen && adminAccessGranted, silentRequests: true }); + }, 3500); + } catch (nextError: unknown) { + setRequestsError(String(nextError || "Failed to save request.")); + } finally { + setRequestSubmitting(false); + } + } + + async function handleAdminPanelToggle(): Promise { + setRequestDraftOpen(false); + setRequestsError(""); + setLogsError(""); + if (adminWindowMode) { + return; + } + if (!openAdminPanelWindow()) { + setAdminNotice("Allow popups to open the admin review window."); + } + } + + async function handleAdminUnlock(): Promise { + const submittedPassword = adminPasswordDraft.trim(); + if (!submittedPassword) { + setAdminPasswordError("Enter the admin password to continue."); + return; + } + setAdminAuthSubmitting(true); + setAdminPasswordError(""); + try { + await verifyAdminPassword(submittedPassword); + setAdminPassword(submittedPassword); + setAdminAccessGranted(true); + setAdminNotice("Admin access granted."); + await refreshAdminData({ includeLogs: true, silentRequests: true }); + } catch (nextError: unknown) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError(String(nextError || "Failed to unlock the admin panel.")); + } finally { + setAdminAuthSubmitting(false); + } + } + + function handleSelectAdminRequest(requestId: string): void { + const nextRequest = requests.find((entry) => entry.id === requestId); + setSelectedAdminRequestId(requestId); + setSelectedAdminAnalysisIndex(0); + setAdminEditorDraft(nextRequest ? cloneLauncherRequest(hydrateLauncherRequestForUi(nextRequest)) : null); + setAdminNotice(""); + setAdminPasswordError(""); + } + + function updateAdminDraft(updater: (current: LauncherRequest) => LauncherRequest): void { + setAdminEditorDraft((current) => (current ? updater(current) : current)); + } + + function updateAdminDraftItem( + itemIndex: number, + updater: (item: LauncherRequestAnalysisItem) => LauncherRequestAnalysisItem, + ): void { + updateAdminDraft((current) => { + const next = cloneLauncherRequest(current); + if (!next.analysis) { + next.analysis = { + state: "needs_review", + items: [], + }; + } + const items = Array.isArray(next.analysis.items) ? [...next.analysis.items] : []; + const existingItem = items[itemIndex] || {}; + items[itemIndex] = updater({ + ...existingItem, + tags: Array.isArray(existingItem.tags) ? [...existingItem.tags] : [], + affectedSystems: Array.isArray(existingItem.affectedSystems) ? [...existingItem.affectedSystems] : [], + affectedFiles: Array.isArray(existingItem.affectedFiles) ? [...existingItem.affectedFiles] : [], + reviewOptions: Array.isArray(existingItem.reviewOptions) ? [...existingItem.reviewOptions] : [], + }); + next.analysis.items = items; + next.analysis.itemCount = items.length; + next.analysis.updatedAt = new Date().toISOString(); + return next; + }); + } + + function buildAdminSavePayload(requestEntry: LauncherRequest): RequestInit { + return { + method: "PATCH", + headers: buildAdminHeaders(adminPassword, { + "Content-Type": "application/json", + }), + body: JSON.stringify({ + title: requestEntry.title, + status: requestEntry.status, + category: requestEntry.category, + tags: requestEntry.tags, + sourceText: requestEntry.sourceText, + summary: requestEntry.summary, + implementationNotes: requestEntry.implementationNotes, + analysis: requestEntry.analysis, + }), + }; + } + + async function handleSaveAdminRequest(): Promise { + if (!adminEditorDraft) { + return; + } + setAdminSaving(true); + try { + const payload = await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>( + `/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}`, + buildAdminSavePayload(adminEditorDraft), + ); + const nextRequests = Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : requests; + setRequests(nextRequests); + const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || hydrateLauncherRequestForUi(payload.request || adminEditorDraft); + setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(refreshed))); + setAdminNotice(`Saved admin changes for "${adminEditorDraft.title}".`); + if (adminPanelOpen) { + void loadRecentSaveEvents(); + } + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } + setLogsError(String(nextError || "Failed to save admin changes.")); + } finally { + setAdminSaving(false); + } + } + + async function handleApproveAdminRequest(): Promise { + if (!adminEditorDraft) { + return; + } + const nextDraft = cloneLauncherRequest(adminEditorDraft); + if (!nextDraft.analysis) { + setLogsError("This request has no analysis to approve yet."); + return; + } + const items = Array.isArray(nextDraft.analysis.items) ? nextDraft.analysis.items : []; + if (items.length === 0) { + setLogsError("This request does not have a structured analysis item to approve yet."); + return; + } + nextDraft.analysis.items = items.map((item) => ({ + ...item, + statusRecommendation: "active", + })); + nextDraft.analysis.state = "processed"; + nextDraft.analysis.updatedAt = new Date().toISOString(); + setAdminEditorDraft(nextDraft); + setAdminSaving(true); + try { + await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>( + `/api/launcher-requests/${encodeURIComponent(nextDraft.id)}`, + buildAdminSavePayload(nextDraft), + ); + const promotePayload = await fetchJsonOrThrow( + `/api/launcher-requests/${encodeURIComponent(nextDraft.id)}/process-analysis`, + { + method: "POST", + headers: buildAdminHeaders(adminPassword, { + "Content-Type": "application/json", + }), + body: JSON.stringify({ + action: "promote", + analysis: nextDraft.analysis, + }), + }, + ); + const nextRequests = Array.isArray(promotePayload.requests) ? promotePayload.requests.map(hydrateLauncherRequestForUi) : []; + setRequests(nextRequests); + const fallbackSelection = nextRequests[0] || null; + setSelectedAdminRequestId(fallbackSelection?.id || ""); + setAdminEditorDraft(fallbackSelection ? cloneLauncherRequest(hydrateLauncherRequestForUi(fallbackSelection)) : null); + setAdminNotice(`Approved "${nextDraft.title}" and promoted its active request item${(nextDraft.analysis.items?.length || 0) === 1 ? "" : "s"}.`); + if (adminPanelOpen) { + void loadRecentSaveEvents(); + } + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } + setLogsError(String(nextError || "Failed to approve this request.")); + } finally { + setAdminSaving(false); + } + } + + async function handleRequeueAnalysis(mode: "saved" | "draft"): Promise { + if (!adminEditorDraft) { + return; + } + setRequeueingMode(mode); + setLogsError(""); + try { + const payload = await fetchJsonOrThrow( + `/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}/requeue-analysis`, + { + method: "POST", + headers: buildAdminHeaders(adminPassword, { + "Content-Type": "application/json", + }), + body: JSON.stringify({ + mode, + request: mode === "draft" + ? { + title: adminEditorDraft.title, + category: adminEditorDraft.category, + tags: adminEditorDraft.tags, + sourceText: adminEditorDraft.sourceText, + summary: adminEditorDraft.summary, + implementationNotes: adminEditorDraft.implementationNotes, + } + : undefined, + }), + }, + ); + const nextRequests = Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : requests; + setRequests(nextRequests); + const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || hydrateLauncherRequestForUi(payload.request || adminEditorDraft); + setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(refreshed))); + if (payload.launched) { + setAdminNotice(mode === "draft" + ? "Edited draft resubmitted to the analyzer." + : "Saved request resubmitted to the analyzer."); + } else { + const reason = String(payload.reason || "no-op"); + if (reason === "request-analysis-already-running") { + setAdminNotice("The queue worker is already running. This request will be picked up on the next pass."); + } else if (reason === "request-not-queued") { + setAdminNotice("That request is not currently eligible for review reruns."); + } else { + setAdminNotice(`Review rerun returned: ${reason}.`); + } + } + await refreshAdminData({ includeLogs: true, silentRequests: true }); + window.setTimeout(() => { + void refreshAdminData({ includeLogs: true, silentRequests: true }); + }, 4200); + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } + setLogsError(String(nextError || "Failed to requeue this request for review.")); + } finally { + setRequeueingMode(""); + } + } + + function handleToggleExpandedRequest(requestId: string): void { + setExpandedRequestIds((current) => ( + current.includes(requestId) + ? current.filter((entry) => entry !== requestId) + : [...current, requestId] + )); + } + + async function handleDeleteRequest(requestEntry: LauncherRequest): Promise { + const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.title}`); + if (!confirmed) { + return; + } + setRequestMutatingId(requestEntry.id); + try { + const payload = await fetchJsonOrThrow(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, { + method: "DELETE", + headers: buildAdminHeaders(adminPassword), + }); + setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []); + setRequestsError(""); + setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id)); + setAdminNotice(`Deleted request "${requestEntry.title}".`); + if (adminPanelOpen) { + void loadRecentSaveEvents(); + } + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } + setRequestsError(String(nextError || "Failed to delete request.")); + } finally { + setRequestMutatingId(""); + } + } + + async function handleProcessPendingQueue(): Promise { + setQueueTriggering(true); + try { + const payload = await fetchJsonOrThrow("/api/launcher-requests/process-pending", { + method: "POST", + headers: buildAdminHeaders(adminPassword), + }); + if (payload.launched) { + setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`); + } else { + const reason = String(payload.reason || "no-op"); + if (reason === "no-pending-requests") { + setAdminNotice("No unprocessed pending requests are waiting in the queue."); + } else if (reason === "request-analysis-already-running") { + setAdminNotice("The request analysis worker is already running on the VPS."); + } else if (reason === "request-analysis-not-configured") { + setAdminNotice("Request analysis is not configured on the server."); + } else { + setAdminNotice(`Queue trigger returned: ${reason}.`); + } + } + await refreshAdminData({ includeLogs: true, silentRequests: true }); + if (payload.launched) { + window.setTimeout(() => { + void refreshAdminData({ includeLogs: true, silentRequests: true }); + }, 4200); + } + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } + setLogsError(String(nextError || "Failed to trigger the queue worker.")); + } finally { + setQueueTriggering(false); + } + } + + const isBusy = launchState === "opening"; + const requestCount = requests.length; + const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length; + const activeRequestCount = requests.filter((entry) => entry.status === "active").length; + const implementedRequestCount = requests.filter((entry) => entry.status === "implemented").length; + const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length; + const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length; + const requestTags = (allowedRequestTags.length > 0 + ? allowedRequestTags + : Array.from(new Set( + requests + .flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : []) + .map((entry) => String(entry || "").trim()) + .filter(Boolean), + ))).sort((a, b) => a.localeCompare(b)); + const requestTagFilterOptions = requestTags + .map((tag) => ({ + tag, + count: requests.filter((entry) => entry.tags.includes(tag)).length, + })) + .filter((entry) => entry.count > 0); + const requestStatusFilterOptions = [ + { id: "pending", label: "Pending", count: pendingRequestCount }, + { id: "queued", label: "Queued", count: queuedPendingRequestCount }, + { id: "review", label: "Needs Review", count: needsReviewRequestCount }, + { id: "active", label: "Active", count: activeRequestCount }, + { id: "implemented", label: "Implemented", count: implementedRequestCount }, + ].filter((entry) => entry.count > 0); + const filteredRequests = requests.filter((entry) => requestMatchesFilters( + entry, + requestSearchText, + requestStatusFilters, + requestTagFilters, + )); + const adminFilteredRequests = requests.filter((entry) => requestMatchesFilters( + entry, + adminSearchText, + adminStatusFilters, + adminTagFilters, + )); + const selectedAnalysisItem = adminEditorDraft?.analysis?.items?.[selectedAdminAnalysisIndex] || null; + const standardizedTagOptions = normalizeStringList([ + ...allowedRequestTags, + ...requestTags, + ...requests.flatMap((entry) => [ + ...entry.tags, + ...(Array.isArray(entry.analysis?.routing?.suggestedTags) ? entry.analysis.routing.suggestedTags : []), + ...(Array.isArray(entry.analysis?.items) ? entry.analysis.items.flatMap((item) => Array.isArray(item.tags) ? item.tags : []) : []), + ]), + ...(adminEditorDraft?.tags || []), + ...(Array.isArray(adminEditorDraft?.analysis?.routing?.suggestedTags) ? adminEditorDraft.analysis.routing.suggestedTags : []), + ...(Array.isArray(selectedAnalysisItem?.tags) ? selectedAnalysisItem.tags : []), + ]); + const categoryOptions = normalizeStringList([ + ...standardizedTagOptions, + ...requests.map((entry) => entry.category), + ...requests.flatMap((entry) => Array.isArray(entry.analysis?.items) ? entry.analysis.items.map((item) => String(item.primaryCategory || "")) : []), + String(adminEditorDraft?.category || ""), + String(selectedAnalysisItem?.primaryCategory || ""), + ]); + const boardTitle = adminWindowMode ? "Worldshaper Admin" : "Worldshaper Board"; + const boardHint = adminWindowMode + ? `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented` + : (activeBoardTab === "news" + ? "Latest announcements" + : `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`); + + return ( +
+
+ {!adminWindowMode ? ( +
+
+
+
+

Worldshaper Studio

+
+
+ + +
+ {launchState === "blocked" ?

Popup blocked. Allow popups, then press Launch again.

: null} + {error ?

{error}

: null} +
+
+
+ ) : null} +
+
+
{boardTitle}
+
{boardHint}
+
+
+
+ {!adminWindowMode ? ( +
+ + +
+ ) : null} + {activeBoardTab === "news" ? ( +
+
+
{CHANGELOG_SPLASH_KICKER}
+
{CHANGELOG_SPLASH_TITLE}
+
Release {CHANGELOG_SPLASH_VERSION}
+
+
+ {CHANGELOG_SECTIONS.map((section) => ( +
+

{section.title}

+
    + {section.items.map((item, index) => { + const key = `${section.title}-${index}`; + const normalizedItem: ChangelogItem = item; + if (typeof normalizedItem === "string") { + return
  • {normalizedItem}
  • ; + } + return ( +
  • +
    {normalizedItem.text}
    + {normalizedItem.note ?
    {normalizedItem.note}
    : null} +
  • + ); + })} +
+
+ ))} +
+
+
{CHANGELOG_SPLASH_FOOTNOTE}
+
+
+ ) : ( +
+ {!adminWindowMode ? ( +
+
Shared Request Board
+
Requests
+
+ {requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, {activeRequestCount} active, and {implementedRequestCount} implemented. +
+
+
+ + {!adminPanelOpen ? ( + + ) : null} +
+
+ setRequestSearchText(event.target.value)} + placeholder="Search requests..." + /> +
+ + {requestFilterMenuOpen ? ( +
+ {requestStatusFilterOptions.length > 0 ? ( +
+
Status
+ {requestStatusFilterOptions.map((option) => ( + + ))} +
+ ) : null} + {requestTagFilterOptions.length > 0 ? ( +
+
Tags
+ {requestTagFilterOptions.map(({ tag, count }) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+
+
+
+ ) : null} + {adminPanelOpen ? ( +
+ {!adminAccessGranted ? ( +
+
Protected Tools
+

Admin Access Required

+

+ Enter the admin password to manage deletions, run the queue worker, and read request logs. +

+ +
+ +
+ {adminPasswordError ?

{adminPasswordError}

: null} +
+ ) : ( + <> +
+
+ + + +
+
+ {queuedPendingRequestCount} queued + {needsReviewRequestCount} review + {pendingRequestCount} pending + {activeRequestCount} active + {implementedRequestCount} implemented +
+
+ {adminNotice ?

{adminNotice}

: null} + {adminPasswordError ?

{adminPasswordError}

: null} + {logsError ?

{logsError}

: null} +
+
+
+
+

Request Management

+
Select a request to load it on the right.
+
+
+ setAdminSearchText(event.target.value)} + placeholder="Search requests..." + /> +
+ + {adminFilterMenuOpen ? ( +
+ {requestStatusFilterOptions.length > 0 ? ( +
+
Status
+ {requestStatusFilterOptions.map((option) => ( + + ))} +
+ ) : null} + {requestTagFilterOptions.length > 0 ? ( +
+
Tags
+ {requestTagFilterOptions.map(({ tag, count }) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+
+
+ {!requestsLoading && adminFilteredRequests.length === 0 ? ( +
No requests match the current search or filters.
+ ) : null} + {adminFilteredRequests.map((requestEntry) => { + const isMutating = requestMutatingId === requestEntry.id; + const isSelected = requestEntry.id === selectedAdminRequestId; + const requestDisplayState = getRequestDisplayStateLabel(requestEntry); + const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry); + return ( +
handleSelectAdminRequest(requestEntry.id)} + > +
+
+
{requestEntry.title}
+
+ {requestDisplayState} +
+
+
+ {formatRequestTimestamp(requestEntry.updatedAt)} +
+ {requestEntry.tags.length > 0 ? ( +
+ {requestEntry.tags.slice(0, 3).map((tag) => ( + {tag} + ))} +
+ ) : null} +
+ +
+ ); + })} +
+
+
+
+ {!adminEditorDraft ? ( +
Select a request from the list to review it.
+ ) : ( +
+
+
+
Selected Request
+

{adminEditorDraft.title}

+
+
+ + +
+
+ + {(adminEditorDraft.analysis?.items?.length || 0) > 1 ? ( + + ) : null} +
+ + + +
+
+
+
+ {adminDetailTab === "routing" ? ( + <> +
+
+
Routing Pass
+
KB Routing Summary
+
+
+
+ + +
+
+ updateAdminDraft((current) => ({ ...current, tags: appendUniqueString(current.tags, value) }))} + onRemove={(value) => updateAdminDraft((current) => ({ ...current, tags: removeStringValue(current.tags, value) }))} + /> + updateAdminDraft((current) => ({ + ...current, + analysis: { + ...(current.analysis || {}), + routing: { + ...(current.analysis?.routing || {}), + suggestedTags: appendUniqueString( + Array.isArray(current.analysis?.routing?.suggestedTags) ? current.analysis.routing.suggestedTags : [], + value, + ), + }, + }, + }))} + onRemove={(value) => updateAdminDraft((current) => ({ + ...current, + analysis: { + ...(current.analysis || {}), + routing: { + ...(current.analysis?.routing || {}), + suggestedTags: removeStringValue( + Array.isArray(current.analysis?.routing?.suggestedTags) ? current.analysis.routing.suggestedTags : [], + value, + ), + }, + }, + }))} + /> +
+
+
+ + +
+
+
+
+
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + +
+ + + +
+
+
+
+
+ +
+
+ +
+
+ + + + `; +} + +export function getWorldshaperStudioBodyMarkup(): string { + return buildWorldshaperStudioPopupMarkup() + .replace(/^/i, "") + .replace(/<\/body>s*$/i, ""); +} + diff --git a/src/worldshaperStudio/domMarkup.ts b/src/worldshaperStudio/domMarkup.ts deleted file mode 100644 index 940322a..0000000 --- a/src/worldshaperStudio/domMarkup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { WORLDSHAPER_THEME_PRESETS } from "./themePresets"; -import { - WORLDSHAPER_STUDIO_MARKUP_SHELL, - WORLDSHAPER_STUDIO_MARKUP_SIDEBAR, - WORLDSHAPER_STUDIO_MARKUP_STAGE, -} from "./domMarkupSections"; - -export function buildWorldshaperStudioPopupMarkup(): string { - const themePresetButtons = WORLDSHAPER_THEME_PRESETS.map((preset) => ` - - `).join(""); - return ( - WORLDSHAPER_STUDIO_MARKUP_SHELL.replace("__THEME_PRESET_BUTTONS__", themePresetButtons) - + WORLDSHAPER_STUDIO_MARKUP_SIDEBAR - + WORLDSHAPER_STUDIO_MARKUP_STAGE - ); -} - -export function getWorldshaperStudioBodyMarkup(): string { - return buildWorldshaperStudioPopupMarkup() - .replace(/^/i, "") - .replace(/<\/body>\s*$/i, ""); -} diff --git a/src/worldshaperStudio/domMarkupSections.ts b/src/worldshaperStudio/domMarkupSections.ts deleted file mode 100644 index 793ec28..0000000 --- a/src/worldshaperStudio/domMarkupSections.ts +++ /dev/null @@ -1,475 +0,0 @@ -export const WORLDSHAPER_STUDIO_MARKUP_SHELL = ` - -
- - -
- -`; - -export const WORLDSHAPER_STUDIO_MARKUP_SIDEBAR = ` - - - -`; - -export const WORLDSHAPER_STUDIO_MARKUP_STAGE = ` -
-
-
-
-
- -
-
- -
-
- -
-
- -`; diff --git a/src/worldshaperStudio/domStyleSections.ts b/src/worldshaperStudio/domStyleSections.ts deleted file mode 100644 index 11a177b..0000000 --- a/src/worldshaperStudio/domStyleSections.ts +++ /dev/null @@ -1,4237 +0,0 @@ -export const WORLDSHAPER_STUDIO_STYLE_SHELL = ` - :root { color-scheme: dark; } - * { box-sizing: border-box; } - html, body { - margin: 0; - width: 100%; - height: 100%; - background: #0a1020; - color: #d8e8ff; - font-family: Segoe UI, Arial, sans-serif; - } - .shell { - display: grid; - grid-template-rows: 40px 1fr; - width: 100vw; - height: 100vh; - } - - /* Top menu bar, clamped to 40px */ - .menu-bar { - width: 100%; - height: 40px; - min-height: 40px; - max-height: 40px; - position: relative; - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - border-bottom: 1px solid #2e426c; - background: linear-gradient(180deg, #152645 0%, #10203c 100%); - overflow: hidden; - user-select: none; - } - .menu-btn { - height: 30px; - padding: 0 12px; - border: 1px solid #3c5e95; - border-radius: 8px; - background: #1a345e; - color: #d6e7ff; - font-size: 12px; - font-weight: 700; - cursor: pointer; - white-space: nowrap; - } - .menu-btn:hover { background: #214679; } - .menu-btn.menu-btn-right { - margin-left: auto; - } - .menu-bar-center { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - display: inline-flex; - align-items: center; - gap: 8px; - min-width: 0; - pointer-events: none; - } - .menu-bar-center > * { - pointer-events: auto; - } - .menu-layer-label { - color: #cfe2ff; - font-size: 12px; - font-weight: 700; - white-space: nowrap; - } - .menu-layer-select { - height: 30px; - min-width: 220px; - max-width: min(320px, 40vw); - border: 1px solid #3c5e95; - border-radius: 8px; - background: #10284b; - color: #d6e7ff; - font-size: 12px; - padding: 0 10px; - } - - .body { - min-height: 0; - display: grid; - grid-template-columns: 325px 1fr; - position: relative; - } - -`; - -export const WORLDSHAPER_STUDIO_STYLE_SIDEBAR = ` - .sidebar { - min-height: 0; - border-right: 1px solid #2e426c; - background: #0e1a33; - display: flex; - flex-direction: column; - padding: 10px; - overflow: hidden; - position: relative; - } - .sidebar-panels-host { - display: flex; - flex-direction: column; - min-height: 0; - flex: 1 1 auto; - overflow-x: hidden; - overflow-y: auto; - padding-right: 2px; - } - .sidebar-panels-host.sidebar-drop-target { - outline: 1px solid rgba(255, 209, 102, 0.78); - outline-offset: 4px; - border-radius: 10px; - } - .sidebar h3 { - margin: 0 0 8px; - font-size: 13px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #9bb5e3; - text-align: center; - } - .sidebar-tabs { - display: grid; - gap: 6px; - margin-bottom: 10px; - position: sticky; - top: 0; - z-index: 30; - background: #0e1a33; - border: 1px solid #274472; - border-radius: 10px; - padding: 6px; - box-shadow: 0 8px 14px rgba(3, 8, 18, 0.8); - isolation: isolate; - } - .sidebar-tabs::before { - content: ""; - position: absolute; - inset: -1px; - background: #0e1a33; - border-radius: 10px; - z-index: -1; - } - .sidebar-tabs.dock-target { - border-color: #ffd166; - box-shadow: 0 0 0 1px rgba(255, 209, 102, 0.75), 0 8px 14px rgba(3, 8, 18, 0.8); - } - .sidebar-tab-row { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; - } - .sidebar-tab-btn { - height: 28px; - width: 100%; - padding: 0 8px; - border: 1px solid #3f5e90; - border-radius: 7px; - background: #1a2f53; - color: #cfe2ff; - font-size: 12px; - line-height: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - } - .sidebar-tab-btn.active { - background: #1e4b82; - border-color: #64aaf8; - color: #e7f1ff; - } - .sidebar-tab-btn.tool-active-hidden { - border-color: var(--editor-tool-armed, #7ee8c6); - background: linear-gradient( - 180deg, - var(--editor-tool-armed-soft, #1a3c40) 0%, - color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 78%, black 22%) 100% - ); - color: var(--editor-tool-armed, #7ee8c6); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-tool-armed, #7ee8c6) 28%, transparent); - } - .sidebar-tab-btn.popped.tool-active-hidden { - border-style: dashed; - border-color: var(--editor-tool-armed, #7ee8c6); - color: var(--editor-tool-armed, #7ee8c6); - } - .sidebar-tab-btn.popped { - border-style: dashed; - border-color: #ffd166; - background: #173962; - color: #f6e4a4; - } - .sidebar-tab-btn.drag-armed { - border-color: #ffd166; - box-shadow: 0 0 0 1px rgba(255, 209, 102, 0.4); - } - .hidden { - display: none !important; - } - .sidebar-panel { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 8px; - min-height: 0; - flex: 1 1 auto; - } - .sidebar-panel > * { - width: 100%; - max-width: 100%; - } - .sidebar-panel.hidden { - display: none; - } - .sidebar-static-footer { - flex: 0 0 auto; - display: grid; - gap: 6px; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid rgba(86, 118, 171, 0.4); - } - .layer-list { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 10px; - } - .layer-row { - width: 100%; - text-align: left; - padding: 6px; - border: 1px solid #2e426c; - border-radius: 8px; - background: #121f3b; - color: #d6e7ff; - font-size: 12px; - min-height: 32px; - display: inline-flex; - align-items: center; - cursor: pointer; - } - .layer-row.active { - border-color: #5fa8ff; - background: #19355e; - } - .layer-row.layer-add-row { - border-style: dashed; - border-color: #4e78b7; - background: #132848; - color: #cce3ff; - margin-top: 4px; - } - .layer-row-wrap { - display: grid; - grid-template-columns: auto auto minmax(0, 1fr) auto auto; - gap: 6px; - align-items: center; - } - .layer-drag-handle { - width: 32px; - height: 32px; - padding: 0; - border: 1px solid #3c5e95; - border-radius: 8px; - background: #132b4f; - color: #d6e7ff; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: grab; - } - .layer-drag-handle:hover { - background: #1a3f6d; - } - .layer-drag-handle:active { - cursor: grabbing; - } - .layer-drag-handle:disabled { - opacity: 0.45; - cursor: not-allowed; - } - .layer-drag-icon { - display: block; - font-size: 15px; - line-height: 1; - } - .layer-visibility-btn { - width: 32px; - height: 32px; - padding: 0; - border: 1px solid #3c5e95; - border-radius: 8px; - background: #132b4f; - color: #d6e7ff; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - } - .layer-visibility-btn.active { - border-color: #64aaf8; - background: #1e4b82; - } - .layer-visibility-btn.hidden { - border-color: #35537f; - background: #0f1d35; - color: #7f9dca; - opacity: 0.82; - } - .layer-visibility-icon { - display: block; - font-size: 16px; - line-height: 1; - } - .layer-row-wrap.reorder-dragging { - opacity: 0.55; - } - .layer-row-wrap.reorder-drop-before { - position: relative; - } - .layer-row-wrap.reorder-drop-before::before { - content: ""; - position: absolute; - left: 4px; - right: 4px; - top: -4px; - height: 2px; - border-radius: 999px; - background: #64aaf8; - box-shadow: 0 0 0 1px rgba(100, 170, 248, 0.28); - } - .layer-row-wrap.reorder-drop-after { - position: relative; - } - .layer-row-wrap.reorder-drop-after::after { - content: ""; - position: absolute; - left: 4px; - right: 4px; - bottom: -4px; - height: 2px; - border-radius: 999px; - background: #64aaf8; - box-shadow: 0 0 0 1px rgba(100, 170, 248, 0.28); - } - .layer-delete-btn { - width: 28px; - height: 28px; - padding: 0; - border: 1px solid #516993; - border-radius: 7px; - background: #11213d; - color: #d6e7ff; - font-size: 14px; - line-height: 1; - cursor: pointer; - } - .layer-delete-btn:hover { - background: #173058; - } - .layer-delete-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - .layer-z-select { - width: 58px; - height: 28px; - border: 1px solid #516993; - border-radius: 7px; - background: #11213d; - color: #d6e7ff; - font-size: 11px; - padding: 0 4px; - } - .layer-actions { - display: flex; - gap: 6px; - margin-bottom: 12px; - justify-content: center; - flex-wrap: wrap; - } - .map-manager { - display: grid; - gap: 8px; - } - .information-panel-layout { - display: flex; - flex-direction: column; - gap: 12px; - min-height: 0; - height: 100%; - flex: 1 1 auto; - } - .information-utility-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - } - .information-utility-actions .mini-btn { - flex: 1 1 auto; - } - .information-bottom-stack { - display: grid; - gap: 10px; - } - .info-help-panel, - .info-footer-bar { - border: 1px solid var(--editor-border, #2e426c); - border-radius: 8px; - background: var(--editor-panel-bg, #121f3b); - } - .experimental-import-panel { - border: 1px solid var(--editor-border, #2e426c); - border-radius: 8px; - background: var(--editor-panel-bg, #121f3b); - overflow: hidden; - } - .experimental-import-toggle { - width: 100%; - padding: 10px; - border: 0; - background: transparent; - color: var(--editor-control-fg, #d6e7ff); - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 8px; - align-items: center; - text-align: left; - cursor: pointer; - } - .experimental-import-toggle:hover { - background: rgba(255, 255, 255, 0.03); - } - .experimental-import-toggle.expanded { - background: rgba(255, 255, 255, 0.04); - } - .experimental-import-check { - font-size: 12px; - font-weight: 700; - color: var(--editor-warn, #ffd166); - white-space: nowrap; - } - .experimental-import-copy { - min-width: 0; - display: grid; - gap: 3px; - } - .experimental-import-title { - font-size: 12px; - font-weight: 700; - color: var(--editor-shell-fg, #d8e8ff); - } - .experimental-import-meta { - font-size: 11px; - line-height: 1.35; - color: var(--editor-muted, #9fb8e5); - } - .experimental-import-chevron { - font-size: 12px; - color: var(--editor-muted, #9fb8e5); - } - .experimental-import-body { - padding: 0 10px 10px; - display: grid; - gap: 8px; - } - .experimental-import-warning { - border: 1px solid rgba(255, 209, 102, 0.32); - border-radius: 7px; - background: rgba(255, 209, 102, 0.08); - color: var(--editor-muted-strong, #cfe2ff); - font-size: 11px; - line-height: 1.4; - padding: 8px 9px; - } - .experimental-import-actions { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 36px; - gap: 8px; - align-items: stretch; - } - .experimental-import-actions .mini-btn { - width: 100%; - min-width: 0; - } - .experimental-import-icon-btn { - width: 36px; - min-width: 36px; - padding: 0; - font-size: 16px; - line-height: 1; - } - .experimental-import-modal { - position: fixed; - inset: 0; - display: grid; - place-items: center; - padding: 16px; - background: rgba(4, 9, 18, 0.72); - z-index: 1200; - } - .experimental-import-modal-card { - width: min(100%, 520px); - display: grid; - gap: 10px; - padding: 12px; - border: 1px solid var(--editor-border, #2e426c); - border-radius: 10px; - background: var(--editor-panel-bg-elevated, #10284b); - box-shadow: 0 18px 40px rgba(2, 8, 18, 0.5); - } - .experimental-import-modal-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - } - .experimental-import-modal-title { - margin: 0; - font-size: 13px; - font-weight: 700; - color: var(--editor-shell-fg, #d8e8ff); - } - .experimental-import-modal-copy { - font-size: 11px; - line-height: 1.4; - color: var(--editor-muted, #9fb8e5); - } - .experimental-import-modal-body { - display: grid; - gap: 10px; - } - .experimental-import-modal-body textarea { - width: 100%; - min-height: 212px; - resize: vertical; - border: 1px solid var(--editor-control-border, #3c5e95); - border-radius: 8px; - background: var(--editor-control-bg, #1a345e); - color: var(--editor-control-fg, #d6e7ff); - padding: 9px 10px; - font: 12px/1.4 Consolas, "Courier New", monospace; - box-sizing: border-box; - } - .experimental-import-modal-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - } - .info-help-panel { - padding: 10px; - display: grid; - align-content: start; - flex: 1 1 auto; - min-height: 0; - gap: 8px; - } - .info-help-title { - font-size: 11px; - font-weight: 700; - color: var(--editor-muted, #9fb8e5); - text-transform: uppercase; - letter-spacing: 0.04em; - } - .info-help-list { - display: grid; - gap: 6px; - } - .shortcut-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(110px, auto); - gap: 10px; - align-items: center; - } - .shortcut-keys { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - min-width: 0; - } - .shortcut-plus { - color: var(--editor-muted, #9fb8e5); - font-size: 12px; - font-weight: 700; - line-height: 1; - } - .shortcut-keycap, - .shortcut-mouse-shell { - min-height: 24px; - padding: 0 8px; - border: 1px solid var(--editor-control-border, #3c5e95); - border-radius: 7px; - background: var(--editor-control-bg, #1a345e); - color: var(--editor-control-fg, #d6e7ff); - font-size: 11px; - display: inline-flex; - align-items: center; - justify-content: center; - white-space: nowrap; - } - .shortcut-mouse-shell { - gap: 4px; - padding-right: 9px; - } - .shortcut-mouse-dot { - width: 6px; - height: 6px; - border-radius: 999px; - background: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08); - } - .shortcut-mouse-label { - font-size: 10px; - color: var(--editor-muted-strong, #cfe2ff); - text-transform: uppercase; - } - .shortcut-action { - font-size: 11px; - line-height: 1.35; - color: var(--editor-muted-strong, #cfe2ff); - text-align: right; - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 6px; - flex-wrap: wrap; - } - .shortcut-shape-icon { - width: 14px; - height: 14px; - flex: 0 0 14px; - border: 2px solid var(--editor-accent, #64aaf8); - background: transparent; - box-sizing: border-box; - } - .shortcut-shape-icon.square { - border-radius: 3px; - } - .shortcut-shape-icon.circle { - border-radius: 999px; - } - .info-footer-stack, - .sidebar-footer-links { - display: grid; - gap: 6px; - } - .info-footer-bar, - .sidebar-footer-linkbar { - min-height: 34px; - padding: 0 10px; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient( - 180deg, - var(--editor-menu-grad-1, #152645) 0%, - var(--editor-menu-grad-2, #10203c) 100% - ); - } - .info-footer-link, - .sidebar-footer-link { - color: var(--editor-accent-strong, #8fd0ff); - font-size: 12px; - font-weight: 600; - text-decoration: none; - } - .info-footer-link:hover, - .info-footer-link:focus-visible, - .sidebar-footer-link:hover, - .sidebar-footer-link:focus-visible { - color: var(--editor-shell-fg, #d8e8ff); - text-decoration: underline; - } - .field-row { - display: grid; - grid-template-columns: 82px minmax(0, 1fr); - gap: 8px; - align-items: center; - } - .info-readonly + .info-dim-controls { - display: none; - } - .field-row label { - font-size: 11px; - color: #9fb8e5; - white-space: nowrap; - } - .field-row input { - min-width: 0; - } - .map-manager label { - font-size: 11px; - color: #9fb8e5; - } - .map-manager select { - height: 30px; - width: 100%; - border: 1px solid #3c5e95; - border-radius: 7px; - background: #10284b; - color: #d6e7ff; - font-size: 12px; - padding: 0 8px; - } - .mini-btn { - height: 28px; - padding: 0 10px; - border: 1px solid #3f5e90; - border-radius: 7px; - background: #1a2f53; - color: #cfe2ff; - font-size: 12px; - cursor: pointer; - } - .mini-btn.active { - border-color: #64aaf8; - background: #245081; - color: #eef7ff; - } - .history-list { - display: flex; - flex-direction: column; - gap: 6px; - min-height: 0; - } - .npc-list { - display: flex; - flex-direction: column; - gap: 6px; - } - .history-row { - width: 100%; - text-align: left; - padding: 7px; - border: 1px solid #2e426c; - border-radius: 8px; - background: #121f3b; - color: #d6e7ff; - font-size: 11px; - cursor: pointer; - line-height: 1.35; - } - .history-row.active { - border-color: #ffd166; - background: #2a3e5f; - } - .npc-row.active { - border-color: #64aaf8; - background: #22466e; - } - .npc-row-header { - display: flex; - align-items: center; - gap: 8px; - } - .npc-row-main { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - } - .npc-row-edit-btn { - width: 28px; - height: 28px; - padding: 0; - border: 1px solid var(--editor-control-border, #3c5e95); - border-radius: 8px; - background: var(--editor-panel-bg-alt, #132b4f); - color: var(--editor-control-fg, #d6e7ff); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex: 0 0 auto; - font-size: 13px; - line-height: 1; - } - .npc-row-edit-btn:hover { - background: var(--editor-panel-bg-hover, #1a3f6d); - } - .npc-thumb { - width: 28px; - height: 28px; - flex: 0 0 28px; - border: 1px solid #35537f; - border-radius: 6px; - background: #0c1730; - object-fit: contain; - image-rendering: pixelated; - } - .npc-thumb-fallback { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - flex: 0 0 28px; - border: 1px solid #35537f; - border-radius: 6px; - background: #0c1730; - color: #9fb8e5; - font-size: 10px; - } - .npc-editor-panel { - margin-top: 8px; - padding: 8px; - border: 1px solid #2d426e; - border-radius: 8px; - background: #0e1d36; - display: grid; - gap: 8px; - } - .npc-top-toolbar { - display: flex; - gap: 6px; - align-items: center; - flex-wrap: wrap; - } - .npc-icon-btn { - width: 30px; - height: 30px; - padding: 0; - border: 1px solid #3c5e95; - border-radius: 8px; - background: #132b4f; - color: #d6e7ff; - font-size: 12px; - font-weight: 700; - cursor: pointer; - } - .npc-icon-btn.active { - border-color: #64aaf8; - background: #1e4b82; - } - .npc-compact-menu { - display: grid; - gap: 4px; - margin-top: 2px; - padding: 4px; - border: 1px solid #3c5e95; - border-radius: 8px; - background: #0f2344; - } - .npc-compact-menu button { - height: 30px; - border: 1px solid #35537f; - border-radius: 7px; - background: #132b4f; - color: #d6e7ff; - font-size: 11px; - text-align: left; - padding: 0 8px; - cursor: pointer; - } - .npc-description-box { - min-height: 72px; - resize: vertical; - } - .selector-toolbar { - display: flex; - justify-content: center; - gap: 8px; - margin-top: -2px; - } - .entity-filter-tabs { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - margin: -2px 0 10px; - } - .entity-filter-tab { - min-height: 32px; - padding: 0 10px; - border: 1px solid var(--editor-control-border, #3c5e95); - border-radius: 9px; - background: var(--editor-panel-bg-alt, #132b4f); - color: var(--editor-control-fg, #d6e7ff); - font-size: 12px; - font-weight: 600; - cursor: pointer; - } - .entity-filter-tab:hover { - background: var(--editor-panel-bg-hover, #1a3f6d); - } - .entity-filter-tab.active { - border-color: var(--editor-accent, #64aaf8); - background: color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, var(--editor-panel-bg-alt, #132b4f)); - color: var(--editor-shell-fg, #eef6ff); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 28%, transparent); - } - .entity-type-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 62px; - height: 18px; - padding: 0 7px; - border-radius: 999px; - background: color-mix(in srgb, var(--editor-accent, #64aaf8) 18%, var(--editor-preview-bg, #0c1730)); - color: var(--editor-shell-fg, #eef6ff); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.02em; - text-transform: uppercase; - } - .panel-square-btn { - width: 32px; - height: 32px; - padding: 0; - border: 1px solid var(--editor-control-border, #3c5e95); - border-radius: 8px; - background: var(--editor-panel-bg-alt, #132b4f); - color: var(--editor-control-fg, #d6e7ff); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 16px; - line-height: 1; - } - .panel-square-btn:hover { - background: var(--editor-panel-bg-hover, #1a3f6d); - } - .panel-square-btn.active { - border-color: var(--editor-accent, #64aaf8); - background: var(--editor-control-bg-active, #1e4b82); - color: var(--editor-shell-fg, #eef6ff); - } - .panel-icon-image-plus { - position: relative; - display: inline-flex; - width: 18px; - height: 16px; - align-items: center; - justify-content: center; - } - .panel-icon-image-frame { - position: absolute; - left: 1px; - top: 2px; - width: 13px; - height: 10px; - border: 1px solid currentColor; - border-radius: 3px; - } - .panel-icon-image-frame::before { - content: ""; - position: absolute; - left: 2px; - bottom: 2px; - width: 7px; - height: 4px; - background: currentColor; - clip-path: polygon(0 100%, 28% 36%, 50% 68%, 73% 18%, 100% 100%); - opacity: 0.92; - } - .panel-icon-image-frame::after { - content: ""; - position: absolute; - right: 2px; - top: 2px; - width: 2px; - height: 2px; - border-radius: 999px; - background: currentColor; - opacity: 0.9; - } - .panel-icon-image-plus-mark { - position: absolute; - right: -1px; - bottom: -1px; - color: var(--editor-tool-armed, #7ee8c6); - font-size: 11px; - font-weight: 700; - line-height: 1; - text-shadow: 0 0 3px rgba(6, 12, 20, 0.95); - } - .panel-icon-search { - font-size: 15px; - line-height: 1; - transform: translateY(-0.5px); - } - .tile-search-mode { - display: grid; - grid-template-rows: auto auto minmax(0, 1fr); - gap: 8px; - min-height: 0; - } - .tile-search-field { - width: 100%; - min-width: 0; - height: 32px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: var(--editor-panel-bg-alt, #132b4f); - color: var(--editor-control-fg, #eef6ff); - padding: 0 10px; - font-size: 12px; - } - .tile-search-field:focus { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-search-meta { - color: var(--editor-muted, #9fb8e5); - font-size: 11px; - line-height: 1.4; - min-height: 16px; - } - .tile-search-results { - min-height: 0; - display: grid; - align-content: start; - gap: 6px; - overflow: auto; - padding-right: 2px; - } - .tile-search-empty { - padding: 12px 10px; - border: 1px dashed color-mix(in srgb, var(--editor-muted, #8fb2e1) 28%, transparent); - border-radius: 10px; - color: var(--editor-muted, #8fb2e1); - font-size: 11px; - line-height: 1.45; - background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 55%, transparent); - } - .tile-search-result-row { - width: 100%; - text-align: left; - cursor: pointer; - } - .tile-search-result-row .npc-row-header { - cursor: pointer; - } - .selector-section { - display: grid; - gap: 8px; - } - .selector-section-header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - } - .selector-section-toggle { - min-width: 0; - width: 100%; - flex: 1 1 auto; - height: 32px; - padding: 0 12px; - border: 1px solid #3f5e90; - border-radius: 8px; - background: #13284b; - color: #d6e7ff; - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 8px; - font-size: 12px; - font-weight: 700; - cursor: pointer; - } - .selector-section-label { - min-width: 0; - width: 100%; - flex: 1 1 auto; - height: 32px; - padding: 0 12px; - border: 1px solid #3f5e90; - border-radius: 8px; - background: #13284b; - color: #d6e7ff; - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 8px; - font-size: 12px; - font-weight: 700; - } - .selector-section-chevron { - display: inline-flex; - align-items: center; - justify-content: center; - width: 12px; - color: #8fd0ff; - font-size: 10px; - transition: transform 120ms ease; - } - .selector-section-toggle[aria-expanded="false"] .selector-section-chevron { - transform: rotate(-90deg); - } - .selector-section-actions { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - } - .selector-section-body { - display: grid; - gap: 8px; - } - .selector-section-body.hidden { - display: none !important; - } - .selector-hint { - margin: 0; - text-align: center; - } - .elevation-toolbar { - display: grid; - gap: 10px; - } - .elevation-z-select { - height: 30px; - width: 100%; - border: 1px solid #3c5e95; - border-radius: 7px; - background: #10284b; - color: #d6e7ff; - font-size: 12px; - padding: 0 8px; - } - .elevation-brush-group { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - } - .elevation-summary { - padding: 9px 10px; - border: 1px solid #2e426c; - border-radius: 8px; - background: #121f3b; - color: #cfe2ff; - font-size: 11px; - text-align: center; - } - .folder-list-root { - display: grid; - gap: 8px; - width: 100%; - } - .selector-drag-handle { - width: 28px; - height: 28px; - padding: 0; - border: 1px solid #3c5e95; - border-radius: 7px; - background: #132b4f; - color: #d6e7ff; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: grab; - flex: 0 0 28px; - } - .selector-drag-handle.dragging, - .selector-drag-handle:active { - cursor: grabbing; - background: #1d4777; - } - .selector-drag-icon { - display: block; - font-size: 14px; - line-height: 1; - } - .folder-block { - display: grid; - gap: 6px; - width: 100%; - } - .folder-block.collapsed .folder-children { - display: none; - } - .folder-row { - background: #162844; - border-color: #47618f; - } - .folder-row-header { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - } - .folder-toggle-btn { - width: 24px; - height: 24px; - padding: 0; - border: 1px solid #3b567f; - border-radius: 6px; - background: #10233f; - color: #b7d7ff; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex: 0 0 24px; - } - .folder-toggle-icon { - display: block; - font-size: 10px; - line-height: 1; - } - .folder-row-icon { - width: 22px; - height: 22px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 16px; - flex: 0 0 22px; - } - .folder-row-copy { - min-width: 0; - display: grid; - gap: 2px; - } - .folder-children { - display: grid; - gap: 6px; - padding-left: 12px; - border-left: 1px dashed rgba(100, 170, 248, 0.35); - margin-left: 8px; - width: calc(100% - 8px); - } - .folder-empty { - min-height: 36px; - border: 1px dashed #42648f; - border-radius: 8px; - color: #8da7d3; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - background: rgba(11, 24, 47, 0.68); - } - .folder-root-drop-zone { - min-height: 34px; - border: 1px dashed #466b99; - border-radius: 8px; - color: #93b2df; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - background: rgba(12, 25, 48, 0.72); - text-align: center; - } - .folder-drop-before, - .folder-drop-after, - .folder-drop-inside, - .folder-root-drop-active { - position: relative; - } - .folder-drop-before::before { - content: ""; - position: absolute; - left: 6px; - right: 6px; - top: -5px; - height: 2px; - border-radius: 999px; - background: #64aaf8; - box-shadow: 0 0 0 1px rgba(100, 170, 248, 0.3); - } - .folder-drop-after::after { - content: ""; - position: absolute; - left: 6px; - right: 6px; - bottom: -5px; - height: 2px; - border-radius: 999px; - background: #64aaf8; - box-shadow: 0 0 0 1px rgba(100, 170, 248, 0.3); - } - .folder-drop-inside { - box-shadow: inset 0 0 0 1px rgba(100, 170, 248, 0.8); - border-radius: 8px; - } - .folder-root-drop-active { - border-style: solid; - border-color: #64aaf8; - color: #d9ecff; - background: rgba(25, 67, 112, 0.46); - } - .folder-list-empty { - margin: 0; - text-align: center; - } - .info-cell-value { - min-width: 0; - display: flex; - align-items: center; - gap: 6px; - } - .info-dim-input { - flex: 1 1 auto; - transition: flex-basis 120ms ease; - } - .info-dim-value.dirty .info-dim-input { - flex-basis: 68%; - } - .info-dim-controls { - display: none; - gap: 6px; - flex: 0 0 auto; - } - .info-dim-controls.visible { - display: flex; - } - .icon-action-btn { - width: 30px; - height: 30px; - padding: 0; - border: 1px solid #3c5e95; - border-radius: 8px; - background: #132b4f; - color: #d6e7ff; - font-size: 14px; - cursor: pointer; - } - .icon-action-btn:hover { background: #1e4b82; } - .icon-action-btn.danger { border-color: #7f4c4c; background: #3c1a1a; } - .icon-action-btn.danger:hover { background: #5a2323; } - .info-readonly { - opacity: 0.75; - cursor: default; - } - .background-mode-btn { - min-height: 46px; - width: 100%; - padding: 6px 8px; - border: 1px solid #3c5e95; - border-radius: 8px; - background: #10284b; - color: #d6e7ff; - display: grid; - grid-template-columns: 32px minmax(0, 1fr); - align-items: center; - gap: 8px; - cursor: pointer; - text-align: left; - } - .background-mode-btn:hover { - background: #16345d; - } - .background-mode-preview { - width: 32px; - height: 32px; - border: 1px solid #4f6992; - border-radius: 6px; - background: #0d1b34; - overflow: hidden; - display: inline-flex; - align-items: center; - justify-content: center; - position: relative; - } - .background-mode-preview img { - width: 100%; - height: 100%; - object-fit: contain; - image-rendering: pixelated; - display: block; - } - .background-mode-preview-hole::before, - .background-mode-preview-hole::after { - content: ""; - position: absolute; - background: rgba(255, 91, 145, 0.95); - border-radius: 999px; - } - .background-mode-preview-hole::before { - width: 18px; - height: 2px; - transform: rotate(45deg); - } - .background-mode-preview-hole::after { - width: 18px; - height: 2px; - transform: rotate(-45deg); - } - .background-mode-preview-inherit::before { - content: ""; - position: absolute; - inset: 6px; - border: 2px dashed rgba(111, 196, 255, 0.95); - border-radius: 5px; - } - .background-mode-copy { - min-width: 0; - display: grid; - gap: 2px; - } - .background-mode-title { - font-size: 12px; - font-weight: 700; - color: #e1eeff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .background-mode-meta { - font-size: 10px; - color: #9fb8e5; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .npc-editor-row { - display: grid; - gap: 4px; - } - .npc-editor-row label { - font-size: 11px; - color: #9fb8e5; - } - .npc-editor-row input, - .npc-editor-row select, - .sprite-dropdown-btn { - height: 28px; - width: 100%; - border: 1px solid #3c5e95; - border-radius: 7px; - background: #10284b; - color: #d6e7ff; - font-size: 12px; - padding: 0 8px; - } - .sprite-dropdown-wrap { - position: relative; - } - .sprite-dropdown-btn { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - gap: 8px; - } - .sprite-dropdown-current { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - } - .sprite-dropdown-menu { - margin-top: 4px; - max-height: 180px; - overflow: auto; - border: 1px solid #3c5e95; - border-radius: 8px; - background: #0f2344; - display: grid; - gap: 4px; - padding: 4px; - } - .sprite-option-btn { - height: 30px; - border: 1px solid #35537f; - border-radius: 7px; - background: #132b4f; - color: #d6e7ff; - font-size: 11px; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - text-align: left; - padding: 0 6px; - } - .sprite-option-btn.active { - border-color: #64aaf8; - background: #1a3f6d; - } - .history-meta { - color: #9fb8e5; - display: block; - margin-top: 3px; - font-size: 10px; - } - .history-preview { - padding: 8px; - border: 1px solid #2d426e; - border-radius: 8px; - background: #101d38; - font-size: 11px; - color: #d6e7ff; - min-height: 0; - overflow: auto; - } - .history-preview h4 { - margin: 0 0 6px; - font-size: 12px; - color: #e6f0ff; - } - .history-preview ul { - margin: 0; - padding-left: 16px; - } - .history-preview-empty { - color: #9fb8e5; - } - .history-panel-layout { - min-height: 0; - } - .history-stack { - display: grid; - grid-template-rows: minmax(0, 1fr) auto minmax(96px, auto); - gap: 8px; - min-height: 0; - flex: 1 1 auto; - } - .history-list-scroll { - min-height: 0; - overflow-y: auto; - padding-right: 2px; - } - .history-current { - padding: 8px; - border: 1px solid #40628f; - border-radius: 8px; - background: linear-gradient(180deg, #153155 0%, #132846 100%); - font-size: 11px; - color: #eef6ff; - } - .history-current-label { - margin-bottom: 4px; - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: #ffd166; - } - .history-current-empty { - color: #c7d8ef; - font-size: 11px; - } - .history-row.current-row { - border-color: #ffd166; - background: #28476c; - cursor: default; - } - .tool-window-layer { - position: absolute; - inset: 0; - pointer-events: none; - z-index: 40; - } - .tool-popout-window { - position: absolute; - min-width: 260px; - min-height: 220px; - max-width: calc(100% - 12px); - max-height: calc(100% - 12px); - border: 1px solid var(--editor-border-strong, #4f79af); - border-radius: 12px; - background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 88%, transparent); - color: var(--editor-shell-fg, #d8e8ff); - box-shadow: 0 16px 34px color-mix(in srgb, var(--editor-tab-shadow, rgba(3, 8, 18, 0.8)) 58%, transparent); - overflow: hidden; - pointer-events: auto; - display: grid; - grid-template-rows: 34px minmax(0, 1fr); - backdrop-filter: blur(6px); - } - .tool-popout-window.is-focused { - border-color: var(--editor-warn, #ffd166); - box-shadow: - 0 18px 36px color-mix(in srgb, var(--editor-tab-shadow, rgba(3, 8, 18, 0.8)) 68%, transparent), - 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 22%, transparent); - } - .tool-popout-window.tool-popout-window-inline { - position: relative; - inset: auto; - width: 100%; - max-width: 100%; - min-width: 0; - min-height: 0; - max-height: none; - margin-bottom: 10px; - box-shadow: 0 10px 20px rgba(2, 8, 18, 0.32); - } - .tool-popout-titlebar { - display: flex; - align-items: center; - gap: 8px; - padding: 0 10px; - background: linear-gradient(180deg, var(--editor-menu-grad-1, #1b365e) 0%, var(--editor-menu-grad-2, #122743) 100%); - border-bottom: 1px solid var(--editor-border, #365782); - cursor: grab; - user-select: none; - } - .tool-popout-titlebar:active { - cursor: grabbing; - } - .tool-popout-window-inline .tool-popout-titlebar { - cursor: grab; - } - .tool-popout-window-inline .tool-popout-titlebar:active { - cursor: grabbing; - } - .tool-popout-window-inline .tool-popout-dock-btn { - display: none; - } - .tool-popout-title { - min-width: 0; - flex: 1 1 auto; - font-size: 12px; - font-weight: 700; - color: var(--editor-shell-fg, #eef6ff); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .tool-popout-hint { - font-size: 10px; - color: var(--editor-muted, #a9c2ec); - white-space: nowrap; - } - .tool-popout-dock-btn { - width: 24px; - height: 24px; - padding: 0; - border: 1px solid var(--editor-control-border, #3c5e95); - border-radius: 7px; - background: var(--editor-panel-bg-alt, #132b4f); - color: var(--editor-control-fg, #d6e7ff); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex: 0 0 auto; - font-size: 12px; - line-height: 1; - } - .tool-popout-dock-btn:hover { - background: var(--editor-panel-bg-hover, #1a3f6d); - } - .tool-popout-close-btn { - width: 24px; - height: 24px; - padding: 0; - border: 1px solid var(--editor-danger-border, #6f4a56); - border-radius: 7px; - background: var(--editor-danger, #3a1a24); - color: var(--editor-shell-fg, #ffe3ea); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex: 0 0 auto; - font-size: 11px; - line-height: 1; - font-weight: 700; - } - .tool-popout-close-btn:hover { - background: var(--editor-danger-hover, #572234); - } - .tool-popout-dock-icon { - font-weight: 700; - letter-spacing: -0.08em; - color: var(--editor-status-error, #ff8e8e); - transform: translateX(-0.5px); - } - .tool-popout-body { - min-height: 0; - overflow: auto; - padding: 10px; - } - .tool-popout-body > .sidebar-panel { - min-height: 100%; - gap: 6px; - } - .tool-popout-body > .sidebar-panel > h3 { - display: none; - } - .tool-popout-window .history-stack { - grid-template-rows: minmax(90px, 1fr) auto minmax(88px, auto); - } - .tool-popout-window-inline .history-stack { - grid-template-rows: minmax(160px, 1fr) auto minmax(96px, auto); - } - .tool-popout-resize { - position: absolute; - right: 0; - bottom: 0; - width: 18px; - height: 18px; - cursor: nwse-resize; - background: - linear-gradient(135deg, transparent 0 45%, color-mix(in srgb, var(--editor-shell-fg, #ffffff) 8%, transparent) 45% 52%, transparent 52% 62%, color-mix(in srgb, var(--editor-shell-fg, #ffffff) 18%, transparent) 62% 69%, transparent 69% 100%); - } - .tool-popout-window-inline .tool-popout-resize { - position: relative; - right: auto; - bottom: auto; - width: 100%; - height: 12px; - cursor: ns-resize; - background: - linear-gradient(180deg, color-mix(in srgb, var(--editor-shell-fg, #ffffff) 4%, transparent) 0%, color-mix(in srgb, var(--editor-shell-fg, #ffffff) 2%, transparent) 45%, transparent 45%, transparent 100%), - repeating-linear-gradient(90deg, transparent 0 10px, color-mix(in srgb, var(--editor-muted, #9fb8e5) 18%, transparent) 10px 12px, transparent 12px 22px); - border-top: 1px solid color-mix(in srgb, var(--editor-border-strong, #3c5e95) 55%, transparent); - } - .world-overview-window { - min-width: 320px; - min-height: 264px; - grid-template-rows: 34px 40px minmax(0, 1fr); - } - .entity-editor-window { - min-width: 380px; - min-height: 420px; - } - .entity-editor-card { - min-height: 100%; - display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr) auto; - gap: 10px; - } - .entity-editor-head { - display: grid; - gap: 3px; - } - .entity-editor-title { - font-size: 13px; - font-weight: 700; - color: var(--editor-shell-fg, #eef6ff); - } - .entity-editor-subtitle { - font-size: 11px; - color: var(--editor-muted, #9fb8e5); - } - .entity-editor-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 10px; - } - .entity-editor-pane { - min-height: 0; - display: grid; - gap: 10px; - align-content: start; - } - .entity-editor-field { - display: grid; - gap: 5px; - min-width: 0; - } - .entity-editor-label { - font-size: 11px; - font-weight: 700; - color: var(--editor-shell-fg, #d6e7ff); - } - .entity-editor-field input, - .entity-editor-field select, - .entity-editor-field textarea, - .entity-editor-static { - width: 100%; - min-width: 0; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: var(--editor-panel-bg-alt, #132b4f); - color: var(--editor-control-fg, #eef6ff); - padding: 8px 10px; - font-size: 12px; - box-sizing: border-box; - } - .entity-editor-field textarea { - resize: vertical; - min-height: 110px; - } - .entity-editor-static { - min-height: 34px; - display: inline-flex; - align-items: center; - } - .entity-editor-footer { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - } - .entity-editor-status { - font-size: 11px; - color: var(--editor-muted, #9fb8e5); - } - .entity-editor-actions { - display: inline-flex; - align-items: center; - gap: 8px; - } - .status-log-window { - min-width: 360px; - min-height: 240px; - } - .changelog-splash-window { - min-width: 520px; - min-height: 360px; - } - .changelog-splash-card { - min-height: 100%; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - gap: 12px; - } - .changelog-splash-hero { - padding: 14px 16px; - border: 1px solid var(--editor-border-strong, #4f79af); - border-radius: 12px; - background: - radial-gradient(circle at top right, color-mix(in srgb, var(--editor-accent, #64aaf8) 24%, transparent) 0%, transparent 48%), - linear-gradient(180deg, color-mix(in srgb, var(--editor-menu-grad-1, #1b365e) 64%, transparent) 0%, color-mix(in srgb, var(--editor-panel-bg, #11203f) 82%, transparent) 100%); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-shell-bg, #0a1020) 22%, transparent); - } - .changelog-splash-kicker { - color: var(--editor-warn, #ffd166); - font-size: 10px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 6px; - } - .changelog-splash-title { - color: var(--editor-shell-fg, #eef6ff); - font-size: 22px; - font-weight: 800; - line-height: 1.1; - margin-bottom: 6px; - } - .changelog-splash-meta { - color: var(--editor-muted, #a9c2ec); - font-size: 12px; - line-height: 1.45; - } - .changelog-splash-list { - min-height: 0; - overflow: auto; - display: grid; - gap: 10px; - padding-right: 4px; - } - .changelog-splash-section { - padding: 12px 14px; - border: 1px solid var(--editor-border, #365782); - border-radius: 12px; - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-shell-bg, #0a1020) 14%, transparent); - } - .changelog-splash-section-title { - margin: 0 0 8px; - color: var(--editor-shell-fg, #eef6ff); - font-size: 13px; - font-weight: 800; - letter-spacing: 0.01em; - } - .changelog-splash-bullets { - margin: 0; - padding-left: 18px; - display: grid; - gap: 5px; - color: var(--editor-text-soft, #d7e7ff); - font-size: 12px; - line-height: 1.45; - } - .changelog-splash-bullet-note { - margin-top: 3px; - color: var(--editor-muted, #a9c2ec); - font-size: 11px; - line-height: 1.4; - font-style: italic; - } - .changelog-splash-footer { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - } - .changelog-splash-footnote { - color: var(--editor-muted, #9fb8e5); - font-size: 11px; - line-height: 1.4; - } - .engine-overrides-launch-btn { - width: 100%; - justify-content: space-between; - gap: 10px; - } - .engine-overrides-summary { - margin-top: 6px; - color: var(--editor-muted, #9fb8e5); - font-size: 11px; - line-height: 1.4; - white-space: pre-wrap; - } - .engine-override-window { - min-width: 420px; - min-height: 260px; - } - .engine-override-card { - min-height: 100%; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 10px; - } - .engine-override-head { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 6px 12px; - align-items: start; - } - .engine-override-title { - grid-column: 1 / 2; - font-size: 13px; - font-weight: 700; - color: var(--editor-shell-fg, #eef6ff); - } - .engine-override-meta { - grid-column: 1 / 2; - font-size: 11px; - color: var(--editor-muted, #9fb8e5); - } - .engine-override-head .mini-btn { - grid-column: 2 / 3; - grid-row: 1 / 3; - align-self: start; - } - .engine-override-list { - display: grid; - gap: 10px; - min-height: 0; - overflow: auto; - padding-right: 2px; - } - .engine-override-empty { - border: 1px dashed var(--editor-control-border, #3f5e90); - border-radius: 10px; - padding: 12px; - background: var(--editor-panel-bg-alt, #132848); - color: var(--editor-muted, #9fb8e5); - font-size: 12px; - line-height: 1.5; - } - .engine-override-row { - display: grid; - gap: 8px; - border: 1px solid var(--editor-border, #2e426c); - border-radius: 10px; - padding: 10px; - background: var(--editor-panel-bg, #121f3b); - } - .engine-override-row-head { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; - } - .engine-override-select, - .engine-override-number-input { - width: 100%; - min-width: 0; - border: 1px solid var(--editor-control-border, #3c5e95); - border-radius: 8px; - background: var(--editor-control-bg, #1a345e); - color: var(--editor-control-fg, #d6e7ff); - padding: 7px 9px; - font-size: 12px; - } - .engine-override-description { - color: var(--editor-muted, #9fb8e5); - font-size: 11px; - line-height: 1.45; - } - .engine-override-value-row { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 10px; - align-items: center; - } - .engine-override-value-label { - color: var(--editor-muted-strong, #cfe2ff); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.02em; - text-transform: uppercase; - } - .engine-override-toggle { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--editor-control-fg, #d6e7ff); - font-size: 12px; - } - .status-log-card { - min-height: 100%; - display: grid; - grid-template-rows: auto auto minmax(0, 1fr); - gap: 10px; - } - .status-log-head { - display: grid; - gap: 3px; - } - .status-log-title { - font-size: 13px; - font-weight: 700; - color: var(--editor-shell-fg, #eef6ff); - } - .status-log-meta { - font-size: 11px; - color: var(--editor-muted, #9fb8e5); - } - .status-log-actions { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - flex-wrap: wrap; - } - .status-log-list { - min-height: 0; - overflow: auto; - display: grid; - align-content: start; - gap: 8px; - padding: 2px; - } - .status-log-empty { - color: var(--editor-muted, #9fb8e5); - font-size: 11px; - line-height: 1.45; - } - .status-log-row { - display: grid; - gap: 6px; - padding: 9px 10px; - border: 1px solid var(--editor-border, #2e426c); - border-radius: 10px; - background: color-mix(in srgb, var(--editor-panel-bg, #121f3b) 96%, transparent); - } - .status-log-row-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - flex-wrap: wrap; - } - .status-log-row-level { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 8px; - border-radius: 999px; - border: 1px solid var(--editor-control-border, #35537f); - background: var(--editor-panel-bg-alt, #132b4f); - color: var(--editor-control-fg, #eef6ff); - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - } - .status-log-row-level-error { - border-color: color-mix(in srgb, var(--editor-status-error, #ff7b93) 50%, transparent); - color: var(--editor-status-error, #ffb7c3); - } - .status-log-row-level-information { - border-color: color-mix(in srgb, var(--editor-accent, #64aaf8) 42%, transparent); - color: var(--editor-shell-fg, #eef6ff); - } - .status-log-row-time { - font-size: 11px; - color: var(--editor-muted, #9fb8e5); - } - .status-log-row-message { - font-size: 12px; - line-height: 1.45; - color: var(--editor-shell-fg, #dbe9ff); - white-space: pre-wrap; - word-break: break-word; - } - .world-overview-body { - display: grid; - grid-template-columns: minmax(150px, 190px) minmax(0, 1fr); - gap: 8px; - overflow: hidden; - } - .world-overview-sidebar { - min-width: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 8px; - padding: 10px; - border: 1px solid var(--editor-border, #2e426c); - border-radius: 10px; - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--editor-panel-bg, #121f3b) 96%, transparent) 0%, - color-mix(in srgb, var(--editor-shell-bg, #0a1020) 98%, transparent) 100% - ); - overflow: hidden; - } - .world-overview-sidebar-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - color: var(--editor-shell-fg, #dbe9ff); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; - } - .world-overview-sidebar-meta { - color: var(--editor-muted, #8fb2e1); - font-size: 10px; - font-weight: 600; - } - .world-overview-poi-list { - min-height: 0; - display: grid; - align-content: start; - gap: 6px; - overflow: auto; - padding-right: 2px; - } - .world-overview-poi-empty { - padding: 12px 10px; - border: 1px dashed color-mix(in srgb, var(--editor-muted, #8fb2e1) 28%, transparent); - border-radius: 10px; - color: var(--editor-muted, #8fb2e1); - font-size: 11px; - line-height: 1.45; - background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 55%, transparent); - } - .world-overview-poi-row { - width: 100%; - display: grid; - gap: 2px; - padding: 8px 9px; - border: 1px solid color-mix(in srgb, var(--editor-border, #2e426c) 92%, black 8%); - border-radius: 10px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 88%, transparent); - color: var(--editor-shell-fg, #dbe9ff); - text-align: left; - cursor: pointer; - transition: border-color 120ms ease, background 120ms ease, transform 120ms ease; - } - .world-overview-poi-row:hover { - border-color: var(--editor-border-strong, #4a73ae); - background: color-mix(in srgb, var(--editor-panel-bg-hover, #1a3f6d) 92%, transparent); - transform: translateY(-1px); - } - .world-overview-poi-row.is-active { - border-color: var(--editor-warn, #ffd166); - background: color-mix(in srgb, var(--editor-accent-soft, #22466e) 72%, var(--editor-panel-bg-alt, #132b4f)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 20%, transparent); - } - .world-overview-poi-title { - font-size: 12px; - font-weight: 700; - line-height: 1.25; - color: var(--editor-shell-fg, #f4f8ff); - } - .world-overview-poi-coords { - color: var(--editor-muted, #8fb2e1); - font-size: 10px; - font-variant-numeric: tabular-nums; - } - .world-overview-main { - min-width: 0; - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - gap: 8px; - overflow: hidden; - } - .world-overview-action-banner { - display: flex; - align-items: center; - min-height: 0; - padding: 0 10px; - border: 1px solid color-mix(in srgb, var(--editor-border, #2e426c) 88%, transparent); - border-radius: 0; - border-left: 0; - border-right: 0; - border-top: 0; - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 90%, transparent); - color: var(--editor-text, #e6eefc); - font-size: 11px; - line-height: 1.3; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - .world-overview-action-banner.hidden { - display: none; - } - .world-overview-action-banner.is-idle { - border-color: color-mix(in srgb, var(--editor-border, #2e426c) 88%, transparent); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 90%, transparent); - color: var(--editor-muted-strong, #bcd2f4); - } - .world-overview-action-banner.is-move { - border-color: color-mix(in srgb, #ff7070 70%, var(--editor-border, #2e426c)); - background: color-mix(in srgb, #5c1116 42%, var(--editor-panel-bg, #11203f)); - } - .world-overview-action-banner.is-duplicate { - border-color: color-mix(in srgb, #ffd166 70%, var(--editor-border, #2e426c)); - background: color-mix(in srgb, #5f4308 44%, var(--editor-panel-bg, #11203f)); - } - .world-overview-viewport { - position: relative; - min-height: 0; - border: 1px solid var(--editor-border, #2e426c); - border-radius: 10px; - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--editor-preview-bg, #0d1b34) 92%, transparent) 0%, - color-mix(in srgb, var(--editor-stage-bg, #060a14) 96%, transparent) 100% - ); - overflow: hidden; - cursor: grab; - } - .world-overview-viewport.is-panning { - cursor: grabbing; - } - .world-overview-canvas { - display: block; - width: 100%; - height: 100%; - image-rendering: pixelated; - } - .world-overview-empty { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - padding: 18px; - text-align: center; - color: var(--editor-muted-strong, #bcd2f4); - font-size: 12px; - background: color-mix(in srgb, var(--editor-stage-bg, #060a14) 82%, transparent); - } - .world-overview-empty.hidden { - display: none; - } - .world-overview-meta { - color: #b8d0f3; - font-size: 11px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-variant-numeric: tabular-nums; - } - .tile-art-window { - min-width: 520px; - width: 520px; - min-height: 628px; - } - .tile-art-preview-window { - min-width: 252px; - width: 252px; - min-height: 288px; - height: 288px; - } - .tile-art-preview-body { - min-height: 0; - display: block; - overflow: hidden; - } - .tile-art-preview-card { - height: 100%; - display: grid; - grid-template-rows: minmax(0, 1fr) auto auto; - gap: 10px; - } - .tile-art-preview-stage { - min-height: 0; - border: 1px solid var(--editor-border, #2d426b); - border-radius: 12px; - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--editor-preview-bg, #0d1b34) 94%, transparent) 0%, - color-mix(in srgb, var(--editor-stage-bg, #060a14) 98%, transparent) 100% - ); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - } - .tile-art-preview-image { - width: 192px; - height: 192px; - object-fit: contain; - image-rendering: pixelated; - image-rendering: crisp-edges; - filter: drop-shadow(0 10px 18px rgba(3, 8, 18, 0.32)); - } - .tile-art-preview-image.hidden, - .tile-art-preview-empty.hidden { - display: none; - } - .tile-art-preview-empty { - color: var(--editor-muted-strong, #bcd2f4); - font-size: 12px; - text-align: center; - padding: 16px; - } - .tile-art-preview-frame-label { - color: var(--editor-shell-fg, #eef6ff); - font-size: 13px; - font-weight: 700; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .tile-art-preview-meta { - color: var(--editor-muted-strong, #bcd2f4); - font-size: 11px; - text-align: center; - font-variant-numeric: tabular-nums; - } - .tile-art-window.is-tags-tab { - min-height: 0; - } - .tile-art-body { - min-height: 0; - display: block; - overflow: hidden; - } - .tile-art-window.is-tags-tab .tile-art-body { - overflow: hidden; - } - .tile-art-window .tool-popout-resize { - display: none; - } - .tile-art-card { - height: 100%; - display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; - gap: 10px; - } - .tile-art-window.is-tags-tab .tile-art-card { - height: auto; - grid-template-rows: auto auto auto; - } - .tile-art-head { - display: grid; - gap: 2px; - } - .tile-art-title-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: 10px; - } - .tile-art-title-stack { - min-width: 0; - display: grid; - gap: 2px; - } - .tile-art-title-display { - display: flex; - align-items: center; - gap: 5px; - min-width: 0; - } - .tile-art-record-title { - color: #e4efff; - font-size: 16px; - font-weight: 700; - line-height: 1.2; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - flex: 0 1 auto; - } - .tile-art-record-meta { - color: #8fb2e1; - font-size: 11px; - font-variant-numeric: tabular-nums; - } - .tile-art-title-input { - width: 100%; - min-width: 0; - height: 30px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent); - color: var(--editor-control-fg, #eef6ff); - padding: 0 10px; - font-size: 12px; - } - .tile-art-title-input:focus { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-title-edit-btn { - width: 30px; - height: 30px; - padding: 0; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent); - color: var(--editor-control-fg, #eef6ff); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex: 0 0 auto; - } - .tile-art-title-edit-btn:hover { - border-color: var(--editor-border-strong, #5e84bd); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent); - } - .tile-art-title-edit-btn:focus-visible { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-title-edit-icon { - position: relative; - display: inline-block; - width: 13px; - height: 4px; - border-radius: 999px; - background: #7ee8c6; - transform: rotate(-35deg); - box-shadow: inset -3px 0 0 #ffcf70; - } - .tile-art-title-edit-icon::before { - content: ""; - position: absolute; - left: -3px; - top: 0; - width: 0; - height: 0; - border-top: 2px solid transparent; - border-bottom: 2px solid transparent; - border-right: 3px solid #eef6ff; - } - .tile-art-title-edit-icon::after { - content: ""; - position: absolute; - right: -2px; - top: 0; - width: 2px; - height: 4px; - border-radius: 999px; - background: #ff7f9f; - } - .tile-art-tabs { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 6px; - flex: 0 0 auto; - align-self: start; - padding-top: 0; - margin-top: 0; - } - .tile-art-tab-help-btn { - width: 30px; - height: 30px; - padding: 0; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent); - color: var(--editor-text-soft, #c7dbfb); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex: 0 0 auto; - } - .tile-art-tab-help-btn:hover { - border-color: var(--editor-border-strong, #5e84bd); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent); - } - .tile-art-tab-help-btn:focus-visible { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-tab-help-icon { - position: relative; - display: inline-block; - width: 14px; - height: 12px; - border: 1px solid color-mix(in srgb, var(--editor-shell-fg, #eef6ff) 88%, transparent); - border-radius: 3px; - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 76%, transparent); - box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08); - } - .tile-art-tab-help-icon::before { - content: ""; - position: absolute; - left: 2px; - right: 2px; - top: 2px; - height: 2px; - border-radius: 999px; - background: #7ee8c6; - box-shadow: - 0 3px 0 #ffd166, - 0 6px 0 #64aaf8; - } - .tile-art-tab-help-icon::after { - content: "?"; - position: absolute; - right: -4px; - bottom: -5px; - width: 10px; - height: 10px; - border-radius: 999px; - background: #ff5f6d; - color: #fff5f6; - font-size: 8px; - font-weight: 800; - line-height: 10px; - text-align: center; - box-shadow: 0 1px 4px rgba(3, 8, 18, 0.32); - } - .tile-art-tab-btn { - min-width: 74px; - height: 30px; - padding: 0 12px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent); - color: var(--editor-text-soft, #c7dbfb); - font-size: 11px; - font-weight: 700; - cursor: pointer; - white-space: nowrap; - } - .tile-art-tab-btn:hover { - border-color: var(--editor-border-strong, #5e84bd); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent); - } - .tile-art-tab-btn.is-active { - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 92%, transparent); - color: var(--editor-shell-fg, #eef6ff); - } - .tile-art-tab-btn:focus-visible { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-pane { - min-height: 0; - } - .tile-art-design-pane { - min-height: 0; - display: grid; - grid-template-rows: auto auto minmax(0, 1fr); - gap: 10px; - } - .tile-art-animation-pane { - min-width: 0; - padding: 8px; - border: 1px solid var(--editor-border, #2d426b); - border-radius: 12px; - background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 90%, transparent); - overflow: hidden; - } - .tile-art-animation-controls { - display: grid; - grid-template-columns: 60px minmax(0, 1fr); - align-items: start; - gap: 8px; - margin-bottom: 8px; - } - .tile-art-animation-speed-host { - grid-column: 2; - display: flex; - justify-content: flex-start; - } - .tile-art-animation-speed-host.hidden { - display: none; - } - .tile-art-animation-speed-menu { - min-width: 220px; - display: grid; - gap: 8px; - padding: 8px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 10px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 90%, transparent); - } - .tile-art-playback-options { - display: grid; - gap: 6px; - } - .tile-art-playback-option { - min-width: 0; - display: grid; - gap: 2px; - padding: 8px 10px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 10px; - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent); - color: var(--editor-control-fg, #eef6ff); - text-align: left; - cursor: pointer; - } - .tile-art-playback-option:hover { - border-color: var(--editor-border-strong, #5e84bd); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 92%, transparent); - } - .tile-art-playback-option.is-active { - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-playback-option-title { - font-size: 11px; - font-weight: 700; - color: var(--editor-shell-fg, #eef6ff); - } - .tile-art-playback-option-help { - font-size: 10px; - color: var(--editor-muted-strong, #bcd2f4); - } - .tile-art-animation-timeline-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: stretch; - gap: 8px; - min-width: 0; - } - .tile-art-animation-timeline { - display: flex; - align-items: stretch; - gap: 8px; - min-width: 0; - overflow-x: auto; - overflow-y: hidden; - padding-bottom: 2px; - } - .tile-art-animation-add-host { - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - } - .tile-art-frame-card { - width: 84px; - min-width: 84px; - min-height: 86px; - padding: 7px 7px 8px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 10px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent); - color: var(--editor-control-fg, #eef6ff); - display: grid; - grid-template-rows: 56px auto; - gap: 7px; - cursor: pointer; - text-align: left; - flex: 0 0 auto; - } - .tile-art-frame-card:hover { - border-color: var(--editor-border-strong, #5e84bd); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent); - } - .tile-art-frame-card.is-active { - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-frame-card.is-disabled { - opacity: 0.65; - } - .tile-art-frame-card.is-dragging { - opacity: 0.5; - } - .tile-art-frame-card.is-drop-before, - .tile-art-frame-card.is-drop-after { - position: relative; - } - .tile-art-frame-card.is-drop-before::before, - .tile-art-frame-card.is-drop-after::after { - content: ""; - position: absolute; - top: 6px; - bottom: 6px; - width: 3px; - border-radius: 999px; - background: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 28%, transparent); - pointer-events: none; - } - .tile-art-frame-card.is-drop-before::before { - left: -6px; - } - .tile-art-frame-card.is-drop-after::after { - right: -6px; - } - .tile-art-frame-card:focus-visible { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-frame-preview { - display: flex; - align-items: center; - justify-content: center; - border: 1px solid color-mix(in srgb, var(--editor-border, #2d426b) 82%, transparent); - border-radius: 8px; - background: - linear-gradient(45deg, rgba(255,255,255,0.05) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.05) 75%), - linear-gradient(45deg, rgba(255,255,255,0.05) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.05) 75%); - background-size: 10px 10px; - background-position: 0 0, 5px 5px; - overflow: hidden; - } - .tile-art-frame-preview-image { - width: 100%; - height: 100%; - object-fit: contain; - image-rendering: pixelated; - display: block; - } - .tile-art-frame-label { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 10px; - font-weight: 700; - color: var(--editor-text-soft, #c7dbfb); - min-width: 0; - } - .tile-art-frame-label-icons { - display: inline-flex; - align-items: center; - gap: 3px; - flex: 0 0 auto; - } - .tile-art-frame-label-icon { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.1em; - height: 1.1em; - border-radius: 999px; - font-size: 0.9em; - line-height: 1; - font-weight: 800; - border: 1px solid color-mix(in srgb, var(--editor-control-border, #35537f) 72%, transparent); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 82%, transparent); - } - .tile-art-frame-label-icon.is-check { - color: #7ee8c6; - } - .tile-art-frame-label-icon.is-x { - color: #ff9cad; - } - .tile-art-frame-label-icon.is-key { - color: #ffd166; - } - .tile-art-frame-label-text { - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1 1 auto; - } - .tile-art-frame-add-btn { - width: 50px; - min-width: 50px; - min-height: 52px; - padding: 5px; - justify-content: center; - align-items: center; - grid-template-rows: 1fr; - text-align: center; - border-style: dashed; - } - .tile-art-frame-add-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--editor-accent, #64aaf8) 48%, transparent); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent); - color: var(--editor-shell-fg, #eef6ff); - font-size: 14px; - line-height: 1; - font-weight: 500; - margin: 0 auto; - } - .tile-art-tool-icon-speed { - width: 14px; - height: 14px; - border: 1.5px solid #eef6ff; - border-radius: 999px; - box-sizing: border-box; - } - .tile-art-tool-icon-speed::before { - content: ""; - position: absolute; - left: 6px; - top: 2px; - width: 1.5px; - height: 4px; - border-radius: 999px; - background: #ffd166; - transform-origin: bottom center; - } - .tile-art-tool-icon-speed::after { - content: ""; - position: absolute; - left: 6px; - top: 6px; - width: 4px; - height: 1.5px; - border-radius: 999px; - background: #7ee8c6; - transform-origin: left center; - transform: rotate(28deg); - } - .tile-art-tool-icon-play { - position: relative; - width: 0; - height: 0; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; - border-left: 14px solid #4edb7f; - margin-left: 3px; - filter: drop-shadow(0 0 4px rgba(78, 219, 127, 0.3)); - } - .tile-art-tool-icon-play::after { - content: ""; - position: absolute; - left: -16px; - top: -9px; - width: 18px; - height: 18px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, #4edb7f 74%, transparent); - background: color-mix(in srgb, #4edb7f 10%, transparent); - } - .tile-art-tool-icon-playback { - position: relative; - width: 18px; - height: 16px; - } - .tile-art-tool-icon-playback::before { - content: ""; - position: absolute; - left: 1px; - right: 1px; - top: 7px; - height: 2px; - border-radius: 999px; - background: linear-gradient(90deg, #56db86 0%, #56db86 100%); - box-shadow: - -5px 0 0 -1px #56db86, - 5px 0 0 -1px #56db86; - } - .tile-art-tool-icon-playback::after { - content: ""; - position: absolute; - left: 2px; - top: 0; - width: 12px; - height: 12px; - border-left: 2px solid #ff6f7f; - border-top: 2px solid #ff6f7f; - border-radius: 10px 0 0 0; - transform: rotate(-18deg); - } - .tile-art-tool-icon-playback .tile-art-tool-icon-playback-arrow-a, - .tile-art-tool-icon-playback .tile-art-tool-icon-playback-arrow-b { - position: absolute; - display: block; - width: 0; - height: 0; - border-top: 3px solid transparent; - border-bottom: 3px solid transparent; - } - .tile-art-tool-icon-playback .tile-art-tool-icon-playback-arrow-a { - right: -1px; - top: 5px; - border-left: 5px solid #56db86; - } - .tile-art-tool-icon-playback .tile-art-tool-icon-playback-arrow-b { - left: -1px; - top: 0; - border-right: 5px solid #ff6f7f; - transform: rotate(-30deg); - } - .tile-art-color-strip { - display: grid; - grid-template-columns: max-content max-content; - align-items: center; - justify-content: center; - gap: 10px; - } - .tile-art-current { - display: grid; - grid-template-rows: auto auto; - align-content: start; - gap: 8px; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - } - .tile-art-current-row { - display: flex; - justify-content: center; - } - .tile-art-current-swatch { - position: relative; - display: block; - width: 42px; - height: 42px; - border-radius: 6px; - border: 1px solid color-mix(in srgb, var(--editor-border, #2d426b) 72%, white 12%); - background: var(--swatch-color, transparent); - overflow: hidden; - box-shadow: inset 0 0 0 1px rgba(8, 17, 29, 0.34); - } - .tile-art-current-swatch.is-transparent { - background: - linear-gradient(45deg, rgba(255,255,255,0.12) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.12) 75%), - linear-gradient(45deg, rgba(255,255,255,0.12) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.12) 75%); - background-size: 12px 12px; - background-position: 0 0, 6px 6px; - border-style: dashed; - } - .tile-art-current-indicator { - position: absolute; - right: 3px; - bottom: 3px; - width: 14px; - height: 18px; - border: 1.5px solid rgba(226, 240, 255, 0.95); - border-radius: 8px 8px 9px 9px; - background: rgba(5, 10, 18, 0.62); - box-shadow: 0 2px 6px rgba(3, 8, 18, 0.28); - pointer-events: none; - overflow: hidden; - } - .tile-art-current-indicator::before { - content: ""; - position: absolute; - left: 50%; - top: 3px; - width: 1.5px; - height: 5px; - background: rgba(226, 240, 255, 0.9); - transform: translateX(-50%); - border-radius: 999px; - } - .tile-art-current-indicator-left, - .tile-art-current-indicator-right { - position: absolute; - top: 0; - width: 50%; - height: 7px; - background: rgba(226, 240, 255, 0.14); - } - .tile-art-current-indicator-left { - left: 0; - border-right: 1px solid rgba(226, 240, 255, 0.22); - border-radius: 7px 0 0 0; - } - .tile-art-current-indicator-right { - right: 0; - border-radius: 0 7px 0 0; - } - .tile-art-current-indicator.is-primary .tile-art-current-indicator-left, - .tile-art-current-indicator.is-secondary .tile-art-current-indicator-right { - background: #ff5f6d; - box-shadow: inset 0 0 0 1px rgba(111, 16, 25, 0.35); - } - .tile-art-current-meta { - color: #b7cdf5; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.02em; - line-height: 1.35; - padding-top: 2px; - } - .tile-art-tools { - display: grid; - grid-template-columns: 60px minmax(0, 1fr); - align-items: start; - gap: 8px; - } - .tile-art-tools-label { - color: #dbe9ff; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.03em; - } - .tile-art-tool-buttons { - display: flex; - gap: 8px; - flex-wrap: wrap; - } - .tile-art-tool-menu-host { - grid-column: 2; - display: flex; - justify-content: flex-start; - } - .tile-art-tool-menu-host.hidden { - display: none; - } - .tile-art-tool-btn { - width: 34px; - height: 34px; - padding: 0; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent); - color: var(--editor-control-fg, #eef6ff); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 11px; - font-weight: 700; - } - .tile-art-tool-btn:hover { - border-color: var(--editor-border-strong, #5e84bd); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent); - } - .tile-art-tool-btn.is-active { - border-color: var(--editor-warn, #ffd166); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 24%, transparent); - background: color-mix(in srgb, #5f4308 36%, var(--editor-panel-bg, #11203f)); - color: #fff2c8; - } - .tile-art-tool-btn.is-open { - border-color: var(--editor-tool-armed, #7ee8c6); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-tool-armed, #7ee8c6) 24%, transparent); - } - .tile-art-tool-btn:focus-visible { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-tool-copy { - display: none; - } - .tile-art-tool-icon { - position: relative; - display: inline-block; - flex: 0 0 auto; - } - .tile-art-tool-icon-pencil { - width: 13px; - height: 4px; - border-radius: 999px; - background: #7ee8c6; - transform: rotate(-35deg); - box-shadow: inset -3px 0 0 #ffcf70; - } - .tile-art-tool-icon-pencil::before { - content: ""; - position: absolute; - left: -3px; - top: 0; - width: 0; - height: 0; - border-top: 2px solid transparent; - border-bottom: 2px solid transparent; - border-right: 3px solid #eef6ff; - } - .tile-art-tool-icon-pencil::after { - content: ""; - position: absolute; - right: -2px; - top: 0; - width: 2px; - height: 4px; - border-radius: 999px; - background: #ff7f9f; - } - .tile-art-tool-icon-line { - width: 15px; - height: 12px; - } - .tile-art-tool-icon-line::before { - content: ""; - position: absolute; - left: 1px; - top: 8px; - width: 13px; - height: 2px; - border-radius: 999px; - background: #7ee8c6; - transform: rotate(-35deg); - transform-origin: left center; - box-shadow: 0 0 0 1px rgba(8, 17, 29, 0.24); - } - .tile-art-tool-icon-line::after { - content: ""; - position: absolute; - left: 0; - top: 0; - width: 15px; - height: 12px; - background: - radial-gradient(circle at 2px 9px, #eef6ff 0 1.4px, transparent 1.5px), - radial-gradient(circle at 13px 2px, #ffd166 0 1.4px, transparent 1.5px); - } - .tile-art-tool-icon-bucket { - width: 13px; - height: 11px; - border: 2px solid #7ee8c6; - border-top: 0; - border-radius: 2px 2px 5px 5px; - transform: rotate(-18deg); - box-sizing: border-box; - } - .tile-art-tool-icon-bucket::before { - content: ""; - position: absolute; - left: 1px; - top: -6px; - width: 7px; - height: 6px; - border: 2px solid #7ee8c6; - border-bottom: 0; - border-radius: 7px 7px 0 0; - box-sizing: border-box; - } - .tile-art-tool-icon-bucket::after { - content: ""; - position: absolute; - right: -4px; - bottom: -2px; - width: 4px; - height: 6px; - border-radius: 999px; - background: #64aaf8; - transform: rotate(20deg); - opacity: 0.92; - } - .tile-art-tool-icon-shape { - width: 14px; - height: 11px; - border: 2px solid #7ee8c6; - border-radius: 2px; - box-sizing: border-box; - } - .tile-art-tool-icon-shape::before { - content: ""; - position: absolute; - left: 3px; - top: 2px; - width: 6px; - height: 5px; - border: 2px solid #ffd166; - border-radius: 1px; - box-sizing: border-box; - background: rgba(255, 209, 102, 0.18); - } - .tile-art-tool-icon-eraser { - width: 14px; - height: 9px; - border-radius: 3px; - background: linear-gradient(135deg, #ff8aa6 0 48%, #eef6ff 48% 100%); - transform: rotate(-20deg); - box-shadow: inset 0 0 0 1px rgba(17, 29, 53, 0.45); - } - .tile-art-tool-icon-transform { - width: 14px; - height: 10px; - } - .tile-art-tool-icon-transform::before, - .tile-art-tool-icon-transform::after { - content: ""; - position: absolute; - top: 4px; - width: 5px; - height: 2px; - background: #7ee8c6; - } - .tile-art-tool-icon-transform::before { - left: 1px; - box-shadow: -2px 0 0 #7ee8c6; - } - .tile-art-tool-icon-transform::after { - right: 1px; - box-shadow: 2px 0 0 #7ee8c6; - } - .tile-art-tool-icon-transform { - border-left: 5px solid #7ee8c6; - border-right: 5px solid #7ee8c6; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - box-sizing: border-box; - } - .tile-art-tool-icon-opacity { - width: 14px; - height: 14px; - border-radius: 999px; - background: linear-gradient(135deg, #05070d 0%, #f5f8ff 100%); - box-shadow: inset 0 0 0 1px rgba(17, 29, 53, 0.55); - } - .tile-art-tool-icon-shift { - width: 14px; - height: 14px; - background: center / contain no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Cg fill='none' stroke='%237ee8c6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 1.5v11M1.5 7h11'/%3E%3Cpath d='M7 1.5 5.4 3.1M7 1.5l1.6 1.6M7 12.5l-1.6-1.6M7 12.5l1.6-1.6M1.5 7l1.6-1.6M1.5 7l1.6 1.6M12.5 7l-1.6-1.6M12.5 7l-1.6 1.6'/%3E%3C/g%3E%3C/svg%3E"); - } - .tile-art-tool-menu { - width: min(100%, 360px); - display: grid; - gap: 8px; - padding: 10px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 10px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 94%, transparent); - box-shadow: 0 12px 28px rgba(5, 10, 22, 0.28); - } - .tile-art-tool-menu-title { - color: #eef6ff; - font-size: 11px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.03em; - } - .tile-art-tool-menu-help { - color: #9db8e4; - font-size: 11px; - line-height: 1.4; - } - .tile-art-tool-menu-row { - display: grid; - gap: 6px; - } - .tile-art-tool-menu-label { - color: #dbe9ff; - font-size: 11px; - font-weight: 700; - } - .tile-art-tool-menu-buttons { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .tile-art-tool-menu-btn { - min-height: 28px; - padding: 0 10px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 86%, transparent); - color: var(--editor-control-fg, #eef6ff); - cursor: pointer; - font-size: 11px; - font-weight: 700; - } - .tile-art-tool-menu-btn:hover { - border-color: var(--editor-border-strong, #5e84bd); - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 92%, transparent); - } - .tile-art-tool-menu-btn.is-active { - border-color: var(--editor-warn, #ffd166); - background: color-mix(in srgb, #5f4308 36%, var(--editor-panel-bg, #11203f)); - color: #fff2c8; - } - .tile-art-tool-menu-btn:focus-visible { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-shape-menu-layout { - display: grid; - grid-template-columns: minmax(124px, auto) minmax(142px, 1fr); - gap: 8px; - align-items: start; - } - .tile-art-shape-menu-primary, - .tile-art-shape-menu-submenu { - display: grid; - gap: 6px; - } - .tile-art-shape-menu-submenu { - min-width: 0; - padding-left: 8px; - border-left: 1px solid color-mix(in srgb, var(--editor-control-border, #35537f) 72%, transparent); - } - .tile-art-shape-menu-subtitle { - margin-bottom: 2px; - } - .tile-art-shape-menu-trigger { - justify-content: space-between; - text-align: left; - } - .tile-art-opacity-controls { - display: grid; - grid-template-columns: minmax(0, 1fr) 64px auto; - align-items: center; - gap: 8px; - } - .tile-art-opacity-range { - width: 100%; - accent-color: var(--editor-accent, #64aaf8); - appearance: none; - -webkit-appearance: none; - height: 6px; - border-radius: 999px; - background: color-mix(in srgb, var(--editor-border, #2d426b) 78%, black 22%); - outline: none; - } - .tile-art-opacity-range::-webkit-slider-runnable-track { - height: 6px; - border-radius: 999px; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--editor-accent, #64aaf8) 88%, white 12%) 0%, - color-mix(in srgb, var(--editor-border, #2d426b) 78%, black 22%) 100% - ); - } - .tile-art-opacity-range::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 14px; - height: 14px; - margin-top: -4px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--editor-accent, #64aaf8) 62%, white 20%); - background: var(--editor-shell-fg, #eef6ff); - box-shadow: 0 2px 6px rgba(3, 8, 18, 0.34); - cursor: pointer; - } - .tile-art-opacity-range::-moz-range-track { - height: 6px; - border-radius: 999px; - background: color-mix(in srgb, var(--editor-border, #2d426b) 78%, black 22%); - } - .tile-art-opacity-range::-moz-range-thumb { - width: 14px; - height: 14px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--editor-accent, #64aaf8) 62%, white 20%); - background: var(--editor-shell-fg, #eef6ff); - box-shadow: 0 2px 6px rgba(3, 8, 18, 0.34); - cursor: pointer; - } - .tile-art-opacity-range:focus-visible { - outline: none; - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 24%, transparent); - } - .tile-art-opacity-number { - width: 64px; - height: 30px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 86%, transparent); - color: var(--editor-control-fg, #eef6ff); - padding: 0 8px; - font-size: 12px; - } - .tile-art-opacity-number:focus-visible { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-opacity-suffix { - color: #dbe9ff; - font-size: 11px; - font-weight: 700; - } - .tile-art-swatches { - display: grid; - grid-template-rows: repeat(3, 21px); - grid-auto-flow: column; - grid-auto-columns: 21px; - align-content: center; - align-self: center; - justify-content: start; - gap: 6px; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - } - .tile-art-swatch-btn { - position: relative; - width: 21px; - height: 21px; - padding: 0; - border: 1px solid var(--editor-border, #2d426b); - border-radius: 4px; - cursor: pointer; - background: var(--swatch-color, transparent); - overflow: hidden; - color: var(--editor-shell-fg, #f5f8ff); - } - .tile-art-swatch-indicator { - position: absolute; - width: 10px; - height: 13px; - border: 1px solid rgba(226, 240, 255, 0.95); - border-radius: 6px 6px 7px 7px; - background: rgba(5, 10, 18, 0.7); - box-shadow: 0 1px 3px rgba(3, 8, 18, 0.28); - pointer-events: none; - overflow: hidden; - z-index: 1; - } - .tile-art-swatch-indicator.is-primary { - left: 1px; - top: 1px; - } - .tile-art-swatch-indicator.is-secondary { - right: 1px; - bottom: 1px; - } - .tile-art-swatch-indicator::before { - content: ""; - position: absolute; - left: 50%; - top: 2px; - width: 1px; - height: 4px; - background: rgba(226, 240, 255, 0.92); - transform: translateX(-50%); - border-radius: 999px; - } - .tile-art-swatch-indicator-left, - .tile-art-swatch-indicator-right { - position: absolute; - top: 0; - width: 50%; - height: 5px; - background: rgba(226, 240, 255, 0.14); - } - .tile-art-swatch-indicator-left { - left: 0; - border-right: 1px solid rgba(226, 240, 255, 0.2); - border-radius: 5px 0 0 0; - } - .tile-art-swatch-indicator-right { - right: 0; - border-radius: 0 5px 0 0; - } - .tile-art-swatch-indicator.is-primary .tile-art-swatch-indicator-left, - .tile-art-swatch-indicator.is-secondary .tile-art-swatch-indicator-right { - background: #ff5f6d; - box-shadow: inset 0 0 0 1px rgba(111, 16, 25, 0.35); - } - .tile-art-swatch-btn:hover { - border-color: var(--editor-border-strong, #5e84bd); - } - .tile-art-swatch-btn:focus-visible { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-swatch-btn.is-active { - border-color: var(--editor-warn, #ffd166); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 24%, transparent); - } - .tile-art-swatch-btn.is-secondary { - border-color: #7ee8c6; - box-shadow: inset 0 0 0 1px rgba(126, 232, 198, 0.4); - } - .tile-art-swatch-btn.is-transparent { - background: - linear-gradient(45deg, rgba(255,255,255,0.08) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.08) 75%), - linear-gradient(45deg, rgba(255,255,255,0.08) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.08) 75%); - background-size: 12px 12px; - background-position: 0 0, 6px 6px; - border-style: dashed; - } - .tile-art-swatch-btn.is-transparent-action { - width: 32px; - height: 32px; - border-width: 2px; - border-color: rgba(214, 231, 255, 0.38); - border-radius: 6px; - box-shadow: inset 0 0 0 1px rgba(8, 17, 29, 0.58); - } - .tile-art-swatch-btn.is-transparent-action:hover { - border-color: #8fd0ff; - } - .tile-art-swatch-btn.is-transparent-action.is-active { - border-color: var(--editor-warn, #ffd166); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 24%, transparent), - inset 0 0 0 1px rgba(8, 17, 29, 0.58); - } - .tile-art-swatch-btn.is-transparent-action.is-secondary { - border-color: #7ee8c6; - box-shadow: - inset 0 0 0 1px rgba(8, 17, 29, 0.58), - 0 0 0 1px rgba(126, 232, 198, 0.26); - } - .tile-art-grid-wrap { - min-height: 0; - overflow: hidden; - padding: 8px; - border: 1px solid var(--editor-border, #2d426b); - border-radius: 12px; - background: - linear-gradient(var(--preview-bg-color, transparent), var(--preview-bg-color, transparent)), - color-mix(in srgb, var(--editor-shell-bg, #0a1020) 92%, transparent); - } - .tile-art-grid-stage { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: start; - gap: 8px; - min-width: 0; - } - .tile-art-grid-stage.is-template-drop-target .tile-art-grid-wrap { - border-color: var(--editor-tool-armed, #7ee8c6); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--editor-tool-armed, #7ee8c6) 28%, transparent), - inset 0 0 0 1px rgba(126, 232, 198, 0.16); - } - .tile-art-grid-stage.is-template-drop-target .tile-art-used-swatches { - border-color: var(--editor-tool-armed, #7ee8c6); - box-shadow: inset 0 0 0 1px rgba(126, 232, 198, 0.14); - } - .tile-art-used-swatches { - width: 38px; - min-height: 40px; - padding: 5px 6px; - border: 1px solid var(--editor-border, #2d426b); - border-radius: 10px; - background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 88%, transparent); - display: grid; - grid-auto-rows: min-content; - align-content: start; - justify-items: center; - justify-content: center; - gap: 6px; - box-sizing: border-box; - } - .tile-art-used-swatch-btn { - width: 24px; - height: 24px; - border-radius: 5px; - } - .tile-art-used-swatch-btn.is-transparent-action { - width: 24px; - height: 24px; - border-width: 1px; - border-radius: 5px; - } - .tile-art-grid { - display: grid; - gap: 2px; - width: max-content; - margin: 0 auto; - touch-action: none; - } - .tile-art-grid.is-eyedropper .tile-art-cell { - cursor: inherit; - } - .tile-art-preview-hint { - color: var(--editor-muted, #8fb2e1); - font-size: 11px; - line-height: 1.35; - text-align: center; - padding: 0 4px; - } - .tile-art-shortcut-help-panel { - min-width: 250px; - display: grid; - gap: 8px; - } - .tile-art-shortcut-help-title { - font-size: 11px; - font-weight: 700; - color: var(--editor-muted, #9fb8e5); - text-transform: uppercase; - letter-spacing: 0.04em; - } - .tile-art-shortcut-help-list { - display: grid; - gap: 6px; - } - .tile-art-shortcut-row { - grid-template-columns: minmax(0, 1fr) auto; - } - .tile-art-shortcut-action { - min-width: 72px; - } - .shortcut-mouse-dot.is-secondary { - background: #7ee8c6; - } - .tile-art-tags-pane { - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 10px; - padding: 2px 0 0; - } - .tile-art-window.is-tags-tab .tile-art-tags-pane { - grid-template-rows: auto auto; - align-content: start; - } - .tile-art-tag-field { - display: grid; - gap: 6px; - } - .tile-art-tag-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - } - .tile-art-tag-label { - color: var(--editor-shell-fg, #dbe9ff); - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.03em; - } - .tile-art-tag-actions { - display: inline-flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - justify-content: flex-end; - } - .tile-art-tag-input { - width: 100%; - min-width: 0; - height: 32px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 8px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent); - color: var(--editor-control-fg, #eef6ff); - padding: 0 10px; - font-size: 12px; - } - .tile-art-tag-input:focus { - outline: none; - border-color: var(--editor-accent, #64aaf8); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent); - } - .tile-art-tag-list { - min-height: 0; - display: flex; - align-content: flex-start; - align-items: flex-start; - flex-wrap: wrap; - gap: 8px; - overflow: auto; - padding: 8px; - border: 1px solid var(--editor-border, #2d426b); - border-radius: 12px; - background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 92%, transparent); - } - .tile-art-window.is-tags-tab .tile-art-tag-list { - min-height: 80px; - max-height: 240px; - } - .tile-art-tags-empty { - color: var(--editor-muted, #8fb2e1); - font-size: 11px; - line-height: 1.45; - } - .tile-art-tag-chip { - max-width: 100%; - min-height: 28px; - padding: 0 10px; - border: 1px solid var(--editor-control-border, #35537f); - border-radius: 999px; - background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent); - color: var(--editor-control-fg, #eef6ff); - display: inline-flex; - align-items: center; - gap: 7px; - cursor: pointer; - font-size: 11px; - font-weight: 700; - } - .tile-art-tag-chip:hover { - border-color: var(--editor-danger-border, #ff9aa7); - background: color-mix(in srgb, var(--editor-danger, #3c1a1a) 88%, transparent); - } - .tile-art-tag-chip-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - } - .tile-art-tag-chip-remove { - color: var(--editor-status-error, #ffb7c3); - font-size: 10px; - line-height: 1; - text-transform: uppercase; - } - .tile-art-cell { - width: 21px; - height: 21px; - border: 1px solid rgba(255,255,255,0.08); - padding: 0; - cursor: crosshair; - background: - linear-gradient(var(--paint-color, transparent), var(--paint-color, transparent)), - linear-gradient(45deg, rgba(255,255,255,0.09) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.09) 75%), - linear-gradient(45deg, rgba(255,255,255,0.09) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.09) 75%), - linear-gradient(var(--preview-bg-color, transparent), var(--preview-bg-color, transparent)); - background-size: cover, 12px 12px, 12px 12px, cover; - background-position: 0 0, 0 0, 6px 6px, 0 0; - } - .tile-art-cell:hover { - border-color: color-mix(in srgb, var(--editor-warn, #ffd166) 60%, transparent); - } - .tile-art-cell.is-transparent { - border-style: dashed; - } - .tile-art-footer { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - } - .tile-art-window.is-tags-tab .tile-art-footer { - display: none; - } - .tile-art-status { - color: var(--editor-muted, #8fb2e1); - font-size: 11px; - line-height: 1.4; - min-height: 16px; - } - .tile-art-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - } - .paint-palette { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 12px; - } - .paint-swatch-btn { - display: inline-flex; - align-items: center; - gap: 6px; - height: 28px; - padding: 0 8px; - border: 1px solid #2d426e; - border-radius: 7px; - background: #132447; - color: #d6e7ff; - font-size: 11px; - cursor: pointer; - } - .paint-swatch-btn.active { - border-color: #ffd166; - background: #2b4669; - } - /* ── AtTooltip floating menu ── */ - .at-tooltip-panel { - position: fixed; - z-index: 9999; - background: #0f2344; - border: 1px solid #3c5e95; - border-radius: 10px; - padding: 5px; - min-width: 190px; - max-width: 290px; - overflow-y: auto; - display: grid; - gap: 3px; - box-shadow: 0 6px 28px rgba(0,0,20,0.8); - outline: none; - } - .at-tooltip-panel:focus-visible { - border-color: #64aaf8; - box-shadow: 0 0 0 1px rgba(100,170,248,0.4), 0 6px 28px rgba(0,0,20,0.8); - } - .at-tooltip-panel:empty::after { - content: 'No options'; - color: #7a9acc; - font-size: 11px; - padding: 6px 8px; - } - .at-tooltip-label { - padding: 4px 8px 2px; - color: #9fb8e5; - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - } - .at-tooltip-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 7px; - height: 32px; - padding: 0 8px; - border: 1px solid #35537f; - border-radius: 7px; - background: #132b4f; - color: #d6e7ff; - font-size: 11px; - text-align: left; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - } - .at-tooltip-item:hover, .at-tooltip-item.active, .at-tooltip-item.is-active { - background: #1a3f6d; - border-color: #64aaf8; - } - .at-tooltip-item:focus-visible { - background: #1a3f6d; - border-color: #64aaf8; - outline: none; - } - .at-tooltip-item:disabled { - cursor: not-allowed; - opacity: 0.5; - } - .at-tooltip-item img { - flex: 0 0 22px; - width: 22px; - height: 22px; - border-radius: 4px; - object-fit: contain; - image-rendering: pixelated; - background: #0c1730; - } - .at-tooltip-item.has-submenu { - padding-right: 6px; - } - .at-tooltip-item.at-tooltip-icon-item { - width: 34px; - height: 34px; - min-height: 34px; - padding: 0; - justify-content: center; - border-radius: 10px; - overflow: hidden; - } - .at-tooltip-item.at-tooltip-icon-item.has-submenu { - padding-right: 0; - } - .at-tooltip-panel.at-tooltip-icon-stack-panel, - .at-tooltip-panel.at-tooltip-icon-row-panel, - .at-tooltip-panel.at-tooltip-icon-grid-panel { - min-width: 0; - width: max-content; - max-width: none; - grid-auto-rows: 34px; - gap: 6px; - padding: 6px; - } - .at-tooltip-panel.at-tooltip-icon-stack-panel { - grid-template-columns: 34px; - } - .at-tooltip-panel.at-tooltip-icon-row-panel { - grid-auto-flow: column; - grid-auto-columns: 34px; - grid-template-columns: none; - } - .at-tooltip-panel.at-tooltip-icon-grid-panel { - grid-template-columns: repeat(3, 34px); - } - .at-tooltip-panel.at-tooltip-icon-grid-panel.at-tooltip-icon-grid-panel-wide { - grid-template-columns: repeat(4, 34px); - } - .at-tooltip-panel.at-tooltip-icon-stack-panel:empty::after, - .at-tooltip-panel.at-tooltip-icon-row-panel:empty::after, - .at-tooltip-panel.at-tooltip-icon-grid-panel:empty::after { - display: none; - } - .at-tooltip-panel.at-tooltip-icon-stack-panel .at-tooltip-label, - .at-tooltip-panel.at-tooltip-icon-row-panel .at-tooltip-label, - .at-tooltip-panel.at-tooltip-icon-grid-panel .at-tooltip-label, - .at-tooltip-item.at-tooltip-icon-item .at-tooltip-submenu-arrow { - display: none; - } - .at-tooltip-submenu-arrow { - margin-left: auto; - color: #9fb8e5; - font-size: 14px; - line-height: 1; - opacity: 0.92; - } - .tile-art-menu-shape-icon, - .tile-art-menu-line-icon, - .tile-art-menu-transform-icon { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - flex: 0 0 18px; - } - .tile-art-menu-shape-outline, - .tile-art-menu-shape-fill, - .tile-art-menu-line-stroke, - .tile-art-menu-transform-part { - position: absolute; - box-sizing: border-box; - pointer-events: none; - } - .tile-art-menu-shape-icon.is-rectangle .tile-art-menu-shape-outline { - inset: 3px; - border: 2px solid #7ee8c6; - border-radius: 3px; - background: transparent; - } - .tile-art-menu-shape-icon.is-rectangle .tile-art-menu-shape-fill { - inset: 6px; - border-radius: 2px; - background: #ffd166; - } - .tile-art-menu-shape-icon.is-circle .tile-art-menu-shape-outline { - inset: 3px; - border: 2px solid #7ee8c6; - border-radius: 999px; - background: transparent; - } - .tile-art-menu-shape-icon.is-circle .tile-art-menu-shape-fill { - inset: 6px; - border-radius: 999px; - background: #ffd166; - } - .tile-art-menu-shape-icon.is-triangle .tile-art-menu-shape-outline { - inset: 1px; - clip-path: polygon(50% 12%, 16% 84%, 84% 84%); - background: #7ee8c6; - } - .tile-art-menu-shape-icon.is-triangle .tile-art-menu-shape-fill { - inset: 1px; - clip-path: polygon(50% 27%, 31% 72%, 69% 72%); - background: #ffd166; - } - .tile-art-menu-shape-icon.is-outline .tile-art-menu-shape-fill { - display: none; - } - .tile-art-menu-shape-icon.is-fill .tile-art-menu-shape-outline { - display: none; - } - .tile-art-menu-shape-icon.is-fill.is-draw .tile-art-menu-shape-fill, - .tile-art-menu-shape-icon.is-two-tone .tile-art-menu-shape-fill { - background: #ffd166; - } - .tile-art-menu-shape-icon.is-fill.is-erase .tile-art-menu-shape-fill, - .tile-art-menu-shape-icon.is-two-tone.is-erase .tile-art-menu-shape-fill { - background: #ff8aa6; - } - .tile-art-menu-shape-icon.is-outline.is-erase.is-rectangle .tile-art-menu-shape-outline, - .tile-art-menu-shape-icon.is-outline.is-erase.is-circle .tile-art-menu-shape-outline { - border-color: #ff8aa6; - } - .tile-art-menu-shape-icon.is-outline.is-erase.is-triangle .tile-art-menu-shape-outline { - background: #ff8aa6; - } - .tile-art-menu-shape-icon.is-fill.is-erase .tile-art-menu-shape-fill { - background: #ff8aa6; - } - .tile-art-menu-line-stroke { - left: 1px; - top: 8px; - width: 16px; - height: 0; - border-top: 3px solid #ffd166; - transform: rotate(-34deg); - transform-origin: center; - border-radius: 999px; - } - .tile-art-menu-line-icon.is-erase .tile-art-menu-line-stroke { - border-top-color: #ff8aa6; - } - .tile-art-menu-transform-icon .tile-art-menu-transform-part { - background: transparent; - } - .tile-art-menu-transform-icon.is-rotate .part-a, - .tile-art-menu-transform-icon.is-rotate-cw .part-a, - .tile-art-menu-transform-icon.is-rotate-ccw .part-a { - left: 2px; - top: 2px; - width: 2px; - height: 14px; - border-radius: 999px; - background: #36e07a; - } - .tile-art-menu-transform-icon.is-rotate .part-b, - .tile-art-menu-transform-icon.is-rotate-cw .part-b, - .tile-art-menu-transform-icon.is-rotate-ccw .part-b { - left: 2px; - bottom: 2px; - width: 14px; - height: 2px; - border-radius: 999px; - background: #ff4b57; - } - .tile-art-menu-transform-icon.is-rotate-ccw .part-a { - background: #ff4b57; - } - .tile-art-menu-transform-icon.is-rotate-ccw .part-b { - background: #36e07a; - } - .tile-art-menu-transform-icon.is-flip .part-a, - .tile-art-menu-transform-icon.is-flip-h .part-a, - .tile-art-menu-transform-icon.is-flip-h .part-b { - top: 3px; - width: 7px; - height: 12px; - } - .tile-art-menu-transform-icon.is-flip .part-a, - .tile-art-menu-transform-icon.is-flip-h .part-a { - left: 1px; - clip-path: polygon(100% 0, 0 50%, 100% 100%); - background: #ff4b57; - } - .tile-art-menu-transform-icon.is-flip .part-b, - .tile-art-menu-transform-icon.is-flip-h .part-b { - right: 1px; - clip-path: polygon(0 0, 100% 50%, 0 100%); - background: #36e07a; - } - .tile-art-menu-transform-icon.is-flip-v .part-a, - .tile-art-menu-transform-icon.is-flip-v .part-b { - left: 2px; - width: 14px; - height: 7px; - } - .tile-art-menu-transform-icon.is-flip-v .part-a { - top: 2px; - clip-path: polygon(50% 0, 100% 100%, 0 100%); - background: #ff4b57; - } - .tile-art-menu-transform-icon.is-flip-v .part-b { - bottom: 2px; - clip-path: polygon(0 0, 100% 0, 50% 100%); - background: #36e07a; - } - .at-tooltip-separator { - height: 1px; - background: #2a426a; - margin: 2px 0; - } - - .legend { - display: flex; - flex-wrap: wrap; - gap: 6px; - font-size: 11px; - color: #b6caed; - } - .legend-item { - display: inline-flex; - align-items: center; - gap: 4px; - border: 1px solid #2d426e; - border-radius: 6px; - padding: 3px 6px; - background: #132447; - } - .swatch { - width: 11px; - height: 11px; - border-radius: 2px; - border: 1px solid rgba(255,255,255,0.2); - } - - -`; - -export const WORLDSHAPER_STUDIO_STYLE_STAGE = ` - .stage { - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - background: #060a14; - position: relative; - } - .meta { - height: 28px; - display: flex; - align-items: center; - gap: 10px; - padding: 0 10px; - border-bottom: 1px solid #2a3d63; - font-size: 12px; - color: #a8bfeb; - flex-shrink: 0; - } - .meta-main { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .meta-stats { - margin-left: auto; - color: #d7e7ff; - font-variant-numeric: tabular-nums; - white-space: nowrap; - } - .viewport { - position: relative; - min-height: 0; - flex: 1; - overflow: auto; - scrollbar-width: none; - -ms-overflow-style: none; - cursor: crosshair; - user-select: none; - background: var(--editor-stage-bg, #060a14); - } - .viewport::-webkit-scrollbar { - width: 0; - height: 0; - display: none; - } - .canvas-tool-btn { - position: absolute; - top: 38px; - left: 10px; - z-index: 4; - width: 42px; - height: 42px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid #35507f; - border-radius: 10px; - background: rgba(10, 20, 37, 0.92); - color: #d9ebff; - box-shadow: 0 8px 20px rgba(3, 8, 18, 0.28); - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; - } - .canvas-tool-btn:hover { - background: #17315b; - border-color: #4f78b5; - transform: translateY(-1px); - } - .canvas-tool-btn.active { - border-color: #5fc3ff; - background: #173d57; - color: #f4fbff; - } - .canvas-tool-btn:focus-visible { - outline: 2px solid rgba(95,195,255,0.75); - outline-offset: 2px; - } - .canvas-tool-btn-icon { - position: relative; - width: 20px; - height: 20px; - display: block; - } - .canvas-tool-btn-icon::before { - content: ""; - position: absolute; - left: 1px; - top: 1px; - width: 12px; - height: 12px; - border: 2px dashed currentColor; - border-radius: 2px; - box-sizing: border-box; - } - .canvas-tool-btn-icon::after { - content: ""; - position: absolute; - right: 0; - bottom: 0; - width: 0; - height: 0; - border-left: 7px solid currentColor; - border-top: 11px solid transparent; - transform: rotate(-18deg); - transform-origin: center; - } - .viewport-layer { - position: sticky; - top: 0; - left: 0; - width: 100%; - height: 0; - overflow: visible; - z-index: 1; - pointer-events: none; - } - .pixi-host { - position: absolute; - inset: 0; - pointer-events: none; - overflow: hidden; - } - .pixi-stage-canvas { - display: block; - width: 100%; - height: 100%; - pointer-events: none; - image-rendering: pixelated; - } - .viewport-layer canvas { - position: absolute; - inset: 0; - display: block; - width: 100%; - height: 100%; - pointer-events: auto; - } - .viewport-spacer { - position: relative; - z-index: 0; - pointer-events: none; - } - -`; diff --git a/src/worldshaperStudio/domStyles.ts b/src/worldshaperStudio/domStyles.ts deleted file mode 100644 index 716a10b..0000000 --- a/src/worldshaperStudio/domStyles.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { buildWorldshaperStudioThemeOverrideCss } from "./themePresets"; -import { - WORLDSHAPER_STUDIO_STYLE_SHELL, - WORLDSHAPER_STUDIO_STYLE_SIDEBAR, - WORLDSHAPER_STUDIO_STYLE_STAGE, -} from "./domStyleSections"; - -export function buildWorldshaperStudioStyles(): string { - return ( - WORLDSHAPER_STUDIO_STYLE_SHELL - + WORLDSHAPER_STUDIO_STYLE_SIDEBAR - + WORLDSHAPER_STUDIO_STYLE_STAGE - + buildWorldshaperStudioThemeOverrideCss() - ); -} diff --git a/src/worldshaperStudio/runtime.ts b/src/worldshaperStudio/runtime.ts index 74c6eb7..3c6c8d0 100644 --- a/src/worldshaperStudio/runtime.ts +++ b/src/worldshaperStudio/runtime.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */ +/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */ // @ts-nocheck import { buildSpritePreviewDataUrl, @@ -10,7 +10,12 @@ import { normalizeImageRecordForSave, normalizeTileRecordForSave, } from "../editorCore"; -import { DEFAULT_TILE_COLOR } from "../components/worldshaperShared"; +import { + buildSpriteCatalog, + buildTileCatalogById, + DEFAULT_MAP_BACKGROUND_COLOR, + DEFAULT_TILE_COLOR, +} from "../components/worldshaperShared"; import type { WorldshaperStudioBootstrap } from "./bootstrap"; import { cacheStandaloneWorldshaperBootstrap, @@ -26,9 +31,13 @@ import { openWorldshaperHeightViewerWindow, persistWorldshaperStudioBounds, } from "./windowing"; +import { createHistoryController } from "./historyController"; import { createHistoryStateStore } from "./historyStateStore"; +import { createInteractionController } from "./interactionController"; +import { createImportController } from "./importController"; import { createMapDocumentController } from "./mapDocumentController"; import { createMapDocumentStore } from "./mapDocumentStore"; +import { createNpcController } from "./npcController"; import { createPanelFolderLayoutFolder, deletePanelFolderLayoutFolder, @@ -37,11 +46,22 @@ import { togglePanelFolderLayoutFolder, } from "./panelFolders"; import { createEditorUiStore } from "./editorUiStore"; +import { createChangelogSplashWindowController } from "./changelogSplashWindowController"; +import { createEntityEditorWindowController } from "./entityEditorWindowController"; +import { createEngineOverrideWindowController } from "./engineOverrideWindowController"; import { getEngineOverrideValue, normalizeEngineOverrideEntries, } from "./engineOverrides"; +import { createPersistenceController } from "./persistenceController"; import { createPopupSessionStore } from "./popupSessionStore"; +import { createRenderController } from "./renderController"; +import { createSidebarController } from "./sidebarController"; +import { createStatusLogWindowController } from "./statusLogWindowController"; +import { createTileArtEditorWindowController } from "./tileArtEditorWindowController"; +import { createToolWindowController } from "./toolWindowController"; +import { createWorldOverviewWindowController } from "./worldOverviewWindowController"; +import { createDebouncedCallback } from "./debounce"; import { buildImageRecordFromSpriteRecord, buildImageRecordFromTileRecord, @@ -60,42 +80,215 @@ import { persistEditorSettings, } from "./themePresets"; import { createAtTooltip } from "./tooltip"; -import { initializeRuntimeControllers } from "./runtimeControllerBootstrap"; -import { - buildNpcOverlaysFromWorldChunks, - buildSpriteCatalogFromBootstrap, - buildTileCatalogByIdFromBootstrap, - cloneRuntimeValue, - createInitialWorldRuntimeState, - MAX_DYNAMIC_WORLD_CHUNK_RADIUS, - MAX_WORLD_CHUNK_CACHE_ENTRIES, - normalizeMapBackgroundColor, - TILE_SYMBOL_POOL, -} from "./runtimeBootstrapHelpers"; -import { createRuntimeLogging } from "./runtimeLogging"; -import { - buildChunkHeightLayersFromDocument as buildChunkHeightLayersFromDocumentHelper, - buildChunkInstancesFromDocument as buildChunkInstancesFromDocumentHelper, - buildWorldChunkLayerInstanceIds, - buildWorldLayerMetadata, - cloneWorldChunkHeightLayers, - composeWorldHeightLayers, - composeWorldRoomLayers, - createEmptyWorldChunkPayload as createEmptyWorldChunkPayloadHelper, - createFilledRows, - isChunkFillSymbol, - isWorldChunkPayloadEmpty as isWorldChunkPayloadEmptyHelper, - normalizeCachedWorldChunkPayload as normalizeCachedWorldChunkPayloadHelper, - normalizeWorldChunkInstances as normalizeWorldChunkInstancesHelper, - normalizeWorldChunkRows, - sliceNormalizedRows, - transformChunkHeightPatch, - transformChunkLocalCoord, - transformChunkRows, - transformWorldChunkPayload as transformWorldChunkPayloadHelper, -} from "./worldChunkRuntimeHelpers"; + +function cloneValue(value) { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function createFilledRows(width, height, fillChar) { + return Array.from({ length: Math.max(1, Number(height) || 1) }, () => String(fillChar || " ").repeat(Math.max(1, Number(width) || 1))); +} + +function writeRowSegment(rows, y, x, segment) { + if (!Array.isArray(rows) || !segment) { + return; + } + const targetY = Math.floor(Number(y) || 0); + if (targetY < 0 || targetY >= rows.length) { + return; + } + const safeX = Math.max(0, Math.floor(Number(x) || 0)); + const sourceRow = String(rows[targetY] || ""); + const paddedRow = sourceRow.length >= safeX + ? sourceRow + : (sourceRow + " ".repeat(Math.max(0, safeX - sourceRow.length))); + const before = paddedRow.slice(0, safeX); + const afterStart = safeX + segment.length; + const after = afterStart < paddedRow.length ? paddedRow.slice(afterStart) : ""; + rows[targetY] = before + segment + after; +} + +function composeWorldRoomLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY, worldWidth, worldHeight) { + const layerMap = new Map(); + (Array.isArray(chunks) ? chunks : []).forEach((chunk) => { + const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0); + const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0); + const offsetX = (baseChunkX - originChunkX) * chunkWidth; + const offsetY = (baseChunkY - originChunkY) * chunkHeight; + const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : []; + rawLayers.forEach((rawLayer) => { + const layerNumber = Number(rawLayer?.layer) || 0; + const fillChar = layerNumber === 0 ? "." : " "; + if (!layerMap.has(layerNumber)) { + layerMap.set(layerNumber, { + layer: layerNumber, + name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined, + rows: createFilledRows(worldWidth, worldHeight, fillChar), + instanceIds: [], + }); + } + const targetLayer = layerMap.get(layerNumber); + const sourceRows = Array.isArray(rawLayer?.rows) ? rawLayer.rows.map((row) => String(row || "")) : []; + sourceRows.forEach((row, localY) => { + const targetY = offsetY + localY; + if (targetY < 0 || targetY >= targetLayer.rows.length) { + return; + } + const maxWidth = Math.max(0, worldWidth - offsetX); + writeRowSegment(targetLayer.rows, targetY, offsetX, row.slice(0, maxWidth)); + }); + const sourceInstanceIds = Array.isArray(rawLayer?.instanceIds) + ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) + : []; + targetLayer.instanceIds = Array.from(new Set([...(targetLayer.instanceIds || []), ...sourceInstanceIds])); + }); + }); + if (!layerMap.has(0)) { + layerMap.set(0, { + layer: 0, + rows: createFilledRows(worldWidth, worldHeight, "."), + instanceIds: [], + }); + } + return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0)); +} + +function composeWorldHeightLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY) { + const patches = []; + (Array.isArray(chunks) ? chunks : []).forEach((chunk) => { + const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0); + const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0); + const offsetX = (baseChunkX - originChunkX) * chunkWidth; + const offsetY = (baseChunkY - originChunkY) * chunkHeight; + const rawHeightLayers = Array.isArray(chunk?.heightLayers) ? chunk.heightLayers : []; + rawHeightLayers.forEach((entry, index) => { + const fallbackId = `height_${baseChunkX}_${baseChunkY}_${index + 1}`; + patches.push({ + id: String(entry?.id || fallbackId).trim() || fallbackId, + name: typeof entry?.name === "string" && String(entry.name).trim() ? String(entry.name).trim() : undefined, + z: Math.max(1, Math.floor(Number(entry?.z) || 1)), + x: offsetX + Math.max(0, Number(entry?.x) || 0), + y: offsetY + Math.max(0, Number(entry?.y) || 0), + rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [], + }); + }); + }); + return patches.sort((a, b) => { + if (a.z !== b.z) { + return a.z - b.z; + } + return String(a.name || a.id).localeCompare(String(b.name || b.id)); + }); +} + +function buildWorldLayerMetadata(chunks) { + const layerMap = new Map(); + (Array.isArray(chunks) ? chunks : []).forEach((chunk) => { + const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : []; + rawLayers.forEach((rawLayer) => { + const layerNumber = Number(rawLayer?.layer) || 0; + if (layerMap.has(layerNumber)) { + return; + } + layerMap.set(layerNumber, { + layer: layerNumber, + name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined, + rows: [], + instanceIds: Array.isArray(rawLayer?.instanceIds) ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) : [], + }); + }); + }); + if (!layerMap.has(0)) { + layerMap.set(0, { + layer: 0, + rows: [], + instanceIds: [], + }); + } + if (!Array.from(layerMap.keys()).some((layerNumber) => layerNumber > 0)) { + layerMap.set(1, { + layer: 1, + rows: [], + instanceIds: [], + }); + } + return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0)); +} + +function getContentRecords(payload, key) { + const records = payload && Array.isArray(payload[key]) ? payload[key] : []; + return records.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)); +} + +function buildSpriteCatalogFromBootstrap(bootstrap) { + const spriteRecords = getContentRecords(bootstrap?.contentByType?.sprites, "sprites"); + if (spriteRecords.length > 0) { + return buildSpriteCatalog(spriteRecords, buildSpritePreviewDataUrl); + } + return cloneValue(bootstrap?.spriteCatalog) || {}; +} + +function buildTileCatalogByIdFromBootstrap(bootstrap) { + const tileRecords = getContentRecords(bootstrap?.contentByType?.tiles, "tiles"); + if (tileRecords.length > 0) { + return buildTileCatalogById(tileRecords, buildSpritePreviewDataUrl); + } + return cloneValue(bootstrap?.tileCatalogById) || {}; +} + +function buildNpcOverlaysFromWorldChunks(chunks, spriteCatalog, chunkWidth, chunkHeight, originChunkX, originChunkY) { + return (Array.isArray(chunks) ? chunks : []).flatMap((chunk) => { + const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0); + const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0); + const offsetX = (baseChunkX - originChunkX) * chunkWidth; + const offsetY = (baseChunkY - originChunkY) * chunkHeight; + const instances = Array.isArray(chunk?.instances) ? chunk.instances : []; + return instances + .filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) + .map((entry) => { + const record = entry.record && typeof entry.record === "object" && !Array.isArray(entry.record) + ? cloneValue(entry.record) + : {}; + const spriteId = String(record.spriteId || entry.spriteId || "").trim(); + const spriteEntry = spriteCatalog[spriteId] || null; + const overlayX = offsetX + Math.max(0, Number(entry.x) || 0); + const overlayY = offsetY + Math.max(0, Number(entry.y) || 0); + record.position = { + x: overlayX, + y: overlayY, + }; + return { + id: String(entry.id || "").trim(), + layer: Number(entry.layer) || 0, + name: String(record.name || entry.id || "NPC"), + spriteId, + x: overlayX, + y: overlayY, + dataUrl: spriteEntry ? spriteEntry.dataUrl : null, + spriteWidth: spriteEntry ? spriteEntry.spriteWidth : 28, + spriteHeight: spriteEntry ? spriteEntry.spriteHeight : 28, + opacity: spriteEntry ? spriteEntry.opacity : 1, + record, + }; + }) + .filter((entry) => entry.id); + }); +} + +const MAX_WORLD_CHUNK_CACHE_ENTRIES = 256; +const MAX_DYNAMIC_WORLD_CHUNK_RADIUS = 4; +const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~="; export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, initialEditorSettings: unknown = getDefaultEditorSettings()): void { + function normalizeMapBackgroundColor(value, fallback) { + const f = fallback || DEFAULT_MAP_BACKGROUND_COLOR; + const raw = String(value || "").trim(); + return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toUpperCase() : f; + } + const baseTileSize = Math.max(8, Number(bootstrap.tileSize) || 32); const minZoomLevel = 0.5; const maxZoomLevel = 4; @@ -103,12 +296,51 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in let tileSize = baseTileSize; let currentMapId = String(bootstrap.mapId || "").trim(); let currentBaseRows = Array.isArray(bootstrap.baseRows) ? bootstrap.baseRows.map((row) => String(row ?? "")) : []; - const worldRuntimeState = createInitialWorldRuntimeState(bootstrap); + const worldRuntimeState = { + enabled: !!String(bootstrap.worldId || bootstrap.mapId || "").trim(), + worldId: String(bootstrap.worldId || bootstrap.mapId || "").trim(), + worldName: String(bootstrap.worldName || bootstrap.mapName || bootstrap.worldId || bootstrap.mapId || "World").trim() || "World", + defaultBackgroundTileId: String(bootstrap.backgroundTileId || "").trim(), + heightBlurStep: Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1)), + chunkWidth: Math.max(1, Number(bootstrap.worldChunkWidth) || 32), + chunkHeight: Math.max(1, Number(bootstrap.worldChunkHeight) || 32), + chunkRadius: Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)), + originChunkX: Math.floor(Number(bootstrap.worldOriginChunkX) || 0), + originChunkY: Math.floor(Number(bootstrap.worldOriginChunkY) || 0), + tileOffsetX: Math.floor(Number(bootstrap.worldTileOffsetX) || 0), + tileOffsetY: Math.floor(Number(bootstrap.worldTileOffsetY) || 0), + spawnX: Math.floor(Number(bootstrap.worldSpawnX) || 0), + spawnY: Math.floor(Number(bootstrap.worldSpawnY) || 0), + centerChunkX: Math.floor(Number(bootstrap.worldOriginChunkX) || 0) + Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)), + centerChunkY: Math.floor(Number(bootstrap.worldOriginChunkY) || 0) + Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)), + sourceChunks: Array.isArray(bootstrap.sourceChunks) + ? bootstrap.sourceChunks.map((entry) => ({ + chunkX: Math.floor(Number(entry?.chunkX) || 0), + chunkY: Math.floor(Number(entry?.chunkY) || 0), + })) + : [], + bookmarks: Array.isArray(bootstrap.worldBookmarks) + ? bootstrap.worldBookmarks.map((entry, index) => ({ + id: String(entry?.id || `poi_${index + 1}`).trim() || `poi_${index + 1}`, + label: String(entry?.label || entry?.id || `POI ${index + 1}`).trim() || `POI ${index + 1}`, + x: Math.floor(Number(entry?.x) || 0), + y: Math.floor(Number(entry?.y) || 0), + })) + : [], + chunkCache: new Map(), + dirtyChunkKeys: new Set(), + pendingNeighborhoodFetches: new Map(), + prefetchedNeighborhoodKeys: new Set(), + pendingLoadKey: "", + pendingLoadPromise: null, + requestSerial: 0, + documentDirty: false, + }; function isWorldModeActive() { return worldRuntimeState.enabled && !!worldRuntimeState.worldId; } const defaultTileColor = DEFAULT_TILE_COLOR; - const tileColors = cloneRuntimeValue(bootstrap.tileColors) || {}; + const tileColors = cloneValue(bootstrap.tileColors) || {}; let graphicsVisualRevision = 0; function applyGraphicsVisualRevision(dataUrl, revision = graphicsVisualRevision) { const normalizedDataUrl = String(dataUrl || "").trim(); @@ -279,9 +511,9 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return merged; } const tileCatalog = buildMergedTileCatalog(); - const contentByType = cloneRuntimeValue(bootstrap.contentByType) || {}; + const contentByType = cloneValue(bootstrap.contentByType) || {}; const spriteCatalog = applySpriteCatalogVisualRevision(buildSpriteCatalogFromBootstrap(bootstrap)); - const defaultNpcTemplate = cloneRuntimeValue(bootstrap.defaultNpcTemplate) || {}; + const defaultNpcTemplate = cloneValue(bootstrap.defaultNpcTemplate) || {}; const apiBase = String(bootstrap.apiBase || "").replace(/\/+$/, ""); function deriveHistoryStorageKey(mapIdValue) { return "worldshaper:world-history:v2:" + String(mapIdValue || "").trim(); @@ -616,7 +848,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in : normalizedBackgroundTileId; const existingChunk = worldRuntimeState.chunkCache.get(chunkKey); const chunkValue = existingChunk - ? cloneRuntimeValue(existingChunk) + ? cloneValue(existingChunk) : (rebuildWorldChunkPayloadFromDocument(safeChunkX, safeChunkY) || { worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), chunkX: safeChunkX, @@ -710,7 +942,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return; } touchWorldChunkCacheEntry(String(chunkKey || "").trim(), { - ...cloneRuntimeValue(existingChunk), + ...cloneValue(existingChunk), backgroundTileId: nextBackgroundTileId, }); changed = true; @@ -849,7 +1081,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return Array.from(worldRuntimeState.dirtyChunkKeys.values()) .map((chunkKey) => worldRuntimeState.chunkCache.get(chunkKey) || null) .filter(Boolean) - .map((entry) => cloneRuntimeValue(entry)); + .map((entry) => cloneValue(entry)); } function pruneWorldChunkCache() { @@ -899,28 +1131,102 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in ); } - function buildChunkHeightLayersFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) { - return buildChunkHeightLayersFromDocumentHelper({ - mapDocument, - cloneHeightLayers, - baseTileX, - baseTileY, - chunkWidth, - chunkHeight, + function sliceNormalizedRows(rows, startX, startY, width, height, fillChar) { + return Array.from({ length: Math.max(1, Number(height) || 1) }, (_, rowOffset) => { + const sourceRow = String((Array.isArray(rows) ? rows[startY + rowOffset] : "") || ""); + const paddedRow = sourceRow.length >= startX + width + ? sourceRow + : sourceRow + String(fillChar || " ").repeat(Math.max(0, (startX + width) - sourceRow.length)); + return paddedRow.slice(startX, startX + width); }); } + function buildChunkHeightLayersFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) { + return (Array.isArray(mapDocument.heightLayers) ? cloneHeightLayers(mapDocument.heightLayers) : []) + .map((entry) => { + const patchX = Math.max(0, Number(entry?.x) || 0); + const patchY = Math.max(0, Number(entry?.y) || 0); + const rows = Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : []; + const patchWidth = rows.reduce((max, row) => Math.max(max, row.length), 0); + const patchHeight = rows.length; + const patchRight = patchX + patchWidth; + const patchBottom = patchY + patchHeight; + const chunkRight = baseTileX + chunkWidth; + const chunkBottom = baseTileY + chunkHeight; + const overlapLeft = Math.max(baseTileX, patchX); + const overlapTop = Math.max(baseTileY, patchY); + const overlapRight = Math.min(chunkRight, patchRight); + const overlapBottom = Math.min(chunkBottom, patchBottom); + if (overlapRight <= overlapLeft || overlapBottom <= overlapTop) { + return null; + } + const localRows = []; + for (let y = overlapTop; y < overlapBottom; y += 1) { + const sourceRow = String(rows[y - patchY] || ""); + localRows.push(sourceRow.slice(overlapLeft - patchX, overlapRight - patchX).replace(/\s+$/g, "")); + } + return { + id: String(entry?.id || "").trim(), + name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined, + z: Math.max(1, Number(entry?.z) || 1), + x: overlapLeft - baseTileX, + y: overlapTop - baseTileY, + rows: localRows, + }; + }) + .filter((entry) => entry && entry.id); + } + function buildChunkInstancesFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) { - return buildChunkInstancesFromDocumentHelper({ - mapDocument, - cloneValue, - baseTileX, - baseTileY, - chunkWidth, - chunkHeight, - tileOffsetX: worldRuntimeState.tileOffsetX, - tileOffsetY: worldRuntimeState.tileOffsetY, + const chunkInstances = cloneValue(mapDocument.npcOverlays) + .filter((npc) => { + const localX = Math.floor(Number(npc?.x)); + const localY = Math.floor(Number(npc?.y)); + return Number.isFinite(localX) + && Number.isFinite(localY) + && localX >= baseTileX + && localX < baseTileX + chunkWidth + && localY >= baseTileY + && localY < baseTileY + chunkHeight; + }) + .map((npc) => ({ + id: String(npc.id || "").trim(), + templateId: String(npc?.record?.templateId || "").trim(), + layer: Number(npc.layer) || 0, + x: Math.floor(Number(npc.x) || 0) - baseTileX, + y: Math.floor(Number(npc.y) || 0) - baseTileY, + record: { + ...cloneValue(npc.record || {}), + id: String(npc.id || "").trim(), + layer: Number(npc.layer) || 0, + templateId: String(npc?.record?.templateId || "").trim(), + name: String(npc.name || npc?.record?.name || ""), + entityType: String(npc?.record?.entityType || npc?.entityType || "friendly"), + faction: String(npc.faction || npc?.record?.faction || ""), + spriteId: String(npc.spriteId || npc?.record?.spriteId || ""), + dialogueId: String(npc.dialogueId || npc?.record?.dialogueId || ""), + description: String(npc.description || npc?.record?.description || ""), + tags: cloneValue(npc?.record?.tags) || [], + enabled: typeof npc?.record?.enabled === "boolean" ? npc.record.enabled : true, + position: { + x: Math.floor(Number(npc.x) || 0) + worldRuntimeState.tileOffsetX, + y: Math.floor(Number(npc.y) || 0) + worldRuntimeState.tileOffsetY, + }, + }, + })) + .filter((entry) => entry.id); + const npcIdsByLayer = new Map(); + chunkInstances.forEach((entry) => { + const layerNumber = Number(entry.layer) || 0; + if (!npcIdsByLayer.has(layerNumber)) { + npcIdsByLayer.set(layerNumber, []); + } + npcIdsByLayer.get(layerNumber).push(entry.id); }); + return { + chunkInstances, + npcIdsByLayer, + }; } function rebuildWorldChunkPayloadFromDocument(chunkX, chunkY) { @@ -1060,7 +1366,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in ? fallbackLayerNumber : (layerNumberMap ? (layerNumberMap[String(previousLayer)] ?? previousLayer) : previousLayer); return { - ...cloneRuntimeValue(entry), + ...cloneValue(entry), layer: nextLayer, }; }); @@ -1084,7 +1390,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const metadata = metadataByLayer.get(nextLayer) || { layer: nextLayer, name: undefined }; const fillChar = nextLayer === 0 ? "." : " "; return { - ...cloneRuntimeValue(entry), + ...cloneValue(entry), layer: nextLayer, name: metadata.name, rows: Array.isArray(entry?.rows) @@ -1107,7 +1413,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in continue; } touchWorldChunkCacheEntry(chunkKey, { - ...cloneRuntimeValue(chunkValue), + ...cloneValue(chunkValue), roomLayers: nextRoomLayers, instances: nextInstances, }); @@ -1206,12 +1512,59 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in activeHeightLayerId: "", }); popupSessionStore.restorePersistedLayout(window); + const editorLogEntries = []; + const EDITOR_LOG_LIMIT = 500; let statusLogWindowController = null; - const runtimeLogging = createRuntimeLogging({ - windowRef: window, - runtimeUniqueId, + + function formatEditorLogTimestamp(timestamp) { + try { + return new Date(timestamp).toLocaleString(); + } catch { + return String(timestamp || ""); + } + } + + function appendEditorLogEntry(level, message) { + const normalizedMessage = String(message || "").trim(); + if (!normalizedMessage) { + return null; + } + const timestamp = Date.now(); + const entry = { + id: runtimeUniqueId(), + timestamp, + timestampLabel: formatEditorLogTimestamp(timestamp), + level: String(level || "Information").trim() || "Information", + message: normalizedMessage, + }; + editorLogEntries.push(entry); + while (editorLogEntries.length > EDITOR_LOG_LIMIT) { + editorLogEntries.shift(); + } + statusLogWindowController?.refresh?.(); + return entry; + } + + function getEditorLogEntries() { + return editorLogEntries.slice(); + } + + function clearEditorLogEntries() { + editorLogEntries.splice(0, editorLogEntries.length); + statusLogWindowController?.refresh?.(); + } + + window.addEventListener("error", (event) => { + const message = String(event?.message || event?.error?.message || "Unknown runtime error"); + appendEditorLogEntry("Error", message); + }); + window.addEventListener("unhandledrejection", (event) => { + const reason = event?.reason; + const message = typeof reason === "string" + ? reason + : String(reason?.message || reason || "Unhandled promise rejection"); + appendEditorLogEntry("Error", message); }); - const { appendEditorLogEntry, getEditorLogEntries, clearEditorLogEntries } = runtimeLogging; let renderController = null; const documentController = createMapDocumentController({ mapId: currentMapId, @@ -1232,7 +1585,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in }); let editorSettingsState = normalizeEditorSettings(initialEditorSettings); - // ── AtTooltip: reusable anchored floating context menu ────────────── + // ── AtTooltip: reusable anchored floating context menu ────────────── const atTooltip = createAtTooltip(); const initialEditorUiState = bootstrap.editorUi; @@ -1445,7 +1798,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return null; } return { - ...cloneRuntimeValue(entry), + ...cloneValue(entry), id: metadata.id, name: metadata.name, z: metadata.z, @@ -1453,7 +1806,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in }) .filter(Boolean); nextEntries.push([chunkKey, { - ...cloneRuntimeValue(chunkValue), + ...cloneValue(chunkValue), heightLayers: nextHeightLayers, }]); } @@ -1548,65 +1901,349 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return emptyChunk; } - function normalizeWorldChunkInstances(sourceInstances, chunkX, chunkY, width, height, options) { - return normalizeWorldChunkInstancesHelper({ - sourceInstances, - chunkX, - chunkY, - width, - height, - options, - cloneValue, - runtimeUniqueId, + function normalizeWorldChunkRows(rows, width, height, fillChar) { + const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); + const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); + return Array.from({ length: safeHeight }, (_entry, rowIndex) => { + const sourceRow = String((Array.isArray(rows) ? rows[rowIndex] : "") || ""); + return sourceRow.length >= safeWidth + ? sourceRow.slice(0, safeWidth) + : (sourceRow + String(fillChar || " ").repeat(Math.max(0, safeWidth - sourceRow.length))); }); } + function cloneWorldChunkHeightLayers(source) { + return (Array.isArray(source) ? source : []) + .map((entry, index) => ({ + id: String(entry?.id || `height_patch_${index + 1}`).trim() || `height_patch_${index + 1}`, + name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined, + z: Math.max(1, Math.floor(Number(entry?.z) || 1)), + x: Math.max(0, Math.floor(Number(entry?.x) || 0)), + y: Math.max(0, Math.floor(Number(entry?.y) || 0)), + rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [], + })) + .filter((entry) => entry.id); + } + + function buildWorldChunkLayerInstanceIds(roomLayers, instances, width, height) { + const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); + const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); + const nextLayers = new Map(); + (Array.isArray(roomLayers) ? roomLayers : []).forEach((layer) => { + const layerNumber = Math.max(0, Math.floor(Number(layer?.layer) || 0)); + nextLayers.set(layerNumber, { + layer: layerNumber, + name: typeof layer?.name === "string" && layer.name.trim() ? layer.name.trim() : undefined, + rows: normalizeWorldChunkRows(layer?.rows, safeWidth, safeHeight, layerNumber === 0 ? "." : " "), + instanceIds: [], + }); + }); + if (!nextLayers.has(0)) { + nextLayers.set(0, { + layer: 0, + rows: normalizeWorldChunkRows([], safeWidth, safeHeight, "."), + instanceIds: [], + }); + } + if (!Array.from(nextLayers.keys()).some((layerNumber) => layerNumber > 0)) { + nextLayers.set(1, { + layer: 1, + rows: normalizeWorldChunkRows([], safeWidth, safeHeight, " "), + instanceIds: [], + }); + } + (Array.isArray(instances) ? instances : []).forEach((entry) => { + const layerNumber = Math.max(0, Math.floor(Number(entry?.layer) || 0)); + const instanceId = String(entry?.id || "").trim(); + if (!instanceId) { + return; + } + if (!nextLayers.has(layerNumber)) { + nextLayers.set(layerNumber, { + layer: layerNumber, + rows: normalizeWorldChunkRows([], safeWidth, safeHeight, layerNumber === 0 ? "." : " "), + instanceIds: [], + }); + } + nextLayers.get(layerNumber).instanceIds.push(instanceId); + }); + return Array.from(nextLayers.values()) + .map((entry) => ({ + ...entry, + instanceIds: Array.from(new Set((Array.isArray(entry.instanceIds) ? entry.instanceIds : []).map((id) => String(id || "").trim()).filter(Boolean))), + })) + .sort((left, right) => (Number(left.layer) || 0) - (Number(right.layer) || 0)); + } + + function normalizeWorldChunkInstances(sourceInstances, chunkX, chunkY, width, height, options) { + const config = options && typeof options === "object" ? options : {}; + const duplicateIds = config.duplicateIds === true; + const safeChunkX = Math.floor(Number(chunkX) || 0); + const safeChunkY = Math.floor(Number(chunkY) || 0); + const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); + const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); + return (Array.isArray(sourceInstances) ? sourceInstances : []) + .map((entry) => { + const record = entry?.record && typeof entry.record === "object" && !Array.isArray(entry.record) + ? cloneValue(entry.record) + : {}; + const nextId = duplicateIds + ? runtimeUniqueId() + : (String(entry?.id || record?.id || runtimeUniqueId()).trim() || runtimeUniqueId()); + const nextLayer = Math.max(0, Math.floor(Number(entry?.layer ?? record?.layer) || 0)); + const nextX = Math.max(0, Math.min(safeWidth - 1, Math.floor(Number(entry?.x) || 0))); + const nextY = Math.max(0, Math.min(safeHeight - 1, Math.floor(Number(entry?.y) || 0))); + const nextTemplateId = String(entry?.templateId || record?.templateId || "").trim(); + record.id = nextId; + record.layer = nextLayer; + record.templateId = nextTemplateId; + record.position = { + x: (safeChunkX * safeWidth) + nextX, + y: (safeChunkY * safeHeight) + nextY, + }; + return { + id: nextId, + templateId: nextTemplateId, + layer: nextLayer, + x: nextX, + y: nextY, + record, + }; + }) + .filter((entry) => entry.id); + } + function createEmptyWorldChunkPayload(chunkX, chunkY) { - return createEmptyWorldChunkPayloadHelper({ - chunkX, - chunkY, - chunkWidth: Math.max(1, Number(worldRuntimeState.chunkWidth) || 32), - chunkHeight: Math.max(1, Number(worldRuntimeState.chunkHeight) || 32), + const safeChunkX = Math.floor(Number(chunkX) || 0); + const safeChunkY = Math.floor(Number(chunkY) || 0); + const chunkWidth = Math.max(1, Number(worldRuntimeState.chunkWidth) || 32); + const chunkHeight = Math.max(1, Number(worldRuntimeState.chunkHeight) || 32); + return { + schemaVersion: 1, worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), - }); + chunkX: safeChunkX, + chunkY: safeChunkY, + width: chunkWidth, + height: chunkHeight, + backgroundTileId: "", + roomLayers: [ + { + layer: 0, + rows: Array.from({ length: chunkHeight }, () => ".".repeat(chunkWidth)), + instanceIds: [], + }, + { + layer: 1, + rows: Array.from({ length: chunkHeight }, () => " ".repeat(chunkWidth)), + instanceIds: [], + }, + ], + heightLayers: [], + instances: [], + }; } function normalizeCachedWorldChunkPayload(chunkPayload, chunkX, chunkY, options) { - return normalizeCachedWorldChunkPayloadHelper({ - chunkPayload, - chunkX, - chunkY, - chunkWidth: Number(worldRuntimeState.chunkWidth) || 32, - chunkHeight: Number(worldRuntimeState.chunkHeight) || 32, - worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), - cloneValue, - runtimeUniqueId, - options, - }); + const safeChunkX = Math.floor(Number(chunkX ?? chunkPayload?.chunkX) || 0); + const safeChunkY = Math.floor(Number(chunkY ?? chunkPayload?.chunkY) || 0); + const safeWidth = Math.max(1, Math.floor(Number(chunkPayload?.width) || Number(worldRuntimeState.chunkWidth) || 32)); + const safeHeight = Math.max(1, Math.floor(Number(chunkPayload?.height) || Number(worldRuntimeState.chunkHeight) || 32)); + const instances = normalizeWorldChunkInstances(chunkPayload?.instances, safeChunkX, safeChunkY, safeWidth, safeHeight, options); + const roomLayers = buildWorldChunkLayerInstanceIds(chunkPayload?.roomLayers, instances, safeWidth, safeHeight); + return { + schemaVersion: Math.max(1, Math.floor(Number(chunkPayload?.schemaVersion) || 1)), + worldId: String(chunkPayload?.worldId || worldRuntimeState.worldId || currentMapId || "").trim(), + chunkX: safeChunkX, + chunkY: safeChunkY, + width: safeWidth, + height: safeHeight, + backgroundTileId: String(chunkPayload?.backgroundTileId || "").trim(), + roomLayers, + heightLayers: cloneWorldChunkHeightLayers(chunkPayload?.heightLayers), + instances, + }; + } + + function isChunkFillSymbol(ch, fillChar) { + const symbol = String(ch || "").charAt(0); + return !symbol || symbol === fillChar || symbol === "." || symbol === " "; } function isWorldChunkPayloadEmpty(chunkPayload) { - return isWorldChunkPayloadEmptyHelper({ - chunkPayload, - chunkWidth: Number(worldRuntimeState.chunkWidth) || 32, - chunkHeight: Number(worldRuntimeState.chunkHeight) || 32, - worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), - cloneValue, - runtimeUniqueId, + const normalized = normalizeCachedWorldChunkPayload(chunkPayload, chunkPayload?.chunkX, chunkPayload?.chunkY); + if (String(normalized?.backgroundTileId || "").trim()) { + return false; + } + if (Array.isArray(normalized?.instances) && normalized.instances.length > 0) { + return false; + } + if ((Array.isArray(normalized?.heightLayers) ? normalized.heightLayers : []).some((entry) => ( + Array.isArray(entry?.rows) && entry.rows.some((row) => /[^ .]/.test(String(row || ""))) + ))) { + return false; + } + return !(Array.isArray(normalized?.roomLayers) ? normalized.roomLayers : []).some((layer) => { + const fillChar = (Number(layer?.layer) || 0) === 0 ? "." : " "; + return (Array.isArray(layer?.rows) ? layer.rows : []).some((row) => { + const sourceRow = String(row || ""); + for (let index = 0; index < sourceRow.length; index += 1) { + if (!isChunkFillSymbol(sourceRow.charAt(index), fillChar)) { + return true; + } + } + return false; + }); }); } - function transformWorldChunkPayload(chunkPayload, operation, options) { - return transformWorldChunkPayloadHelper({ - chunkPayload, - operation, - chunkWidth: Number(worldRuntimeState.chunkWidth) || 32, - chunkHeight: Number(worldRuntimeState.chunkHeight) || 32, - worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), - cloneValue, - runtimeUniqueId, - options, + function transformChunkLocalCoord(localX, localY, width, height, operation) { + const safeX = Math.floor(Number(localX) || 0); + const safeY = Math.floor(Number(localY) || 0); + const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); + const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); + switch (String(operation || "").trim()) { + case "flipHorizontal": + return { x: (safeWidth - 1) - safeX, y: safeY }; + case "flipVertical": + return { x: safeX, y: (safeHeight - 1) - safeY }; + case "rotate180": + return { x: (safeWidth - 1) - safeX, y: (safeHeight - 1) - safeY }; + case "rotate90cw": + if (safeWidth !== safeHeight) { + return null; + } + return { x: (safeWidth - 1) - safeY, y: safeX }; + case "rotate90ccw": + if (safeWidth !== safeHeight) { + return null; + } + return { x: safeY, y: (safeHeight - 1) - safeX }; + default: + return { x: safeX, y: safeY }; + } + } + + function transformChunkRows(rows, width, height, fillChar, operation) { + const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); + const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); + const sourceRows = normalizeWorldChunkRows(rows, safeWidth, safeHeight, fillChar); + const nextRows = Array.from({ length: safeHeight }, () => Array.from({ length: safeWidth }, () => String(fillChar || " ").charAt(0) || " ")); + for (let rowIndex = 0; rowIndex < safeHeight; rowIndex += 1) { + const sourceRow = sourceRows[rowIndex]; + for (let columnIndex = 0; columnIndex < safeWidth; columnIndex += 1) { + const char = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || String(fillChar || " ").charAt(0) || " "; + if (isChunkFillSymbol(char, fillChar)) { + continue; + } + const nextCoord = transformChunkLocalCoord(columnIndex, rowIndex, safeWidth, safeHeight, operation); + if (!nextCoord) { + continue; + } + nextRows[nextCoord.y][nextCoord.x] = char; + } + } + return nextRows.map((row) => row.join("")); + } + + function transformChunkHeightPatch(patch, width, height, operation) { + const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); + const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); + const sourceRows = Array.isArray(patch?.rows) ? patch.rows.map((row) => String(row || "")) : []; + const patchWidth = sourceRows.reduce((max, row) => Math.max(max, row.length), 0); + const patchHeight = sourceRows.length; + const transformedCells = []; + for (let localY = 0; localY < patchHeight; localY += 1) { + const row = sourceRows[localY] || ""; + for (let localX = 0; localX < patchWidth; localX += 1) { + const char = String(row.charAt(localX) || " ").charAt(0) || " "; + if (char === " " || char === ".") { + continue; + } + const worldX = Math.max(0, Math.floor(Number(patch?.x) || 0)) + localX; + const worldY = Math.max(0, Math.floor(Number(patch?.y) || 0)) + localY; + if (worldX < 0 || worldY < 0 || worldX >= safeWidth || worldY >= safeHeight) { + continue; + } + const nextCoord = transformChunkLocalCoord(worldX, worldY, safeWidth, safeHeight, operation); + if (!nextCoord) { + continue; + } + transformedCells.push({ + x: nextCoord.x, + y: nextCoord.y, + char, + }); + } + } + if (transformedCells.length <= 0) { + return null; + } + const minX = transformedCells.reduce((min, entry) => Math.min(min, entry.x), transformedCells[0].x); + const maxX = transformedCells.reduce((max, entry) => Math.max(max, entry.x), transformedCells[0].x); + const minY = transformedCells.reduce((min, entry) => Math.min(min, entry.y), transformedCells[0].y); + const maxY = transformedCells.reduce((max, entry) => Math.max(max, entry.y), transformedCells[0].y); + const nextRows = Array.from({ length: (maxY - minY) + 1 }, () => Array.from({ length: (maxX - minX) + 1 }, () => " ")); + transformedCells.forEach((entry) => { + nextRows[entry.y - minY][entry.x - minX] = entry.char; }); + return { + id: String(patch?.id || "").trim(), + name: typeof patch?.name === "string" && patch.name.trim() ? patch.name.trim() : undefined, + z: Math.max(1, Math.floor(Number(patch?.z) || 1)), + x: minX, + y: minY, + rows: nextRows.map((row) => row.join("").replace(/\s+$/g, "")), + }; + } + + function transformWorldChunkPayload(chunkPayload, operation, options) { + const config = options && typeof options === "object" ? options : {}; + const normalized = normalizeCachedWorldChunkPayload(chunkPayload, chunkPayload?.chunkX, chunkPayload?.chunkY, config); + const safeWidth = Math.max(1, Math.floor(Number(normalized?.width) || 1)); + const safeHeight = Math.max(1, Math.floor(Number(normalized?.height) || 1)); + const normalizedOperation = String(operation || "").trim(); + if ((normalizedOperation === "rotate90cw" || normalizedOperation === "rotate90ccw") && safeWidth !== safeHeight) { + throw new Error("Chunk rotation requires square chunks."); + } + const instances = normalizeWorldChunkInstances( + (Array.isArray(normalized.instances) ? normalized.instances : []).map((entry) => { + const nextCoord = transformChunkLocalCoord(entry.x, entry.y, safeWidth, safeHeight, normalizedOperation); + return { + ...cloneValue(entry), + x: nextCoord?.x ?? entry.x, + y: nextCoord?.y ?? entry.y, + }; + }), + normalized.chunkX, + normalized.chunkY, + safeWidth, + safeHeight, + config, + ); + const roomLayers = buildWorldChunkLayerInstanceIds( + (Array.isArray(normalized.roomLayers) ? normalized.roomLayers : []).map((layer) => ({ + ...cloneValue(layer), + rows: transformChunkRows(layer?.rows, safeWidth, safeHeight, (Number(layer?.layer) || 0) === 0 ? "." : " ", normalizedOperation), + })), + instances, + safeWidth, + safeHeight, + ); + const heightLayers = cloneWorldChunkHeightLayers(normalized.heightLayers) + .map((entry) => transformChunkHeightPatch(entry, safeWidth, safeHeight, normalizedOperation)) + .filter(Boolean) + .sort((left, right) => { + if ((Number(left?.z) || 0) !== (Number(right?.z) || 0)) { + return (Number(left?.z) || 0) - (Number(right?.z) || 0); + } + return String(left?.name || left?.id || "").localeCompare(String(right?.name || right?.id || "")); + }); + return { + ...normalized, + roomLayers, + heightLayers, + instances, + }; } function commitWorldChunkPayloads(nextChunks, reason) { @@ -1646,7 +2283,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "same-chunk" }; } const sourceChunk = normalizeCachedWorldChunkPayload( - cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY), + cloneValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY), safeSourceChunkX, safeSourceChunkY, ); @@ -1655,7 +2292,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "source-empty" }; } const destinationChunk = normalizeCachedWorldChunkPayload( - cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY), + cloneValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY), safeTargetChunkX, safeTargetChunkY, ); @@ -1696,7 +2333,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "same-chunk" }; } const sourceChunk = normalizeCachedWorldChunkPayload( - cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY), + cloneValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY), safeSourceChunkX, safeSourceChunkY, ); @@ -1705,7 +2342,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "source-empty" }; } const destinationChunk = normalizeCachedWorldChunkPayload( - cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY), + cloneValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY), safeTargetChunkX, safeTargetChunkY, ); @@ -1714,7 +2351,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "destination-occupied" }; } const duplicatedChunk = normalizeCachedWorldChunkPayload({ - ...cloneRuntimeValue(sourceChunk), + ...cloneValue(sourceChunk), instances: [], }, safeTargetChunkX, safeTargetChunkY); commitWorldChunkPayloads([duplicatedChunk], "world-chunk-duplicate"); @@ -1744,7 +2381,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const safeChunkY = Math.floor(Number(chunkY) || 0); const normalizedOperation = String(operation || "").trim(); const sourceChunk = normalizeCachedWorldChunkPayload( - cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY), + cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY), safeChunkX, safeChunkY, ); @@ -1777,7 +2414,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const safeChunkX = Math.floor(Number(chunkX) || 0); const safeChunkY = Math.floor(Number(chunkY) || 0); const existingChunk = normalizeCachedWorldChunkPayload( - cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY), + cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY), safeChunkX, safeChunkY, ); @@ -1815,7 +2452,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in ? normalizedBackgroundTileId : ""; const chunkKey = buildChunkKey(safeChunkX, safeChunkY); - const existingChunk = cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || { + const existingChunk = cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || { chunkX: safeChunkX, chunkY: safeChunkY, width: chunkWidth, @@ -2234,13 +2871,13 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in backgroundTileId: normalizeBackgroundTileId(mapDocument.backgroundTileId), roomLayers: cloneLayers(mapDocument.roomLayers), heightLayers: cloneHeightLayers(mapDocument.heightLayers), - tileColors: cloneRuntimeValue(tileColors), + tileColors: cloneValue(tileColors), baseRows, npcOverlays: cloneNpcOverlays(mapDocument.npcOverlays), - contentByType: cloneRuntimeValue(mapDocument.contentBundle), - spriteCatalog: cloneRuntimeValue(spriteCatalog), - tileCatalogById: cloneRuntimeValue(tileCatalogById), - defaultNpcTemplate: cloneRuntimeValue(defaultNpcTemplate), + contentByType: cloneValue(mapDocument.contentBundle), + spriteCatalog: cloneValue(spriteCatalog), + tileCatalogById: cloneValue(tileCatalogById), + defaultNpcTemplate: cloneValue(defaultNpcTemplate), apiBase, backgroundColor: normalizeMapBackgroundColor(mapDocument.backgroundColor), heightBlurStep: Math.max(0, Math.min(1, Number(mapDocument.heightBlurStep ?? mapDocument.heightDetailStep) || 0.1)), @@ -2258,7 +2895,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in worldSpawnX: isWorldModeActive() ? worldRuntimeState.spawnX : undefined, worldSpawnY: isWorldModeActive() ? worldRuntimeState.spawnY : undefined, worldBookmarks: isWorldModeActive() ? cloneWorldBookmarks() : undefined, - sourceChunks: isWorldModeActive() ? cloneRuntimeValue(worldRuntimeState.sourceChunks) : undefined, + sourceChunks: isWorldModeActive() ? cloneValue(worldRuntimeState.sourceChunks) : undefined, }; } @@ -2346,7 +2983,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in } function getImagesPayload() { - return cloneRuntimeValue(ensureDocumentContentPayload("images", { schemaVersion: 1, images: [] })) || { schemaVersion: 1, images: [] }; + return cloneValue(ensureDocumentContentPayload("images", { schemaVersion: 1, images: [] })) || { schemaVersion: 1, images: [] }; } function buildDuplicateGraphicName(baseName, imagesPayload) { @@ -2399,9 +3036,9 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const nextTilesPayload = buildTilesPayloadFromImagesPayload(normalizedImagesPayload); const nextSpritesPayload = buildSpritesPayloadFromImagesPayload(normalizedImagesPayload); graphicsVisualRevision += 1; - setDocumentContentPayload("images", cloneRuntimeValue(normalizedImagesPayload) || { schemaVersion: 1, images: [] }); - setDocumentContentPayload("tiles", cloneRuntimeValue(nextTilesPayload) || { schemaVersion: 1, tiles: [] }); - setDocumentContentPayload("sprites", cloneRuntimeValue(nextSpritesPayload) || { schemaVersion: 1, sprites: [] }); + setDocumentContentPayload("images", cloneValue(normalizedImagesPayload) || { schemaVersion: 1, images: [] }); + setDocumentContentPayload("tiles", cloneValue(nextTilesPayload) || { schemaVersion: 1, tiles: [] }); + setDocumentContentPayload("sprites", cloneValue(nextSpritesPayload) || { schemaVersion: 1, sprites: [] }); replaceObjectContents( tileCatalogById, applyTileCatalogVisualRevision( @@ -2542,14 +3179,14 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in } } const nextRecord = normalizeImageRecordForSave({ - ...cloneRuntimeValue(sourceRecord), + ...cloneValue(sourceRecord), id: (normalizedType === "tile" ? "tile_" : "sprite_") + runtimeUniqueId().replace(/^inst_/, ""), name: buildDuplicateGraphicName(String(sourceRecord.name || normalizedId || "Graphic"), imagesPayload), tileSymbol: normalizedType === "tile" ? nextTileSymbol : String(sourceRecord.tileSymbol || "").trim().charAt(0), rows: Array.isArray(sourceRecord.rows) ? sourceRecord.rows.map((row) => String(row || "")) : [], - frames: Array.isArray(sourceRecord.frames) ? cloneRuntimeValue(sourceRecord.frames) : [], - tags: Array.isArray(sourceRecord.tags) ? cloneRuntimeValue(sourceRecord.tags) : [], - roles: Array.isArray(sourceRecord.roles) ? cloneRuntimeValue(sourceRecord.roles) : [], + frames: Array.isArray(sourceRecord.frames) ? cloneValue(sourceRecord.frames) : [], + tags: Array.isArray(sourceRecord.tags) ? cloneValue(sourceRecord.tags) : [], + roles: Array.isArray(sourceRecord.roles) ? cloneValue(sourceRecord.roles) : [], }); const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : []; nextImages.push(nextRecord); @@ -2598,7 +3235,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in } const existing = nextImages[existingIndex] || {}; nextImages[existingIndex] = buildImageRecordFromTileRecord({ - ...cloneRuntimeValue(record), + ...cloneValue(record), symbol: String(record?.symbol || existing?.tileSymbol || "").charAt(0) || takeNextAvailableTileSymbol() || "T", }, existing, cloneValue); const nextPayload = { @@ -2647,7 +3284,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return false; } const nextRecord = buildImageRecordFromTileRecord({ - ...cloneRuntimeValue(sourceRecord), + ...cloneValue(sourceRecord), symbol: nextSymbol, }, existing, cloneValue); if (existingIndex >= 0) { @@ -2775,7 +3412,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in clearedBackgrounds += 1; } touchWorldChunkCacheEntry(chunkKey, { - ...cloneRuntimeValue(chunkValue), + ...cloneValue(chunkValue), backgroundTileId: clearsBackground ? "" : String(chunkValue.backgroundTileId || "").trim(), roomLayers: scrubbedLayers.roomLayers, heightLayers: scrubbedHeightLayers.heightLayers, @@ -2802,7 +3439,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return entry; } - const nextEntry = cloneRuntimeValue(entry) || {}; + const nextEntry = cloneValue(entry) || {}; const nextRecord = nextEntry.record && typeof nextEntry.record === "object" && !Array.isArray(nextEntry.record) ? { ...nextEntry.record } : {}; @@ -2830,7 +3467,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in updatedChunks += 1; scrubbedEntities += changedEntities; touchWorldChunkCacheEntry(chunkKey, { - ...cloneRuntimeValue(chunkValue), + ...cloneValue(chunkValue), instances: nextInstances, }); } @@ -3776,7 +4413,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in Object.keys(target || {}).forEach((key) => { delete target[key]; }); - Object.assign(target, cloneRuntimeValue(nextValue) || {}); + Object.assign(target, cloneValue(nextValue) || {}); return target; } @@ -3793,7 +4430,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const nextImagesPayload = mergeImagesPayloadWithSpritesPayload(getImagesPayload(), payload); syncRuntimeGraphicsFromImagesPayload(nextImagesPayload, config); } else { - setDocumentContentPayload(normalizedType, cloneRuntimeValue(payload) || {}); + setDocumentContentPayload(normalizedType, cloneValue(payload) || {}); } if (!config.deferRefresh) { @@ -4233,7 +4870,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in duplicateWorldChunkContent, transformWorldChunkAt, clearWorldChunkAt, - getCachedWorldChunkPayloads: () => Array.from(worldRuntimeState.chunkCache.values()).map((entry) => cloneRuntimeValue(entry)), + getCachedWorldChunkPayloads: () => Array.from(worldRuntimeState.chunkCache.values()).map((entry) => cloneValue(entry)), getDirtyWorldChunkKeys, getDirtyWorldChunkPayloads, clearDirtyWorldChunks, @@ -4715,44 +5352,142 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in scope.historyScope = historyScope; scope.uiScope = uiScope; scope.sessionScope = sessionScope; + const toolWindowController = createToolWindowController(scope); + const tileArtEditorWindowController = createTileArtEditorWindowController(scope); + const entityEditorWindowController = createEntityEditorWindowController(scope); + const engineOverrideWindowController = createEngineOverrideWindowController(scope); + const worldOverviewWindowController = createWorldOverviewWindowController(scope); + const changelogSplashWindowController = createChangelogSplashWindowController(scope); + statusLogWindowController = createStatusLogWindowController(scope); + const syncToolPanels = () => toolWindowController.syncPanels(); + const handleSidebarTabButtonClick = (tab) => toolWindowController.handleTabButtonClick(tab); + const restoreAllToolWindows = () => toolWindowController.restoreAllWindows(); + const openTileArtEditorWindow = (recordTypeOrId, maybeRecordId) => tileArtEditorWindowController.open(recordTypeOrId, maybeRecordId); + const closeTileArtEditorWindow = () => tileArtEditorWindowController.close(); + const openEntityEditorWindow = (entityId) => entityEditorWindowController.open(entityId); + const closeEntityEditorWindow = () => entityEditorWindowController.close(); + const openEngineOverrideWindow = () => engineOverrideWindowController.open(); + const closeEngineOverrideWindow = () => engineOverrideWindowController.close(); + const refreshEngineOverrideWindow = () => engineOverrideWindowController.refresh(); + const refreshEngineOverrideSummary = () => engineOverrideWindowController.updateSummary(); + const openWorldOverviewWindow = () => worldOverviewWindowController.open(); + const closeWorldOverviewWindow = () => worldOverviewWindowController.close(); + const refreshWorldOverviewWindow = () => worldOverviewWindowController.refresh(); + const invalidateWorldOverviewChunkSurfaces = (chunkKeys, options) => worldOverviewWindowController.invalidateChunkSurfaces?.(chunkKeys, options); + const openStatusLogWindow = () => statusLogWindowController.open(); + const closeStatusLogWindow = () => statusLogWindowController.close(); + const openNewsWindow = (options = {}) => changelogSplashWindowController.open({ markSeen: false, ...options }); + const resetWorkspaceLayoutFlow = () => { + resetWorkspaceLayout(); + toolWindowController.restoreAllWindows(); + setStatus("Workspace layout reset.", false); + }; + scope.syncToolPanels = syncToolPanels; + scope.handleSidebarTabButtonClick = handleSidebarTabButtonClick; + scope.restoreAllToolWindows = restoreAllToolWindows; + scope.resetWorkspaceLayout = resetWorkspaceLayoutFlow; + scope.createNewTile = createNewTile; + scope.createNewSpriteGraphic = createNewSpriteGraphic; + scope.duplicateGraphicRecord = duplicateGraphicRecord; + scope.openTileArtEditorWindow = openTileArtEditorWindow; + scope.closeTileArtEditorWindow = closeTileArtEditorWindow; + scope.openEntityEditorWindow = openEntityEditorWindow; + scope.closeEntityEditorWindow = closeEntityEditorWindow; + scope.openEngineOverrideWindow = openEngineOverrideWindow; + scope.closeEngineOverrideWindow = closeEngineOverrideWindow; + scope.refreshEngineOverrideWindow = refreshEngineOverrideWindow; + scope.refreshEngineOverrideSummary = refreshEngineOverrideSummary; + scope.openWorldOverviewWindow = openWorldOverviewWindow; + scope.closeWorldOverviewWindow = closeWorldOverviewWindow; + scope.refreshWorldOverviewWindow = refreshWorldOverviewWindow; + scope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces; + scope.openStatusLogWindow = openStatusLogWindow; + scope.closeStatusLogWindow = closeStatusLogWindow; + scope.openNewsWindow = openNewsWindow; + scope.openTilePaletteContextMenu = openTilePaletteContextMenu; + scope.openPlacedEntityContextMenu = openPlacedEntityContextMenu; + scope.applyNpcEditorChange = applyNpcEditorChange; + scope.getEditorEngineOverrides = getEditorEngineOverrides; + scope.saveEditorEngineOverrides = saveEditorEngineOverrides; + scope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep; + scope.isRendererDebugEnabled = isRendererDebugEnabled; + scope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi; + uiScope.syncToolPanels = syncToolPanels; + uiScope.handleSidebarTabButtonClick = handleSidebarTabButtonClick; + uiScope.restoreAllToolWindows = restoreAllToolWindows; + uiScope.resetWorkspaceLayout = resetWorkspaceLayoutFlow; + uiScope.createNewTile = createNewTile; + uiScope.createNewSpriteGraphic = createNewSpriteGraphic; + uiScope.duplicateGraphicRecord = duplicateGraphicRecord; + uiScope.openEntityEditorWindow = openEntityEditorWindow; + uiScope.closeEntityEditorWindow = closeEntityEditorWindow; + uiScope.openEngineOverrideWindow = openEngineOverrideWindow; + uiScope.closeEngineOverrideWindow = closeEngineOverrideWindow; + uiScope.refreshEngineOverrideWindow = refreshEngineOverrideWindow; + uiScope.refreshEngineOverrideSummary = refreshEngineOverrideSummary; + uiScope.openWorldOverviewWindow = openWorldOverviewWindow; + uiScope.closeWorldOverviewWindow = closeWorldOverviewWindow; + uiScope.refreshWorldOverviewWindow = refreshWorldOverviewWindow; + uiScope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces; + uiScope.openStatusLogWindow = openStatusLogWindow; + uiScope.closeStatusLogWindow = closeStatusLogWindow; + uiScope.openNewsWindow = openNewsWindow; + uiScope.openTilePaletteContextMenu = openTilePaletteContextMenu; + uiScope.openPlacedEntityContextMenu = openPlacedEntityContextMenu; + uiScope.applyNpcEditorChange = applyNpcEditorChange; + uiScope.getEditorEngineOverrides = getEditorEngineOverrides; + uiScope.saveEditorEngineOverrides = saveEditorEngineOverrides; + uiScope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep; + uiScope.isRendererDebugEnabled = isRendererDebugEnabled; + uiScope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi; + + syncDocumentTitle(); + const historyController = createHistoryController(scope); + const npcController = createNpcController(scope); + const sidebarController = createSidebarController(scope); + renderController = createRenderController(scope); + const persistenceController = createPersistenceController(scope); + const importController = createImportController(scope); + const interactionController = createInteractionController(scope); const persistPopupBounds = () => { persistWorldshaperStudioBounds(window); }; - const runtimeControllerBootstrap = initializeRuntimeControllers({ - scope, - uiScope, - resetWorkspaceLayout, - setStatus, - createNewTile, - createNewSpriteGraphic, - duplicateGraphicRecord, - openTilePaletteContextMenu, - openPlacedEntityContextMenu, - applyNpcEditorChange, - getEditorEngineOverrides, - saveEditorEngineOverrides, - getEffectiveHeightBlurStep, - isRendererDebugEnabled, - reloadGraphicsContentFromApi, - syncDocumentTitle, - syncCanvasDimensionsToTileSize, - refreshEditorEngineOverridesUi, - cacheStandaloneMapBootstrap, - currentMapId, - persistPopupBounds, - popupSessionStore, - windowRef: window, - isWorldModeActive, - getInitialWorldViewTile, - centerViewportOnWorldTile, - prefetchAdjacentWorldNeighborhoods, - worldRuntimeState, - syncWorldNeighborhoodForViewport, - drawNow, + const persistPopupBoundsDeferred = createDebouncedCallback(() => { + persistPopupBounds(); + }, 160); + syncCanvasDimensionsToTileSize(); + toolWindowController.initialize(); + tileArtEditorWindowController.initialize(); + entityEditorWindowController.initialize(); + engineOverrideWindowController.initialize(); + worldOverviewWindowController.initialize(); + changelogSplashWindowController.initialize(); + statusLogWindowController.initialize(); + renderController.initializeRenderAssets(); + interactionController.initializeEditorState(); + interactionController.bindDomEvents(); + interactionController.initializeUi(); + refreshEditorEngineOverridesUi(); + cacheStandaloneMapBootstrap(currentMapId); + if (isWorldModeActive()) { + window.requestAnimationFrame(() => { + const initialWorldView = getInitialWorldViewTile(); + centerViewportOnWorldTile(initialWorldView.worldTileX, initialWorldView.worldTileY); + prefetchAdjacentWorldNeighborhoods(worldRuntimeState.centerChunkX, worldRuntimeState.centerChunkY); + syncWorldNeighborhoodForViewport(); + drawNow(); + setStatus("World mode loaded. Endless navigation is active.", false); + }); + } + window.requestAnimationFrame(() => { + changelogSplashWindowController.maybeOpenForCurrentVersion(); + }); + window.addEventListener("resize", () => { + persistPopupBoundsDeferred(); + }); + window.addEventListener("beforeunload", () => { + popupSessionStore.flushPersistedLayout(window); + persistPopupBounds(); }); - renderController = runtimeControllerBootstrap.renderController; - statusLogWindowController = runtimeControllerBootstrap.statusLogWindowController; - runtimeLogging.setStatusLogWindowController(statusLogWindowController); } - diff --git a/src/worldshaperStudio/runtimeBootstrapHelpers.ts b/src/worldshaperStudio/runtimeBootstrapHelpers.ts deleted file mode 100644 index 8b19ee2..0000000 --- a/src/worldshaperStudio/runtimeBootstrapHelpers.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */ -// @ts-nocheck -import { buildSpritePreviewDataUrl } from "../editorCore"; -import { - buildSpriteCatalog, - buildTileCatalogById, - DEFAULT_MAP_BACKGROUND_COLOR, -} from "../components/worldshaperShared"; -import type { WorldshaperStudioBootstrap } from "./bootstrap"; - -function getContentRecords(payload: unknown, key: string) { - const records = payload && Array.isArray(payload[key]) ? payload[key] : []; - return records.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)); -} - -export function cloneRuntimeValue(value: T): T { - if (typeof structuredClone === "function") { - return structuredClone(value); - } - return value == null ? value : JSON.parse(JSON.stringify(value)); -} - -export function buildSpriteCatalogFromBootstrap(bootstrap: WorldshaperStudioBootstrap) { - const spriteRecords = getContentRecords(bootstrap?.contentByType?.sprites, "sprites"); - if (spriteRecords.length > 0) { - return buildSpriteCatalog(spriteRecords, buildSpritePreviewDataUrl); - } - return cloneRuntimeValue(bootstrap?.spriteCatalog) || {}; -} - -export function buildTileCatalogByIdFromBootstrap(bootstrap: WorldshaperStudioBootstrap) { - const tileRecords = getContentRecords(bootstrap?.contentByType?.tiles, "tiles"); - if (tileRecords.length > 0) { - return buildTileCatalogById(tileRecords, buildSpritePreviewDataUrl); - } - return cloneRuntimeValue(bootstrap?.tileCatalogById) || {}; -} - -export function buildNpcOverlaysFromWorldChunks( - chunks: unknown[], - spriteCatalog: Record, - chunkWidth: number, - chunkHeight: number, - originChunkX: number, - originChunkY: number, -) { - return (Array.isArray(chunks) ? chunks : []).flatMap((chunk) => { - const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0); - const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0); - const offsetX = (baseChunkX - originChunkX) * chunkWidth; - const offsetY = (baseChunkY - originChunkY) * chunkHeight; - const instances = Array.isArray(chunk?.instances) ? chunk.instances : []; - return instances - .filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) - .map((entry) => { - const record = entry.record && typeof entry.record === "object" && !Array.isArray(entry.record) - ? cloneRuntimeValue(entry.record) - : {}; - const spriteId = String(record.spriteId || entry.spriteId || "").trim(); - const spriteEntry = spriteCatalog[spriteId] || null; - const overlayX = offsetX + Math.max(0, Number(entry.x) || 0); - const overlayY = offsetY + Math.max(0, Number(entry.y) || 0); - record.position = { - x: overlayX, - y: overlayY, - }; - return { - id: String(entry.id || "").trim(), - layer: Number(entry.layer) || 0, - name: String(record.name || entry.id || "NPC"), - spriteId, - x: overlayX, - y: overlayY, - dataUrl: spriteEntry ? spriteEntry.dataUrl : null, - spriteWidth: spriteEntry ? spriteEntry.spriteWidth : 28, - spriteHeight: spriteEntry ? spriteEntry.spriteHeight : 28, - opacity: spriteEntry ? spriteEntry.opacity : 1, - record, - }; - }) - .filter((entry) => entry.id); - }); -} - -export function normalizeMapBackgroundColor(value: unknown, fallback?: string) { - const safeFallback = fallback || DEFAULT_MAP_BACKGROUND_COLOR; - const raw = String(value || "").trim(); - return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toUpperCase() : safeFallback; -} - -export function createInitialWorldRuntimeState(bootstrap: WorldshaperStudioBootstrap) { - const worldId = String(bootstrap.worldId || bootstrap.mapId || "").trim(); - const chunkRadius = Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)); - const originChunkX = Math.floor(Number(bootstrap.worldOriginChunkX) || 0); - const originChunkY = Math.floor(Number(bootstrap.worldOriginChunkY) || 0); - - return { - enabled: !!worldId, - worldId, - worldName: String(bootstrap.worldName || bootstrap.mapName || bootstrap.worldId || bootstrap.mapId || "World").trim() || "World", - defaultBackgroundTileId: String(bootstrap.backgroundTileId || "").trim(), - heightBlurStep: Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1)), - chunkWidth: Math.max(1, Number(bootstrap.worldChunkWidth) || 32), - chunkHeight: Math.max(1, Number(bootstrap.worldChunkHeight) || 32), - chunkRadius, - originChunkX, - originChunkY, - tileOffsetX: Math.floor(Number(bootstrap.worldTileOffsetX) || 0), - tileOffsetY: Math.floor(Number(bootstrap.worldTileOffsetY) || 0), - spawnX: Math.floor(Number(bootstrap.worldSpawnX) || 0), - spawnY: Math.floor(Number(bootstrap.worldSpawnY) || 0), - centerChunkX: originChunkX + chunkRadius, - centerChunkY: originChunkY + chunkRadius, - sourceChunks: Array.isArray(bootstrap.sourceChunks) - ? bootstrap.sourceChunks.map((entry) => ({ - chunkX: Math.floor(Number(entry?.chunkX) || 0), - chunkY: Math.floor(Number(entry?.chunkY) || 0), - })) - : [], - bookmarks: Array.isArray(bootstrap.worldBookmarks) - ? bootstrap.worldBookmarks.map((entry, index) => ({ - id: String(entry?.id || `poi_${index + 1}`).trim() || `poi_${index + 1}`, - label: String(entry?.label || entry?.id || `POI ${index + 1}`).trim() || `POI ${index + 1}`, - x: Math.floor(Number(entry?.x) || 0), - y: Math.floor(Number(entry?.y) || 0), - })) - : [], - chunkCache: new Map(), - dirtyChunkKeys: new Set(), - pendingNeighborhoodFetches: new Map(), - prefetchedNeighborhoodKeys: new Set(), - pendingLoadKey: "", - pendingLoadPromise: null, - requestSerial: 0, - documentDirty: false, - }; -} - -export const MAX_WORLD_CHUNK_CACHE_ENTRIES = 256; -export const MAX_DYNAMIC_WORLD_CHUNK_RADIUS = 4; -export const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~="; diff --git a/src/worldshaperStudio/runtimeControllerBootstrap.ts b/src/worldshaperStudio/runtimeControllerBootstrap.ts deleted file mode 100644 index 9149b45..0000000 --- a/src/worldshaperStudio/runtimeControllerBootstrap.ts +++ /dev/null @@ -1,201 +0,0 @@ -// @ts-nocheck - -import { createHistoryController } from "./historyController"; -import { createInteractionController } from "./interactionController"; -import { createImportController } from "./importController"; -import { createNpcController } from "./npcController"; -import { createChangelogSplashWindowController } from "./changelogSplashWindowController"; -import { createEntityEditorWindowController } from "./entityEditorWindowController"; -import { createEngineOverrideWindowController } from "./engineOverrideWindowController"; -import { createPersistenceController } from "./persistenceController"; -import { createRenderController } from "./renderController"; -import { createSidebarController } from "./sidebarController"; -import { createStatusLogWindowController } from "./statusLogWindowController"; -import { createTileArtEditorWindowController } from "./tileArtEditorWindowController"; -import { createToolWindowController } from "./toolWindowController"; -import { createWorldOverviewWindowController } from "./worldOverviewWindowController"; -import { createDebouncedCallback } from "./debounce"; - -export function initializeRuntimeControllers(config) { - const { - scope, - uiScope, - resetWorkspaceLayout, - setStatus, - createNewTile, - createNewSpriteGraphic, - duplicateGraphicRecord, - openTilePaletteContextMenu, - openPlacedEntityContextMenu, - applyNpcEditorChange, - getEditorEngineOverrides, - saveEditorEngineOverrides, - getEffectiveHeightBlurStep, - isRendererDebugEnabled, - reloadGraphicsContentFromApi, - syncDocumentTitle, - syncCanvasDimensionsToTileSize, - refreshEditorEngineOverridesUi, - cacheStandaloneMapBootstrap, - currentMapId, - persistPopupBounds, - popupSessionStore, - windowRef, - isWorldModeActive, - getInitialWorldViewTile, - centerViewportOnWorldTile, - prefetchAdjacentWorldNeighborhoods, - worldRuntimeState, - syncWorldNeighborhoodForViewport, - drawNow, - } = config; - - const toolWindowController = createToolWindowController(scope); - const tileArtEditorWindowController = createTileArtEditorWindowController(scope); - const entityEditorWindowController = createEntityEditorWindowController(scope); - const engineOverrideWindowController = createEngineOverrideWindowController(scope); - const worldOverviewWindowController = createWorldOverviewWindowController(scope); - const changelogSplashWindowController = createChangelogSplashWindowController(scope); - const statusLogWindowController = createStatusLogWindowController(scope); - - const syncToolPanels = () => toolWindowController.syncPanels(); - const handleSidebarTabButtonClick = (tab) => toolWindowController.handleTabButtonClick(tab); - const restoreAllToolWindows = () => toolWindowController.restoreAllWindows(); - const openTileArtEditorWindow = (recordTypeOrId, maybeRecordId) => tileArtEditorWindowController.open(recordTypeOrId, maybeRecordId); - const closeTileArtEditorWindow = () => tileArtEditorWindowController.close(); - const openEntityEditorWindow = (entityId) => entityEditorWindowController.open(entityId); - const closeEntityEditorWindow = () => entityEditorWindowController.close(); - const openEngineOverrideWindow = () => engineOverrideWindowController.open(); - const closeEngineOverrideWindow = () => engineOverrideWindowController.close(); - const refreshEngineOverrideWindow = () => engineOverrideWindowController.refresh(); - const refreshEngineOverrideSummary = () => engineOverrideWindowController.updateSummary(); - const openWorldOverviewWindow = () => worldOverviewWindowController.open(); - const closeWorldOverviewWindow = () => worldOverviewWindowController.close(); - const refreshWorldOverviewWindow = () => worldOverviewWindowController.refresh(); - const invalidateWorldOverviewChunkSurfaces = (chunkKeys, options) => worldOverviewWindowController.invalidateChunkSurfaces?.(chunkKeys, options); - const openStatusLogWindow = () => statusLogWindowController.open(); - const closeStatusLogWindow = () => statusLogWindowController.close(); - const openNewsWindow = (options = {}) => changelogSplashWindowController.open({ markSeen: false, ...options }); - const resetWorkspaceLayoutFlow = () => { - resetWorkspaceLayout(); - toolWindowController.restoreAllWindows(); - setStatus("Workspace layout reset.", false); - }; - - scope.syncToolPanels = syncToolPanels; - scope.handleSidebarTabButtonClick = handleSidebarTabButtonClick; - scope.restoreAllToolWindows = restoreAllToolWindows; - scope.resetWorkspaceLayout = resetWorkspaceLayoutFlow; - scope.createNewTile = createNewTile; - scope.createNewSpriteGraphic = createNewSpriteGraphic; - scope.duplicateGraphicRecord = duplicateGraphicRecord; - scope.openTileArtEditorWindow = openTileArtEditorWindow; - scope.closeTileArtEditorWindow = closeTileArtEditorWindow; - scope.openEntityEditorWindow = openEntityEditorWindow; - scope.closeEntityEditorWindow = closeEntityEditorWindow; - scope.openEngineOverrideWindow = openEngineOverrideWindow; - scope.closeEngineOverrideWindow = closeEngineOverrideWindow; - scope.refreshEngineOverrideWindow = refreshEngineOverrideWindow; - scope.refreshEngineOverrideSummary = refreshEngineOverrideSummary; - scope.openWorldOverviewWindow = openWorldOverviewWindow; - scope.closeWorldOverviewWindow = closeWorldOverviewWindow; - scope.refreshWorldOverviewWindow = refreshWorldOverviewWindow; - scope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces; - scope.openStatusLogWindow = openStatusLogWindow; - scope.closeStatusLogWindow = closeStatusLogWindow; - scope.openNewsWindow = openNewsWindow; - scope.openTilePaletteContextMenu = openTilePaletteContextMenu; - scope.openPlacedEntityContextMenu = openPlacedEntityContextMenu; - scope.applyNpcEditorChange = applyNpcEditorChange; - scope.getEditorEngineOverrides = getEditorEngineOverrides; - scope.saveEditorEngineOverrides = saveEditorEngineOverrides; - scope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep; - scope.isRendererDebugEnabled = isRendererDebugEnabled; - scope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi; - - uiScope.syncToolPanels = syncToolPanels; - uiScope.handleSidebarTabButtonClick = handleSidebarTabButtonClick; - uiScope.restoreAllToolWindows = restoreAllToolWindows; - uiScope.resetWorkspaceLayout = resetWorkspaceLayoutFlow; - uiScope.createNewTile = createNewTile; - uiScope.createNewSpriteGraphic = createNewSpriteGraphic; - uiScope.duplicateGraphicRecord = duplicateGraphicRecord; - uiScope.openEntityEditorWindow = openEntityEditorWindow; - uiScope.closeEntityEditorWindow = closeEntityEditorWindow; - uiScope.openEngineOverrideWindow = openEngineOverrideWindow; - uiScope.closeEngineOverrideWindow = closeEngineOverrideWindow; - uiScope.refreshEngineOverrideWindow = refreshEngineOverrideWindow; - uiScope.refreshEngineOverrideSummary = refreshEngineOverrideSummary; - uiScope.openWorldOverviewWindow = openWorldOverviewWindow; - uiScope.closeWorldOverviewWindow = closeWorldOverviewWindow; - uiScope.refreshWorldOverviewWindow = refreshWorldOverviewWindow; - uiScope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces; - uiScope.openStatusLogWindow = openStatusLogWindow; - uiScope.closeStatusLogWindow = closeStatusLogWindow; - uiScope.openNewsWindow = openNewsWindow; - uiScope.openTilePaletteContextMenu = openTilePaletteContextMenu; - uiScope.openPlacedEntityContextMenu = openPlacedEntityContextMenu; - uiScope.applyNpcEditorChange = applyNpcEditorChange; - uiScope.getEditorEngineOverrides = getEditorEngineOverrides; - uiScope.saveEditorEngineOverrides = saveEditorEngineOverrides; - uiScope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep; - uiScope.isRendererDebugEnabled = isRendererDebugEnabled; - uiScope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi; - - syncDocumentTitle(); - createHistoryController(scope); - createNpcController(scope); - createSidebarController(scope); - const renderController = createRenderController(scope); - createPersistenceController(scope); - createImportController(scope); - const interactionController = createInteractionController(scope); - - const persistPopupBoundsDeferred = createDebouncedCallback(() => { - persistPopupBounds(); - }, 160); - - syncCanvasDimensionsToTileSize(); - toolWindowController.initialize(); - tileArtEditorWindowController.initialize(); - entityEditorWindowController.initialize(); - engineOverrideWindowController.initialize(); - worldOverviewWindowController.initialize(); - changelogSplashWindowController.initialize(); - statusLogWindowController.initialize(); - renderController.initializeRenderAssets(); - interactionController.initializeEditorState(); - interactionController.bindDomEvents(); - interactionController.initializeUi(); - refreshEditorEngineOverridesUi(); - cacheStandaloneMapBootstrap(currentMapId); - - if (isWorldModeActive()) { - windowRef.requestAnimationFrame(() => { - const initialWorldView = getInitialWorldViewTile(); - centerViewportOnWorldTile(initialWorldView.worldTileX, initialWorldView.worldTileY); - prefetchAdjacentWorldNeighborhoods(worldRuntimeState.centerChunkX, worldRuntimeState.centerChunkY); - syncWorldNeighborhoodForViewport(); - drawNow(); - setStatus("World mode loaded. Endless navigation is active.", false); - }); - } - - windowRef.requestAnimationFrame(() => { - changelogSplashWindowController.maybeOpenForCurrentVersion(); - }); - windowRef.addEventListener("resize", () => { - persistPopupBoundsDeferred(); - }); - windowRef.addEventListener("beforeunload", () => { - popupSessionStore.flushPersistedLayout(windowRef); - persistPopupBounds(); - }); - - return { - renderController, - statusLogWindowController, - changelogSplashWindowController, - persistPopupBoundsDeferred, - }; -} diff --git a/src/worldshaperStudio/runtimeLogging.ts b/src/worldshaperStudio/runtimeLogging.ts deleted file mode 100644 index 8d08890..0000000 --- a/src/worldshaperStudio/runtimeLogging.ts +++ /dev/null @@ -1,67 +0,0 @@ -// @ts-nocheck - -export function createRuntimeLogging({ windowRef, runtimeUniqueId }) { - const editorLogEntries = []; - const EDITOR_LOG_LIMIT = 500; - let statusLogWindowController = null; - - function formatEditorLogTimestamp(timestamp) { - try { - return new Date(timestamp).toLocaleString(); - } catch { - return String(timestamp || ""); - } - } - - function appendEditorLogEntry(level, message) { - const normalizedMessage = String(message || "").trim(); - if (!normalizedMessage) { - return null; - } - const timestamp = Date.now(); - const entry = { - id: runtimeUniqueId(), - timestamp, - timestampLabel: formatEditorLogTimestamp(timestamp), - level: String(level || "Information").trim() || "Information", - message: normalizedMessage, - }; - editorLogEntries.push(entry); - while (editorLogEntries.length > EDITOR_LOG_LIMIT) { - editorLogEntries.shift(); - } - statusLogWindowController?.refresh?.(); - return entry; - } - - function getEditorLogEntries() { - return editorLogEntries.slice(); - } - - function clearEditorLogEntries() { - editorLogEntries.splice(0, editorLogEntries.length); - statusLogWindowController?.refresh?.(); - } - - windowRef.addEventListener("error", (event) => { - const message = String(event?.message || event?.error?.message || "Unknown runtime error"); - appendEditorLogEntry("Error", message); - }); - - windowRef.addEventListener("unhandledrejection", (event) => { - const reason = event?.reason; - const message = typeof reason === "string" - ? reason - : String(reason?.message || reason || "Unhandled promise rejection"); - appendEditorLogEntry("Error", message); - }); - - return { - appendEditorLogEntry, - getEditorLogEntries, - clearEditorLogEntries, - setStatusLogWindowController(nextController) { - statusLogWindowController = nextController; - }, - }; -} diff --git a/src/worldshaperStudio/tileArtEditorHelpers.ts b/src/worldshaperStudio/tileArtEditorHelpers.ts deleted file mode 100644 index 2110c56..0000000 --- a/src/worldshaperStudio/tileArtEditorHelpers.ts +++ /dev/null @@ -1,420 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -// @ts-nocheck - -import { - buildSpritePreviewDataUrl, - getSpritePalette, - normalizeImagePlayback, -} from "../editorCore"; -import { normalizeEditorTags } from "./tagUtils"; - -export const TILE_ART_SIZE = 16; - -export const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( - ` - - - - `, -)}") 4 28, crosshair`; - -export function cloneValue(value) { - if (typeof structuredClone === "function") { - return structuredClone(value); - } - return value == null ? value : JSON.parse(JSON.stringify(value)); -} - -function normalizeRoleList(value) { - if (!Array.isArray(value)) { - return []; - } - return Array.from(new Set( - value - .map((entry) => String(entry || "").trim().toLowerCase()) - .filter((entry) => entry === "tile" || entry === "sprite"), - )); -} - -export function normalizeTimelineRows(rows) { - return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => { - const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : ""; - return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE); - }); -} - -export function normalizeWorkingFrames(record) { - const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : []; - const normalizedFrames = rawFrames.map((entry, index) => ({ - ...cloneValue(entry), - id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`, - enabled: entry.enabled !== false, - index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index, - rows: normalizeTimelineRows(entry.rows), - })); - if (normalizedFrames.length > 0) { - return normalizedFrames; - } - return [{ - id: "frame_0", - enabled: true, - index: 0, - rows: normalizeTimelineRows(record?.rows), - }]; -} - -export function sortWorkingFrames(frames) { - return frames - .map((frame, sourceIndex) => ({ - frame, - sourceIndex, - sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex, - })) - .sort((left, right) => ( - left.sortIndex !== right.sortIndex - ? left.sortIndex - right.sortIndex - : left.sourceIndex - right.sourceIndex - )) - .map((entry) => entry.frame); -} - -export function normalizeWorkingGraphicRecord(recordType, record) { - const source = cloneValue(record) || {}; - const roles = normalizeRoleList(source.roles); - const nextRoles = recordType === "tile" - ? Array.from(new Set([...roles, "tile"])) - : ( - recordType === "sprite" - ? Array.from(new Set([...roles, "sprite"])) - : roles.filter((entry) => entry !== "sprite") - ); - const frames = normalizeWorkingFrames(source).map((frame, index) => ({ - ...frame, - index, - })); - const requestedDefaultFrameId = String(source.defaultFrame || "").trim(); - const defaultFrameId = String( - frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id - || frames[0]?.id - || "frame_0", - ).trim() || "frame_0"; - const workingRows = normalizeTimelineRows( - Array.isArray(source.rows) && source.rows.length > 0 - ? source.rows - : (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || []) - ); - return { - ...source, - id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(), - name: typeof source.name === "string" ? source.name : "", - description: typeof source.description === "string" ? source.description : "", - width: TILE_ART_SIZE, - height: TILE_ART_SIZE, - pixelScale: Math.max(1, Number(source.pixelScale) || 2), - opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1, - tags: normalizeEditorTags(source.tags), - roles: nextRoles, - tileSymbol: nextRoles.includes("tile") - ? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T") - : "", - defaultFrame: defaultFrameId, - speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0, - playback: normalizeImagePlayback(source.playback), - frames, - rows: workingRows, - }; -} - -export function normalizeOpacityValue(value, fallback = 1) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return fallback; - } - return Math.max(0, Math.min(1, parsed)); -} - -export function formatOpacityValue(value) { - const normalized = normalizeOpacityValue(value, 1); - return normalized.toFixed(2).replace(/\.?0+$/, ""); -} - -export function cloneRows(rows) { - return Array.isArray(rows) - ? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE)) - : Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE)); -} - -export function buildRowsPreviewRecord(rows) { - return { - width: TILE_ART_SIZE, - height: TILE_ART_SIZE, - rows: cloneRows(rows), - }; -} - -export function formatPlaybackLabel(value) { - const normalized = normalizeImagePlayback(value); - if (normalized === "rewind") { - return "Rewind"; - } - if (normalized === "stop") { - return "Stop"; - } - return "Normal"; -} - -export function getWorkingCellSymbol(record, x, y) { - const rows = Array.isArray(record?.rows) ? record.rows : []; - const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE); - return String(row.charAt(x) || ".").charAt(0) || "."; -} - -export function paintWorkingRowsCell(rows, x, y, symbol) { - const nextRows = cloneRows(rows); - const nextSymbol = String(symbol || ".").charAt(0) || "."; - const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE); - nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`; - return nextRows; -} - -export function getRowsMatrix(rows) { - return cloneRows(rows).map((row) => Array.from(row)); -} - -export function buildRowsFromMatrix(matrix) { - return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => { - const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : []; - return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE); - }); -} - -export function getAlternatePaintSymbol(record, preferredSymbol) { - const normalizedPreferred = String(preferredSymbol || "").charAt(0) || "."; - const palette = getSpritePalette(record || undefined); - const nextSymbol = Object.keys(palette) - .map((symbol) => String(symbol || "").charAt(0)) - .find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== "."); - return nextSymbol || "."; -} - -export function shiftRows(rows, offsetX, offsetY) { - const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split("")); - const sourceRows = cloneRows(rows); - for (let y = 0; y < TILE_ART_SIZE; y += 1) { - const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE); - for (let x = 0; x < TILE_ART_SIZE; x += 1) { - const nextX = x + offsetX; - const nextY = y + offsetY; - if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) { - continue; - } - nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || "."; - } - } - return buildRowsFromMatrix(nextRows); -} - -export function flipRowsHorizontally(rows) { - return cloneRows(rows).map((row) => row.split("").reverse().join("")); -} - -export function flipRowsVertically(rows) { - return cloneRows(rows).slice().reverse(); -} - -export function rotateRowsClockwise(rows) { - const matrix = getRowsMatrix(rows); - const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => ".")); - for (let y = 0; y < TILE_ART_SIZE; y += 1) { - for (let x = 0; x < TILE_ART_SIZE; x += 1) { - nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || "."; - } - } - return buildRowsFromMatrix(nextMatrix); -} - -export function rotateRowsCounterClockwise(rows) { - const matrix = getRowsMatrix(rows); - const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => ".")); - for (let y = 0; y < TILE_ART_SIZE; y += 1) { - for (let x = 0; x < TILE_ART_SIZE; x += 1) { - nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || "."; - } - } - return buildRowsFromMatrix(nextMatrix); -} - -export function buildShapeFillMask(shapeKind, startX, startY, endX, endY) { - const minX = Math.max(0, Math.min(startX, endX)); - const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX)); - const minY = Math.max(0, Math.min(startY, endY)); - const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY)); - const fillMask = new Set(); - const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle"; - const width = Math.max(1, (maxX - minX) + 1); - const height = Math.max(1, (maxY - minY) + 1); - const centerX = minX + (width / 2); - const centerY = minY + (height / 2); - const denomX = Math.max(0.5, width / 2); - const denomY = Math.max(0.5, height / 2); - const triangleAx = minX + (width - 1) / 2; - const triangleAy = minY; - const triangleBx = minX; - const triangleBy = maxY; - const triangleCx = maxX; - const triangleCy = maxY; - const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy)); - for (let y = minY; y <= maxY; y += 1) { - for (let x = minX; x <= maxX; x += 1) { - let include; - const sampleX = x + 0.5; - const sampleY = y + 0.5; - if (shape === "rectangle") { - include = true; - } else if (shape === "circle") { - const normX = (sampleX - centerX) / denomX; - const normY = (sampleY - centerY) / denomY; - include = (normX * normX) + (normY * normY) <= 1; - } else if (triangleDenominator !== 0) { - const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator; - const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator; - const c = 1 - a - b; - include = a >= 0 && b >= 0 && c >= 0; - } else { - include = x === Math.round(triangleAx) && y >= minY && y <= maxY; - } - if (include === true) { - fillMask.add(`${x}:${y}`); - } - } - } - return fillMask; -} - -export function buildOutlineMask(fillMask) { - const outlineMask = new Set(); - fillMask.forEach((key) => { - const [xText, yText] = String(key || "").split(":"); - const x = Number(xText); - const y = Number(yText); - const neighbors = [ - `${x - 1}:${y}`, - `${x + 1}:${y}`, - `${x}:${y - 1}`, - `${x}:${y + 1}`, - ]; - if (neighbors.some((neighbor) => !fillMask.has(neighbor))) { - outlineMask.add(key); - } - }); - return outlineMask; -} - -export function applyMaskToRows(baseRows, mask, symbol) { - const matrix = getRowsMatrix(baseRows); - mask.forEach((key) => { - const [xText, yText] = String(key || "").split(":"); - const x = Number(xText); - const y = Number(yText); - if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) { - return; - } - matrix[y][x] = String(symbol || ".").charAt(0) || "."; - }); - return buildRowsFromMatrix(matrix); -} - -export function getLineRows(baseRows, startX, startY, endX, endY, symbol) { - const normalizedSymbol = String(symbol || ".").charAt(0) || "."; - const matrix = getRowsMatrix(baseRows); - let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0)); - let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0)); - const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0)); - const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0)); - const deltaX = Math.abs(x1 - x0); - const deltaY = Math.abs(y1 - y0); - const stepX = x0 < x1 ? 1 : -1; - const stepY = y0 < y1 ? 1 : -1; - let error = deltaX - deltaY; - while (true) { - matrix[y0][x0] = normalizedSymbol; - if (x0 === x1 && y0 === y1) { - break; - } - const nextError = error * 2; - if (nextError > -deltaY) { - error -= deltaY; - x0 += stepX; - } - if (nextError < deltaX) { - error += deltaX; - y0 += stepY; - } - } - return buildRowsFromMatrix(matrix); -} - -export function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") { - const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle"; - const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill"; - const normalizedTone = tone === "erase" ? "erase" : "draw"; - return "" - + `"; -} - -export function buildLineOptionIconMarkup(tone = "draw") { - const normalizedTone = tone === "erase" ? "erase" : "draw"; - return "" - + `"; -} - -export function buildCurrentShapeToolIconMarkup(state) { - if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") { - return buildLineOptionIconMarkup("draw"); - } - return buildShapeOptionIconMarkup( - state?.activeShapeKind || "rectangle", - state?.activeShapeVariant || "outline", - "draw", - ); -} - -export function buildCurrentEraseToolIconMarkup(state) { - return buildShapeOptionIconMarkup( - state?.activeEraseKind || "rectangle", - "fill", - "erase", - ); -} - -export function buildTransformCategoryIconMarkup(kind) { - const normalizedKind = kind === "flip" ? "flip" : "rotate"; - return "" - + `"; -} - -export function buildTransformOptionIconMarkup(kind) { - const normalizedKind = [ - "rotate-cw", - "rotate-ccw", - "flip-h", - "flip-v", - ].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw"; - return "" - + `"; -} - -export function buildFramePreviewDataUrl(rows, scale = 10) { - return buildSpritePreviewDataUrl(buildRowsPreviewRecord(rows), scale); -} diff --git a/src/worldshaperStudio/tileArtEditorWindowController.ts b/src/worldshaperStudio/tileArtEditorWindowController.ts index 8087005..597f0ee 100644 --- a/src/worldshaperStudio/tileArtEditorWindowController.ts +++ b/src/worldshaperStudio/tileArtEditorWindowController.ts @@ -22,40 +22,9 @@ import { } from "./textTransferUtils"; import { clampFloatingWindowRect } from "./floatingWindowUtils"; import { appendContextMenuItems, menuItem, menuSubmenu, openContextMenuAtPoint } from "./contextMenuSchema"; -import { - applyMaskToRows, - buildCurrentEraseToolIconMarkup, - buildCurrentShapeToolIconMarkup, - buildFramePreviewDataUrl, - buildLineOptionIconMarkup, - buildOutlineMask, - buildShapeFillMask, - buildShapeOptionIconMarkup, - buildTransformCategoryIconMarkup, - buildTransformOptionIconMarkup, - cloneRows, - cloneValue, - EYEDROPPER_CURSOR, - flipRowsHorizontally, - flipRowsVertically, - formatOpacityValue, - formatPlaybackLabel, - getAlternatePaintSymbol, - getLineRows, - getWorkingCellSymbol, - normalizeOpacityValue, - normalizeTimelineRows, - normalizeWorkingFrames, - normalizeWorkingGraphicRecord, - paintWorkingRowsCell, - rotateRowsClockwise, - rotateRowsCounterClockwise, - shiftRows, - sortWorkingFrames, - TILE_ART_SIZE, -} from "./tileArtEditorHelpers"; const TILE_ART_WINDOW_KEY = "tileArtEditor"; +const TILE_ART_SIZE = 16; const GRID_CELL_SIZE = 21; const MIN_WIDTH = 452; const MIN_HEIGHT = 628; @@ -69,11 +38,415 @@ const TOOL_MENU_TAG_PREFIX = "tile-art-tool-menu:"; const SHORTCUT_HELP_TOOLTIP_TAG = "tile-art-shortcut-help"; const ANIMATION_SPEED_TOOLTIP_TAG = "tile-art-animation-speed"; const ANIMATION_PLAYBACK_TOOLTIP_TAG = "tile-art-animation-playback"; +const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( + ` + + + + `, +)}") 4 28, crosshair`; + +function cloneValue(value) { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return value == null ? value : JSON.parse(JSON.stringify(value)); +} function clampWindowRect(layerRect, left, top, width, height) { return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT); } +function normalizeRoleList(value) { + if (!Array.isArray(value)) { + return []; + } + return Array.from(new Set( + value + .map((entry) => String(entry || "").trim().toLowerCase()) + .filter((entry) => entry === "tile" || entry === "sprite"), + )); +} + +function normalizeTimelineRows(rows) { + return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => { + const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : ""; + return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE); + }); +} + +function normalizeWorkingFrames(record) { + const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : []; + const normalizedFrames = rawFrames.map((entry, index) => ({ + ...cloneValue(entry), + id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`, + enabled: entry.enabled !== false, + index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index, + rows: normalizeTimelineRows(entry.rows), + })); + if (normalizedFrames.length > 0) { + return normalizedFrames; + } + return [{ + id: "frame_0", + enabled: true, + index: 0, + rows: normalizeTimelineRows(record?.rows), + }]; +} + +function sortWorkingFrames(frames) { + return frames + .map((frame, sourceIndex) => ({ + frame, + sourceIndex, + sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex, + })) + .sort((left, right) => ( + left.sortIndex !== right.sortIndex + ? left.sortIndex - right.sortIndex + : left.sourceIndex - right.sourceIndex + )) + .map((entry) => entry.frame); +} + +function normalizeWorkingGraphicRecord(recordType, record) { + const source = cloneValue(record) || {}; + const roles = normalizeRoleList(source.roles); + const nextRoles = recordType === "tile" + ? Array.from(new Set([...roles, "tile"])) + : ( + recordType === "sprite" + ? Array.from(new Set([...roles, "sprite"])) + : roles.filter((entry) => entry !== "sprite") + ); + const frames = normalizeWorkingFrames(source).map((frame, index) => ({ + ...frame, + index, + })); + const requestedDefaultFrameId = String(source.defaultFrame || "").trim(); + const defaultFrameId = String( + frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id + || frames[0]?.id + || "frame_0", + ).trim() || "frame_0"; + const workingRows = normalizeTimelineRows( + Array.isArray(source.rows) && source.rows.length > 0 + ? source.rows + : (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || []) + ); + return { + ...source, + id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(), + name: typeof source.name === "string" ? source.name : "", + description: typeof source.description === "string" ? source.description : "", + width: TILE_ART_SIZE, + height: TILE_ART_SIZE, + pixelScale: Math.max(1, Number(source.pixelScale) || 2), + opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1, + tags: normalizeEditorTags(source.tags), + roles: nextRoles, + tileSymbol: nextRoles.includes("tile") + ? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T") + : "", + defaultFrame: defaultFrameId, + speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0, + playback: normalizeImagePlayback(source.playback), + frames, + rows: workingRows, + }; +} + +function normalizeOpacityValue(value, fallback = 1) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + return Math.max(0, Math.min(1, parsed)); +} + +function formatOpacityValue(value) { + const normalized = normalizeOpacityValue(value, 1); + return normalized.toFixed(2).replace(/\.?0+$/, ""); +} + +function buildRowsPreviewRecord(rows) { + return { + width: TILE_ART_SIZE, + height: TILE_ART_SIZE, + rows: cloneRows(rows), + }; +} + +function formatPlaybackLabel(value) { + const normalized = normalizeImagePlayback(value); + if (normalized === "rewind") { + return "Rewind"; + } + if (normalized === "stop") { + return "Stop"; + } + return "Normal"; +} + +function cloneRows(rows) { + return Array.isArray(rows) + ? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE)) + : Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE)); +} + +function getWorkingCellSymbol(record, x, y) { + const rows = Array.isArray(record?.rows) ? record.rows : []; + const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE); + return String(row.charAt(x) || ".").charAt(0) || "."; +} + +function paintWorkingRowsCell(rows, x, y, symbol) { + const nextRows = cloneRows(rows); + const nextSymbol = String(symbol || ".").charAt(0) || "."; + const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE); + nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`; + return nextRows; +} + +function getRowsMatrix(rows) { + return cloneRows(rows).map((row) => Array.from(row)); +} + +function buildRowsFromMatrix(matrix) { + return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => { + const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : []; + return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE); + }); +} + +function getAlternatePaintSymbol(record, preferredSymbol) { + const normalizedPreferred = String(preferredSymbol || "").charAt(0) || "."; + const palette = getSpritePalette(record || undefined); + const nextSymbol = Object.keys(palette) + .map((symbol) => String(symbol || "").charAt(0)) + .find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== "."); + return nextSymbol || "."; +} + +function shiftRows(rows, offsetX, offsetY) { + const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split("")); + const sourceRows = cloneRows(rows); + for (let y = 0; y < TILE_ART_SIZE; y += 1) { + const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE); + for (let x = 0; x < TILE_ART_SIZE; x += 1) { + const nextX = x + offsetX; + const nextY = y + offsetY; + if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) { + continue; + } + nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || "."; + } + } + return buildRowsFromMatrix(nextRows); +} + +function flipRowsHorizontally(rows) { + return cloneRows(rows).map((row) => row.split("").reverse().join("")); +} + +function flipRowsVertically(rows) { + return cloneRows(rows).slice().reverse(); +} + +function rotateRowsClockwise(rows) { + const matrix = getRowsMatrix(rows); + const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => ".")); + for (let y = 0; y < TILE_ART_SIZE; y += 1) { + for (let x = 0; x < TILE_ART_SIZE; x += 1) { + nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || "."; + } + } + return buildRowsFromMatrix(nextMatrix); +} + +function rotateRowsCounterClockwise(rows) { + const matrix = getRowsMatrix(rows); + const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => ".")); + for (let y = 0; y < TILE_ART_SIZE; y += 1) { + for (let x = 0; x < TILE_ART_SIZE; x += 1) { + nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || "."; + } + } + return buildRowsFromMatrix(nextMatrix); +} + +function buildShapeFillMask(shapeKind, startX, startY, endX, endY) { + const minX = Math.max(0, Math.min(startX, endX)); + const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX)); + const minY = Math.max(0, Math.min(startY, endY)); + const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY)); + const fillMask = new Set(); + const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle"; + const width = Math.max(1, (maxX - minX) + 1); + const height = Math.max(1, (maxY - minY) + 1); + const centerX = minX + (width / 2); + const centerY = minY + (height / 2); + const denomX = Math.max(0.5, width / 2); + const denomY = Math.max(0.5, height / 2); + const triangleAx = minX + (width - 1) / 2; + const triangleAy = minY; + const triangleBx = minX; + const triangleBy = maxY; + const triangleCx = maxX; + const triangleCy = maxY; + const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy)); + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) { + let include; + const sampleX = x + 0.5; + const sampleY = y + 0.5; + if (shape === "rectangle") { + include = true; + } else if (shape === "circle") { + const normX = (sampleX - centerX) / denomX; + const normY = (sampleY - centerY) / denomY; + include = (normX * normX) + (normY * normY) <= 1; + } else if (triangleDenominator !== 0) { + const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator; + const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator; + const c = 1 - a - b; + include = a >= 0 && b >= 0 && c >= 0; + } else { + include = x === Math.round(triangleAx) && y >= minY && y <= maxY; + } + if (include === true) { + fillMask.add(`${x}:${y}`); + } + } + } + return fillMask; +} + +function buildOutlineMask(fillMask) { + const outlineMask = new Set(); + fillMask.forEach((key) => { + const [xText, yText] = String(key || "").split(":"); + const x = Number(xText); + const y = Number(yText); + const neighbors = [ + `${x - 1}:${y}`, + `${x + 1}:${y}`, + `${x}:${y - 1}`, + `${x}:${y + 1}`, + ]; + if (neighbors.some((neighbor) => !fillMask.has(neighbor))) { + outlineMask.add(key); + } + }); + return outlineMask; +} + +function applyMaskToRows(baseRows, mask, symbol) { + const matrix = getRowsMatrix(baseRows); + mask.forEach((key) => { + const [xText, yText] = String(key || "").split(":"); + const x = Number(xText); + const y = Number(yText); + if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) { + return; + } + matrix[y][x] = String(symbol || ".").charAt(0) || "."; + }); + return buildRowsFromMatrix(matrix); +} + +function getLineRows(baseRows, startX, startY, endX, endY, symbol) { + const normalizedSymbol = String(symbol || ".").charAt(0) || "."; + const matrix = getRowsMatrix(baseRows); + let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0)); + let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0)); + const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0)); + const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0)); + const deltaX = Math.abs(x1 - x0); + const deltaY = Math.abs(y1 - y0); + const stepX = x0 < x1 ? 1 : -1; + const stepY = y0 < y1 ? 1 : -1; + let error = deltaX - deltaY; + while (true) { + matrix[y0][x0] = normalizedSymbol; + if (x0 === x1 && y0 === y1) { + break; + } + const nextError = error * 2; + if (nextError > -deltaY) { + error -= deltaY; + x0 += stepX; + } + if (nextError < deltaX) { + error += deltaX; + y0 += stepY; + } + } + return buildRowsFromMatrix(matrix); +} + +function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") { + const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle"; + const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill"; + const normalizedTone = tone === "erase" ? "erase" : "draw"; + return "" + + `"; +} + +function buildLineOptionIconMarkup(tone = "draw") { + const normalizedTone = tone === "erase" ? "erase" : "draw"; + return "" + + `"; +} + +function buildCurrentShapeToolIconMarkup(state) { + if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") { + return buildLineOptionIconMarkup("draw"); + } + return buildShapeOptionIconMarkup( + state?.activeShapeKind || "rectangle", + state?.activeShapeVariant || "outline", + "draw", + ); +} + +function buildCurrentEraseToolIconMarkup(state) { + return buildShapeOptionIconMarkup( + state?.activeEraseKind || "rectangle", + "fill", + "erase", + ); +} + +function buildTransformCategoryIconMarkup(kind) { + const normalizedKind = kind === "flip" ? "flip" : "rotate"; + return "" + + `"; +} + +function buildTransformOptionIconMarkup(kind) { + const normalizedKind = [ + "rotate-cw", + "rotate-ccw", + "flip-h", + "flip-v", + ].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw"; + return "" + + `"; +} + export function createTileArtEditorWindowController(scope) { let initialized = false; const uiScope = scope.uiScope || scope; @@ -460,7 +833,7 @@ export function createTileArtEditorWindowController(scope) { } const currentFrame = playbackFrames.find((frame) => String(frame.id || "").trim() === String(state.animationPreviewFrameId || "").trim()) || playbackFrames[0]; state.animationPreviewFrameId = String(currentFrame?.id || "").trim(); - const previewUrl = buildFramePreviewDataUrl(currentFrame?.rows, 10); + const previewUrl = buildSpritePreviewDataUrl(buildRowsPreviewRecord(currentFrame?.rows), 10); if (previewUrl) { state.animationPreviewImageEl.src = previewUrl; state.animationPreviewImageEl.classList.remove("hidden"); diff --git a/src/worldshaperStudio/windowing.ts b/src/worldshaperStudio/windowing.ts index 6038ed8..4a0b176 100644 --- a/src/worldshaperStudio/windowing.ts +++ b/src/worldshaperStudio/windowing.ts @@ -1 +1,210 @@ -export * from "../shared/windowing"; +export type PopupBounds = { + left: number; + top: number; + width: number; + height: number; +}; + +export const WORLDSHAPER_STUDIO_WINDOW_NAME = "worldshaper-studio"; +export const WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY = "worldshaper:studio-window-bounds"; +export const WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME = "worldshaper-height-viewer"; +export const WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY = "worldshaper:height-viewer-window-bounds"; + +export function buildWorldshaperStudioUrl(mapId: string, hostWindow: Window = window, options?: { worldId?: string }): string { + const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-studio.html`, hostWindow.location.origin); + const normalizedMapId = String(mapId || "").trim(); + const normalizedWorldId = String(options?.worldId || "").trim(); + if (normalizedMapId) { + popupUrl.searchParams.set("mapId", normalizedMapId); + } + if (normalizedWorldId) { + popupUrl.searchParams.set("worldId", normalizedWorldId); + } + return popupUrl.toString(); +} + +export function buildWorldshaperHeightViewerUrl(mapId: string, token = "", hostWindow: Window = window): string { + const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-height-viewer.html`, hostWindow.location.origin); + const normalizedMapId = String(mapId || "").trim(); + const normalizedToken = String(token || "").trim(); + if (normalizedMapId) { + popupUrl.searchParams.set("mapId", normalizedMapId); + } + if (normalizedToken) { + popupUrl.searchParams.set("token", normalizedToken); + } + return popupUrl.toString(); +} + +export function getCenteredWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds { + const width = 1360; + const height = 900; + const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0; + const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0; + const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0 + ? hostWindow.outerWidth + : hostWindow.innerWidth; + const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0 + ? hostWindow.outerHeight + : hostWindow.innerHeight; + const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2)); + const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2)); + return { left, top, width, height }; +} + +export function getCenteredWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds { + const width = 1280; + const height = 820; + const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0; + const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0; + const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0 + ? hostWindow.outerWidth + : hostWindow.innerWidth; + const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0 + ? hostWindow.outerHeight + : hostWindow.innerHeight; + const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2)); + const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2)); + return { left, top, width, height }; +} + +export function readWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds { + try { + const raw = hostWindow.localStorage.getItem(WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY); + if (!raw) { + return getCenteredWorldshaperStudioBounds(hostWindow); + } + const parsed = JSON.parse(raw) as Partial; + const width = Math.max(640, Number(parsed.width) || 0); + const height = Math.max(480, Number(parsed.height) || 0); + const left = Math.max(0, Number(parsed.left) || 0); + const top = Math.max(0, Number(parsed.top) || 0); + if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) { + return getCenteredWorldshaperStudioBounds(hostWindow); + } + return { left, top, width, height }; + } catch { + return getCenteredWorldshaperStudioBounds(hostWindow); + } +} + +export function readWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds { + try { + const raw = hostWindow.localStorage.getItem(WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY); + if (!raw) { + return getCenteredWorldshaperHeightViewerBounds(hostWindow); + } + const parsed = JSON.parse(raw) as Partial; + const width = Math.max(640, Number(parsed.width) || 0); + const height = Math.max(480, Number(parsed.height) || 0); + const left = Math.max(0, Number(parsed.left) || 0); + const top = Math.max(0, Number(parsed.top) || 0); + if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) { + return getCenteredWorldshaperHeightViewerBounds(hostWindow); + } + return { left, top, width, height }; + } catch { + return getCenteredWorldshaperHeightViewerBounds(hostWindow); + } +} + +export function persistWorldshaperStudioBounds(sourceWindow: Window = window): void { + if (sourceWindow.closed) { + return; + } + try { + const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0)); + const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0)); + const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0)); + const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0)); + sourceWindow.localStorage.setItem( + WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY, + JSON.stringify({ left, top, width, height }), + ); + } catch { + // Ignore storage and same-origin failures. + } +} + +export function persistWorldshaperHeightViewerBounds(sourceWindow: Window = window): void { + if (sourceWindow.closed) { + return; + } + try { + const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0)); + const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0)); + const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0)); + const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0)); + sourceWindow.localStorage.setItem( + WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY, + JSON.stringify({ left, top, width, height }), + ); + } catch { + // Ignore storage and same-origin failures. + } +} + +export function openWorldshaperStudioWindow( + mapId: string, + hostWindow: Window = window, + options?: { worldId?: string }, +): Window | null { + const popupUrl = buildWorldshaperStudioUrl(mapId, hostWindow, options); + const initialBounds = readWorldshaperStudioBounds(hostWindow); + const popupFeatures = [ + "popup=yes", + "resizable=yes", + "scrollbars=no", + "width=" + initialBounds.width, + "height=" + initialBounds.height, + "left=" + initialBounds.left, + "top=" + initialBounds.top, + ].join(","); + + const popup = hostWindow.open(popupUrl, WORLDSHAPER_STUDIO_WINDOW_NAME, popupFeatures); + if (!popup) { + return null; + } + + try { + popup.moveTo(initialBounds.left, initialBounds.top); + popup.resizeTo(initialBounds.width, initialBounds.height); + } catch { + // Ignore browser restrictions. + } + + popup.location.href = popupUrl; + popup.focus(); + return popup; +} + +export function openWorldshaperHeightViewerWindow(mapId: string, token = "", hostWindow: Window = window): Window | null { + const popupUrl = buildWorldshaperHeightViewerUrl(mapId, token, hostWindow); + const initialBounds = readWorldshaperHeightViewerBounds(hostWindow); + const popupFeatures = [ + "popup=yes", + "resizable=yes", + "scrollbars=no", + "width=" + initialBounds.width, + "height=" + initialBounds.height, + "left=" + initialBounds.left, + "top=" + initialBounds.top, + ].join(","); + + const popup = hostWindow.open(popupUrl, WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME, popupFeatures); + if (!popup) { + return null; + } + + try { + popup.moveTo(initialBounds.left, initialBounds.top); + popup.resizeTo(initialBounds.width, initialBounds.height); + } catch { + // Ignore browser restrictions. + } + + popup.location.href = popupUrl; + popup.focus(); + return popup; +} + diff --git a/src/worldshaperStudio/worldChunkRuntimeHelpers.ts b/src/worldshaperStudio/worldChunkRuntimeHelpers.ts deleted file mode 100644 index 857d1b4..0000000 --- a/src/worldshaperStudio/worldChunkRuntimeHelpers.ts +++ /dev/null @@ -1,602 +0,0 @@ -// @ts-nocheck - -export function createFilledRows(width, height, fillChar) { - return Array.from({ length: Math.max(1, Number(height) || 1) }, () => String(fillChar || " ").repeat(Math.max(1, Number(width) || 1))); -} - -function writeRowSegment(rows, y, x, segment) { - if (!Array.isArray(rows) || !segment) { - return; - } - const targetY = Math.floor(Number(y) || 0); - if (targetY < 0 || targetY >= rows.length) { - return; - } - const safeX = Math.max(0, Math.floor(Number(x) || 0)); - const sourceRow = String(rows[targetY] || ""); - const paddedRow = sourceRow.length >= safeX - ? sourceRow - : (sourceRow + " ".repeat(Math.max(0, safeX - sourceRow.length))); - const before = paddedRow.slice(0, safeX); - const afterStart = safeX + segment.length; - const after = afterStart < paddedRow.length ? paddedRow.slice(afterStart) : ""; - rows[targetY] = before + segment + after; -} - -export function composeWorldRoomLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY, worldWidth, worldHeight) { - const layerMap = new Map(); - (Array.isArray(chunks) ? chunks : []).forEach((chunk) => { - const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0); - const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0); - const offsetX = (baseChunkX - originChunkX) * chunkWidth; - const offsetY = (baseChunkY - originChunkY) * chunkHeight; - const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : []; - rawLayers.forEach((rawLayer) => { - const layerNumber = Number(rawLayer?.layer) || 0; - const fillChar = layerNumber === 0 ? "." : " "; - if (!layerMap.has(layerNumber)) { - layerMap.set(layerNumber, { - layer: layerNumber, - name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined, - rows: createFilledRows(worldWidth, worldHeight, fillChar), - instanceIds: [], - }); - } - const targetLayer = layerMap.get(layerNumber); - const sourceRows = Array.isArray(rawLayer?.rows) ? rawLayer.rows.map((row) => String(row || "")) : []; - sourceRows.forEach((row, localY) => { - const targetY = offsetY + localY; - if (targetY < 0 || targetY >= targetLayer.rows.length) { - return; - } - const maxWidth = Math.max(0, worldWidth - offsetX); - writeRowSegment(targetLayer.rows, targetY, offsetX, row.slice(0, maxWidth)); - }); - const sourceInstanceIds = Array.isArray(rawLayer?.instanceIds) - ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) - : []; - targetLayer.instanceIds = Array.from(new Set([...(targetLayer.instanceIds || []), ...sourceInstanceIds])); - }); - }); - if (!layerMap.has(0)) { - layerMap.set(0, { - layer: 0, - rows: createFilledRows(worldWidth, worldHeight, "."), - instanceIds: [], - }); - } - return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0)); -} - -export function composeWorldHeightLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY) { - const patches = []; - (Array.isArray(chunks) ? chunks : []).forEach((chunk) => { - const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0); - const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0); - const offsetX = (baseChunkX - originChunkX) * chunkWidth; - const offsetY = (baseChunkY - originChunkY) * chunkHeight; - const rawHeightLayers = Array.isArray(chunk?.heightLayers) ? chunk.heightLayers : []; - rawHeightLayers.forEach((entry, index) => { - const fallbackId = `height_${baseChunkX}_${baseChunkY}_${index + 1}`; - patches.push({ - id: String(entry?.id || fallbackId).trim() || fallbackId, - name: typeof entry?.name === "string" && String(entry.name).trim() ? String(entry.name).trim() : undefined, - z: Math.max(1, Math.floor(Number(entry?.z) || 1)), - x: offsetX + Math.max(0, Number(entry?.x) || 0), - y: offsetY + Math.max(0, Number(entry?.y) || 0), - rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [], - }); - }); - }); - return patches.sort((a, b) => { - if (a.z !== b.z) { - return a.z - b.z; - } - return String(a.name || a.id).localeCompare(String(b.name || b.id)); - }); -} - -export function buildWorldLayerMetadata(chunks) { - const layerMap = new Map(); - (Array.isArray(chunks) ? chunks : []).forEach((chunk) => { - const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : []; - rawLayers.forEach((rawLayer) => { - const layerNumber = Number(rawLayer?.layer) || 0; - if (layerMap.has(layerNumber)) { - return; - } - layerMap.set(layerNumber, { - layer: layerNumber, - name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined, - rows: [], - instanceIds: Array.isArray(rawLayer?.instanceIds) ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) : [], - }); - }); - }); - if (!layerMap.has(0)) { - layerMap.set(0, { - layer: 0, - rows: [], - instanceIds: [], - }); - } - if (!Array.from(layerMap.keys()).some((layerNumber) => layerNumber > 0)) { - layerMap.set(1, { - layer: 1, - rows: [], - instanceIds: [], - }); - } - return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0)); -} - -export function sliceNormalizedRows(rows, startX, startY, width, height, fillChar) { - return Array.from({ length: Math.max(1, Number(height) || 1) }, (_, rowOffset) => { - const sourceRow = String((Array.isArray(rows) ? rows[startY + rowOffset] : "") || ""); - const paddedRow = sourceRow.length >= startX + width - ? sourceRow - : sourceRow + String(fillChar || " ").repeat(Math.max(0, (startX + width) - sourceRow.length)); - return paddedRow.slice(startX, startX + width); - }); -} - -export function buildChunkHeightLayersFromDocument({ mapDocument, cloneHeightLayers, baseTileX, baseTileY, chunkWidth, chunkHeight }) { - return (Array.isArray(mapDocument.heightLayers) ? cloneHeightLayers(mapDocument.heightLayers) : []) - .map((entry) => { - const patchX = Math.max(0, Number(entry?.x) || 0); - const patchY = Math.max(0, Number(entry?.y) || 0); - const rows = Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : []; - const patchWidth = rows.reduce((max, row) => Math.max(max, row.length), 0); - const patchHeight = rows.length; - const patchRight = patchX + patchWidth; - const patchBottom = patchY + patchHeight; - const chunkRight = baseTileX + chunkWidth; - const chunkBottom = baseTileY + chunkHeight; - const overlapLeft = Math.max(baseTileX, patchX); - const overlapTop = Math.max(baseTileY, patchY); - const overlapRight = Math.min(chunkRight, patchRight); - const overlapBottom = Math.min(chunkBottom, patchBottom); - if (overlapRight <= overlapLeft || overlapBottom <= overlapTop) { - return null; - } - const localRows = []; - for (let y = overlapTop; y < overlapBottom; y += 1) { - const sourceRow = String(rows[y - patchY] || ""); - localRows.push(sourceRow.slice(overlapLeft - patchX, overlapRight - patchX).replace(/\s+$/g, "")); - } - return { - id: String(entry?.id || "").trim(), - name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined, - z: Math.max(1, Number(entry?.z) || 1), - x: overlapLeft - baseTileX, - y: overlapTop - baseTileY, - rows: localRows, - }; - }) - .filter((entry) => entry && entry.id); -} - -export function buildChunkInstancesFromDocument({ mapDocument, cloneValue, baseTileX, baseTileY, chunkWidth, chunkHeight, tileOffsetX, tileOffsetY }) { - const chunkInstances = cloneValue(mapDocument.npcOverlays) - .filter((npc) => { - const localX = Math.floor(Number(npc?.x)); - const localY = Math.floor(Number(npc?.y)); - return Number.isFinite(localX) - && Number.isFinite(localY) - && localX >= baseTileX - && localX < baseTileX + chunkWidth - && localY >= baseTileY - && localY < baseTileY + chunkHeight; - }) - .map((npc) => ({ - id: String(npc.id || "").trim(), - templateId: String(npc?.record?.templateId || "").trim(), - layer: Number(npc.layer) || 0, - x: Math.floor(Number(npc.x) || 0) - baseTileX, - y: Math.floor(Number(npc.y) || 0) - baseTileY, - record: { - ...cloneValue(npc.record || {}), - id: String(npc.id || "").trim(), - layer: Number(npc.layer) || 0, - templateId: String(npc?.record?.templateId || "").trim(), - name: String(npc.name || npc?.record?.name || ""), - entityType: String(npc?.record?.entityType || npc?.entityType || "friendly"), - faction: String(npc.faction || npc?.record?.faction || ""), - spriteId: String(npc.spriteId || npc?.record?.spriteId || ""), - dialogueId: String(npc.dialogueId || npc?.record?.dialogueId || ""), - description: String(npc.description || npc?.record?.description || ""), - tags: cloneValue(npc?.record?.tags) || [], - enabled: typeof npc?.record?.enabled === "boolean" ? npc.record.enabled : true, - position: { - x: Math.floor(Number(npc.x) || 0) + tileOffsetX, - y: Math.floor(Number(npc.y) || 0) + tileOffsetY, - }, - }, - })) - .filter((entry) => entry.id); - const npcIdsByLayer = new Map(); - chunkInstances.forEach((entry) => { - const layerNumber = Number(entry.layer) || 0; - if (!npcIdsByLayer.has(layerNumber)) { - npcIdsByLayer.set(layerNumber, []); - } - npcIdsByLayer.get(layerNumber).push(entry.id); - }); - return { - chunkInstances, - npcIdsByLayer, - }; -} - -export function normalizeWorldChunkRows(rows, width, height, fillChar) { - const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); - const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); - return Array.from({ length: safeHeight }, (_entry, rowIndex) => { - const sourceRow = String((Array.isArray(rows) ? rows[rowIndex] : "") || ""); - return sourceRow.length >= safeWidth - ? sourceRow.slice(0, safeWidth) - : (sourceRow + String(fillChar || " ").repeat(Math.max(0, safeWidth - sourceRow.length))); - }); -} - -export function cloneWorldChunkHeightLayers(source) { - return (Array.isArray(source) ? source : []) - .map((entry, index) => ({ - id: String(entry?.id || `height_patch_${index + 1}`).trim() || `height_patch_${index + 1}`, - name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined, - z: Math.max(1, Math.floor(Number(entry?.z) || 1)), - x: Math.max(0, Math.floor(Number(entry?.x) || 0)), - y: Math.max(0, Math.floor(Number(entry?.y) || 0)), - rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [], - })) - .filter((entry) => entry.id); -} - -export function buildWorldChunkLayerInstanceIds(roomLayers, instances, width, height) { - const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); - const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); - const nextLayers = new Map(); - (Array.isArray(roomLayers) ? roomLayers : []).forEach((layer) => { - const layerNumber = Math.max(0, Math.floor(Number(layer?.layer) || 0)); - nextLayers.set(layerNumber, { - layer: layerNumber, - name: typeof layer?.name === "string" && layer.name.trim() ? layer.name.trim() : undefined, - rows: normalizeWorldChunkRows(layer?.rows, safeWidth, safeHeight, layerNumber === 0 ? "." : " "), - instanceIds: [], - }); - }); - if (!nextLayers.has(0)) { - nextLayers.set(0, { - layer: 0, - rows: normalizeWorldChunkRows([], safeWidth, safeHeight, "."), - instanceIds: [], - }); - } - if (!Array.from(nextLayers.keys()).some((layerNumber) => layerNumber > 0)) { - nextLayers.set(1, { - layer: 1, - rows: normalizeWorldChunkRows([], safeWidth, safeHeight, " "), - instanceIds: [], - }); - } - (Array.isArray(instances) ? instances : []).forEach((entry) => { - const layerNumber = Math.max(0, Math.floor(Number(entry?.layer) || 0)); - const instanceId = String(entry?.id || "").trim(); - if (!instanceId) { - return; - } - if (!nextLayers.has(layerNumber)) { - nextLayers.set(layerNumber, { - layer: layerNumber, - rows: normalizeWorldChunkRows([], safeWidth, safeHeight, layerNumber === 0 ? "." : " "), - instanceIds: [], - }); - } - nextLayers.get(layerNumber).instanceIds.push(instanceId); - }); - return Array.from(nextLayers.values()) - .map((entry) => ({ - ...entry, - instanceIds: Array.from(new Set((Array.isArray(entry.instanceIds) ? entry.instanceIds : []).map((id) => String(id || "").trim()).filter(Boolean))), - })) - .sort((left, right) => (Number(left.layer) || 0) - (Number(right.layer) || 0)); -} - -export function normalizeWorldChunkInstances({ sourceInstances, chunkX, chunkY, width, height, options, cloneValue, runtimeUniqueId }) { - const config = options && typeof options === "object" ? options : {}; - const duplicateIds = config.duplicateIds === true; - const safeChunkX = Math.floor(Number(chunkX) || 0); - const safeChunkY = Math.floor(Number(chunkY) || 0); - const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); - const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); - return (Array.isArray(sourceInstances) ? sourceInstances : []) - .map((entry) => { - const record = entry?.record && typeof entry.record === "object" && !Array.isArray(entry.record) - ? cloneValue(entry.record) - : {}; - const nextId = duplicateIds - ? runtimeUniqueId() - : (String(entry?.id || record?.id || runtimeUniqueId()).trim() || runtimeUniqueId()); - const nextLayer = Math.max(0, Math.floor(Number(entry?.layer ?? record?.layer) || 0)); - const nextX = Math.max(0, Math.min(safeWidth - 1, Math.floor(Number(entry?.x) || 0))); - const nextY = Math.max(0, Math.min(safeHeight - 1, Math.floor(Number(entry?.y) || 0))); - const nextTemplateId = String(entry?.templateId || record?.templateId || "").trim(); - record.id = nextId; - record.layer = nextLayer; - record.templateId = nextTemplateId; - record.position = { - x: (safeChunkX * safeWidth) + nextX, - y: (safeChunkY * safeHeight) + nextY, - }; - return { - id: nextId, - templateId: nextTemplateId, - layer: nextLayer, - x: nextX, - y: nextY, - record, - }; - }) - .filter((entry) => entry.id); -} - -export function createEmptyWorldChunkPayload({ chunkX, chunkY, chunkWidth, chunkHeight, worldId }) { - const safeChunkX = Math.floor(Number(chunkX) || 0); - const safeChunkY = Math.floor(Number(chunkY) || 0); - return { - schemaVersion: 1, - worldId: String(worldId || "").trim(), - chunkX: safeChunkX, - chunkY: safeChunkY, - width: chunkWidth, - height: chunkHeight, - backgroundTileId: "", - roomLayers: [ - { - layer: 0, - rows: Array.from({ length: chunkHeight }, () => ".".repeat(chunkWidth)), - instanceIds: [], - }, - { - layer: 1, - rows: Array.from({ length: chunkHeight }, () => " ".repeat(chunkWidth)), - instanceIds: [], - }, - ], - heightLayers: [], - instances: [], - }; -} - -export function normalizeCachedWorldChunkPayload({ chunkPayload, chunkX, chunkY, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId, options }) { - const safeChunkX = Math.floor(Number(chunkX ?? chunkPayload?.chunkX) || 0); - const safeChunkY = Math.floor(Number(chunkY ?? chunkPayload?.chunkY) || 0); - const safeWidth = Math.max(1, Math.floor(Number(chunkPayload?.width) || Number(chunkWidth) || 32)); - const safeHeight = Math.max(1, Math.floor(Number(chunkPayload?.height) || Number(chunkHeight) || 32)); - const instances = normalizeWorldChunkInstances({ - sourceInstances: chunkPayload?.instances, - chunkX: safeChunkX, - chunkY: safeChunkY, - width: safeWidth, - height: safeHeight, - options, - cloneValue, - runtimeUniqueId, - }); - const roomLayers = buildWorldChunkLayerInstanceIds(chunkPayload?.roomLayers, instances, safeWidth, safeHeight); - return { - schemaVersion: Math.max(1, Math.floor(Number(chunkPayload?.schemaVersion) || 1)), - worldId: String(chunkPayload?.worldId || worldId || "").trim(), - chunkX: safeChunkX, - chunkY: safeChunkY, - width: safeWidth, - height: safeHeight, - backgroundTileId: String(chunkPayload?.backgroundTileId || "").trim(), - roomLayers, - heightLayers: cloneWorldChunkHeightLayers(chunkPayload?.heightLayers), - instances, - }; -} - -export function isChunkFillSymbol(ch, fillChar) { - const symbol = String(ch || "").charAt(0); - return !symbol || symbol === fillChar || symbol === "." || symbol === " "; -} - -export function isWorldChunkPayloadEmpty({ chunkPayload, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId }) { - const normalized = normalizeCachedWorldChunkPayload({ - chunkPayload, - chunkX: chunkPayload?.chunkX, - chunkY: chunkPayload?.chunkY, - chunkWidth, - chunkHeight, - worldId, - cloneValue, - runtimeUniqueId, - }); - if (String(normalized?.backgroundTileId || "").trim()) { - return false; - } - if (Array.isArray(normalized?.instances) && normalized.instances.length > 0) { - return false; - } - if ((Array.isArray(normalized?.heightLayers) ? normalized.heightLayers : []).some((entry) => ( - Array.isArray(entry?.rows) && entry.rows.some((row) => /[^ .]/.test(String(row || ""))) - ))) { - return false; - } - return !(Array.isArray(normalized?.roomLayers) ? normalized.roomLayers : []).some((layer) => { - const fillChar = (Number(layer?.layer) || 0) === 0 ? "." : " "; - return (Array.isArray(layer?.rows) ? layer.rows : []).some((row) => { - const sourceRow = String(row || ""); - for (let index = 0; index < sourceRow.length; index += 1) { - if (!isChunkFillSymbol(sourceRow.charAt(index), fillChar)) { - return true; - } - } - return false; - }); - }); -} - -export function transformChunkLocalCoord(localX, localY, width, height, operation) { - const safeX = Math.floor(Number(localX) || 0); - const safeY = Math.floor(Number(localY) || 0); - const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); - const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); - switch (String(operation || "").trim()) { - case "flipHorizontal": - return { x: (safeWidth - 1) - safeX, y: safeY }; - case "flipVertical": - return { x: safeX, y: (safeHeight - 1) - safeY }; - case "rotate180": - return { x: (safeWidth - 1) - safeX, y: (safeHeight - 1) - safeY }; - case "rotate90cw": - if (safeWidth !== safeHeight) { - return null; - } - return { x: (safeWidth - 1) - safeY, y: safeX }; - case "rotate90ccw": - if (safeWidth !== safeHeight) { - return null; - } - return { x: safeY, y: (safeHeight - 1) - safeX }; - default: - return { x: safeX, y: safeY }; - } -} - -export function transformChunkRows(rows, width, height, fillChar, operation) { - const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); - const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); - const sourceRows = normalizeWorldChunkRows(rows, safeWidth, safeHeight, fillChar); - const nextRows = Array.from({ length: safeHeight }, () => Array.from({ length: safeWidth }, () => String(fillChar || " ").charAt(0) || " ")); - for (let rowIndex = 0; rowIndex < safeHeight; rowIndex += 1) { - const sourceRow = sourceRows[rowIndex]; - for (let columnIndex = 0; columnIndex < safeWidth; columnIndex += 1) { - const char = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || String(fillChar || " ").charAt(0) || " "; - if (isChunkFillSymbol(char, fillChar)) { - continue; - } - const nextCoord = transformChunkLocalCoord(columnIndex, rowIndex, safeWidth, safeHeight, operation); - if (!nextCoord) { - continue; - } - nextRows[nextCoord.y][nextCoord.x] = char; - } - } - return nextRows.map((row) => row.join("")); -} - -export function transformChunkHeightPatch(patch, width, height, operation) { - const safeWidth = Math.max(1, Math.floor(Number(width) || 1)); - const safeHeight = Math.max(1, Math.floor(Number(height) || 1)); - const sourceRows = Array.isArray(patch?.rows) ? patch.rows.map((row) => String(row || "")) : []; - const patchWidth = sourceRows.reduce((max, row) => Math.max(max, row.length), 0); - const patchHeight = sourceRows.length; - const transformedCells = []; - for (let localY = 0; localY < patchHeight; localY += 1) { - const row = sourceRows[localY] || ""; - for (let localX = 0; localX < patchWidth; localX += 1) { - const char = String(row.charAt(localX) || " ").charAt(0) || " "; - if (char === " " || char === ".") { - continue; - } - const worldX = Math.max(0, Math.floor(Number(patch?.x) || 0)) + localX; - const worldY = Math.max(0, Math.floor(Number(patch?.y) || 0)) + localY; - if (worldX < 0 || worldY < 0 || worldX >= safeWidth || worldY >= safeHeight) { - continue; - } - const nextCoord = transformChunkLocalCoord(worldX, worldY, safeWidth, safeHeight, operation); - if (!nextCoord) { - continue; - } - transformedCells.push({ - x: nextCoord.x, - y: nextCoord.y, - char, - }); - } - } - if (transformedCells.length <= 0) { - return null; - } - const minX = transformedCells.reduce((min, entry) => Math.min(min, entry.x), transformedCells[0].x); - const maxX = transformedCells.reduce((max, entry) => Math.max(max, entry.x), transformedCells[0].x); - const minY = transformedCells.reduce((min, entry) => Math.min(min, entry.y), transformedCells[0].y); - const maxY = transformedCells.reduce((max, entry) => Math.max(max, entry.y), transformedCells[0].y); - const nextRows = Array.from({ length: (maxY - minY) + 1 }, () => Array.from({ length: (maxX - minX) + 1 }, () => " ")); - transformedCells.forEach((entry) => { - nextRows[entry.y - minY][entry.x - minX] = entry.char; - }); - return { - id: String(patch?.id || "").trim(), - name: typeof patch?.name === "string" && patch.name.trim() ? patch.name.trim() : undefined, - z: Math.max(1, Math.floor(Number(patch?.z) || 1)), - x: minX, - y: minY, - rows: nextRows.map((row) => row.join("").replace(/\s+$/g, "")), - }; -} - -export function transformWorldChunkPayload({ chunkPayload, operation, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId, options }) { - const config = options && typeof options === "object" ? options : {}; - const normalized = normalizeCachedWorldChunkPayload({ - chunkPayload, - chunkX: chunkPayload?.chunkX, - chunkY: chunkPayload?.chunkY, - chunkWidth, - chunkHeight, - worldId, - cloneValue, - runtimeUniqueId, - options: config, - }); - const safeWidth = Math.max(1, Math.floor(Number(normalized?.width) || 1)); - const safeHeight = Math.max(1, Math.floor(Number(normalized?.height) || 1)); - const normalizedOperation = String(operation || "").trim(); - if ((normalizedOperation === "rotate90cw" || normalizedOperation === "rotate90ccw") && safeWidth !== safeHeight) { - throw new Error("Chunk rotation requires square chunks."); - } - const instances = normalizeWorldChunkInstances({ - sourceInstances: (Array.isArray(normalized.instances) ? normalized.instances : []).map((entry) => { - const nextCoord = transformChunkLocalCoord(entry.x, entry.y, safeWidth, safeHeight, normalizedOperation); - return { - ...cloneValue(entry), - x: nextCoord?.x ?? entry.x, - y: nextCoord?.y ?? entry.y, - }; - }), - chunkX: normalized.chunkX, - chunkY: normalized.chunkY, - width: safeWidth, - height: safeHeight, - options: config, - cloneValue, - runtimeUniqueId, - }); - const roomLayers = buildWorldChunkLayerInstanceIds( - (Array.isArray(normalized.roomLayers) ? normalized.roomLayers : []).map((layer) => ({ - ...cloneValue(layer), - rows: transformChunkRows(layer?.rows, safeWidth, safeHeight, (Number(layer?.layer) || 0) === 0 ? "." : " ", normalizedOperation), - })), - instances, - safeWidth, - safeHeight, - ); - const heightLayers = cloneWorldChunkHeightLayers(normalized.heightLayers) - .map((entry) => transformChunkHeightPatch(entry, safeWidth, safeHeight, normalizedOperation)) - .filter(Boolean) - .sort((left, right) => { - if ((Number(left?.z) || 0) !== (Number(right?.z) || 0)) { - return (Number(left?.z) || 0) - (Number(right?.z) || 0); - } - return String(left?.name || left?.id || "").localeCompare(String(right?.name || right?.id || "")); - }); - return { - ...normalized, - roomLayers, - heightLayers, - instances, - }; -}