diff --git a/GDD.md b/GDD.md index 44c68070..c05d21b2 100644 --- a/GDD.md +++ b/GDD.md @@ -8,7 +8,7 @@ # Текстовый интерфейс -Наша игра продолжает традиции классических Adventure, которые когда-то были полностью текстовыми. Кроме того, нарратив связан с темой компьютеров. Поэтому у нас есть классическая "консоль терминала" со строкой ввода команд и областью вывода сообщений над ней (буфер консоли). В этот буфер выводятся все введенные пользователем команды и все игровые сообщения (за исключением неигровых, служебных уведомлений движка и редактора сцены, которые выводятся через toast notifications). +Наша игра продолжает традиции классических Adventure, которые изначально когда-то были полностью текстовыми. Кроме того, нарратив связан с темой компьютеров. Поэтому у нас есть классическая "консоль терминала" со строкой ввода команд и областью вывода сообщений над ней (буфер консоли). В этот буфер выводятся все введенные пользователем команды и все игровые сообщения (за исключением неигровых, служебных уведомлений движка и редактора сцены, которые выводятся через toast notifications). Есть два основных формата пользовательского ввода: - **Команда**: указание что нужно сделать, напр. "открой дверь ключом"; @@ -23,8 +23,8 @@ - **закрытое модальное**; - **открытое**. В _закрытом_ состоянии пользователь видит только последние две строки буфера консоли в нижней части экрана, и под ними строку ввода команды. - При нажатии на специальную клавишу на клавиатуре (тильда ~) консоль _открывается_ поверх картинки, почти на весь игровой экран, накладываясь на него с небольшой полупрозрачностью. При этом, в закрытом виде консоль и строка ввода интегрированы в игровую картинку, то есть рисуются на low-res 2d канвасе и поверх накладывается CRT фильтр. В открытом же виде консоль рисуется поверх игровой картинки, в том же слое, что UI редактора, в высоком разрешении, без CRT фильтра,чтобы пользователям было комфортно читать текст. Строка ввода работает и в открытом состоянии, так что пользователи могут вводить команды в консоль не закрывая её. - Для показа важных сообщений, которые не влазят в 2 строки закрытой консоли, она может переходить в _модальный_ режим, когда командная строка убирается, а если текст сообщения не помещается и в три строки, то высота области буфера увеличивается на нужное число строк, чтобы текст сообщения выводился поверх картинки. В модальном режиме после текста сообщения всегда идёт надпись "[Continue]" и ожидается нажатие любой клавиши или клик мыши, после чего происходит переход в обычный режим. + При нажатии на специальную клавишу на клавиатуре (тильда ~) консоль _открывается_ поверх картинки, почти на весь игровой экран, накладываясь на него с небольшой полупрозрачностью. При этом, в закрытом виде консоль и строка ввода интегрированы в игровую картинку, то есть рисуются на low-res канвасе и поверх накладывается CRT фильтр. В открытом же виде консоль рисуется поверх игровой картинки, в том же слое, что UI редактора, в высоком разрешении, без CRT фильтра,чтобы пользователям было комфортно читать текст. Строка ввода работает и в открытом состоянии, так что пользователи могут вводить команды в консоль не закрывая её. + Для показа важных сообщений, которые не влазят в 2 строки закрытой консоли, она может переходить в _модальный_ режим, когда командная строка убирается, а если текст сообщения не помещается и в три строки, то высота области буфера увеличивается на нужное число строк, чтобы текст сообщения выводился поверх картинки. В модальном режиме после текста сообщения всегда идёт мигающая надпись "[Continue]" и ожидается нажатие любой клавиши или клик мыши, после чего происходит переход в обычный режим. Открытая консоль не переходит в модальный режим. Текст открытой консоли можно прокручивать колесом мыши или клавишами Page Up/Down чтобы увидеть более ранние сообщения. Буфер должен быть достаточно большим, порядка 150 Kb. При сохранении игры в файл буфер сохраняется вместе с игрой. @@ -36,12 +36,14 @@ ## Парсер - посредник -Парсер играет роль **посредника** между движком игры и игроком, своеобразного гейм-мастера. Он принимает пользовательский ввод, наряду с контекстом (информацией о сцене, находящихся в ней предметах и NPC, доступны действиях и состояниях). Затем парсер обрабатывает это и даёт команды игровому движку через API, опционально получает возвращаемые API значения и составляет сообщения для пользователя. +Парсер играет роль **посредника** между движком игры и игроком, своеобразного гейм-мастера. Он принимает пользовательский ввод, наряду с контекстом (информацией о сцене, находящихся в ней предметах и NPC, доступныx действиях и состояниях). Затем парсер обрабатывает это и даёт команды игровому движку через API, опционально получает возвращаемые API значения и составляет сообщения для пользователя. + + ---json---> | | | | +| | ---text--> | | ---json--> | | +| | <--text--- | | <--------- | | +| | + - ---json--> | | | | -| | ---text--> | | ---json--> | | -| | <--text--- | | <--------- | | -| | Parser обрабатывает пользовательский ввод каскадно, если каскад не смог обработать команду, она передаётся следующему: @@ -69,17 +71,26 @@ Parser обрабатывает пользовательский ввод кас Сцена это отдельная локация, в которой находится персонаж игрока, и другие объекты. Может занимать один физический экран, либо быть больше его. Сцена может содержать _объекты_ следующих типов: -- _WalkBox_: замкнутый многоугольник, определяющий область, в которой можно перемещаться персонажем игрока (или NPC). Несколько WalkBox могут быть на одной сцене и взаимодействовать друг с другом, в зависимости от их типа: add, substract, invert; -- _TriggerBox_: замкнутый многоугольник, определяющий область, активирующую какие-то события и сюжетную логику, например коллайдер, попав в который персонаж игрока проваливается в люк, переносится в другую сцену, запускает диалог с NPC и т.п; +- _WalkBox_: замкнутый многоугольник, определяющий служебную область, в которой можно перемещаться персонажем игрока (или NPC). Несколько WalkBox могут быть на одной сцене и взаимодействовать друг с другом, в зависимости от их типа: add, substract, invert; + +- _TriggerBox_: замкнутый многоугольник, определяющий служебную область, активирующую какие-то события и сюжетную логику, например коллайдер, попав в который персонаж игрока проваливается в люк, переносится в другую сцену, запускает диалог с NPC и т.п; + - _Static_: прямоугольник с координатами X/Y, размерами X/Y, цветом заполнения и опционально спрайтом/анимацией, отображающимся вместо прямоугольника. Спрайт можно переключать на лету. В основном Static это фоны, декоративные элементы и предметы, которые не перемещаются. + - _Actor_: объект, который помимо свойств Static имеет направление, в котором он повёрнут и, опционально, спрайты/анимации состояний (idle, walk, talk, etc), причём для каждого направления свой набор. Обычно Actor это NPC и анимированные объекты. Персонаж игрока также является разновидностью Actor. +- _Quad_ : четырёхугольный объект, каждая вершина которого обладает отдельным параллаксом. Используется для создания псевдо 3d поверхностей и эффектов типа лучей света и теней. + +### ID + Каждая сцена и каждый объект имеют свой уникальный _ID_, который используется для ссылок на них. При этом: 1. id (содержимое поля id/file) для сцен, спрайтов, и также префабов (т.е. сохранённых объектов) может включать один или несколько обратных слешей "\". При сохранении такого объекта слеши работают как маркеры подпапок (относительно дефолтной папки для данного типа объектов), например "home\room1" сохранится как файл room.json в папке home. 2. При загрузке такого объекта его id не читается из файла, а формируется с учётом пути относительно дефолтной папки и имени файла, таким образом этот объект загрузится c id "home\room1" а не "room1". Соответственно, если пользователь нажмёт "Save", объект сохранится не в дефолтной папке а в подпапке home как room1. 3. При завершении загрузки объекта сформированный id дополнительно проверяется на предмет совпадения с уже имеющимися в сцене. Если это не уникальный id, то он дополняется до уникального. -4. API при создании объекта или загрузки сцены получает id, трактует его как имя файла с возможным учётом подпапок и загружает его оттуда. +4. Игровой движок при создании объекта или загрузки сцены получает id, трактует его как имя файла с возможным учётом подпапок и загружает его оттуда. + +### Свойства сцены Сцена может поддерживать _Depth-scaling_ -- масштабирование объектов, имитирующее 3d перспективу, когда объекты, находящиеся "дальше от камеры" (то есть, выше по оси Y), становятся меньше. Настройки масштабирования для каждой сцены свои. Кроме того, объекты типа Static и Actor имеют свойство, запрещающее их Depth-scaling. Если Depth-scaling объекта запрещен, то он не изменяет свой размер при изменении Y, даже если Depth-scaling включен для сцены. Это полезно, например, для сцен, где персонаж лезет вертикально вверх по пожарной лестнице, и не должен уменьшаться по мере подъёма, поскольку не удаляется от камеры. @@ -87,21 +98,23 @@ Parser обрабатывает пользовательский ввод кас Сцена имеет свойство, определяющее _положение "камеры"_ (viewport), т.е. задаёт какая область сцены будет отображаться на экране и с каким зумом. Например, при приближении персонажа игрока к краю экрана сцена скроллится. По умолчанию камера позиционируется на персонаже игрока, но позиционированием можно управлять и динамически, например если игрок выходит из дома на улицу, то масштаб изображения может уменьшиться кастомной логикой (скриптом) этой сцены, отдалив камеру чтобы передать ощущение большого открытого пространства. Чтобы облегчить манипуляции с камерой, сцена имеет два значения параметра zoom: дефолтный и текущий. Дефолтный zoom задаётся при создании сцены и применяется при её загрузке, а текущий -- изменяется динамически во время игры и при редактировании. -Важно отметить, что все свойства сцены и всех объектов должны быть доступны для изменения не только в редакторе, но и динамически прямо во время игры, со стороны игровой логики (скриптов). Примерно как свойства в Unity или Unreal Engine. +Важно отметить, что все свойства сцены и всех объектов доступны для изменения не только в редакторе, но и динамически прямо во время игры, со стороны игровой логики (скриптов). Примерно как свойства в Unity или Unreal Engine. + +## Объекты сцены -## Структура классов +### Структура классов -С точки зрения кода класс _SceneObject_ является прародительским для всех объектов сцены в игре, включая Static, Actor, а также полигональные объекты TriggerBox и WalkBox. +С точки зрения ООП класс _SceneObject_ является прародителем для всех объектов сцены в игре, включая Static, Actor, Quad, а также полигональные служебные области TriggerBox и WalkBox, которые в игре не видны. SceneObject ├── PolygonObject -│ ├── Walkbox -│ └── Triggerbox +│ ├── Walkbox +│ └── Triggerbox ├── QuadObject └── Entity (≈ Static) -└── Actor + └── Actor -## Свойства объектов SceneObject +### Свойства объектов SceneObject Эти свойства наследуются всеми объектами в игре: @@ -119,9 +132,9 @@ _Disabled_ : (boolean) _Locked_ : (boolean) Объект может быть заблокирован (Locked) для редактирования в редакторе сцены. Заблокированные объекты нельзя выбрать или переместить кликом мыши на экране (они становятся "прозрачными" для кликов), но их всё ещё можно выбрать в списке объектов. В режиме игры это свойство игнорируется. -## Коллайдеры (Collision Box) +#### Коллайдеры (Collision Box) -Объекты типа Static и Actor имеют свойства `Collider Width` и `Collider Height`, задающие размер прямоугольной области столкновения, которая по X центрирована по объекту, а по Y нижняя граница прямоугольника коллайдера приходится на нижнюю границу спрайта/прямоугольника объекта. То есть, при увеличении высоты коллайдера он растёт вверх, а при увеличении ширины он растёт в обе стороны от центра объекта. +Объекты Entity (Static и Actor имеют свойства `Collider Width` и `Collider Height`, задающие размер прямоугольной области столкновения, которая по X центрирована по объекту, а по Y нижняя граница прямоугольника коллайдера приходится на нижнюю границу спрайта/прямоугольника объекта. То есть, при увеличении высоты коллайдера он растёт вверх, а при увеличении ширины он растёт в обе стороны от центра объекта. - Если размеры коллайдера больше 0, этот объект является препятствием для других объектов (Actor), имеющих коллайдер. - Коллайдер взаимодействует с WalkBox: @@ -129,7 +142,7 @@ _Locked_ : (boolean) - В режиме _Invert_: коллайдер объекта должен полностью находиться внутри разрешенной зоны. - Если размеры коллайдера равны 0, объект считается проходимым, не сталкивается с другими и игнорирует WalkBox. -## Свойства Static +### Свойства Static _Parallax_ Управляет их перемещением при движении камеры. При значении 1 они движутся так же, как другие объекты, при 0.5 движутся вдвое медленней, при 0 остаются вообще неподвижными, а при значениях >1, соответственно, движутся быстрее чем обычные объекты. Это позволяет делать параллаксные фоны с эффектом глубины. Например, спрайт с небом не движется при скроллинге сцены, спрайт с отдалёнными домами движется вдвое медленней, чем остальная сцена, а деревья на переднем плане -- чуть быстрее. @@ -144,7 +157,7 @@ _Visual Effects_ - **Blend Mode**: Режим наложения цвета (Normal, Multiply, Screen, Overlay, etc). - **Blur**: Эффект размытия (в пикселях). -## Свойства Actor +### Свойства Actor Actor это расширение Static. Помимо текущего спрайта, как у Static, имеет направление и (опционально) визуальное состояние. @@ -157,7 +170,7 @@ Actor может иметь сколько угодно групп анимац Для скриптов есть возможность через API переключать состояние. Например, если переключить на группу "talk", то персонаж будет воспроизводить анимации разговора в зависимости от того, куда он повёрнут. Он будет сохранять эту анимацию до тех пор, пока ему не придёт команда перемещаться, тогда он переключится на walk а после остановки автоматически на idle. -## Свойства Quad +### Свойства Quad _Quad_ это примитив, определяемый четырьмя вершинами. В отличие от Static, это не прямоугольник, а произвольный четырёхугольник. Основное назначение -- создание поверхностей и стен с учётом 2.5D перспективы, а также эффектов тени и освещения. @@ -165,15 +178,15 @@ _Vertices_ Quad имеет 4 вершины. У каждой вершины свои координаты X, Y и свой коэффициент Parallax (P). Это позволяет создавать объекты, которые корректно деформируются при движении камеры, имитируя 3D перспективу. Например, "пол" будет иметь вершины с разным параллаксом: ближние к камере P > 1, дальние P < 1. _Retro Grid Mode_ -Quad может отображаться как "сетка" (Retro-Grid), что соответствует стилистике ретро-футуризма 80х. Настраивается цвет линий, толщина и количество ячеек сетки. Этот режим не отменяет заливку цветом и может использоваться одновременно с ней. +Quad может отображаться как сетка линий (Retro-Grid) в стиле компьютерной графики 80х. Настраивается цвет линий, толщина и количество ячеек сетки. Этот режим не отменяет заливку цветом и может использоваться одновременно с ней. +Помимо эстетической, Retro-Grid несёт и функциональную роль, играя роль сетки для выравнивания объектов относительно друг-друга. Её узлы могут служить точками привязки (когда объект перетаскивается с зажатым ) наряду с вершинами Quad и Entity. _Sort Mode_ (v0, v1, v2, v3, ignore) Определяет точку сортировки (Z-Sort) для объекта. Поскольку Quad может быть сильно вытянут в глубину (наподобие пола), его центр может быть некорректной точкой для сортировки относительно других объектов (например, персонажа стоящего на этом полу). Режим сортировки позволяет привязать Z-индекс к конкретной вершине (например, самой дальней). ## Компонентная система -Кроме простых свойств, которые есть всегда, объекты сцены могут содержать компоненты (структуры данных), которые могут быть добавлены и удалены в редакторе. Каждый объект может иметь один или несколько компонентов разных типов. Но не любой объект может содержать любой компонент. -Каждый компонент имеет уникальный id компонента. +Кроме простых свойств, которые есть всегда, объекты сцены способны содержать _компоненты_ (структуры данных), которые могут быть добавлены и удалены в редакторе. Каждый объект может иметь один или несколько компонентов разных типов. Но не любой объект может содержать любой компонент. #### Компоненты групп анимаций @@ -266,68 +279,266 @@ Static и Actor могут содержать скриптовые событи > Примечание: События _Always_ и _OnCollide_ зарезервированы в дизайне, но на текущий момент технически не реализованы в движке. +## Текстовые ассеты (TA) + +Наша игра в значительной степени текстовая. Каждый объект или сцена имеет название, а также описания, выдаваемые при различных действиях с объектами, например по методу look ("You see _a desk_"). Есть также тексты, предназначенные не для пользователя, а для SLM/LLM: описывающие возможные действия c предметами, промпты для задания нужной атмосферы и тп. Всё это удобно хранить в виде json файлов. Когда игра загружает сцену и объекты, она читает и текстовые ассеты с ними связанные. + +Текстовые ассеты хранятся в Public\text\ в виде json файлов с именами, совпадающими с id сцен и объектов. + +- `public\text\scenes\.json` +- `public\text\objects\.json` + +Поскольку ID могут быть составными, ссылаясь на файлы в подпапках, соответствующие TA тоже могут находиться в подпапках. Например: 'public\text\scenes\home\room.json' для сцены 'home\room' + +Текстовый asset содержит стандартные (используемые движком) поля, а также может содержать дополнительные (кастомные). + +> сейчас стандартными текстовыми полями считаются `title` и `description`. + +Кроме текстовых ассетов сцен и объектов, в проекте есть и **служебные TA** для строк самого движка, парсера, UI и скриптов. Они хранятся отдельно, в `public\text\system\`, разбиваются по доменам (`parser.json`, `engine.json`, `scripts.json`, etc) и адресуются по строковым ключам вида `parser.take_prompt` или `engine.click_you_see`. +Служебные TA не имеют таблицы переадресации. Это просто словари строк, доступных по ключу. +В строках служебных TA допускаются именованные плейсхолдеры, например `{item}` или `{title}`, которые заполняются вызывающим кодом. + # Игровая логика (API & scripting system) Поскольку у нас игра, основанная на сюжетной логике, то нам нужно реагировать на события, такие как: -- столкновения Actor между собой, попадание их в TriggerBox, -- условия, такие как наличие у игрока предмета в инветаре, текущей сцены, присутствия в ней NPC, состояния какой-то внутренней переменной -- команды игрока -- реплики игрока в диалоге NPC и ответы NPC на них +- столкновения Actor между собой, попадание их в TriggerBox; +- условия, такие как наличие у игрока предмета в инветаре, текущей сцены, присутствия в ней NPC, состояния какой-то внутренней переменной; +- команды игрока; +- реплики игрока в диалоге NPC и ответы NPC на них; +- и тд. При этом в качестве реакции на это может потребоваться: -- изменение свойств сцены/объекта (например, скрыть/показать объект, приблизить или отдалить камеру) -- изменение состояния игры (например, изменить внутреннюю переменную timeOfDay на 'night') -- работа с инвентарём игрока (например, взять/забрать предмет) -- перенос игрока или NPC в другую сцену -- работа с анимациями объектов (например, персонаж садится на стул) +- изменение свойств сцены/объекта (например, скрыть/показать объект, приблизить или отдалить камеру); +- изменение состояния игры (например, изменить внутреннюю переменную timeOfDay на 'night'); +- работа с инвентарём игрока (например, взять/забрать предмет); +- перенос игрока или NPC в другую сцену; +- работа с анимациями объектов (например, персонаж садится на стул); - и тд. -Комплексный пример: игрок подходит к стене, на которой есть кнопка. Если игрок находится рядом с кнопкой и отдаёт команду нажать на неё, сверху спускается лестница, после чего становится доступна новая команда: "лезь по лестнице". Когда игрок переходит в режим лазания по лестнице, то обычный WalkBox отключается, а включается WalkBox для лестницы, который позволяет персонажу перемещаться лишь вверх и вниз. Кроме того, у персонажа игрока заменяются анимации walk для ходьбы вверх и вниз на анимации лазания вверх и вниз по лестнице, а ещё устанавливается запрет на Depth-scaling, чтобы поднимаясь по лестнице персонаж не уменьшался в размере. Когда игрок долазит до TriggerBox вверху лестницы, он оказывается в другой сцене, при этом свойства его персонажа сбрасываются на дефолтные, то есть он вновь масштабируется и ходит, а не лазит. +Комплексный пример: игрок подходит к стене, на которой есть кнопка. Если игрок отдаёт команду "push the button", сверху спускается лестница, после чего становится доступна новая команда: "climb the ladder". Когда игрок переходит в режим лазания по лестнице, то обычный WalkBox отключается, а включается WalkBox для лестницы, который позволяет персонажу перемещаться лишь вверх и вниз. Кроме того, у персонажа игрока заменяются анимации walk для ходьбы вверх и вниз на анимации лазания вверх и вниз по лестнице, а ещё устанавливается запрет на Depth-scaling, чтобы поднимаясь по лестнице персонаж не уменьшался в размере. Когда игрок долазит до TriggerBox вверху лестницы, он оказывается в другой сцене, при этом свойства его персонажа сбрасываются на дефолтные, то есть он вновь масштабируется и ходит, а не лазит. Очевидно, что это требует какой-то системы скриптов. Для этого мы используем тот же язык, на котором написан движок, то есть Typescript с паттерном Script Registry и API для взаимодействия с игрой. -> Парсер и UI используют этот же API. Например, если пользователь ввёл команду Look <объект> или кликнул наэтот объект, вызовется game.look(object_id) +> Парсер и UI используют этот же API. Например, если пользователь ввёл команду Look <объект> или кликнул на этот объект, вызовется game.look(target_id) ## API -Все скрипты регистрируются в `ScriptRegistry` и получают объект контекста `ScriptContext` со следующими аргументами: +Все скрипты регистрируются в `ScriptRegistry`. При выполнении скрипт получает объект `ScriptContext` со следующими полями: -- `game`: Ссылка на основной экземпляр игры (`Game.instance`). -- `entity`: Ссылка на объект, на котором сработал скрипт (Entity/Actor/Triggerbox). -- `args`: Опциональные дополнительные аргументы. +- `game`: основной экземпляр игры (`Game.instance`); +- `entity`: объект, на котором сработал скрипт, если он есть; +- `api`: экземпляр `ScriptAPI`, то есть компактная script-oriented обёртка над частью runtime API; +- `args`: опциональные дополнительные аргументы. -### Основные методы +Базовый шаблон скрипта: -#### Game +```typescript +ScriptRegistry.register('demo.test', ({ game, entity, api, args }) => { + game.showMessage('Script started'); +}); +``` -- `game.showMessage(text: string)`: Выводит сообщение в игровую консоль/UI. -- `game.playSound(filename: string)`: Проигрывает звуковой файл из папки `public/sounds`. -- `game.sceneManager.switchTo(sceneId: string)`: Загружает и переключает на указанную сцену. +### Видимость и модель доступа -- 'game.look()' +Важно различать **контекст скрипта** и **контекст браузерной консоли**. -#### Entity / Actor +Штатный игровой скрипт работает только с тем, что передано в `ScriptContext` или доступно через эти ссылки. Нормальный способ доступа к сцене и объектам из скрипта: -- `entity.setSprite(filename: string)`: Меняет спрайт объекта. -- `entity.description = "..."`: Меняет описание объекта (для команды look). -- `actor.setDirection(dir: 'up'|'down'|'left'|'right')`: Поворачивает персонажа. -- `actor.playAnimSet(id: string)`: Переключает набор анимаций (например, на 'talk'). -- `actor.resetAnimSet()`: Возвращает набор анимаций к дефолтному ('idle'/'walk'). -- `actor.walkTo(x, y)`: Заставляет персонажа идти в указанную точку (с учетом Walkbox). -- `actor.stop()`: Останавливает движение. +- `game` +- `entity` +- `api` +- `game.sceneManager.currentScene` +- `api.getEntity(name)` +- `api.getActor(name)` +- `api.getQuad(name)` +- `game.sceneManager.currentScene?.findEntity(name)` + +Вызовы вида: + +```typescript +Hero.walkTo(100, 100); +``` + +не являются нормальным способом использования API. Такой синтаксис относится к debug-видимости объектов в `window` для браузерной консоли. Он может быть полезен для отладки, но не должен использоваться как опора для игровых скриптов. + +### Доступ через `game` + +`game` — это базовый runtime API. Им пользуются не только скрипты, но и parser, компонентные системы и сам движок. + +Основные методы и свойства, полезные в скриптах: + +- `game.showMessage(text: string)`: выводит сообщение в игровую консоль; +- `game.log(text: string)`: выводит сообщение напрямую в буфер консоли; +- `game.text(key: string, params?: Record)`: получает строку из служебного TA по ключу; +- `game.playSound(filename: string)`: проигрывает звук из `public/sounds`; +- `game.sceneManager.currentScene`: ссылка на текущую сцену; +- `game.sceneManager.switchTo(sceneId: string)`: переключает игру на другую сцену; +- `game.inventory`: массив предметов в инвентаре игрока. + +Пример: + +```typescript +ScriptRegistry.register('door.locked', ({ game }) => { + game.showMessage(game.text('engine.locked_needs', { item: 'keycard' })); +}); +``` + +### Доступ через `api` + +`api` — это удобная script-side обёртка. Она не заменяет `game`, а сокращает наиболее частые операции. + +Методы `ScriptAPI`: + +- `api.log(text: string)`: выводит текст в игровую консоль; +- `api.text(key: string, params?: Record)`: получает строку из служебного TA; +- `api.getEntity(name: string)`: возвращает объект сцены по имени; +- `api.getActor(name: string)`: возвращает `Actor` по имени; +- `api.getQuad(name: string)`: возвращает `QuadObject` по имени; +- `api.setTimeout(...)`, `api.clearTimeout(...)`: таймеры; +- `api.setInterval(...)`, `api.clearInterval(...)`: интервалы; +- `api.saveCheckpoint()`: сохраняет текущее состояние сцены в undo history редактора. + +`api.text(...)` и `game.text(...)` по сути делают одно и то же. Разница только в форме доступа: + +- `game.text(...)` — базовый runtime метод; +- `api.text(...)` — его сокращённая обёртка для скриптов. + +Ни `game.text(...)`, ни `api.text(...)` не выводят текст сами по себе. Они только возвращают строку. + +Примеры: + +```typescript +api.log(api.text('scripts.puzzle_solved')); + +const lamp = api.getEntity('lamp'); +const hero = api.getActor('Hero'); +const floor = api.getQuad('floor_main'); +``` + +api.getQuad(name) по сути делает: + +1. берёт game.sceneManager.currentScene +2. ищет объект через scene.findEntity(name) +3. проверяет obj.type === 'Quad' +4. возвращает объект или null + +Упрощённый эквивалент: + +```typescript +function getQuad(game, name) { + const scene = game.sceneManager.currentScene; + if (!scene) return null; + const obj = scene.findEntity(name); + if (obj && obj.type === 'Quad') { + return obj; + } +} +``` + +### Работа с текущей сценой + +Текущая сцена доступна как: + +```typescript +const scene = game.sceneManager.currentScene; +``` + +Основные полезные методы и свойства сцены: + +- `scene.findEntity(name)`: ищет объект по `id`, `customName` или `title` из TA; +- `scene.resolveTarget(targetStr)`: разрешает цель по `id`, `#group` или смешанному списку целей; +- `scene.setTextRedirect(field, targetField)`: устанавливает runtime-переадресацию стандартного текстового поля сцены на кастомное поле из её TA; +- `scene.clearTextRedirect(field)`: сбрасывает переадресацию; +- `scene.activeSubscene`: текущее состояние Subscene; +- `scene.player`: ссылка на персонажа игрока, если он есть. + +#### Text Redirects + +Каждая сцена и объект _в рантайме_ имеют _таблицу переадресации полей_ TA, позволяющую стандартным полям динамически ссылаться на кастомные поля из того же TA. Если переадресации нет, используется стандартное поле. Если целевое поле отсутствует, движок делает fallback на стандартное поле. + +Например, если мы хотим, чтобы описание сцены зависело от времени суток, можно хранить в TA поля `description`, `description_morning`, `description_evening` и переключать `description` скриптом: + +```typescript +const scene = game.sceneManager.currentScene; +scene?.setTextRedirect('description', 'description_evening'); +``` + +Сброс: + +```typescript +scene?.clearTextRedirect('description'); +``` + +Таблица переадресации, как и другие runtime-изменения сцены, сохраняется вместе с сохранённой игрой. + +### Работа с `entity` + +Если скрипт вызван событием конкретного объекта, он получает его в `entity`. + +Основные операции, доступные на уровне `SceneObject`: + +- `entity.setTextRedirect(field, targetField)` +- `entity.clearTextRedirect(field)` +- `entity.description = '...'` +- `entity.customName = '...'` +- `entity.disabled = true/false` +- `entity.visible = true/false` +- `entity.groupID = '#tag'` +- `entity.layer = number` +- `entity.locked = true/false` + +Если `entity` является `Entity` или `Actor`, также доступны типичные визуальные и пространственные свойства: + +- `entity.x`, `entity.y` +- `entity.scale` +- `entity.parallax` +- `entity.opacity` +- `entity.blur` +- `entity.blendMode` +- `entity.setSprite(filename: string, keepSize?: boolean)` + +Пример: + +```typescript +ScriptRegistry.register('interaction.lamp.use', ({ entity }) => { + entity.visible = false; + entity.setTextRedirect('description', 'description_broken'); +}); +``` + +### Работа с `Actor` + +Если объект является `Actor`, для него доступны методы управления движением и анимацией: + +- `actor.setDirection(dir: 'up' | 'down' | 'left' | 'right')` +- `actor.walkTo(x, y)` +- `actor.moveTo(x, y)` +- `actor.stop()` +- `actor.setState(state)` +- `actor.playAnimSet(id: string)` +- `actor.resetAnimSet()` + +Пример: + +```typescript +ScriptRegistry.register('npc.go_to_door', ({ api }) => { + const hero = api.getActor('Hero'); + hero?.walkTo(180, 140); +}); +``` ### Пример скрипта ```typescript ScriptRegistry.register('interaction.pillar.key', ({ game, entity }) => { - game.showMessage('You insert the key into a hidden slot in the pillar.'); + game.showMessage(game.text('scripts.pillar_key_inserted')); game.playSound('secret_reveal.wav'); // Change pillar appearance entity.setSprite('pillar_open'); - entity.description = 'The pillar is open.'; + entity.description = game.text('scripts.pillar_open_description'); }); ``` @@ -353,11 +564,11 @@ export function registerUserScripts() { } ``` -## Текстовые ресурсы -Наша игра в значительной степени текстовая. Каждый объект или сцена имеет название, а также описания, выдаваемые при различных дейсвиях с объектами, например по методу look ("You see _a desk_"). Есть также тексты, предназначенные не для пользователя, а для SLM/LLM: описывающие возможные действия c предметами, промпты для задания нужной атмосферы и тп. Всё это удобно хранить в виде текстового файла, или файлов. Когда игра загружает сцену и объекты, она читает и текстовые ассеты с ними связанные. -# Редактор cцены + + +# Редактор cцены ################################# Используется для создания/редактирования cцен и объектов. Включается по нажатию клавиши F1. Визуально отображается как набор UI элементов за пределами пользовательского игрового экрана: @@ -462,17 +673,18 @@ Prefab можно загрузить в текущую сцену из файл ### 1. Общие (General) -| Сочетание | Действие | Описание | -+-----------+-------------------+---------- | -| **F1** | Toggle Editor | Открыть/Закрыть редактор сцены | -| **F5** | Sprite Editor | Открыть/Закрыть редактор спрайтов | -| **F9** | Settings | Открыть/Закрыть настройки игры | -| **F2** | Smart Save | Быстрое сохранение сцены (по текущему пути) | -| **Shift+F2** | Save As... | Сохранить сцену как (открывает диалог) | -| **F3** | Load Scene | Загрузить сцену | -| **F4** | New Scene | Создать новую сцену | -| **Alt + L** | Lock Object | Заблокировать/разблокировать объект | -| **Ctrl+Z** | Undo/Redo | Отменить последнее действие | +| Сочетание | Действие | Описание | +| ------------ | ------------- | ----------------------------------------- | +| **F1** | Toggle Editor | Открыть/Закрыть редактор сцены | +| **F5** | Sprite Editor | Открыть/Закрыть редактор спрайтов | +| **F9** | Settings | Открыть/Закрыть настройки игры | +| **F2** | Smart Save | Быстрое сохранение сцены (ID = имя файла) | +| **Shift+F2** | Save As... | Сохранить сцену как... (открывает диалог) | +| **F3** | Load Scene | Загрузить сцену | +| **F4** | New Scene | Создать новую сцену | +| **Alt+L** | Lock Object | Заблокировать/разблокировать объект | +| **Ctrl+Z** | Undo | Отменить последнее действие | +| **Ctrl+R** | Redo | Вернуть отменённое действие | ### 2. Работа с объектами (Object Manipulation) diff --git a/TextAssets.md b/TextAssets.md new file mode 100644 index 00000000..31c78179 --- /dev/null +++ b/TextAssets.md @@ -0,0 +1,71 @@ +# Text Assets + +## V1 decision + +We start with a minimal text asset system for scene and object descriptions. + +Text assets are stored separately from scene and prefab JSON files: + +- `public/text/scenes/.json` +- `public/text/objects/.json` + +Since scene/object IDs according to GDD can contain paths like "building\room", which means that the 'room.json scene' is located in the 'building' folder, there may be subfolders inside these folders. + +## Main rules + +- Scene text asset is created automatically when a scene is created or first saved, if it does not exist yet. +- Object text asset is stored independently from scenes and prefabs, because objects may exist outside a scene or move between scenes. +- Missing text asset files are not errors; runtime falls back to existing built-in fields. +- Text assets contain only data, not code. +- Dynamic text changes are controlled by scripts through runtime properties of scenes and objects. + +## Minimal fields + +Scene asset: + +- `title` +- `description` + +Object asset: + +- `title` +- `description` + +## Custom text variants + +Text assets may also contain custom named fields in the same JSON file, for example: + +- `description_morning` +- `description_evening` +- `title_locked` + +These are alternative text values that can be activated at runtime. + +## Runtime redirection + +The redirection table does not live inside text asset JSON files. + +Instead, each scene and object may have a runtime property such as `textRedirects` that remaps standard text fields to custom fields from the same text asset. + +Example: + +```json +{ + "description": "description_evening" +} +``` + +Meaning: + +- when runtime asks for `description`, it should use `description_evening` from the text asset; +- if no redirect is set, the default `description` field is used; +- if redirect points to a missing field, runtime should fall back to the standard field. + +Scripts do not generate text themselves. They only change which named text field is currently active. + +## Runtime integration + +- `title` maps to the user-facing object or scene name. +- `description` maps to the basic text used by parser/runtime for `look` or `look around`. +- Existing runtime fields remain as fallback and for backward compatibility. +- Parser and UI should read only the resolved standard fields, not custom variant names directly. diff --git a/package.json b/package.json index 34ea177b..16351385 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,6 @@ "prettier --write", "eslint --max-warnings=0 --fix" ], - "*.{json,md,css,scss}": "prettier --write" + "*.{json,css,scss}": "prettier --write" } } diff --git a/public/scenes/home/room.json b/public/scenes/home/room.json index 7585f5c1..086ae220 100644 --- a/public/scenes/home/room.json +++ b/public/scenes/home/room.json @@ -1,6 +1,8 @@ { "id": "home\\room", "name": "Test Room", + "description": "You are in Test Room.", + "textRedirects": {}, "filename": "home/room", "walkbox": [ { @@ -10,6 +12,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -41,6 +44,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -106,6 +110,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -159,6 +164,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -171,7 +177,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -200,6 +206,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -214,6 +221,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -226,7 +234,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -264,6 +272,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -2, @@ -294,6 +303,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -1, @@ -324,6 +334,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -354,23 +365,24 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 249.76314957424822, - "y": 295.45481146309464, - "width": 119.88, - "height": 289.34, - "baseWidth": 162, - "baseHeight": 391, + "x": 241.58710837405346, + "y": 236.58147331007746, + "width": 119.33276583023533, + "height": 278.4431202705491, + "baseWidth": 168, + "baseHeight": 392, "colliderWidth": 88, "colliderHeight": 4, - "spriteName": "miles_ds-idle-down.json", + "spriteName": "miles_ds-idle-up.json", "color": "#00ffff", - "scale": 0.74, + "scale": 0.7103140823228293, "modelScale": 0.74, - "parallax": 1.0715390924595392, + "parallax": 1.033097301991213, "ignoreScaling": false, "animationSpeed": 30, "opacity": 1, @@ -378,7 +390,7 @@ "blur": 0, "isPlayer": true, "speed": 0.24, - "direction": "down", + "direction": "up", "animSets": { "idle": { "id": "idle", @@ -403,6 +415,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -433,9 +446,10 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 3, + "layer": 0, "visible": true, "x": 135, "y": 310, @@ -463,8 +477,14 @@ "disabled": true, "groupID": "#D2", "customName": "", + "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d2" + } + ], "layer": 4, "visible": true, "x": 134, @@ -493,9 +513,10 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 5, + "layer": 4, "visible": true, "x": 136, "y": -4, @@ -523,6 +544,7 @@ "disabled": true, "groupID": "#D1", "customName": "your ID card", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -558,6 +580,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 5, @@ -588,6 +611,7 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 6, @@ -618,6 +642,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -653,6 +678,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -706,12 +732,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 222.90433731748038, - "y": 306.92845155295413, + "x": 222.90433731748033, + "y": 306.928451552954, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -722,7 +749,7 @@ "color": "#36d87fff", "scale": 0.8, "modelScale": 0.8, - "parallax": 1.079038203629382, + "parallax": 1.0790382036293817, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -732,6 +759,37 @@ "speed": 0.1, "direction": "down", "animSets": {} + }, + { + "name": "boombox", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -152, + "y": -11, + "width": 112, + "height": 47, + "baseWidth": 123.07692307692307, + "baseHeight": 51.64835164835165, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 0, + "blendMode": "source-over", + "blur": 0 } ], "camera": { diff --git a/public/scenes/home/room_backup.json b/public/scenes/home/room_backup.json index 7b52df5e..a37a3dcb 100644 --- a/public/scenes/home/room_backup.json +++ b/public/scenes/home/room_backup.json @@ -1,6 +1,8 @@ { "id": "home\\room_backup", "name": "Test Room", + "description": "You are in Test Room.", + "textRedirects": {}, "filename": "home/room_backup", "walkbox": [ { @@ -10,6 +12,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -41,6 +44,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -106,6 +110,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -159,6 +164,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -171,7 +177,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -200,6 +206,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -214,6 +221,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -226,7 +234,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -264,6 +272,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -2, @@ -294,6 +303,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -1, @@ -324,6 +334,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -354,12 +365,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, "x": 249.76314957424822, - "y": 295.45481146309464, + "y": 295.45481146309453, "width": 119.88, "height": 289.34, "baseWidth": 162, @@ -403,6 +415,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -433,9 +446,10 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 3, + "layer": 0, "visible": true, "x": 135, "y": 310, @@ -463,8 +477,14 @@ "disabled": true, "groupID": "#D2", "customName": "", + "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d2" + } + ], "layer": 4, "visible": true, "x": 134, @@ -493,9 +513,10 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 5, + "layer": 4, "visible": true, "x": 136, "y": -4, @@ -523,6 +544,7 @@ "disabled": true, "groupID": "#D1", "customName": "your ID card", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -558,6 +580,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 5, @@ -588,6 +611,7 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 6, @@ -618,6 +642,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -653,6 +678,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -706,12 +732,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 222.90433731748038, - "y": 306.92845155295413, + "x": 222.90433731748033, + "y": 306.92845155295396, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -722,7 +749,7 @@ "color": "#36d87fff", "scale": 0.8, "modelScale": 0.8, - "parallax": 1.079038203629382, + "parallax": 1.0790382036293817, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json new file mode 100644 index 00000000..072159a2 --- /dev/null +++ b/public/text/objects/boombox.json @@ -0,0 +1,5 @@ +{ + "title": "Boombox", + "description": "A compact tape recorder with a radio.", + "details": "The Sharp GF-7 boombox is connected to the computer. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape." +} diff --git a/public/text/objects/logo_1.json b/public/text/objects/logo_1.json new file mode 100644 index 00000000..86973794 --- /dev/null +++ b/public/text/objects/logo_1.json @@ -0,0 +1,4 @@ +{ + "title": "logo", + "description": "You see nothing special." +} diff --git a/public/text/objects/miles_id.json b/public/text/objects/miles_id.json new file mode 100644 index 00000000..442b3058 --- /dev/null +++ b/public/text/objects/miles_id.json @@ -0,0 +1,4 @@ +{ + "title": "your ID card", + "description": "You see nothing special." +} diff --git a/public/text/scenes/home/room.json b/public/text/scenes/home/room.json new file mode 100644 index 00000000..03435560 --- /dev/null +++ b/public/text/scenes/home/room.json @@ -0,0 +1,4 @@ +{ + "title": "Test Room", + "description": "You are in Test Room." +} diff --git a/public/text/scenes/home/room_backup.json b/public/text/scenes/home/room_backup.json new file mode 100644 index 00000000..03435560 --- /dev/null +++ b/public/text/scenes/home/room_backup.json @@ -0,0 +1,4 @@ +{ + "title": "Test Room", + "description": "You are in Test Room." +} diff --git a/public/text/scenes/new_scene.json b/public/text/scenes/new_scene.json new file mode 100644 index 00000000..faa8b8b7 --- /dev/null +++ b/public/text/scenes/new_scene.json @@ -0,0 +1,4 @@ +{ + "title": "New Scene", + "description": "You are in New Scene." +} diff --git a/public/text/system/engine.json b/public/text/system/engine.json new file mode 100644 index 00000000..b7ff50ca --- /dev/null +++ b/public/text/system/engine.json @@ -0,0 +1,6 @@ +{ + "click_you_see": "You see {title}", + "too_far_generic": "You are too far away.", + "too_far_from_entity": "You are too far away from the {target}.", + "locked_needs": "Locked. Needs {item}" +} diff --git a/public/text/system/parser.json b/public/text/system/parser.json new file mode 100644 index 00000000..8f8e12c1 --- /dev/null +++ b/public/text/system/parser.json @@ -0,0 +1,16 @@ +{ + "look_default_scene": "You are in {scene}.", + "look_default_object": "You see nothing special about the {target}.", + "look_not_found": "You don't see any {target} here.", + "take_prompt": "Take what?", + "take_pickup_success": "You picked up the {item}.", + "take_cannot": "You cannot take that.", + "inventory_empty": "You are not carrying anything.", + "inventory_items": "You are carrying: {items}", + "use_prompt": "Use what?", + "use_format_prompt": "Use what on what? (Format: USE ITEM ON TARGET)", + "use_missing_item": "You don't have the {item}.", + "use_no_effect_pair": "Using the {item} on the {target} does nothing.", + "use_no_effect_single": "You try to use the {target}, but nothing happens.", + "parse_unknown": "I don't understand." +} diff --git a/public/text/system/scripts.json b/public/text/system/scripts.json new file mode 100644 index 00000000..4fd9d570 --- /dev/null +++ b/public/text/system/scripts.json @@ -0,0 +1,6 @@ +{ + "pillar_key_inserted": "You insert the key into a hidden slot in the pillar.", + "pillar_compartment_opened": "Click! A secret compartment opens!", + "pillar_open_description": "The pillar is open, revealing a secret compartment.", + "test_audio_playing": "Playing test sound..." +} diff --git a/src/components/ConsoleOverlay.tsx b/src/components/ConsoleOverlay.tsx index 89ceb693..988bfabb 100644 --- a/src/components/ConsoleOverlay.tsx +++ b/src/components/ConsoleOverlay.tsx @@ -135,28 +135,6 @@ const InputMirror: React.FC<{ game: Game }> = ({ game }) => { const input = game.getCommandInput(); if (!input) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - const command = input.value; - if (command.trim()) { - // Send to Game Console Processing - if (game.console) { - game.console.processCommand(command); - } else { - // Fallback purely for parser if console not active? - // Actually, if we are in ConsoleOverlay, we want Console logic. - // The original game parser logic might still listen to 'Enter' globally? - // Let's ensure we don't double submit. - // Game.ts -> onKeyDown usually handles parser. - // We might need to coordinate who consumes the input. - // For now, let's assume this is the Console input. - } - input.value = ''; - setVal(''); - } - } - }; - const update = () => { if (input && input.value !== val) { setVal(input.value); @@ -164,11 +142,9 @@ const InputMirror: React.FC<{ game: Game }> = ({ game }) => { requestAnimationFrame(update); }; - input.addEventListener('keydown', handleKeyDown); const rAF = requestAnimationFrame(update); return () => { - input.removeEventListener('keydown', handleKeyDown); cancelAnimationFrame(rAF); }; }, [game, val]); diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 7d0b6d25..de79f864 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -16,6 +16,10 @@ export const PropertiesPanel: React.FC = () => { selectedVertexIndex, } = useEditorStore(); const [groupIdDraft, setGroupIdDraft] = React.useState(''); + const [resolvedTitle, setResolvedTitle] = React.useState(''); + const [textAssetPath, setTextAssetPath] = React.useState(''); + const [isReadingTA, setIsReadingTA] = React.useState(false); + const [hasTextAsset, setHasTextAsset] = React.useState(false); // Derived Object Binding (Source of Truth) // We re-render whenever objectVersion changes (subscribed via store hook) @@ -59,6 +63,125 @@ export const PropertiesPanel: React.FC = () => { incrementHierarchyVersion(); }; + const loadResolvedTitle = React.useCallback( + async (forceReload: boolean = false) => { + if (!game || !obj || selectedObjectType === 'MULTI' || selectedObjectType === 'SETTINGS') { + setResolvedTitle(''); + setTextAssetPath(''); + return; + } + + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + const asset = forceReload + ? await game.textAssets.readSceneAsset(scene, true) + : await game.textAssets.readSceneAsset(scene, false); + setHasTextAsset(!!asset); + setResolvedTitle(game.textAssets.getResolvedSceneField(scene, 'title') || ''); + setTextAssetPath(game.textAssets.getSceneAssetProjectPath(scene.id)); + return; + } + + if (game.editor?.selectedObject) { + const selected = game.editor.selectedObject; + const asset = forceReload + ? await game.textAssets.readObjectAsset(selected, true) + : await game.textAssets.readObjectAsset(selected, false); + setHasTextAsset(!!asset); + setResolvedTitle(game.textAssets.getResolvedObjectField(selected, 'title') || ''); + setTextAssetPath(game.textAssets.getObjectAssetProjectPath(selected.name)); + } + }, + [game, obj, selectedObjectType] + ); + + React.useEffect(() => { + loadResolvedTitle(false).catch((err) => { + console.error('Failed to load text asset title:', err); + }); + }, [loadResolvedTitle, selectedObjectId, selectedObjectType]); + + const handleOpenTA = async () => { + if (!game || !obj) return; + try { + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + await game.textAssets.openSceneAsset(scene); + } else if (game.editor?.selectedObject) { + await game.textAssets.openObjectAsset(game.editor.selectedObject); + } + await loadResolvedTitle(true); + } catch (err) { + console.error('Failed to open text asset:', err); + game.showNotification?.(`Failed to open TA: ${err}`); + } + }; + + const handleReadTA = async () => { + if (!game || !obj) return; + setIsReadingTA(true); + try { + const path = + selectedObjectType === 'SCENE' + ? game.textAssets.getSceneAssetProjectPath(game.sceneManager.currentScene?.id || '') + : game.editor?.selectedObject + ? game.textAssets.getObjectAssetProjectPath(game.editor.selectedObject.name) + : ''; + const defaultContent = + selectedObjectType === 'SCENE' + ? JSON.stringify( + game.textAssets.buildDefaultSceneAsset(game.sceneManager.currentScene as any), + null, + 2 + ) + : game.editor?.selectedObject + ? JSON.stringify( + game.textAssets.buildDefaultObjectAsset(game.editor.selectedObject), + null, + 2 + ) + : '{}'; + + await fetch('/api/read-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, content: defaultContent }), + }); + await loadResolvedTitle(true); + incrementObjectVersion(); + game.showNotification?.('Text asset reloaded'); + } catch (err) { + console.error('Failed to read text asset:', err); + game.showNotification?.(`Failed to read TA: ${err}`); + } finally { + setIsReadingTA(false); + } + }; + + const handleDeleteTA = async () => { + if (!game || !obj || !hasTextAsset) return; + const confirmed = window.confirm(`Delete text asset?\n${textAssetPath}`); + if (!confirmed) return; + + try { + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + await game.textAssets.deleteSceneAsset(scene); + } else if (game.editor?.selectedObject) { + await game.textAssets.deleteObjectAsset(game.editor.selectedObject); + } + await loadResolvedTitle(true); + incrementObjectVersion(); + game.showNotification?.('Text asset deleted'); + } catch (err) { + console.error('Failed to delete text asset:', err); + game.showNotification?.(`Failed to delete TA: ${err}`); + } + }; + React.useEffect(() => { if (selectedObjectType !== 'MULTI') { setGroupIdDraft(''); @@ -751,6 +874,38 @@ export const PropertiesPanel: React.FC = () => { }} /> +
+ + e.currentTarget.blur()} + style={{ pointerEvents: 'none', color: '#888' }} + /> + {textAssetPath && ( + <> +
+ + + {hasTextAsset && ( + + )} +
+
+ {textAssetPath} +
+ + )} +
)} @@ -792,18 +947,6 @@ export const PropertiesPanel: React.FC = () => { selectedObjectType === 'Actor' || selectedObjectType === 'Static') && ( <> - {/* Display Name */} -
- - handleChange('customName', e.target.value)} - /> -
- {/* Transform: X, Y, W, H */}
{ {/* SCENE Properties */} {selectedObjectType === 'SCENE' && ( <> -
- - handleChange('name', e.target.value)} - /> -
- {/* Camera properties */} {(obj.camera || obj.defaultCamera) && (
diff --git a/src/core/Game.ts b/src/core/Game.ts index 2b8b8473..932e7ffc 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -9,6 +9,7 @@ import { Entity } from '../entities/Entity'; import { registerDemoScripts } from '../scripts/DemoScripts'; import { registerUserScripts } from '../scripts/main'; import { AudioManager } from './AudioManager'; +import { TextAssetManager } from './TextAssetManager'; import { Console } from './Console'; @@ -41,6 +42,7 @@ export class Game implements IGame { sceneManager: SceneManager; assets: AssetLoader; audio: AudioManager; + textAssets: TextAssetManager; editor: SceneEditor; spriteEditor: SpriteEditor; console: Console; // Virtual Console @@ -149,6 +151,8 @@ export class Game implements IGame { this.parser = new Parser(this); this.assets = new AssetLoader(); this.audio = new AudioManager(); + this.textAssets = new TextAssetManager(); + void this.textAssets.preloadServiceAssets(); this.sceneManager = new SceneManager(this); this.editor = new SceneEditor(this); this.spriteEditor = new SpriteEditor(this); @@ -412,6 +416,10 @@ export class Game implements IGame { this.console.log(text); } + text(key: string, params?: Record): string { + return this.textAssets.getServiceText(key, params); + } + showNotification(text: string): void { if (this.onMessage) { this.onMessage(text); diff --git a/src/core/IGame.ts b/src/core/IGame.ts index c0f30ecd..752bda81 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -3,16 +3,19 @@ import { AudioManager } from './AudioManager'; import { SceneManager } from '../scene/SceneManager'; import { SceneEditor } from '../tools/SceneEditor'; import { Entity } from '../entities/Entity'; +import { TextAssetManager } from './TextAssetManager'; export interface IGame { assets: AssetLoader; audio: AudioManager; + textAssets: TextAssetManager; sceneManager: SceneManager; editor: SceneEditor; inventory: Entity[]; showMessage(text: string): void; log(text: string): void; + text(key: string, params?: Record): string; showNotification?(text: string): void; // Optional onSceneChange?(sceneName: string): void; playSound(name: string): void; diff --git a/src/core/ScriptAPI.ts b/src/core/ScriptAPI.ts index 814d76c7..23eb3cf8 100644 --- a/src/core/ScriptAPI.ts +++ b/src/core/ScriptAPI.ts @@ -14,6 +14,10 @@ export class ScriptAPI { this.game.log(message); } + text(key: string, params?: Record): string { + return this.game.text(key, params); + } + setInterval(handler: TimerHandler, timeout?: number, ...args: any[]): number { const id = setInterval(handler, timeout, ...args); this.intervals.push(id); diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts new file mode 100644 index 00000000..9f1df12a --- /dev/null +++ b/src/core/TextAssetManager.ts @@ -0,0 +1,332 @@ +import type { Scene } from '../scene/Scene'; +import type { SceneObject } from '../entities/SceneObject'; + +type TextAssetData = Record; + +const DEFAULT_SERVICE_ASSETS: Record = { + parser: { + look_default_scene: 'You are in {scene}.', + look_default_object: 'You see nothing special about the {target}.', + look_not_found: "You don't see any {target} here.", + take_prompt: 'Take what?', + take_pickup_success: 'You picked up the {item}.', + take_cannot: 'You cannot take that.', + inventory_empty: 'You are not carrying anything.', + inventory_items: 'You are carrying: {items}', + use_prompt: 'Use what?', + use_format_prompt: 'Use what on what? (Format: USE ITEM ON TARGET)', + use_missing_item: "You don't have the {item}.", + use_no_effect_pair: 'Using the {item} on the {target} does nothing.', + use_no_effect_single: 'You try to use the {target}, but nothing happens.', + parse_unknown: "I don't understand.", + }, + engine: { + click_you_see: 'You see {title}', + too_far_generic: 'You are too far away.', + too_far_from_entity: 'You are too far away from the {target}.', + locked_needs: 'Locked. Needs {item}', + }, + scripts: { + pillar_key_inserted: 'You insert the key into a hidden slot in the pillar.', + pillar_compartment_opened: 'Click! A secret compartment opens!', + pillar_open_description: 'The pillar is open, revealing a secret compartment.', + test_audio_playing: 'Playing test sound...', + }, +}; + +export class TextAssetManager { + private sceneCache = new Map(); + private objectCache = new Map(); + private serviceCache = new Map(); + + private normalizeId(id: string): string { + return String(id || '') + .replace(/\//g, '\\') + .trim(); + } + + private idToRelativePath(id: string): string { + return this.normalizeId(id).replace(/\\/g, '/'); + } + + getSceneAssetProjectPath(sceneId: string): string { + return `public/text/scenes/${this.idToRelativePath(sceneId)}.json`; + } + + getObjectAssetProjectPath(objectId: string): string { + return `public/text/objects/${this.idToRelativePath(objectId)}.json`; + } + + private getSceneAssetUrl(sceneId: string): string { + return `/text/scenes/${this.idToRelativePath(sceneId)}.json`; + } + + private getObjectAssetUrl(objectId: string): string { + return `/text/objects/${this.idToRelativePath(objectId)}.json`; + } + + private getServiceAssetUrl(domain: string): string { + return `/text/system/${domain}.json`; + } + + private getDefaultServiceDomain(domain: string): TextAssetData { + return { ...(DEFAULT_SERVICE_ASSETS[domain] || {}) }; + } + + buildDefaultSceneAsset(scene: Scene): TextAssetData { + return { + title: scene.name || scene.id || 'Untitled Scene', + description: + scene.description || `You are in ${scene.name || scene.id || 'an unnamed scene'}.`, + }; + } + + buildDefaultObjectAsset(obj: SceneObject): TextAssetData { + const fallbackTitle = (obj as any).customName || obj.name || obj.type || 'Object'; + const fallbackDescription = (obj as any).description || 'You see nothing special.'; + return { + title: fallbackTitle, + description: fallbackDescription, + }; + } + + async ensureSceneAssetFile(scene: Scene): Promise { + if (!scene?.id) return; + const assetPath = this.getSceneAssetProjectPath(scene.id); + const content = JSON.stringify(this.buildDefaultSceneAsset(scene), null, 2); + await this.ensureFile(assetPath, content); + } + + async ensureObjectAssetFile(obj: SceneObject): Promise { + if (!obj?.name) return; + const assetPath = this.getObjectAssetProjectPath(obj.name); + const content = JSON.stringify(this.buildDefaultObjectAsset(obj), null, 2); + await this.ensureFile(assetPath, content); + } + + async openSceneAsset(scene: Scene): Promise { + const assetPath = this.getSceneAssetProjectPath(scene.id); + const content = JSON.stringify(this.buildDefaultSceneAsset(scene), null, 2); + await this.openFile(assetPath, content); + } + + async openObjectAsset(obj: SceneObject): Promise { + const assetPath = this.getObjectAssetProjectPath(obj.name); + const content = JSON.stringify(this.buildDefaultObjectAsset(obj), null, 2); + await this.openFile(assetPath, content); + } + + async deleteSceneAsset(scene: Scene): Promise { + await this.deleteFile(this.getSceneAssetProjectPath(scene.id)); + this.sceneCache.delete(this.normalizeId(scene.id)); + } + + async deleteObjectAsset(obj: SceneObject): Promise { + await this.deleteFile(this.getObjectAssetProjectPath(obj.name)); + this.objectCache.delete(this.normalizeId(obj.name)); + } + + async readSceneAsset(scene: Scene, forceReload: boolean = false): Promise { + const sceneId = this.normalizeId(scene?.id || ''); + if (!sceneId) return null; + if (!forceReload && this.sceneCache.has(sceneId)) { + return this.sceneCache.get(sceneId) || null; + } + const data = await this.fetchJson(this.getSceneAssetUrl(sceneId)); + this.sceneCache.set(sceneId, data); + return data; + } + + async readObjectAsset( + obj: SceneObject, + forceReload: boolean = false + ): Promise { + const objectId = this.normalizeId(obj?.name || ''); + if (!objectId) return null; + if (!forceReload && this.objectCache.has(objectId)) { + return this.objectCache.get(objectId) || null; + } + const data = await this.fetchJson(this.getObjectAssetUrl(objectId)); + this.objectCache.set(objectId, data); + return data; + } + + async preloadScene(scene: Scene): Promise { + await this.readSceneAsset(scene, true); + await Promise.all( + (scene.entities || []).map((entity: SceneObject) => this.readObjectAsset(entity, true)) + ); + } + + async preloadServiceAssets(domains?: string[]): Promise { + const targetDomains = domains?.length ? domains : Object.keys(DEFAULT_SERVICE_ASSETS); + await Promise.all(targetDomains.map((domain) => this.readServiceAsset(domain, true))); + } + + clearCaches(): void { + this.sceneCache.clear(); + this.objectCache.clear(); + this.serviceCache.clear(); + } + + async readServiceAsset(domain: string, forceReload: boolean = false): Promise { + const normalizedDomain = String(domain || '') + .trim() + .toLowerCase(); + if (!normalizedDomain) return {}; + if (!forceReload && this.serviceCache.has(normalizedDomain)) { + return this.serviceCache.get(normalizedDomain) || {}; + } + + const defaults = this.getDefaultServiceDomain(normalizedDomain); + const loaded = await this.fetchJson(this.getServiceAssetUrl(normalizedDomain)); + const merged = { ...defaults, ...(loaded || {}) }; + this.serviceCache.set(normalizedDomain, merged); + return merged; + } + + getResolvedSceneField(scene: Scene, field: string): string | null { + const sceneId = this.normalizeId(scene?.id || ''); + const asset = sceneId ? this.sceneCache.get(sceneId) : null; + const fallback = field === 'description' ? scene?.description || null : null; + return this.resolveField(asset, scene?.textRedirects || null, field, fallback); + } + + getResolvedObjectField(obj: SceneObject, field: string): string | null { + const objectId = this.normalizeId(obj?.name || ''); + const asset = objectId ? this.objectCache.get(objectId) : null; + const fallback = field === 'description' ? (obj as any).description || null : null; + return this.resolveField(asset, obj?.textRedirects || null, field, fallback); + } + + getServiceText(key: string, params?: Record, fallback?: string): string { + const rawKey = String(key || '').trim(); + if (!rawKey) return fallback || ''; + + const dotIndex = rawKey.indexOf('.'); + if (dotIndex === -1) { + console.warn(`[TextAssetManager] Invalid service text key '${rawKey}'.`); + return fallback || rawKey; + } + + const domain = rawKey.slice(0, dotIndex).toLowerCase(); + const entryKey = rawKey.slice(dotIndex + 1); + if (!entryKey) { + console.warn(`[TextAssetManager] Invalid service text key '${rawKey}'.`); + return fallback || rawKey; + } + + if (!this.serviceCache.has(domain)) { + this.serviceCache.set(domain, this.getDefaultServiceDomain(domain)); + void this.readServiceAsset(domain, true); + } + + const domainAsset = this.serviceCache.get(domain) || {}; + const template = domainAsset[entryKey]; + if (typeof template !== 'string') { + console.warn(`[TextAssetManager] Missing service text '${rawKey}'.`); + return fallback || rawKey; + } + + return this.interpolate(template, params); + } + + private resolveField( + asset: TextAssetData | null | undefined, + redirects: Record | null | undefined, + field: string, + fallback: string | null + ): string | null { + if (!asset) return fallback; + const redirectTarget = redirects && redirects[field]; + if (redirectTarget) { + const redirected = asset[redirectTarget]; + if (typeof redirected === 'string') return redirected; + console.warn( + `[TextAssetManager] Missing redirected field '${redirectTarget}' for '${field}'.` + ); + } + const direct = asset[field]; + if (typeof direct === 'string') return direct; + return fallback; + } + + private async fetchJson(url: string): Promise { + try { + const response = await fetch(`${url}?t=${Date.now()}`); + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(await response.text()); + } + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + return null; + } + return (await response.json()) as TextAssetData; + } catch (error) { + console.error('[TextAssetManager] Failed to fetch text asset:', error); + return null; + } + } + + private interpolate( + template: string, + params?: Record | null | undefined + ): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match, token: string) => { + const value = params[token]; + return value === undefined || value === null ? `{${token}}` : String(value); + }); + } + + private async ensureFile(filePath: string, content: string): Promise { + await fetch('/api/ensure-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }); + } + + private async openFile(filePath: string, content: string): Promise { + const response = await fetch('/api/open-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + } + + async duplicateObjectAssetIfExists( + sourceObjectId: string, + targetObjectId: string + ): Promise { + const sourceUrl = this.getObjectAssetUrl(sourceObjectId); + const sourceData = await this.fetchJson(sourceUrl); + if (!sourceData) return; + + const targetPath = this.getObjectAssetProjectPath(targetObjectId); + const response = await fetch('/api/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: targetPath, content: JSON.stringify(sourceData, null, 2) }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + this.objectCache.set(this.normalizeId(targetObjectId), sourceData); + } + + private async deleteFile(filePath: string): Promise { + const response = await fetch('/api/delete-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + } +} diff --git a/src/entities/Actor.ts b/src/entities/Actor.ts index 78d9866c..f4dde7d2 100644 --- a/src/entities/Actor.ts +++ b/src/entities/Actor.ts @@ -2,6 +2,7 @@ import { Entity, type EntityData } from './Entity'; import { Animator } from '../core/Animator'; import { useEditorStore } from '../store/editorStore'; import type { IGame } from '../core/IGame'; +import { toWorldPosition } from '../utils/Parallax'; export type ActorState = 'idle' | 'walk' | 'talk' | 'interact' | string; export type ActorDirection = 'up' | 'down' | 'left' | 'right'; @@ -34,6 +35,7 @@ export class Actor extends Entity { speed: number; target: { x: number; y: number } | null; + visualTarget: { x: number; y: number } | null; readonly type: string = 'Actor'; isPlayer: boolean = false; @@ -63,6 +65,7 @@ export class Actor extends Entity { this.state = 'idle'; this.speed = 0.1; this.target = null; + this.visualTarget = null; this.isPlayer = false; this.animSets = {}; @@ -130,12 +133,21 @@ export class Actor extends Entity { moveTo(x: number, y: number): void { this.target = { x, y }; + this.visualTarget = null; + this.setState('walk'); + this.overrideAnimSet = null; + } + + moveToVisual(x: number, y: number): void { + this.visualTarget = { x, y }; + this.target = null; this.setState('walk'); this.overrideAnimSet = null; } stop(): void { this.target = null; + this.visualTarget = null; this.setState('idle'); } @@ -159,9 +171,22 @@ export class Actor extends Entity { this.handlePlayerInput(deltaTime, isWalkable); } - if (this.state === 'walk' && this.target) { - const dx = this.target.x - this.x; - const dy = this.target.y - this.y; + if (this.state === 'walk' && (this.target || this.visualTarget)) { + const currentTarget = this.visualTarget + ? toWorldPosition( + this.visualTarget, + this.scene?.camera || { x: 0, y: 0 }, + this.parallax !== undefined ? this.parallax : 1.0 + ) + : this.target; + + if (!currentTarget) { + this.stop(); + return; + } + + const dx = currentTarget.x - this.x; + const dy = currentTarget.y - this.y; const dist = Math.sqrt(dx * dx + dy * dy); const p = this.parallax !== undefined ? this.parallax : 1.0; @@ -170,8 +195,8 @@ export class Actor extends Entity { const step = this.speed * speedScale * deltaTime; if (dist <= step) { - this.x = this.target.x; - this.y = this.target.y; + this.x = currentTarget.x; + this.y = currentTarget.y; this.stop(); } else { const moveX = (dx / dist) * step; @@ -258,7 +283,7 @@ export class Actor extends Entity { this.y = nextY; } } - } else if (!this.target) { + } else if (!this.target && !this.visualTarget) { this.setState('idle'); } } diff --git a/src/entities/SceneObject.ts b/src/entities/SceneObject.ts index 85ee6b19..a7bdb6a1 100644 --- a/src/entities/SceneObject.ts +++ b/src/entities/SceneObject.ts @@ -9,6 +9,7 @@ export class SceneObject { // User-facing name for parser (e.g. "Pillar" instead of "Pillar_01") customName: string = ''; + textRedirects: Record = {}; // Script bindings for verbs: { "LOOK": "script.id", "USE": "script.id" } interactions: Record = {}; @@ -30,6 +31,7 @@ export class SceneObject { 'disabled', 'groupID', 'customName', + 'textRedirects', 'interactions', 'components', 'layer', @@ -44,6 +46,7 @@ export class SceneObject { this.layer = 0; this.visible = true; this.customName = ''; + this.textRedirects = {}; this.interactions = {}; this.components = []; } @@ -85,6 +88,29 @@ export class SceneObject { }); } + setTextRedirect(field: string, targetField: string): void { + const source = String(field || '').trim(); + const target = String(targetField || '').trim(); + if (!source || !target) return; + this.textRedirects[source] = target; + this.notifyTextRedirectChanged(); + } + + clearTextRedirect(field: string): void { + const source = String(field || '').trim(); + if (!source) return; + if (this.textRedirects[source] === undefined) return; + delete this.textRedirects[source]; + this.notifyTextRedirectChanged(); + } + + private notifyTextRedirectChanged(): void { + const game = (this as any).game; + if (game?.editor?.selectionManager) { + game.editor.selectionManager.notifyObjectChanged(this); + } + } + /** * Checks if a World Coordinate point hits this object. * Base implementation returns false. Subclasses should override. diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index b0b32051..67e5ece8 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -40,14 +40,24 @@ export class Parser { execute(verb: string, noun: string): void { const scene = this.game.sceneManager.currentScene; if (!scene) return; + const normalizedNoun = noun.trim().toUpperCase(); + const isSceneLook = + !normalizedNoun || + normalizedNoun === 'AROUND' || + normalizedNoun === 'HERE' || + normalizedNoun === 'SCENE'; // Basic command handling switch (verb) { case 'LOOK': case 'EXAMINE': case 'X': // Common shortcut - if (!noun) { - this.game.log(`You are in ${scene.name}.`); + if (isSceneLook) { + const sceneDescription = + this.game.textAssets.getResolvedSceneField(scene, 'description') || + scene.description || + this.game.text('parser.look_default_scene', { scene: scene.name }); + this.game.log(sceneDescription); } else { const entity = scene.findEntity(noun); if (entity) { @@ -58,10 +68,15 @@ export class Parser { ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); } else { // Fallback to description - this.game.log(entity.description || `You see nothing special about the ${noun}.`); + const description = + this.game.textAssets.getResolvedObjectField(entity, 'description') || + entity.description; + this.game.log( + description || this.game.text('parser.look_default_object', { target: noun }) + ); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } break; @@ -69,7 +84,7 @@ export class Parser { case 'GET': case 'PICKUP': if (!noun) { - this.game.log('Take what?'); + this.game.log(this.game.text('parser.take_prompt')); } else { const entity = scene.findEntity(noun); if (entity) { @@ -102,12 +117,16 @@ export class Parser { if (isItem || entity.isTakeable) { scene.removeEntity(entity); this.game.inventory.push(entity); - this.game.log(`You picked up the ${entity.customName || entity.name}.`); + this.game.log( + this.game.text('parser.take_pickup_success', { + item: entity.customName || entity.name, + }) + ); } else { - this.game.log('You cannot take that.'); + this.game.log(this.game.text('parser.take_cannot')); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } break; @@ -115,22 +134,22 @@ export class Parser { case 'INVENTORY': case 'I': if (this.game.inventory.length === 0) { - this.game.log('You are not carrying anything.'); + this.game.log(this.game.text('parser.inventory_empty')); } else { const items = this.game.inventory.map((e: any) => e.customName || e.name).join(', '); - this.game.log(`You are carrying: ${items}`); + this.game.log(this.game.text('parser.inventory_items', { items })); } break; case 'USE': if (!noun) { - this.game.log('Use what?'); + this.game.log(this.game.text('parser.use_prompt')); } else { // Check if it's "USE [ID] ON [ID]" vs "USE [ID]" if (noun.includes(' ON ')) { // Parse "USE X ON Y" const parts = noun.split(' ON '); if (parts.length !== 2) { - this.game.log('Use what on what? (Format: USE ITEM ON TARGET)'); + this.game.log(this.game.text('parser.use_format_prompt')); } else { const itemName = parts[0].trim(); const targetName = parts[1].trim(); @@ -140,7 +159,7 @@ export class Parser { (i: any) => (i.customName || i.name).toUpperCase() === itemName.toUpperCase() ); if (!item) { - this.game.log(`You don't have the ${itemName}.`); + this.game.log(this.game.text('parser.use_missing_item', { item: itemName })); } else { // Check if target is in the scene const target = scene.findEntity(targetName); @@ -154,10 +173,15 @@ export class Parser { if (interactionId) { ScriptRegistry.execute(interactionId, { game: this.game, entity: target }); } else { - this.game.log(`Using the ${itemName} on the ${targetName} does nothing.`); + this.game.log( + this.game.text('parser.use_no_effect_pair', { + item: itemName, + target: targetName, + }) + ); } } else { - this.game.log(`You don't see any ${targetName} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: targetName })); } } } @@ -170,16 +194,16 @@ export class Parser { if (interactionId) { ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); } else { - this.game.log(`You try to use the ${noun}, but nothing happens.`); + this.game.log(this.game.text('parser.use_no_effect_single', { target: noun })); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } } break; default: - this.game.log("I don't understand."); + this.game.log(this.game.text('parser.parse_unknown')); } } } diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 50a8dadf..c9f7a276 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -12,6 +12,7 @@ import { toVisualPosition } from '../utils/Parallax'; import { updateSceneCamera } from './SceneCamera'; import { resolveSceneTargets, cleanupClosingSubscene } from './SceneSubscene'; import { handleSceneClick, activateSceneObject } from './SceneInteraction'; +import { useEditorStore } from '../store/editorStore'; export interface SceneScaling { enabled: boolean; @@ -24,6 +25,8 @@ export interface SceneScaling { export interface SceneData { id: string; name: string; + description?: string; + textRedirects?: Record; filename?: string; walkbox: { poly: { x: number; y: number }[]; @@ -50,6 +53,7 @@ export class Scene { id: string; name: string; + description: string; filename: string; background: HTMLImageElement | null; entities: Entity[]; @@ -77,6 +81,7 @@ export class Scene { // Default Camera (saved to scene file, restored on load/reset) defaultCamera: { x: number; y: number; zoom: number }; + textRedirects: Record = {}; // Subscene State private _activeSubscene: string | null = null; @@ -104,6 +109,7 @@ export class Scene { this.game = game; this.id = id; this.name = name; + this.description = `You are in ${name}.`; this.filename = ''; // Default empty this.background = null; // Image object this.entities = []; @@ -149,11 +155,35 @@ export class Scene { } findEntity(name: string): Entity | undefined { - return this.entities.find( - (e) => - e.name.toUpperCase() === name.toUpperCase() || - (e.customName && e.customName.toUpperCase() === name.toUpperCase()) - ); + const normalized = name.toUpperCase(); + return this.entities.find((e) => { + const resolvedTitle = this.game.textAssets.getResolvedObjectField(e, 'title'); + return ( + e.name.toUpperCase() === normalized || + (e.customName && e.customName.toUpperCase() === normalized) || + (resolvedTitle && resolvedTitle.toUpperCase() === normalized) + ); + }); + } + + setTextRedirect(field: string, targetField: string): void { + const source = String(field || '').trim(); + const target = String(targetField || '').trim(); + if (!source || !target) return; + this.textRedirects[source] = target; + this.notifyTextRedirectChanged(); + } + + clearTextRedirect(field: string): void { + const source = String(field || '').trim(); + if (!source) return; + if (this.textRedirects[source] === undefined) return; + delete this.textRedirects[source]; + this.notifyTextRedirectChanged(); + } + + private notifyTextRedirectChanged(): void { + useEditorStore.getState().incrementObjectVersion(); } getScaling(y: number): number { @@ -486,6 +516,8 @@ export class Scene { return { id: this.id, name: this.name, + description: this.description, + textRedirects: this.textRedirects, filename: this.filename, walkbox: this.walkbox.map((wb) => wb.toJSON()), triggerboxes: this.triggerboxes.map((tb) => tb.toJSON()), diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index aad36b31..56802f9c 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -2,6 +2,7 @@ import type { Scene } from './Scene'; import { SceneObject } from '../entities/SceneObject'; import { Triggerbox } from '../entities/Triggerbox'; import { ComponentSystem } from '../systems/ComponentSystem'; +import { Geometry } from '../utils/Geometry'; function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { const screenW = 420; @@ -14,53 +15,247 @@ function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { }; } -export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): void { +function toWorldForParallax( + scene: Scene, + x: number, + y: number, + parallax: number = 1.0 +): { x: number; y: number } { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + return { + x: (x - halfW) / scene.camera.zoom + scene.camera.x * parallax, + y: (y - halfH) / scene.camera.zoom + scene.camera.y * parallax, + }; +} + +function findVisibleHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + const camX = scene.camera.x; + const camY = scene.camera.y; + const zoom = scene.camera.zoom; + + const entities = scene.entities || []; + for (let i = entities.length - 1; i >= 0; i--) { + const entity = entities[i]; + if (entity.disabled || !entity.visible) continue; + + const p = entity.parallax !== undefined ? entity.parallax : 1.0; + const vOx = (entity as any).visualOffset ? (entity as any).visualOffset.x : 0; + const vOy = (entity as any).visualOffset ? (entity as any).visualOffset.y : 0; + const worldX = (screenX - halfW) / zoom + camX * p - vOx; + const worldY = (screenY - halfH) / zoom + camY * p - vOy; + + if (entity.hitTest(worldX, worldY)) return entity; + } + + const worldPos = { + x: (screenX - halfW) / zoom + camX, + y: (screenY - halfH) / zoom + camY, + }; + + if (scene.triggerboxes) { + for (const tb of scene.triggerboxes) { + if (tb.disabled || !tb.visible) continue; + if (Geometry.isPointInPolygon(worldPos, tb.poly)) return tb; + } + } + + if (scene.walkbox) { + for (const wb of scene.walkbox) { + if (wb.disabled || !wb.visible) continue; + if (Geometry.isPointInPolygon(worldPos, wb.poly)) return wb; + } + } + + return null; +} + +function isHitAtScreenPoint( + scene: Scene, + obj: SceneObject, + screenX: number, + screenY: number +): boolean { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + const camX = scene.camera.x; + const camY = scene.camera.y; + const zoom = scene.camera.zoom; + + if ('x' in obj && 'y' in obj) { + const entity = obj as any; + const p = entity.parallax !== undefined ? entity.parallax : 1.0; + const vOx = entity.visualOffset ? entity.visualOffset.x : 0; + const vOy = entity.visualOffset ? entity.visualOffset.y : 0; + const worldX = (screenX - halfW) / zoom + camX * p - vOx; + const worldY = (screenY - halfH) / zoom + camY * p - vOy; + return obj.hitTest(worldX, worldY); + } + + const worldPos = { + x: (screenX - halfW) / zoom + camX, + y: (screenY - halfH) / zoom + camY, + }; + return obj.hitTest(worldPos.x, worldPos.y); +} + +function sortClickableCandidates(candidates: SceneObject[]): SceneObject[] { + const sorted = [...candidates]; + sorted.sort((a, b) => { + const layerA = a.layer || 0; + const layerB = b.layer || 0; + if (layerA !== layerB) return layerB - layerA; + + const hasXYA = 'x' in (a as any) && 'y' in (a as any); + const hasXYB = 'x' in (b as any) && 'y' in (b as any); + if (hasXYA && !hasXYB) return -1; + if (!hasXYA && hasXYB) return 1; + return 0; + }); + + return sorted; +} + +function getSortedClickableCandidates(scene: Scene): SceneObject[] { + return sortClickableCandidates([ + ...scene.entities.filter((e) => !e.disabled && e.visible), + ...(scene.triggerboxes?.filter((t) => !t.disabled && t.visible) || []), + ...(scene.walkbox?.filter((w) => !w.disabled && w.visible) || []), + ]); +} + +function findTopHitInCandidates( + scene: Scene, + candidates: SceneObject[], + screenX: number, + screenY: number +): SceneObject | null { + for (const candidate of sortClickableCandidates(candidates)) { + if (isHitAtScreenPoint(scene, candidate, screenX, screenY)) { + return candidate; + } + } + return null; +} + +function findTopHitInWorldCandidates( + candidates: SceneObject[], + worldX: number, + worldY: number +): SceneObject | null { + for (const candidate of sortClickableCandidates(candidates)) { + if (candidate.hitTest(worldX, worldY)) { + return candidate; + } + } + return null; +} + +function findTopHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { + return findTopHitInCandidates(scene, getSortedClickableCandidates(scene), screenX, screenY); +} + +function resolveSubtriggerTarget(scene: Scene, obj: SceneObject): SceneObject { + const subtrigger = obj.components?.find((c: any) => c?.type === 'Subtrigger') as + | { target?: string } + | undefined; + if (!subtrigger?.target) return obj; + + const target = + scene.triggerboxes.find((t) => t.name === subtrigger.target) || + scene.entities.find((e) => e.name === subtrigger.target); + return target || obj; +} + +export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): boolean { if (depth > 5) { console.warn('[Scene] Recursion limit reached.'); - return; + return false; } if (ComponentSystem.handleActivation(obj, scene, depth)) { - return; + return true; } if (obj instanceof Triggerbox && obj.script) { // Intentionally silent: triggering handled by systems/scripts + return true; } + + return false; } export function handleSceneClick(scene: Scene, x: number, y: number): void { const world = toWorld(scene, x, y); - const hitObj = scene.getHitObject(world.x, world.y); + + if (scene.activeSubscene) { + const subsceneHit = findTopHitInWorldCandidates( + Array.from(scene.subsceneEntities).filter((obj) => !obj.disabled && obj.visible), + world.x, + world.y + ); + + if (subsceneHit) { + const titleOwner = resolveSubtriggerTarget(scene, subsceneHit); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + if (title && title.trim()) { + scene.game.log(scene.game.text('engine.click_you_see', { title })); + } + activateSceneObject(scene, subsceneHit); + return; + } + + scene.activeSubscene = null; + return; + } + + const hitObj = findTopHitObject(scene, x, y); if (hitObj) { - const isWalkBox = hitObj.components && hitObj.components.some((c) => c.type === 'WalkBox'); - const isMechanism = - hitObj.components && - hitObj.components.some((c) => ['Switch', 'Subscene', 'Subtrigger'].includes(c.type)); - const hasScript = hitObj instanceof Triggerbox && hitObj.script && hitObj.script.length > 0; - - if (!(isWalkBox && !isMechanism && !hasScript)) { - activateSceneObject(scene, hitObj); + const titleOwner = resolveSubtriggerTarget(scene, hitObj); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + const activated = activateSceneObject(scene, hitObj); + + if (title) { + scene.game.log(scene.game.text('engine.click_you_see', { title })); + return; + } + + if (activated) { return; } } - if (scene.activeSubscene) { - for (const obj of scene.subsceneEntities) { - if (obj.hitTest(world.x, world.y)) { - return; - } + const visibleHitObj = findTopHitObject(scene, x, y) || findVisibleHitObject(scene, x, y); + if (visibleHitObj) { + const titleOwner = resolveSubtriggerTarget(scene, visibleHitObj); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + if (title && title.trim()) { + scene.game.log(scene.game.text('engine.click_you_see', { title })); + return; } - scene.activeSubscene = null; - return; } if (scene.player) { - if (typeof scene.player.walkTo === 'function') { - scene.player.walkTo(world.x, world.y); + const visualTarget = toWorld(scene, x, y); + if (typeof (scene.player as any).moveToVisual === 'function') { + (scene.player as any).moveToVisual(visualTarget.x, visualTarget.y); + } else if (typeof scene.player.walkTo === 'function') { + const playerParallax = scene.player.parallax !== undefined ? scene.player.parallax : 1.0; + const playerTarget = toWorldForParallax(scene, x, y, playerParallax); + scene.player.walkTo(playerTarget.x, playerTarget.y); } else if (typeof scene.player.moveTo === 'function') { - scene.player.moveTo(world.x, world.y); + const playerParallax = scene.player.parallax !== undefined ? scene.player.parallax : 1.0; + const playerTarget = toWorldForParallax(scene, x, y, playerParallax); + scene.player.moveTo(playerTarget.x, playerTarget.y); } } } diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index ca619eb2..02078ce1 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -73,14 +73,14 @@ export class SceneManager { const data = await response.json(); // Pass the derived ID to loadSceneData - this.loadSceneData(data, idFromPath); + await this.loadSceneData(data, idFromPath); } catch (e) { console.error(e); this.game.showNotification?.('Failed to load scene'); } } - loadSceneData(data: any, filename?: string): void { + async loadSceneData(data: any, filename?: string): Promise { try { // Priority: // 1. filename argument (derived from path: "sub\scene") @@ -96,6 +96,8 @@ export class SceneManager { // If ID was missing in File but provided by filename, ensure consistency newScene.id = sceneId; + if (data.description !== undefined) newScene.description = data.description; + if (data.textRedirects) newScene.textRedirects = { ...data.textRedirects }; // Restore Camera if (data.camera) { @@ -162,6 +164,7 @@ export class SceneManager { this.addScene(newScene); this.switchTo(newScene.id); + await this.game.textAssets.preloadScene(newScene); // If Editor is active, it needs to know if (this.game.editor) { diff --git a/src/scripts/DemoScripts.ts b/src/scripts/DemoScripts.ts index 89097c84..a56127ff 100644 --- a/src/scripts/DemoScripts.ts +++ b/src/scripts/DemoScripts.ts @@ -3,11 +3,11 @@ import { ScriptRegistry } from '../core/ScriptRegistry'; // We can improve types later to avoid 'any' export function registerDemoScripts() { ScriptRegistry.register('interaction.pillar.key', ({ game, entity }) => { - game.showMessage('You insert the key into a hidden slot in the pillar.'); - game.showMessage('Click! A secret compartment opens!'); + game.showMessage(game.text('scripts.pillar_key_inserted')); + game.showMessage(game.text('scripts.pillar_compartment_opened')); // Update entity state - entity.description = 'The pillar is open, revealing a secret compartment.'; + entity.description = game.text('scripts.pillar_open_description'); // Example of a permanent state change (we'll adding a real state system later) // game.state.set('pillar_opened', true); @@ -18,7 +18,7 @@ export function registerDemoScripts() { }); ScriptRegistry.register('test.audio', ({ game }) => { - game.showMessage('Playing test sound...'); + game.showMessage(game.text('scripts.test_audio_playing')); game.playSound('drawer_open.wav'); // Ensure it exists in public/sounds }); } diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 4609372f..46e065dd 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -95,7 +95,8 @@ export class ComponentSystem { // Called when trying to TAKE an item // Returns string (error message) or null (success) static canTakeItem(entity: SceneObject, player: Actor | null): string | null { - if (!entity.components) return 'You cannot take that.'; + const game = (entity as any).game as IGame | undefined; + if (!entity.components) return game?.text('parser.take_cannot') || 'You cannot take that.'; const itemComp = entity.components.find((c: any) => c.type === 'Item') as | ItemComponent @@ -112,7 +113,10 @@ export class ComponentSystem { const allowedDist = (player.width || 30) * 4; // Tolerance if (dist > allowedDist) { - return `You are too far away from the ${entity.name}.`; + return ( + game?.text('engine.too_far_from_entity', { target: entity.name }) || + `You are too far away from the ${entity.name}.` + ); } } @@ -178,7 +182,7 @@ export class ComponentSystem { if (dist > allowedDist) { const game = scene.game as unknown as IGame; if (game && typeof game.showMessage === 'function') { - game.showMessage('You are too far away.'); + game.showMessage(game.text('engine.too_far_generic')); } return true; // Blocked } @@ -208,7 +212,7 @@ export class ComponentSystem { (i) => i.name === sw.idKey || (i as unknown as { id?: string }).id === sw.idKey ); if (!hasKey) { - game.showMessage(`Locked. Needs ${sw.idKey}`); + game.showMessage(game.text('engine.locked_needs', { item: sw.idKey })); return true; // Handled (Blocked) } } diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index 71304674..d772e820 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -584,6 +584,9 @@ export class SceneEditor { newScene.scaling.enabled = true; this.game.sceneManager.addScene(newScene); this.game.sceneManager.switchTo(newScene.id); + this.game.textAssets.ensureSceneAssetFile(newScene).catch((err: unknown) => { + console.error('Failed to create default scene text asset:', err); + }); this.syncUI(); this.refreshHierarchy(); this.selectObject('SCENE'); diff --git a/src/tools/editor/EditorPersistenceManager.ts b/src/tools/editor/EditorPersistenceManager.ts index c5f9b1d5..4b414bcd 100644 --- a/src/tools/editor/EditorPersistenceManager.ts +++ b/src/tools/editor/EditorPersistenceManager.ts @@ -63,6 +63,7 @@ export class EditorPersistenceManager { }); if (response.ok) { + await this.editor.game.textAssets.ensureSceneAssetFile(scene); // Use Toast Message this.editor.game.showNotification(`Scene saved as ${normalizedPath}.json`); } else { diff --git a/src/tools/editor/EditorSelectionManager.ts b/src/tools/editor/EditorSelectionManager.ts index bd891f73..4a5d9679 100644 --- a/src/tools/editor/EditorSelectionManager.ts +++ b/src/tools/editor/EditorSelectionManager.ts @@ -558,7 +558,9 @@ export class EditorSelectionManager { const created: SceneObject[] = []; const preserveQuadBindings = payload.items.length > 1; - orderedItems.forEach((item) => { + orderedItems.forEach((item, index) => { + const originalName = + typeof payload.items[index]?.name === 'string' ? payload.items[index].name : item.name; const sourcePoint = this.getReferencePointFromSerializedData(item); const overrideX = insertionPoint.x + (sourcePoint.x - anchorSourcePoint.x); const overrideY = insertionPoint.y + (sourcePoint.y - anchorSourcePoint.y); @@ -571,7 +573,14 @@ export class EditorSelectionManager { const newObj = this.editor.createObjectFromData(item, overrideX, overrideY, { preserveBindings: preserveQuadBindings && item?.type === 'Quad', }); - if (newObj) created.push(newObj); + if (newObj) { + created.push(newObj); + if (originalName && originalName !== newObj.name) { + this.editor.game.textAssets + .duplicateObjectAssetIfExists(originalName, newObj.name) + .catch((err: unknown) => console.error('Failed to duplicate text asset:', err)); + } + } }); return created; diff --git a/vite.config.ts b/vite.config.ts index 41bf373c..1cdf2ac3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,16 @@ import fs from 'fs'; import path from 'path'; import { exec } from 'child_process'; +function ensureFile(targetPath: string, content: string) { + const dir = path.dirname(targetPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + if (!fs.existsSync(targetPath)) { + fs.writeFileSync(targetPath, content); + } +} + // https://vite.dev/config/ export default defineConfig({ plugins: [ @@ -45,6 +55,30 @@ export default defineConfig({ } }); + server.middlewares.use('/api/ensure-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Ensure file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); + // LIST FILES ENDPOINT server.middlewares.use('/api/list', (req, res, next) => { if (req.method === 'POST') { @@ -90,6 +124,30 @@ export default defineConfig({ next(); } }); + server.middlewares.use('/api/read-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + const fileContent = fs.readFileSync(targetPath, 'utf-8'); + res.statusCode = 200; + res.end(JSON.stringify({ success: true, content: fileContent })); + } catch (err) { + console.error('[Vite] Read file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); // OPEN FOLDER ENDPOINT server.middlewares.use('/api/open-folder', (req, res, next) => { if (req.method === 'POST') { @@ -122,6 +180,57 @@ export default defineConfig({ next(); } }); + server.middlewares.use('/api/open-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + + exec(`start "" "${targetPath}"`); + + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Open file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); + server.middlewares.use('/api/delete-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + if (fs.existsSync(targetPath)) { + fs.unlinkSync(targetPath); + } + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Delete file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); }, }, ],