The great move >.<

This commit is contained in:
kolaflash
2026-02-05 22:26:34 +01:00
parent 175ea1b726
commit 083cb70fbe
90 changed files with 85 additions and 137 deletions
+33
View File
@@ -0,0 +1,33 @@
.file-browser {
background: var(--c-bg-0);
margin-left: calc(var(--sp-2) * -1);
}
.file-browser-list {
margin: 0;
list-style: none;
padding: 0 0 0 var(--sp-2);
}
.file-brwoser-item {
display: flex;
flex-flow: column nowrap;
}
.file-browser-label {
display: flex;
justify-content: flex-start;
align-items: center;
padding: var(--sp-1);
border-radius: var(--sp-1);
cursor: pointer;
svg {
margin-right: var(--sp-1);
}
&.active,
&:hover {
background: var(--c-bg-2);
}
}
+96
View File
@@ -0,0 +1,96 @@
import "./FileBrowser.css";
import {
type Accessor,
type Component,
createSignal,
For,
Show,
} from "solid-js";
import { FileIcon } from "./icons/File.tsx";
import { FolderIcon } from "./icons/Folder.tsx";
import { FolderOpenIcon } from "./icons/FolderOpen.tsx";
export type BrowserFile = {
path: string;
isActive: boolean;
};
export type BrowserFolder = Map<string, BrowserFile | BrowserFolder>;
export type FileHandler = (path: string) => void;
function isFolder(entry: BrowserFile | BrowserFolder): entry is BrowserFolder {
return typeof (entry as BrowserFolder).size === "number";
}
export const FileBrowser: Component<{
root: Accessor<BrowserFolder>;
handler: FileHandler;
}> = (props) => {
const FileEntry: Component<{
name: string;
entry: BrowserFile;
}> = ({ name, entry }) => {
return (
<div
class="file-browser-label"
classList={{
active: entry.isActive,
}}
onClick={() => props.handler(entry.path)}
>
<FileIcon />
<span>{name}</span>
</div>
);
};
const FolderEntry: Component<{
folder: BrowserFolder;
name: string;
open: boolean;
}> = ({ folder, name, open }) => {
const [isOpen, setIsOpen] = createSignal(open);
return (
<>
<div class="file-browser-label" onClick={() => setIsOpen(!isOpen())}>
{isOpen() ? <FolderOpenIcon /> : <FolderIcon />}
<span>{name}</span>
</div>
<Show when={isOpen()}>
<FileList folder={folder} />
</Show>
</>
);
};
const FileList: Component<{ folder: BrowserFolder }> = (props) => {
return (
<ul class="file-browser-list">
<For each={[...props.folder.entries()]}>
{(item, index) => {
const [name, entry] = item;
return (
<li class="file-browser-item">
{isFolder(entry) ? (
<FolderEntry folder={entry} name={name} open={index() < 1} />
) : (
<FileEntry name={name} entry={entry} />
)}
</li>
);
}}
</For>
</ul>
);
};
return (
<div class="file-browser">
<FileList folder={props.root()} />
</div>
);
};
+11
View File
@@ -0,0 +1,11 @@
.graph-browser {
border-radius: var(--sp-1);
overflow: hidden;
canvas {
display: block;
user-select: none;
height: 100%;
width: 100%;
}
}
+246
View File
@@ -0,0 +1,246 @@
import "./GraphBrowser.css";
import { type Component, createSignal, onMount } from "solid-js";
import {
type Edge,
type Graph,
getNeighbours,
type Node,
} from "#/engine/Graph.ts";
import { mulberry32 } from "#/engine/lib/mulberry32.ts";
import { currentScene } from "#/engine/Scene.ts";
import {
createVector,
getDistance,
scale,
setDistanceNormal,
type Vector,
} from "#/engine/Vector.ts";
type PhysicsPartial = {
position: Vector;
velocity: Vector;
acceleration: Vector;
};
function drawNode(
node: Node<PhysicsPartial>,
ctx: CanvasRenderingContext2D,
color: string = "#dbdee1",
): void {
const text = (node as PhysicsPartial & { name: string }).name;
const metrics = ctx.measureText(text);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(
node.position.x - metrics.width / 2,
node.position.y,
11,
Math.PI * 0.5,
Math.PI * 1.5,
);
ctx.arc(
node.position.x + metrics.width / 2,
node.position.y,
11,
-Math.PI * 0.5,
-Math.PI * 1.5,
);
ctx.fill();
ctx.shadowColor = "#000";
ctx.fillStyle = "#fff";
ctx.fillText(text, node.position.x, node.position.y);
ctx.shadowColor = "transparent";
}
function drawEdge(
edge: Edge<PhysicsPartial>,
ctx: CanvasRenderingContext2D,
color: string = "#dbdee1",
): void {
const tail = edge[0].position;
const tip = edge[1].position;
ctx.lineWidth = 2;
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(tail.x, tail.y);
ctx.lineTo(tip.x, tip.y);
ctx.stroke();
}
function applyRepulsion(nodes: Array<PhysicsPartial>): void {
const strength = 4;
nodes.forEach((node, index) => {
for (let i = 0; i < nodes.length; i++) {
if (index === i) {
continue;
}
const a = node.position;
const b = nodes[i].position;
const dx = a.x - b.x;
const dy = a.y - b.y;
const direction = createVector(dx, dy);
const distanceSquare = dx * dx + dy * dy;
const magnitude = strength / (distanceSquare + Number.EPSILON);
scale(direction, magnitude);
node.acceleration.x += direction.x;
node.acceleration.y += direction.y;
}
});
}
function hookEdge<T extends PhysicsPartial>([a, b]: Edge<T>): void {
const restLength = 86;
const stiffness = 0.05;
const direction = createVector();
setDistanceNormal(direction, a.position, b.position);
const distance = getDistance(a.position, b.position);
const magnitude = (distance - restLength) * stiffness + Number.EPSILON;
scale(direction, magnitude);
a.acceleration.x -= direction.x;
a.acceleration.y -= direction.y;
b.acceleration.x += direction.x;
b.acceleration.y += direction.y;
}
export const GraphBrowser: Component<{
graph: Graph<string>;
seed: number;
}> = (props) => {
let canvasRef!: HTMLCanvasElement;
const next = mulberry32(props.seed);
const [width] = createSignal(300);
const [height] = createSignal(300);
const enhanceNode = (node: string) => {
return {
name: node,
position: createVector(next() * 128 - 64, next() * 32 - 16),
velocity: createVector(),
acceleration: createVector(),
};
};
const nodes: Array<Node<PhysicsPartial & { name: string }>> = [];
let edges: Array<Edge<PhysicsPartial>> = [];
const updateEdges = () => {
edges = props.graph.entries().reduce(
(acc, [name, neighbours]) => {
const node = nodes.find((n) => n.name === name)!;
for (const neighbour of neighbours) {
const n = nodes.find((node) => node.name === neighbour);
if (n !== undefined) {
acc.push([node, n]);
}
}
return acc;
},
[] as Array<Edge<PhysicsPartial>>,
);
};
onMount(() => {
const ctx: CanvasRenderingContext2D = canvasRef.getContext("2d", {
alpha: false,
})!;
ctx.font = "18px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowBlur = 2;
const damping = 0.8;
const animate = (): void => {
for (const node of props.graph.keys()) {
if (nodes.find((n) => n.name === node) === undefined) {
nodes.push(enhanceNode(node));
updateEdges();
}
}
const activeNode = props.graph
.keys()
.find((name) => name === currentScene.data.name);
const activeNeighbours = getNeighbours(props.graph, activeNode!);
applyRepulsion(nodes);
for (const edge of edges) {
hookEdge(edge);
}
let absV = 0;
for (const node of nodes) {
node.velocity.x += node.acceleration.x;
node.velocity.y += node.acceleration.y;
node.velocity.x *= damping;
node.velocity.y *= damping;
node.position.x += node.velocity.x;
node.position.y += node.velocity.y;
node.acceleration.x = 0;
node.acceleration.y = 0;
absV = Math.abs(node.velocity.x) + Math.abs(node.velocity.y);
}
if (absV < 0.01) {
ctx.restore();
ctx.clearRect(0, 0, canvasRef.width, canvasRef.height);
ctx.save();
const activeRenderNode = nodes.find((n) => n.name === activeNode);
if (!activeRenderNode) {
setTimeout(animate, 100);
return;
}
ctx.translate(
width() / 2 - activeRenderNode.position.x,
height() / 2 - activeRenderNode.position.y,
);
for (const edge of edges) {
drawEdge(edge, ctx);
}
for (const node of nodes) {
if (activeNeighbours.includes(node.name)) {
drawNode(node, ctx, "#6572f5");
} else {
drawNode(node, ctx, "#404249");
}
}
drawNode(activeRenderNode, ctx, "#ee459e");
setTimeout(animate, 100);
} else {
animate();
}
};
animate();
});
return (
<div class="graph-browser">
<canvas ref={canvasRef} width={width()} height={height()}></canvas>
</div>
);
};
+74
View File
@@ -0,0 +1,74 @@
.item-list {
ul {
background: var(--c-bg-1);
color: var(--c-fg-1);
list-style: none;
margin: 0;
padding: 0;
border-radius: 0 0 var(--sp-1) var(--sp-1);
}
li {
padding: var(--sp-1) var(--sp-2);
border-radius: var(--sp-1);
cursor: pointer;
&.active,
&:hover {
background: var(--c-bg-3);
}
}
button {
color: inherit;
border-radius: var(--sp-1);
border: none;
background: transparent;
padding: var(--sp-1);
margin-left: var(--sp-1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
button:hover {
background: var(--c-bg-3);
}
svg {
width: 1.5em;
height: 1.5em;
}
}
.item-list-header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--c-fg-3);
}
.item-list-name {
padding: 0 var(--sp-1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-start;
width: fit-content;
border-radius: var(--sp-1);
&:hover {
background: var(--c-bg-3);
}
svg {
margin-left: var(--sp-1);
width: 0.875em;
height: 0.875em;
}
}
.item-list-name.hide {
svg {
rotate: 0.75turn;
}
}
+176
View File
@@ -0,0 +1,176 @@
import "./ItemList.css";
import {
type Accessor,
type Component,
createEffect,
createSignal,
For,
Match,
Show,
type Signal,
Switch,
} from "solid-js";
import { ArrowNarrowDownIcon } from "./icons/ArrowNarrowDown.tsx";
import { ArrowNarrowUpIcon } from "./icons/ArrowNarrowUp.tsx";
import { ArrowsSortIcon } from "./icons/ArrowsSort.tsx";
import { ChevronDownIcon } from "./icons/ChevronDown.tsx";
export type ListItem = { label: string; isActive: boolean };
export type ItemsRef = Array<{ item: ListItem; index: number }>;
export type ItemHandler = (items: ItemsRef) => void;
export type Direction = "default" | "asc" | "desc";
function mapActiveItem(items: ItemsRef, index: number): ItemsRef {
return items.map((ref, idx) => ({
item: {
...ref.item,
isActive: idx === index,
},
index: ref.index,
}));
}
function useDirection(
signal: Signal<Direction>,
): [Accessor<Direction>, (forcedDirection?: Direction) => Direction] {
const [direction, setDirection] = signal;
return [
direction,
(forcedDirection) => {
if (forcedDirection) {
setDirection(forcedDirection);
return direction();
}
switch (direction()) {
case "asc": {
setDirection("desc");
break;
}
case "desc": {
setDirection("default");
break;
}
default: {
setDirection("asc");
}
}
return direction();
},
];
}
function createItemsRef(items: Array<ListItem>): ItemsRef {
return items.map((item, index) => ({
item,
index,
}));
}
export const ItemList: Component<{
name: string;
items: Accessor<string[]>;
handler: ItemHandler;
}> = (props) => {
const [showList, setShowList] = createSignal(true);
const [items, setItems] = createSignal(
createItemsRef(props.items().map((i) => ({ label: i, isActive: false }))),
);
const [direction, toggleDirection] = useDirection(
createSignal<Direction>("default"),
);
createEffect(() => {
sortItems(
createItemsRef(props.items().map((i) => ({ label: i, isActive: false }))),
direction(),
);
});
const sortItems = (items: ItemsRef, direction: Direction) => {
const newItems = [...items];
if (direction === "default") {
newItems.sort((a, b) => a.index - b.index);
setItems(newItems);
return;
}
newItems.sort((a, b) => {
const labelA = a.item.label.toLocaleLowerCase();
const labelB = b.item.label.toLocaleLowerCase();
if (labelA < labelB) {
return direction === "asc" ? -1 : 1;
}
if (labelA > labelB) {
return direction === "asc" ? 1 : -1;
}
return 0;
});
setItems(newItems);
};
return (
<div class="item-list">
<div class="item-list-header">
<div
class="item-list-name"
classList={{
hide: !showList(),
}}
role="button"
onClick={() => setShowList(!showList())}
>
{props.name}
<ChevronDownIcon />
</div>
<button
type="button"
onClick={() => {
sortItems(items(), toggleDirection());
}}
>
<Switch fallback={<ArrowsSortIcon />}>
<Match when={direction() === "asc"}>
<ArrowNarrowUpIcon />
</Match>
<Match when={direction() === "desc"}>
<ArrowNarrowDownIcon />
</Match>
</Switch>
</button>
</div>
<Show when={showList()}>
<ul>
<For each={items()}>
{(ref, index) => {
return (
<li
classList={{
active: ref.item.isActive,
}}
onClick={() => {
const newItems = mapActiveItem(items(), index());
setItems(newItems);
props.handler(newItems);
}}
>
{ref.item.label}
</li>
);
}}
</For>
</ul>
</Show>
</div>
);
};
+42
View File
@@ -0,0 +1,42 @@
.input-field {
width: 100%;
display: block;
background: var(--c-bg-2);
color: var(--c-fg-1);
border-radius: var(--sp-1);
}
.input-field input {
width: 100%;
height: 2em;
font-size: medium;
margin: 0;
padding: var(--sp-1);
border: none;
background: var(--c-bg-2);
color: inherit;
border-radius: var(--sp-1);
}
.input-field.icon {
display: flex;
flex-flow: row nowrap;
align-items: center;
svg {
padding: var(--sp-1);
display: inline-block;
height: 100%;
width: auto;
}
&:has(:focus) {
outline-width: 2px;
outline-style: solid;
outline-color: var(--c-primary);
input {
outline: none;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import "./InputField.css";
import type { Component, JSX } from "solid-js";
export const InputField: Component<{
name: string;
value: string;
type?: string;
icon?: JSX.Element;
children?: JSX.Element;
onChange?: (value: string) => void;
onInput?: (value: string) => void;
}> = (props) => (
<label class="input-field" classList={{ icon: !!props.icon }}>
{props.children}
{props.icon}
<input
type={props.type ?? "text"}
name={props.name}
value={props.value}
onChange={(e) => props.onChange?.(e.target.value)}
onInput={(e) => props.onInput?.(e.target.value)}
/>
</label>
);
+69
View File
@@ -0,0 +1,69 @@
.range-slider {
width: 100%;
color: var(--c-fg-1);
display: flex;
flex-flow: row nowrap;
justify-content: center;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-sans);
font-weight: bold;
}
.range-slider [type="range"] {
cursor: pointer;
appearance: none;
background: transparent;
border-radius: var(--sp-2);
height: var(--sp-3);
flex: auto;
&:focus {
outline: 0;
}
&::-webkit-slider-runnable-track {
width: 100%;
height: var(--sp-2);
border-radius: var(--sp-3);
color: transparent;
background: var(--c-bg-2);
border: none;
}
&::-webkit-slider-thumb {
height: var(--sp-3);
width: var(--sp-3);
border-radius: var(--sp-3);
border: none;
background: var(--c-fg-1);
margin-top: calc(var(--sp-1) * -1);
--webkit-appearance: none;
appearance: none;
}
&:focus::-webkit-slider-thumb {
outline-width: 2px;
outline-style: solid;
outline-color: var(--c-primary);
}
&::-moz-range-track {
width: 100%;
height: var(--sp-2);
border-radius: var(--sp-3);
color: transparent;
background: var(--c-bg-2);
border: none;
}
&::-moz-range-thumb {
height: var(--sp-3);
width: var(--sp-3);
border-radius: var(--sp-3);
border: none;
background: var(--c-fg-1);
}
&:focus::-moz-range-thumb {
outline-width: 2px;
outline-style: solid;
outline-color: var(--c-primary);
}
}
+27
View File
@@ -0,0 +1,27 @@
import "./RangeSlider.css";
import type { Component, JSX } from "solid-js";
export const RangeSlider: Component<{
name: string;
min: number;
max: number;
step: number;
value: number;
children?: JSX.Element;
onChange?: (value: number) => void;
onInput?: (value: number) => void;
}> = (props) => (
<label class="range-slider">
{props.children}
<input
type="range"
name={props.name}
min={props.min}
max={props.max}
step={props.step}
value={props.value}
onChange={(e) => props.onChange?.(e.target.valueAsNumber)}
onInput={(e) => props.onInput?.(e.target.valueAsNumber)}
/>
</label>
);
+14
View File
@@ -0,0 +1,14 @@
import type { BrowserFolder } from "./FileBrowser.tsx";
export async function fetchBrowserData(dir: string): Promise<BrowserFolder> {
const indexRef: string[] = await (await fetch(`/api/${dir}`)).json();
const scenes = indexRef.reduce((root, entry) => {
const [name] = entry.substring(1).split("/");
root.set(name, { path: name, isActive: false });
return root;
}, new Map() as BrowserFolder);
return new Map([[dir, scenes]]);
}
+17
View File
@@ -0,0 +1,17 @@
export const ArrowAutofitHeightIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 20H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h6m6 10v7m0-18v7m-3 8l3 3l3-3M15 6l3-3l3 3"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const ArrowAutofitWidthIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 12V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6m-10 6H3m18 0h-7m-8-3l-3 3l3 3m12-6l3 3l-3 3"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const ArrowNarrowDownIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v14m4-4l-4 4m-4-4l4 4"
/>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const ArrowNarrowUpIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v14m4-10l-4-4M8 9l4-4"
/>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const ArrowsSortIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m3 9l4-4l4 4M7 5v14m14-4l-4 4l-4-4m4 4V5"
/>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const ChevronDownIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m6 9l6 6l6-6"
/>
</svg>
);
+19
View File
@@ -0,0 +1,19 @@
export const FileIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"></path>
</g>
</svg>
);
+20
View File
@@ -0,0 +1,20 @@
export const FileDeltaIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"></path>
<path d="M9 17h6l-3-6z"></path>
</g>
</svg>
);
+20
View File
@@ -0,0 +1,20 @@
export const FileMusicIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"></path>
<path d="M10 16a1 1 0 1 0 2 0a1 1 0 1 0-2 0m2 0v-5l2 1"></path>
</g>
</svg>
);
+20
View File
@@ -0,0 +1,20 @@
export const FilePencilIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"></path>
<path d="m10 18l5-5a1.414 1.414 0 0 0-2-2l-5 5v2z"></path>
</g>
</svg>
);
+20
View File
@@ -0,0 +1,20 @@
export const FileUnknownIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2m-5-4v.01"></path>
<path d="M12 14a1.5 1.5 0 1 0-1.14-2.474"></path>
</g>
</svg>
);
+19
View File
@@ -0,0 +1,19 @@
export const FloppyDiscIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M6 4h10l4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2"></path>
<path d="M10 14a2 2 0 1 0 4 0a2 2 0 1 0-4 0m4-10v4H8V4"></path>
</g>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const FolderIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const FolderOpenIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 19l2.757-7.351A1 1 0 0 1 8.693 11H21a1 1 0 0 1 .986 1.164l-.996 5.211A2 2 0 0 1 19.026 19za2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v2"
></path>
</svg>
);
+20
View File
@@ -0,0 +1,20 @@
export const LassoIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M4.028 13.252C3.371 12.28 3 11.174 3 10c0-3.866 4.03-7 9-7s9 3.134 9 7s-4.03 7-9 7c-1.913 0-3.686-.464-5.144-1.255"></path>
<path d="M3 15a2 2 0 1 0 4 0a2 2 0 1 0-4 0"></path>
<path d="M5 17c0 1.42.316 2.805 1 4"></path>
</g>
</svg>
);
+20
View File
@@ -0,0 +1,20 @@
export const LassoPolygonIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M4.028 13.252L3 10l2-7l7 5l8-3l1 9l-9 3l-5.144-1.255"></path>
<path d="M3 15a2 2 0 1 0 4 0a2 2 0 1 0-4 0"></path>
<path d="M5 17c0 1.42.316 2.805 1 4"></path>
</g>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const LayoutGridIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1zM4 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const LetterXIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m7 4l10 16m0-16L7 20"
/>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const LetterYIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m7 4l5 9l5-9m-5 9v7"
/>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const PencilIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 20h4L18.5 9.5a2.828 2.828 0 1 0-4-4L4 16zm9.5-13.5l4 4"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const PolygonIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 5a2 2 0 1 0 4 0a2 2 0 1 0-4 0m7 3a2 2 0 1 0 4 0a2 2 0 1 0-4 0M3 11a2 2 0 1 0 4 0a2 2 0 1 0-4 0m10 8a2 2 0 1 0 4 0a2 2 0 1 0-4 0M6.5 9.5l3.5-3m4-1L17 7m1.5 3L16 17m-2.5.5l-7-5"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const RefreshIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const RefreshAlertIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4m-4-6v3m0 3h.01"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const RefreshDotIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4m-5-3a1 1 0 1 0 2 0a1 1 0 1 0-2 0"
></path>
</svg>
);
+19
View File
@@ -0,0 +1,19 @@
export const ReloadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const ScriptIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20H6a3 3 0 0 1 0-6h11a3 3 0 0 0 0 6h1a3 3 0 0 0 3-3V6a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v8"
></path>
</svg>
);
+19
View File
@@ -0,0 +1,19 @@
export const StackBackwardIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="m14 12l6-3l-8-4l-8 4l6 3"></path>
<path fill="currentColor" d="m10 12l-6 3l8 4l8-4l-6-3l-2 1z"></path>
</g>
</svg>
);
+19
View File
@@ -0,0 +1,19 @@
export const StackForwardIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path fill="currentColor" d="M12 5L4 9l8 4l8-4z"></path>
<path d="m10 12l-6 3l8 4l8-4l-6-3"></path>
</g>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const TransformPointIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zm0 14a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zM17 4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zm0 14a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zM11 5h2m-8 6v2m14-2v2m-8 6h2"
></path>
</svg>
);
+17
View File
@@ -0,0 +1,17 @@
export const ZoomScanIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8V6a2 2 0 0 1 2-2h2M4 16v2a2 2 0 0 0 2 2h2m8-16h2a2 2 0 0 1 2 2v2m-4 12h2a2 2 0 0 0 2-2v-2M8 11a3 3 0 1 0 6 0a3 3 0 0 0-6 0m8 5l-2.5-2.5"
></path>
</svg>
);
+141
View File
@@ -0,0 +1,141 @@
.dev-tools {
--font-sans:
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--rgb-blurple: 101, 114, 245;
--c-blurple: rgb(var(--rgb-blurple));
--rgb-fuchsia: 238, 69, 158;
--c-fuchsia: rgb(var(--rgb-fuchsia));
--rgb-green: 35, 165, 89;
--c-green: rgb(var(--rgb-green));
--rgb-yellow: 240, 178, 50;
--c-yellow: rgb(var(--rgb-yellow));
--rgb-orange: 255, 144, 0;
--c-orange: rgb(var(--rgb-orange));
--rgb-red: 218, 55, 60;
--c-red: rgb(var(--rgb-red));
--rgb-cyan: 95, 205, 228;
--c-cyan: rgb(var(--rgb-cyan));
--rgb-pink: 246, 42, 245;
--c-pink: rgb(var(--rgb-pink));
--rgb-gray: 117, 126, 138;
--c-gray: rgb(var(--rgb-gray));
--rgb-white: 255, 255, 255;
--c-white: rgb(var(--rgb-white));
--rgb-black: 30, 31, 34;
--c-black: rgb(var(--rgb-black));
--c-bg-0: rgb(30, 31, 34);
--c-bg-1: rgb(43, 45, 49);
--c-bg-2: rgb(49, 51, 56);
--c-bg-3: rgb(64, 66, 73);
--c-fg-0: rgb(255 255 225);
--c-fg-1: rgb(219 222 225);
--c-fg-2: rgb(181 186 193);
--c-fg-3: rgb(113 116 120);
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 16px;
--sp-4: 24px;
--sp-5: 32px;
--sp-6: 48px;
height: 100%;
min-width: 1px;
max-width: 100%;
position: absolute;
top: 0;
left: 0;
order: -1;
font-size: medium;
font-family: var(--font-sans);
*,
*::after,
*::before {
box-sizing: border-box;
}
*:focus {
outline-width: 2px;
outline-style: solid;
outline-color: var(--c-blurple);
}
}
.tool-bar {
overflow: hidden;
border-right: 1px solid var(--c-fg-3);
background: var(--c-bg-0);
color: var(--c-fg-1);
width: 100%;
height: 100%;
padding: var(--sp-2);
display: flex;
flex-flow: column nowrap;
gap: var(--sp-2);
}
.tool-bar-resize {
position: absolute;
top: 0;
right: -3px;
width: 5px;
height: 100%;
cursor: ew-resize;
user-select: none;
}
.zoom-bar {
display: flex;
gap: var(--sp-2);
button {
color: inherit;
border-radius: var(--sp-1);
border: none;
background: transparent;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
button:hover {
background: var(--c-bg-3);
}
}
.scene-props {
display: flex;
gap: var(--sp-2);
flex-flow: row wrap;
& > :not(:first-child) {
width: calc(50% - var(--sp-1));
flex-basis: calc(50% - var(--sp-1));
}
}
.entity-props {
display: grid;
gap: var(--sp-2);
grid-template-columns: 1fr 1fr;
}
+229
View File
@@ -0,0 +1,229 @@
import "./main.css";
import {
type Component,
createEffect,
createSignal,
onMount,
Show,
} from "solid-js";
import { render } from "solid-js/web";
import { spriteMap } from "#/engine/Animation.ts";
import type { Entity } from "#/engine/Entity.ts";
import {
currentScene,
type SceneData,
sceneChangedHandlers,
sceneGraph,
sceneProcessMap,
setScene,
} from "#/engine/Scene.ts";
import { setScale, viewport } from "#/engine/Viewport.ts";
import { InputField } from "./controls/InputField.tsx";
import { RangeSlider } from "./controls/RangeSlider.tsx";
import { GraphBrowser } from "./GraphBrowser.tsx";
import { ItemList, type ItemsRef } from "./ItemList.tsx";
import { ArrowAutofitHeightIcon } from "./icons/ArrowAutofitHeight.tsx";
import { ArrowAutofitWidthIcon } from "./icons/ArrowAutofitWidth.tsx";
import { FileIcon } from "./icons/File.tsx";
import { LetterXIcon } from "./icons/LetterX.tsx";
import { LetterYIcon } from "./icons/LetterY.tsx";
import { ZoomScanIcon } from "./icons/ZoomScan.tsx";
async function fetchScenes(): Promise<string[]> {
const indexRef: string[] = await (await fetch(`/api/scenes`)).json();
const items = indexRef.filter((value) => value.includes("scene"));
return items.map((i) => i.substring(1).split("/")[0]);
}
function mapSceneEntities(sceneData: SceneData): string[] {
return sceneData.entities.map((entity) => entity.name);
}
function adjustGameContainer(gameContainer: HTMLElement, width: number): void {
gameContainer.style = `position: relative; top: 0; left: ${width}px; width: ${document.body.offsetWidth - width}px;`;
}
const scenesRaw = await fetchScenes();
const DevTools: Component<{ gameContainer: HTMLElement }> = ({
gameContainer,
}) => {
const [width, setWidth] = createSignal(320);
let isResizing = false;
const startResizing = (): void => {
isResizing = true;
};
const stopResizing = (): void => {
isResizing = false;
};
const handleMouseMove = (event: MouseEvent) => {
if (isResizing) {
setWidth(event.clientX);
self.dispatchEvent(new Event("resize"));
}
};
adjustGameContainer(gameContainer, width());
const [currentScale, setCurrentScale] = createSignal(viewport.scale);
const [sceneData, setSceneData] = createSignal(currentScene.data);
const [scenes] = createSignal(scenesRaw);
const [entities, setEntities] = createSignal(
mapSceneEntities(currentScene.data),
);
const [activeEntity, setActiveEntity] = createSignal<null | Entity<unknown>>(
null,
);
createEffect(() => {
setScale(currentScale());
});
const setCurrentScene = async (items: ItemsRef): Promise<void> => {
const name = items.find((ref) => ref.item.isActive)!.item.label;
await setScene(name);
};
sceneChangedHandlers.add((data) => {
setActiveEntity(null);
setSceneData(data);
setEntities(mapSceneEntities(data));
});
const selectEntity = (items: ItemsRef): void => {
const activeRef = items.find((ref) => ref.item.isActive)!;
const instances = currentScene.entityInstanceMap.get(
activeRef.item.label,
)!.instances;
const entityRefs = items.filter(
(ref) => ref.item.label === activeRef.item.label,
);
const instance =
instances[entityRefs.findIndex((ref) => ref.item.isActive)];
setActiveEntity(instance);
const sprite = spriteMap.get(instance.animation.spriteSrc)!;
const processProxy = sceneProcessMap.get(currentScene.data.name)!;
const ctx = viewport.ctx;
currentScene.process = (...args) => {
processProxy(...args);
ctx.save();
ctx.lineWidth = 1;
ctx.strokeStyle = "#6572f5";
viewport.ctx.strokeRect(
instance.position.x - 0.5,
instance.position.y - 0.5,
sprite.image.width / sprite.xFrames + 1,
sprite.image.height / sprite.yFrames + 1,
);
ctx.fillStyle = "#ee459e";
viewport.ctx.fillRect(
instance.position.x - 1,
instance.position.y - 1,
2,
2,
);
ctx.restore();
};
};
onMount(() => {
self.addEventListener("resize", () => {
adjustGameContainer(gameContainer, width());
});
self.dispatchEvent(new Event("resize"));
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", stopResizing);
});
return (
<div class="dev-tools" style={{ width: `${width()}px` }}>
<div class="tool-bar">
<div class="zoom-bar">
<button type="button" onClick={() => setCurrentScale(4)}>
<ZoomScanIcon />
</button>
<RangeSlider
name="scale"
min={1}
max={8}
step={1}
value={currentScale()}
onInput={(value) => setCurrentScale(value)}
></RangeSlider>
<span>{currentScale()}</span>
</div>
<div class="scene-props">
<InputField
name="file"
value={sceneData().name}
icon={FileIcon()}
></InputField>
<InputField
type="number"
name="width"
value={String(sceneData().width)}
icon={ArrowAutofitWidthIcon()}
onChange={(value) => {
currentScene.data.width = parseInt(value);
self.dispatchEvent(new Event("resize"));
}}
></InputField>
<InputField
type="number"
name="height"
value={String(sceneData().height)}
icon={ArrowAutofitHeightIcon()}
onChange={(value) => {
currentScene.data.height = parseInt(value);
self.dispatchEvent(new Event("resize"));
}}
></InputField>
</div>
<ItemList name="Scenes" handler={setCurrentScene} items={scenes} />
<GraphBrowser graph={sceneGraph} seed={2802446152} />
<ItemList name="Entities" handler={selectEntity} items={entities} />
<Show when={activeEntity() !== null}>
<div class="entity-props">
<InputField
type="number"
name="x"
value={String(activeEntity()!.position.x)}
icon={LetterXIcon()}
onChange={(value) => {
activeEntity()!.position.x = parseInt(value);
}}
></InputField>
<InputField
type="number"
name="y"
value={String(activeEntity()!.position.y)}
icon={LetterYIcon()}
onChange={(value) => {
activeEntity()!.position.y = parseInt(value);
}}
></InputField>
</div>
</Show>
</div>
<div class="tool-bar-resize" onMouseDown={startResizing}></div>
</div>
);
};
export function use(container: HTMLElement): void {
render(() => <DevTools gameContainer={container} />, document.body);
}
+72
View File
@@ -0,0 +1,72 @@
import { readdirSync, writeFileSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { root } from "../../../vite.config.ts";
function buildBrowserIndex(dir: string): string[] {
const dirents = readdirSync(dir, {
withFileTypes: true,
recursive: true,
});
return dirents
.filter((dirent) => dirent.isFile())
.map((dirent) => {
const path = dirent.parentPath.substring(dir.length - 1);
return `${path}/${dirent.name}`;
});
}
function getScenes(req: IncomingMessage, res: ServerResponse) {
const sceneData = buildBrowserIndex(`${root}/game/scenes`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(sceneData));
}
function get(req: IncomingMessage, res: ServerResponse): void {
const match = req.url?.match(/^\/(\w+)$/);
if (!match) {
console.error(req.url);
res.statusCode = 400;
res.end();
return;
}
switch (req.url) {
case "/scenes": {
getScenes(req, res);
break;
}
}
}
function post(req: IncomingMessage, res: ServerResponse): void {
// let body = "";
// req.on("data", (chunk) => {
// body += chunk.toString();
// });
// req.on("end", () => {
// const data: SceneData = JSON.parse(body);
// writeFileSync(
// `${dev.scenesDir}/${data.name}.json`,
// JSON.stringify(data, null, 2),
// "utf8",
// );
// res.statusCode = 204;
// res.end();
// });
}
export default function (req: IncomingMessage, res: ServerResponse): void {
switch (req.method) {
case "GET": {
get(req, res);
break;
}
case "POST": {
post(req, res);
break;
}
}
}