| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664 |
- import { stringify } from "yaml";
- import type { SceneEntity } from "@nengine/core-schema";
- import {
- TILE_SIZE,
- addTileId,
- erase,
- isWalkableTile,
- markSaved as markMapSaved,
- paint,
- pointToTile,
- serialize as serializeMap,
- setSpawn,
- tileColor,
- toggleWalkable,
- } from "./map-model.js";
- import type { MapModel } from "./map-model.js";
- import {
- SELECT_COLOR,
- addAnimal,
- addBuilding,
- addNpc,
- describeEntity,
- entityColor,
- entityFootprint,
- entityIndexAt,
- markSaved as markSceneSaved,
- moveEntity,
- removeEntity,
- serialize as serializeScene,
- } from "./scene-model.js";
- import type { SceneOpResult, SceneFootprint, SceneModel } from "./scene-model.js";
- import type { PlaytestFlush } from "./playtest-session.js";
- export interface SceneBuildingPaletteEntry {
- id: string;
- name?: string;
- footprint: SceneFootprint;
- }
- export interface SceneEditorOptions {
- root: string;
- scenePath: string;
- mapPath: string;
- mapModel: MapModel;
- sceneModel: SceneModel;
- /** Building palette entries with footprint data from the project's defs. */
- buildings: SceneBuildingPaletteEntry[];
- /** Animal ids from the project's animal defs. */
- animals: string[];
- onStatus: (message: string, isError?: boolean) => void;
- onSaved?: () => void;
- onFlush?: (collect: () => PlaytestFlush[]) => void;
- }
- type SceneEditorMode = "scene" | "map";
- type MapTool = "paint" | "erase" | "spawn" | "walkable";
- type ActivePlacement = { type: "building"; id: string } | { type: "animal"; id: string } | { type: "npc"; id: string } | null;
- const MAP_TOOLS: { id: MapTool; label: string; title: string }[] = [
- { id: "paint", label: "Paint", title: "Left-click / drag paints the selected tile" },
- { id: "erase", label: "Erase", title: "Left-drag erases; right-click erases anywhere" },
- { id: "spawn", label: "Spawn", title: "Click moves the spawn point" },
- { id: "walkable", label: "Walkable", title: "Shows blocked tiles and toggles a tile id's walkability on click" },
- ];
- export function mountSceneEditor(container: HTMLElement, options: SceneEditorOptions): () => void {
- const root = document.createElement("div");
- root.className = "scene-editor";
- container.replaceChildren(root);
- let mode: SceneEditorMode = "scene";
- let activePlacement: ActivePlacement = null;
- let selectedIndex: number | null = null;
- let mapTool: MapTool = "paint";
- let mapSelectedId = options.mapModel.palette[0] ?? "";
- let painting = false;
- let disposed = false;
- const toolbar = document.createElement("div");
- toolbar.className = "scene-toolbar";
- const title = document.createElement("h3");
- title.textContent = `Scene: ${options.sceneModel.def.id}`;
- const path = document.createElement("span");
- path.className = "scene-path";
- path.textContent = options.scenePath;
- path.title = options.scenePath;
- const modeToggle = document.createElement("div");
- modeToggle.className = "scene-mode-toggle";
- const modeButtons = new Map<SceneEditorMode, HTMLButtonElement>();
- for (const [value, label] of [["scene", "Edit Scene"], ["map", "Edit Map"]] as const) {
- const button = document.createElement("button");
- button.type = "button";
- button.className = "scene-mode-button";
- button.textContent = label;
- button.addEventListener("click", () => setMode(value));
- modeButtons.set(value, button);
- modeToggle.appendChild(button);
- }
- const save = document.createElement("button");
- save.type = "button";
- save.className = "primary";
- save.addEventListener("click", () => void saveAll());
- toolbar.append(title, path, modeToggle, save);
- root.appendChild(toolbar);
- const body = document.createElement("div");
- body.className = "scene-body";
- const palette = document.createElement("div");
- palette.className = "scene-palette";
- body.appendChild(palette);
- const main = document.createElement("div");
- main.className = "scene-main";
- const wrap = document.createElement("div");
- wrap.className = "map-canvas-wrap";
- const canvas = document.createElement("canvas");
- canvas.width = options.mapModel.def.width * TILE_SIZE;
- canvas.height = options.mapModel.def.height * TILE_SIZE;
- wrap.appendChild(canvas);
- main.appendChild(wrap);
- const entityList = document.createElement("div");
- entityList.className = "scene-entity-list";
- const listTitle = document.createElement("h4");
- listTitle.textContent = "Entities";
- const listBody = document.createElement("div");
- listBody.className = "scene-entity-rows";
- entityList.append(listTitle, listBody);
- main.appendChild(entityList);
- body.appendChild(main);
- root.appendChild(body);
- function setMode(next: SceneEditorMode): void {
- mode = next;
- activePlacement = null;
- selectedIndex = null;
- for (const [value, button] of modeButtons) button.classList.toggle("selected", value === next);
- renderPalette();
- draw();
- }
- function setArm(placement: NonNullable<ActivePlacement>): void {
- activePlacement = placement;
- selectedIndex = null;
- renderPalette();
- renderEntityList();
- draw();
- }
- function setSelected(index: number | null): void {
- activePlacement = null;
- selectedIndex = index;
- renderPalette();
- renderEntityList();
- draw();
- }
- function renderPalette(): void {
- palette.replaceChildren();
- if (mode === "scene") renderScenePalette();
- else renderMapPalette();
- }
- function renderScenePalette(): void {
- const buildings = sectionHeader("Buildings");
- const buildingList = document.createElement("div");
- buildingList.className = "scene-palette-list";
- if (options.buildings.length === 0) {
- const none = document.createElement("span");
- none.className = "muted";
- none.textContent = "no building defs in the project";
- buildingList.appendChild(none);
- }
- for (const entry of options.buildings) {
- const button = document.createElement("button");
- button.type = "button";
- button.className = "scene-palette-item";
- const active = activePlacement !== null && activePlacement.type === "building" && activePlacement.id === entry.id;
- if (active) button.classList.add("selected");
- const badge = document.createElement("span");
- badge.className = "scene-palette-badge";
- badge.textContent = `${entry.footprint.w}×${entry.footprint.h}`;
- const label = document.createElement("span");
- label.textContent = entry.name ?? entry.id;
- button.title = `${entry.id} — ${entry.footprint.w}×${entry.footprint.h} footprint`;
- button.append(label, badge);
- button.addEventListener("click", () => setArm({ type: "building", id: entry.id }));
- buildingList.appendChild(button);
- }
- palette.append(buildings, buildingList);
- const animals = sectionHeader("Animals");
- const animalList = document.createElement("div");
- animalList.className = "scene-palette-list";
- if (options.animals.length === 0) {
- const none = document.createElement("span");
- none.className = "muted";
- none.textContent = "no animal defs in the project";
- animalList.appendChild(none);
- }
- for (const id of options.animals) {
- const button = document.createElement("button");
- button.type = "button";
- button.className = "scene-palette-item";
- if (activePlacement !== null && activePlacement.type === "animal" && activePlacement.id === id) button.classList.add("selected");
- const label = document.createElement("span");
- label.textContent = id;
- button.append(label);
- button.addEventListener("click", () => setArm({ type: "animal", id }));
- animalList.appendChild(button);
- }
- palette.append(animals, animalList);
- const npcSection = sectionHeader("NPC");
- const npcForm = document.createElement("div");
- npcForm.className = "scene-npc-form";
- const npcIdInput = document.createElement("input");
- npcIdInput.type = "text";
- npcIdInput.placeholder = "npc id";
- npcIdInput.spellcheck = false;
- const dialogueInput = document.createElement("input");
- dialogueInput.type = "text";
- dialogueInput.placeholder = "dialogue id (optional)";
- dialogueInput.spellcheck = false;
- const labelInput = document.createElement("input");
- labelInput.type = "text";
- labelInput.placeholder = "label (optional)";
- labelInput.spellcheck = false;
- const npcButton = document.createElement("button");
- npcButton.type = "button";
- npcButton.textContent = "Place NPC";
- npcButton.addEventListener("click", () => {
- const id = npcIdInput.value.trim();
- if (id === "") {
- options.onStatus("enter an npc id first", true);
- return;
- }
- setArm({ type: "npc", id });
- options.onStatus(`armed: place npc "${id}" on the map`);
- });
- npcForm.append(npcIdInput, dialogueInput, labelInput, npcButton);
- palette.append(npcSection, npcForm);
- }
- function renderMapPalette(): void {
- const tools = document.createElement("div");
- tools.className = "map-tools";
- const toolButtons = new Map<MapTool, HTMLButtonElement>();
- for (const tool of MAP_TOOLS) {
- const button = document.createElement("button");
- button.type = "button";
- button.className = "map-tool-button";
- button.textContent = tool.label;
- button.title = tool.title;
- button.addEventListener("click", () => {
- mapTool = tool.id === mapTool && tool.id === "walkable" ? "paint" : tool.id;
- renderPalette();
- draw();
- });
- toolButtons.set(tool.id, button);
- tools.appendChild(button);
- }
- for (const [id, button] of toolButtons) button.classList.toggle("selected", id === mapTool);
- palette.append(tools);
- const list = document.createElement("div");
- list.className = "palette-list";
- for (const id of options.mapModel.palette) {
- const swatch = document.createElement("button");
- swatch.type = "button";
- swatch.className = "palette-swatch";
- if (id === mapSelectedId && mapTool === "paint") swatch.classList.add("selected");
- const chip = document.createElement("span");
- chip.className = "swatch-chip";
- chip.style.background = tileColor(id);
- const label = document.createElement("span");
- label.className = "swatch-label";
- label.textContent = isWalkableTile(options.mapModel, id) ? id : `${id} (blocked)`;
- swatch.append(chip, label);
- swatch.addEventListener("click", () => {
- if (mapTool === "walkable") {
- toggleWalkable(options.mapModel, id);
- renderPalette();
- draw();
- updateSave();
- } else {
- mapSelectedId = id;
- renderPalette();
- }
- });
- list.appendChild(swatch);
- }
- palette.append(list);
- const addForm = document.createElement("form");
- addForm.className = "add-tile-form";
- const addInput = document.createElement("input");
- addInput.type = "text";
- addInput.placeholder = "tile id";
- const addButton = document.createElement("button");
- addButton.type = "submit";
- addButton.textContent = "Add";
- addForm.append(addInput, addButton);
- addForm.addEventListener("submit", (event) => {
- event.preventDefault();
- const value = addInput.value.trim();
- addInput.value = "";
- if (value !== "") {
- addTileId(options.mapModel, value);
- renderPalette();
- }
- });
- palette.append(addForm);
- }
- function renderEntityList(): void {
- listBody.replaceChildren();
- if (options.sceneModel.def.entities.length === 0) {
- const none = document.createElement("span");
- none.className = "muted";
- none.textContent = "no entities yet — pick from the palette and click the map";
- listBody.appendChild(none);
- return;
- }
- options.sceneModel.def.entities.forEach((entity, index) => {
- const row = document.createElement("div");
- row.className = "scene-entity-row";
- if (index === selectedIndex) row.classList.add("selected");
- const dot = document.createElement("span");
- dot.className = "scene-entity-dot";
- dot.style.background = entityColor(entity);
- const label = document.createElement("span");
- label.textContent = entityLabel(entity);
- label.title = describeEntity(entity);
- const coords = document.createElement("span");
- coords.className = "scene-entity-coords";
- coords.textContent = `(${entity.x},${entity.y})`;
- const removeButton = document.createElement("button");
- removeButton.type = "button";
- removeButton.className = "scene-entity-delete";
- removeButton.textContent = "×";
- removeButton.title = "Remove entity";
- removeButton.addEventListener("click", (event) => {
- event.stopPropagation();
- if (window.confirm(`Remove ${entityLabel(entity)}?`)) {
- const result = removeEntity(options.sceneModel, index);
- if (result.ok) {
- selectedIndex = null;
- options.onStatus(`removed ${entityLabel(entity)}`);
- } else {
- options.onStatus(result.reason, true);
- }
- renderEntityList();
- draw();
- updateSave();
- }
- });
- row.append(dot, label, coords, removeButton);
- row.addEventListener("click", () => setSelected(selectedIndex === index ? null : index));
- listBody.appendChild(row);
- });
- }
- function draw(): void {
- if (disposed) return;
- const ctx = canvas.getContext("2d");
- if (ctx === null) return;
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- drawTiles(ctx);
- drawGrid(ctx);
- if (mode === "map") {
- if (mapTool === "walkable") drawBlocked(ctx);
- drawSpawn(ctx);
- }
- drawEntities(ctx);
- }
- function drawTiles(ctx: CanvasRenderingContext2D): void {
- const data = options.mapModel.def.layers[options.mapModel.groundIndex]?.data;
- for (let y = 0; y < options.mapModel.def.height; y++) {
- for (let x = 0; x < options.mapModel.def.width; x++) {
- const tile = data?.[y]?.[x] ?? "";
- ctx.fillStyle = tileColor(tile);
- ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
- }
- }
- }
- function drawGrid(ctx: CanvasRenderingContext2D): void {
- ctx.strokeStyle = "rgba(10, 12, 16, 0.35)";
- ctx.lineWidth = 1;
- ctx.beginPath();
- for (let x = 0; x <= options.mapModel.def.width; x++) {
- ctx.moveTo(x * TILE_SIZE + 0.5, 0);
- ctx.lineTo(x * TILE_SIZE + 0.5, canvas.height);
- }
- for (let y = 0; y <= options.mapModel.def.height; y++) {
- ctx.moveTo(0, y * TILE_SIZE + 0.5);
- ctx.lineTo(canvas.width, y * TILE_SIZE + 0.5);
- }
- ctx.stroke();
- }
- function drawBlocked(ctx: CanvasRenderingContext2D): void {
- const data = options.mapModel.def.layers[options.mapModel.groundIndex]?.data;
- for (let y = 0; y < options.mapModel.def.height; y++) {
- for (let x = 0; x < options.mapModel.def.width; x++) {
- const tile = data?.[y]?.[x] ?? "";
- if (tile === "" || isWalkableTile(options.mapModel, tile)) continue;
- ctx.strokeStyle = "rgba(231, 76, 60, 0.9)";
- ctx.lineWidth = 2;
- const px = x * TILE_SIZE;
- const py = y * TILE_SIZE;
- ctx.beginPath();
- ctx.moveTo(px, py);
- ctx.lineTo(px + TILE_SIZE, py + TILE_SIZE);
- ctx.moveTo(px + TILE_SIZE, py);
- ctx.lineTo(px, py + TILE_SIZE);
- ctx.stroke();
- }
- }
- }
- function drawSpawn(ctx: CanvasRenderingContext2D): void {
- const sx = options.mapModel.def.spawn.x * TILE_SIZE + TILE_SIZE / 2;
- const sy = options.mapModel.def.spawn.y * TILE_SIZE + TILE_SIZE / 2;
- ctx.strokeStyle = "#ffd54f";
- ctx.fillStyle = "#ffd54f";
- ctx.lineWidth = 3;
- ctx.beginPath();
- ctx.arc(sx, sy, TILE_SIZE * 0.32, 0, Math.PI * 2);
- ctx.stroke();
- ctx.beginPath();
- ctx.arc(sx, sy, TILE_SIZE * 0.14, 0, Math.PI * 2);
- ctx.fill();
- }
- function drawEntities(ctx: CanvasRenderingContext2D): void {
- options.sceneModel.def.entities.forEach((entity, index) => {
- const footprint = entityFootprint(options.sceneModel, entity);
- const px = entity.x * TILE_SIZE;
- const py = entity.y * TILE_SIZE;
- if (entity.type === "building") {
- ctx.fillStyle = entityColor(entity);
- ctx.globalAlpha = 0.85;
- ctx.fillRect(px, py, footprint.w * TILE_SIZE, footprint.h * TILE_SIZE);
- ctx.globalAlpha = 1;
- ctx.strokeStyle = "rgba(10, 12, 16, 0.6)";
- ctx.lineWidth = 1;
- ctx.strokeRect(px + 0.5, py + 0.5, footprint.w * TILE_SIZE - 1, footprint.h * TILE_SIZE - 1);
- ctx.fillStyle = "#e6e9ec";
- ctx.font = "10px system-ui, sans-serif";
- ctx.fillText(entity.buildingId, px + 3, py + 12);
- } else {
- const cx = px + TILE_SIZE / 2;
- const cy = py + TILE_SIZE / 2;
- const radius = entity.type === "npc" ? TILE_SIZE * 0.3 : TILE_SIZE * 0.25;
- ctx.fillStyle = entityColor(entity);
- ctx.beginPath();
- ctx.arc(cx, cy, radius, 0, Math.PI * 2);
- ctx.fill();
- ctx.strokeStyle = "rgba(10, 12, 16, 0.6)";
- ctx.lineWidth = 1;
- ctx.stroke();
- ctx.fillStyle = "#e6e9ec";
- ctx.font = "10px system-ui, sans-serif";
- ctx.fillText(entity.type === "npc" ? entity.npcId : entity.animalId, px + 2, py + 24);
- }
- if (index === selectedIndex) {
- ctx.strokeStyle = SELECT_COLOR;
- ctx.lineWidth = 2;
- ctx.strokeRect(px - 1.5, py - 1.5, footprint.w * TILE_SIZE + 3, footprint.h * TILE_SIZE + 3);
- }
- });
- }
- function cellAt(event: MouseEvent): { x: number; y: number } {
- const rect = canvas.getBoundingClientRect();
- return pointToTile(event.clientX - rect.left, event.clientY - rect.top);
- }
- function applyPlacement(result: SceneOpResult, placed: string): void {
- if (result.ok) {
- const last = options.sceneModel.def.entities[options.sceneModel.def.entities.length - 1];
- const at = last !== undefined ? ` at (${last.x},${last.y})` : "";
- options.onStatus(`placed ${placed}${at}`);
- } else {
- options.onStatus(result.reason, true);
- }
- renderEntityList();
- draw();
- updateSave();
- }
- function applyMapAction(x: number, y: number): void {
- const model = options.mapModel;
- const paletteBefore = model.palette.join("\u0000");
- switch (mapTool) {
- case "paint":
- paint(model, x, y, mapSelectedId);
- break;
- case "erase":
- erase(model, x, y);
- break;
- case "spawn":
- setSpawn(model, x, y);
- break;
- case "walkable": {
- const tile = model.def.layers[model.groundIndex]?.data[y]?.[x] ?? "";
- if (tile !== "") toggleWalkable(model, tile);
- break;
- }
- }
- draw();
- if (mapTool === "walkable" || model.palette.join("\u0000") !== paletteBefore) renderPalette();
- updateSave();
- }
- function updateSave(): void {
- const dirty = options.sceneModel.dirty || options.mapModel.dirty;
- save.textContent = dirty ? "Save *" : "Save";
- save.disabled = !dirty;
- }
- async function saveAll(): Promise<void> {
- const saved: string[] = [];
- if (options.sceneModel.dirty) {
- const def = serializeScene(options.sceneModel);
- if (def === null) {
- options.onStatus(`${options.scenePath}: scene invalid — not saved`, true);
- return;
- }
- try {
- await window.nengine.project.writeYaml({ root: options.root, path: options.scenePath, content: stringify(def) });
- markSceneSaved(options.sceneModel);
- saved.push(options.scenePath);
- } catch (err) {
- options.onStatus(err instanceof Error ? err.message : String(err), true);
- return;
- }
- }
- if (options.mapModel.dirty) {
- const def = serializeMap(options.mapModel);
- if (def === null) {
- options.onStatus(`${options.mapPath}: map invalid — not saved`, true);
- return;
- }
- try {
- await window.nengine.project.writeYaml({ root: options.root, path: options.mapPath, content: stringify(def) });
- markMapSaved(options.mapModel);
- saved.push(options.mapPath);
- } catch (err) {
- options.onStatus(err instanceof Error ? err.message : String(err), true);
- return;
- }
- }
- if (saved.length > 0) {
- updateSave();
- options.onStatus(`saved ${saved.join(" · ")}`);
- options.onSaved?.();
- }
- }
- canvas.addEventListener("mousedown", (event) => {
- if (event.button === 2) {
- if (mode === "map") {
- const { x, y } = cellAt(event);
- erase(options.mapModel, x, y);
- draw();
- updateSave();
- }
- event.preventDefault();
- return;
- }
- if (event.button !== 0) return;
- const { x, y } = cellAt(event);
- if (mode === "map") {
- painting = true;
- applyMapAction(x, y);
- return;
- }
- if (activePlacement !== null) {
- if (activePlacement.type === "npc") {
- applyPlacement(addNpc(options.sceneModel, activePlacement.id, x, y), `npc "${activePlacement.id}"`);
- } else if (activePlacement.type === "building") {
- applyPlacement(addBuilding(options.sceneModel, activePlacement.id, x, y), `building "${activePlacement.id}"`);
- } else {
- applyPlacement(addAnimal(options.sceneModel, activePlacement.id, x, y), `animal "${activePlacement.id}"`);
- }
- return;
- }
- const index = entityIndexAt(options.sceneModel, x, y);
- if (selectedIndex !== null) {
- if (index === -1) {
- const entity = options.sceneModel.def.entities[selectedIndex];
- if (entity === undefined) return;
- const result = moveEntity(options.sceneModel, selectedIndex, x, y);
- if (result.ok) options.onStatus(`moved ${describeEntity(entity)} to (${x},${y})`);
- else options.onStatus(result.reason, true);
- renderEntityList();
- draw();
- updateSave();
- return;
- }
- if (index === selectedIndex) setSelected(null);
- else setSelected(index);
- return;
- }
- setSelected(index === -1 ? null : index);
- });
- canvas.addEventListener("mousemove", (event) => {
- if (mode !== "map" || !painting) return;
- const { x, y } = cellAt(event);
- applyMapAction(x, y);
- });
- canvas.addEventListener("mouseup", () => {
- painting = false;
- });
- canvas.addEventListener("mouseleave", () => {
- painting = false;
- });
- canvas.addEventListener("contextmenu", (event) => event.preventDefault());
- setMode("scene");
- renderEntityList();
- updateSave();
- if (options.onFlush !== undefined) {
- options.onFlush(() => {
- const flushes: PlaytestFlush[] = [];
- if (options.sceneModel.dirty) {
- const def = serializeScene(options.sceneModel);
- if (def === null) flushes.push({ path: options.scenePath, error: "scene invalid — not saved" });
- else flushes.push({ path: options.scenePath, content: stringify(def) });
- }
- if (options.mapModel.dirty) {
- const def = serializeMap(options.mapModel);
- if (def === null) flushes.push({ path: options.mapPath, error: "map invalid — not saved" });
- else flushes.push({ path: options.mapPath, content: stringify(def) });
- }
- return flushes;
- });
- }
- return () => {
- disposed = true;
- };
- }
- function sectionHeader(text: string): HTMLHeadingElement {
- const header = document.createElement("h4");
- header.className = "scene-palette-section";
- header.textContent = text;
- return header;
- }
- function entityLabel(entity: SceneEntity): string {
- if (entity.type === "building") return `building ${entity.buildingId}`;
- if (entity.type === "npc") return `npc ${entity.npcId}`;
- return `animal ${entity.animalId}`;
- }
|