diff --git a/.gitignore b/.gitignore index 17513c8..1dbba20 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ 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 new file mode 100644 index 0000000..25db960 --- /dev/null +++ b/docs/kb/request-system-flowchart.md @@ -0,0 +1,52 @@ +# 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 new file mode 100644 index 0000000..6ea0f2d Binary files /dev/null and b/docs/kb/request-system-flowchart.png differ diff --git a/docs/kb/request-system-flowchart.svg b/docs/kb/request-system-flowchart.svg new file mode 100644 index 0000000..9452241 --- /dev/null +++ b/docs/kb/request-system-flowchart.svg @@ -0,0 +1,148 @@ + + 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 f18406b..a119af9 100644 --- a/docs/kb/systems/request-board.md +++ b/docs/kb/systems/request-board.md @@ -41,6 +41,7 @@ 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 39781d5..3339225 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 "./worldshaperStudio/windowing"; +import { openWorldshaperStudioWindow } from "./shared/windowing"; import { CONFIG_TAB_TO_KEY, DIALOGUE_NODE_FIELD_ORDER, diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index f2a2c02..893a47b 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -1,2308 +1 @@ -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, ""); -} - +export { buildWorldshaperStudioStyles } from "./domStyles"; +export { buildWorldshaperStudioPopupMarkup, getWorldshaperStudioBodyMarkup } from "./domMarkup"; diff --git a/src/worldshaperStudio/domMarkup.ts b/src/worldshaperStudio/domMarkup.ts new file mode 100644 index 0000000..940322a --- /dev/null +++ b/src/worldshaperStudio/domMarkup.ts @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..793ec28 --- /dev/null +++ b/src/worldshaperStudio/domMarkupSections.ts @@ -0,0 +1,475 @@ +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 new file mode 100644 index 0000000..11a177b --- /dev/null +++ b/src/worldshaperStudio/domStyleSections.ts @@ -0,0 +1,4237 @@ +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 new file mode 100644 index 0000000..716a10b --- /dev/null +++ b/src/worldshaperStudio/domStyles.ts @@ -0,0 +1,15 @@ +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 3c6c8d0..74c6eb7 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,12 +10,7 @@ import { normalizeImageRecordForSave, normalizeTileRecordForSave, } from "../editorCore"; -import { - buildSpriteCatalog, - buildTileCatalogById, - DEFAULT_MAP_BACKGROUND_COLOR, - DEFAULT_TILE_COLOR, -} from "../components/worldshaperShared"; +import { DEFAULT_TILE_COLOR } from "../components/worldshaperShared"; import type { WorldshaperStudioBootstrap } from "./bootstrap"; import { cacheStandaloneWorldshaperBootstrap, @@ -31,13 +26,9 @@ 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, @@ -46,22 +37,11 @@ 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, @@ -80,215 +60,42 @@ import { persistEditorSettings, } from "./themePresets"; import { createAtTooltip } from "./tooltip"; - -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!$%&()*+,-/:;<=>?@[]^_{|}~="; +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"; 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; @@ -296,51 +103,12 @@ 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 = { - 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, - }; + const worldRuntimeState = createInitialWorldRuntimeState(bootstrap); function isWorldModeActive() { return worldRuntimeState.enabled && !!worldRuntimeState.worldId; } const defaultTileColor = DEFAULT_TILE_COLOR; - const tileColors = cloneValue(bootstrap.tileColors) || {}; + const tileColors = cloneRuntimeValue(bootstrap.tileColors) || {}; let graphicsVisualRevision = 0; function applyGraphicsVisualRevision(dataUrl, revision = graphicsVisualRevision) { const normalizedDataUrl = String(dataUrl || "").trim(); @@ -511,9 +279,9 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return merged; } const tileCatalog = buildMergedTileCatalog(); - const contentByType = cloneValue(bootstrap.contentByType) || {}; + const contentByType = cloneRuntimeValue(bootstrap.contentByType) || {}; const spriteCatalog = applySpriteCatalogVisualRevision(buildSpriteCatalogFromBootstrap(bootstrap)); - const defaultNpcTemplate = cloneValue(bootstrap.defaultNpcTemplate) || {}; + const defaultNpcTemplate = cloneRuntimeValue(bootstrap.defaultNpcTemplate) || {}; const apiBase = String(bootstrap.apiBase || "").replace(/\/+$/, ""); function deriveHistoryStorageKey(mapIdValue) { return "worldshaper:world-history:v2:" + String(mapIdValue || "").trim(); @@ -848,7 +616,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in : normalizedBackgroundTileId; const existingChunk = worldRuntimeState.chunkCache.get(chunkKey); const chunkValue = existingChunk - ? cloneValue(existingChunk) + ? cloneRuntimeValue(existingChunk) : (rebuildWorldChunkPayloadFromDocument(safeChunkX, safeChunkY) || { worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), chunkX: safeChunkX, @@ -942,7 +710,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return; } touchWorldChunkCacheEntry(String(chunkKey || "").trim(), { - ...cloneValue(existingChunk), + ...cloneRuntimeValue(existingChunk), backgroundTileId: nextBackgroundTileId, }); changed = true; @@ -1081,7 +849,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return Array.from(worldRuntimeState.dirtyChunkKeys.values()) .map((chunkKey) => worldRuntimeState.chunkCache.get(chunkKey) || null) .filter(Boolean) - .map((entry) => cloneValue(entry)); + .map((entry) => cloneRuntimeValue(entry)); } function pruneWorldChunkCache() { @@ -1131,102 +899,28 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in ); } - 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); + return buildChunkHeightLayersFromDocumentHelper({ + mapDocument, + cloneHeightLayers, + baseTileX, + baseTileY, + chunkWidth, + chunkHeight, + }); } function buildChunkInstancesFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) { - 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 buildChunkInstancesFromDocumentHelper({ + mapDocument, + cloneValue, + baseTileX, + baseTileY, + chunkWidth, + chunkHeight, + tileOffsetX: worldRuntimeState.tileOffsetX, + tileOffsetY: worldRuntimeState.tileOffsetY, }); - return { - chunkInstances, - npcIdsByLayer, - }; } function rebuildWorldChunkPayloadFromDocument(chunkX, chunkY) { @@ -1366,7 +1060,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in ? fallbackLayerNumber : (layerNumberMap ? (layerNumberMap[String(previousLayer)] ?? previousLayer) : previousLayer); return { - ...cloneValue(entry), + ...cloneRuntimeValue(entry), layer: nextLayer, }; }); @@ -1390,7 +1084,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const metadata = metadataByLayer.get(nextLayer) || { layer: nextLayer, name: undefined }; const fillChar = nextLayer === 0 ? "." : " "; return { - ...cloneValue(entry), + ...cloneRuntimeValue(entry), layer: nextLayer, name: metadata.name, rows: Array.isArray(entry?.rows) @@ -1413,7 +1107,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in continue; } touchWorldChunkCacheEntry(chunkKey, { - ...cloneValue(chunkValue), + ...cloneRuntimeValue(chunkValue), roomLayers: nextRoomLayers, instances: nextInstances, }); @@ -1512,59 +1206,12 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in activeHeightLayerId: "", }); popupSessionStore.restorePersistedLayout(window); - 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?.(); - } - - 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 runtimeLogging = createRuntimeLogging({ + windowRef: window, + runtimeUniqueId, }); + const { appendEditorLogEntry, getEditorLogEntries, clearEditorLogEntries } = runtimeLogging; let renderController = null; const documentController = createMapDocumentController({ mapId: currentMapId, @@ -1585,7 +1232,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; @@ -1798,7 +1445,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return null; } return { - ...cloneValue(entry), + ...cloneRuntimeValue(entry), id: metadata.id, name: metadata.name, z: metadata.z, @@ -1806,7 +1453,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in }) .filter(Boolean); nextEntries.push([chunkKey, { - ...cloneValue(chunkValue), + ...cloneRuntimeValue(chunkValue), heightLayers: nextHeightLayers, }]); } @@ -1901,349 +1548,65 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return emptyChunk; } - 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); + return normalizeWorldChunkInstancesHelper({ + sourceInstances, + chunkX, + chunkY, + width, + height, + options, + cloneValue, + runtimeUniqueId, + }); } function createEmptyWorldChunkPayload(chunkX, chunkY) { - 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, + return createEmptyWorldChunkPayloadHelper({ + chunkX, + chunkY, + chunkWidth: Math.max(1, Number(worldRuntimeState.chunkWidth) || 32), + chunkHeight: Math.max(1, Number(worldRuntimeState.chunkHeight) || 32), 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) { - 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 === " "; + return normalizeCachedWorldChunkPayloadHelper({ + chunkPayload, + chunkX, + chunkY, + chunkWidth: Number(worldRuntimeState.chunkWidth) || 32, + chunkHeight: Number(worldRuntimeState.chunkHeight) || 32, + worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), + cloneValue, + runtimeUniqueId, + options, + }); } function isWorldChunkPayloadEmpty(chunkPayload) { - 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; - }); + return isWorldChunkPayloadEmptyHelper({ + chunkPayload, + chunkWidth: Number(worldRuntimeState.chunkWidth) || 32, + chunkHeight: Number(worldRuntimeState.chunkHeight) || 32, + worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), + cloneValue, + runtimeUniqueId, }); } - 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, - }; + return transformWorldChunkPayloadHelper({ + chunkPayload, + operation, + chunkWidth: Number(worldRuntimeState.chunkWidth) || 32, + chunkHeight: Number(worldRuntimeState.chunkHeight) || 32, + worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(), + cloneValue, + runtimeUniqueId, + options, + }); } function commitWorldChunkPayloads(nextChunks, reason) { @@ -2283,7 +1646,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "same-chunk" }; } const sourceChunk = normalizeCachedWorldChunkPayload( - cloneValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY), + cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY), safeSourceChunkX, safeSourceChunkY, ); @@ -2292,7 +1655,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "source-empty" }; } const destinationChunk = normalizeCachedWorldChunkPayload( - cloneValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY), + cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY), safeTargetChunkX, safeTargetChunkY, ); @@ -2333,7 +1696,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "same-chunk" }; } const sourceChunk = normalizeCachedWorldChunkPayload( - cloneValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY), + cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY), safeSourceChunkX, safeSourceChunkY, ); @@ -2342,7 +1705,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "source-empty" }; } const destinationChunk = normalizeCachedWorldChunkPayload( - cloneValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY), + cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY), safeTargetChunkX, safeTargetChunkY, ); @@ -2351,7 +1714,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return { ok: false, reason: "destination-occupied" }; } const duplicatedChunk = normalizeCachedWorldChunkPayload({ - ...cloneValue(sourceChunk), + ...cloneRuntimeValue(sourceChunk), instances: [], }, safeTargetChunkX, safeTargetChunkY); commitWorldChunkPayloads([duplicatedChunk], "world-chunk-duplicate"); @@ -2381,7 +1744,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const safeChunkY = Math.floor(Number(chunkY) || 0); const normalizedOperation = String(operation || "").trim(); const sourceChunk = normalizeCachedWorldChunkPayload( - cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY), + cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY), safeChunkX, safeChunkY, ); @@ -2414,7 +1777,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const safeChunkX = Math.floor(Number(chunkX) || 0); const safeChunkY = Math.floor(Number(chunkY) || 0); const existingChunk = normalizeCachedWorldChunkPayload( - cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY), + cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY), safeChunkX, safeChunkY, ); @@ -2452,7 +1815,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in ? normalizedBackgroundTileId : ""; const chunkKey = buildChunkKey(safeChunkX, safeChunkY); - const existingChunk = cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || { + const existingChunk = cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || { chunkX: safeChunkX, chunkY: safeChunkY, width: chunkWidth, @@ -2871,13 +2234,13 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in backgroundTileId: normalizeBackgroundTileId(mapDocument.backgroundTileId), roomLayers: cloneLayers(mapDocument.roomLayers), heightLayers: cloneHeightLayers(mapDocument.heightLayers), - tileColors: cloneValue(tileColors), + tileColors: cloneRuntimeValue(tileColors), baseRows, npcOverlays: cloneNpcOverlays(mapDocument.npcOverlays), - contentByType: cloneValue(mapDocument.contentBundle), - spriteCatalog: cloneValue(spriteCatalog), - tileCatalogById: cloneValue(tileCatalogById), - defaultNpcTemplate: cloneValue(defaultNpcTemplate), + contentByType: cloneRuntimeValue(mapDocument.contentBundle), + spriteCatalog: cloneRuntimeValue(spriteCatalog), + tileCatalogById: cloneRuntimeValue(tileCatalogById), + defaultNpcTemplate: cloneRuntimeValue(defaultNpcTemplate), apiBase, backgroundColor: normalizeMapBackgroundColor(mapDocument.backgroundColor), heightBlurStep: Math.max(0, Math.min(1, Number(mapDocument.heightBlurStep ?? mapDocument.heightDetailStep) || 0.1)), @@ -2895,7 +2258,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in worldSpawnX: isWorldModeActive() ? worldRuntimeState.spawnX : undefined, worldSpawnY: isWorldModeActive() ? worldRuntimeState.spawnY : undefined, worldBookmarks: isWorldModeActive() ? cloneWorldBookmarks() : undefined, - sourceChunks: isWorldModeActive() ? cloneValue(worldRuntimeState.sourceChunks) : undefined, + sourceChunks: isWorldModeActive() ? cloneRuntimeValue(worldRuntimeState.sourceChunks) : undefined, }; } @@ -2983,7 +2346,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in } function getImagesPayload() { - return cloneValue(ensureDocumentContentPayload("images", { schemaVersion: 1, images: [] })) || { schemaVersion: 1, images: [] }; + return cloneRuntimeValue(ensureDocumentContentPayload("images", { schemaVersion: 1, images: [] })) || { schemaVersion: 1, images: [] }; } function buildDuplicateGraphicName(baseName, imagesPayload) { @@ -3036,9 +2399,9 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const nextTilesPayload = buildTilesPayloadFromImagesPayload(normalizedImagesPayload); const nextSpritesPayload = buildSpritesPayloadFromImagesPayload(normalizedImagesPayload); graphicsVisualRevision += 1; - setDocumentContentPayload("images", cloneValue(normalizedImagesPayload) || { schemaVersion: 1, images: [] }); - setDocumentContentPayload("tiles", cloneValue(nextTilesPayload) || { schemaVersion: 1, tiles: [] }); - setDocumentContentPayload("sprites", cloneValue(nextSpritesPayload) || { schemaVersion: 1, sprites: [] }); + setDocumentContentPayload("images", cloneRuntimeValue(normalizedImagesPayload) || { schemaVersion: 1, images: [] }); + setDocumentContentPayload("tiles", cloneRuntimeValue(nextTilesPayload) || { schemaVersion: 1, tiles: [] }); + setDocumentContentPayload("sprites", cloneRuntimeValue(nextSpritesPayload) || { schemaVersion: 1, sprites: [] }); replaceObjectContents( tileCatalogById, applyTileCatalogVisualRevision( @@ -3179,14 +2542,14 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in } } const nextRecord = normalizeImageRecordForSave({ - ...cloneValue(sourceRecord), + ...cloneRuntimeValue(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) ? cloneValue(sourceRecord.frames) : [], - tags: Array.isArray(sourceRecord.tags) ? cloneValue(sourceRecord.tags) : [], - roles: Array.isArray(sourceRecord.roles) ? cloneValue(sourceRecord.roles) : [], + frames: Array.isArray(sourceRecord.frames) ? cloneRuntimeValue(sourceRecord.frames) : [], + tags: Array.isArray(sourceRecord.tags) ? cloneRuntimeValue(sourceRecord.tags) : [], + roles: Array.isArray(sourceRecord.roles) ? cloneRuntimeValue(sourceRecord.roles) : [], }); const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : []; nextImages.push(nextRecord); @@ -3235,7 +2598,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in } const existing = nextImages[existingIndex] || {}; nextImages[existingIndex] = buildImageRecordFromTileRecord({ - ...cloneValue(record), + ...cloneRuntimeValue(record), symbol: String(record?.symbol || existing?.tileSymbol || "").charAt(0) || takeNextAvailableTileSymbol() || "T", }, existing, cloneValue); const nextPayload = { @@ -3284,7 +2647,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in return false; } const nextRecord = buildImageRecordFromTileRecord({ - ...cloneValue(sourceRecord), + ...cloneRuntimeValue(sourceRecord), symbol: nextSymbol, }, existing, cloneValue); if (existingIndex >= 0) { @@ -3412,7 +2775,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in clearedBackgrounds += 1; } touchWorldChunkCacheEntry(chunkKey, { - ...cloneValue(chunkValue), + ...cloneRuntimeValue(chunkValue), backgroundTileId: clearsBackground ? "" : String(chunkValue.backgroundTileId || "").trim(), roomLayers: scrubbedLayers.roomLayers, heightLayers: scrubbedHeightLayers.heightLayers, @@ -3439,7 +2802,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return entry; } - const nextEntry = cloneValue(entry) || {}; + const nextEntry = cloneRuntimeValue(entry) || {}; const nextRecord = nextEntry.record && typeof nextEntry.record === "object" && !Array.isArray(nextEntry.record) ? { ...nextEntry.record } : {}; @@ -3467,7 +2830,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in updatedChunks += 1; scrubbedEntities += changedEntities; touchWorldChunkCacheEntry(chunkKey, { - ...cloneValue(chunkValue), + ...cloneRuntimeValue(chunkValue), instances: nextInstances, }); } @@ -4413,7 +3776,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in Object.keys(target || {}).forEach((key) => { delete target[key]; }); - Object.assign(target, cloneValue(nextValue) || {}); + Object.assign(target, cloneRuntimeValue(nextValue) || {}); return target; } @@ -4430,7 +3793,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in const nextImagesPayload = mergeImagesPayloadWithSpritesPayload(getImagesPayload(), payload); syncRuntimeGraphicsFromImagesPayload(nextImagesPayload, config); } else { - setDocumentContentPayload(normalizedType, cloneValue(payload) || {}); + setDocumentContentPayload(normalizedType, cloneRuntimeValue(payload) || {}); } if (!config.deferRefresh) { @@ -4870,7 +4233,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in duplicateWorldChunkContent, transformWorldChunkAt, clearWorldChunkAt, - getCachedWorldChunkPayloads: () => Array.from(worldRuntimeState.chunkCache.values()).map((entry) => cloneValue(entry)), + getCachedWorldChunkPayloads: () => Array.from(worldRuntimeState.chunkCache.values()).map((entry) => cloneRuntimeValue(entry)), getDirtyWorldChunkKeys, getDirtyWorldChunkPayloads, clearDirtyWorldChunks, @@ -5352,142 +4715,44 @@ 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 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(); + 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, }); + renderController = runtimeControllerBootstrap.renderController; + statusLogWindowController = runtimeControllerBootstrap.statusLogWindowController; + runtimeLogging.setStatusLogWindowController(statusLogWindowController); } + diff --git a/src/worldshaperStudio/runtimeBootstrapHelpers.ts b/src/worldshaperStudio/runtimeBootstrapHelpers.ts new file mode 100644 index 0000000..8b19ee2 --- /dev/null +++ b/src/worldshaperStudio/runtimeBootstrapHelpers.ts @@ -0,0 +1,141 @@ +/* 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 new file mode 100644 index 0000000..9149b45 --- /dev/null +++ b/src/worldshaperStudio/runtimeControllerBootstrap.ts @@ -0,0 +1,201 @@ +// @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 new file mode 100644 index 0000000..8d08890 --- /dev/null +++ b/src/worldshaperStudio/runtimeLogging.ts @@ -0,0 +1,67 @@ +// @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 new file mode 100644 index 0000000..2110c56 --- /dev/null +++ b/src/worldshaperStudio/tileArtEditorHelpers.ts @@ -0,0 +1,420 @@ +/* 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 597f0ee..8087005 100644 --- a/src/worldshaperStudio/tileArtEditorWindowController.ts +++ b/src/worldshaperStudio/tileArtEditorWindowController.ts @@ -22,9 +22,40 @@ 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; @@ -38,415 +69,11 @@ 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; @@ -833,7 +460,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 = buildSpritePreviewDataUrl(buildRowsPreviewRecord(currentFrame?.rows), 10); + const previewUrl = buildFramePreviewDataUrl(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 4a0b176..6038ed8 100644 --- a/src/worldshaperStudio/windowing.ts +++ b/src/worldshaperStudio/windowing.ts @@ -1,210 +1 @@ -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; -} - +export * from "../shared/windowing"; diff --git a/src/worldshaperStudio/worldChunkRuntimeHelpers.ts b/src/worldshaperStudio/worldChunkRuntimeHelpers.ts new file mode 100644 index 0000000..857d1b4 --- /dev/null +++ b/src/worldshaperStudio/worldChunkRuntimeHelpers.ts @@ -0,0 +1,602 @@ +// @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, + }; +}