Clean up duplicates and dead code, and fix the + menu check (#85)
Refactoring round 9 covers everything since round 8 (14972d0): 62 commits with about 29 000 new lines in src, api, tools and tests. Behaviour is unchanged. The one fix is in a check: e2e 04 counted 25 asset types, and the shader function from #303 makes 26, so the run ofa526e1bfailed there. Duplicate code (jscpd over src, 50 tokens, without the two licence texts: 35 clones / 289 lines before, 29 / 232 after): - ShaderPicture (new, viewport/shaderPicture.ts) is the picture a camera's shader and a particle field's shader are laid over. It keeps the render texture while its size holds and frees the replaced one once the shader holds the new one. CameraShade and ParticleField.shadeField drew it the same way, line for line. - CameraShade builds its mesh on the first draw. Before, every redraw of a view without a camera shader built a mesh and threw it away. - emptyHost in inspector/host.ts builds the empty host the Inspector, the Variables tab and the Tilemap panel start from. - paddedQuad(null) is the plain unit quad. symbols.ts and shaderPasses.ts each wrote it out by hand. - In graph/nested.ts, calls of shader functions and On behavior nodes are followed by one walk, `reaches`, over ids `namedBy` a node type. Dead code: - ShaderEditor.colorFieldOf and the getter CurveCanvas.snapping had no reader. - 22 values used only in their own file lose their `export`. Types and the node catalogues of shaders and particles stay exported. - Kept on purpose: PhysicsSystem.holdsJoint is read by tests only, but it is their one view of whether a joint holds. - A pass with stricter lint rules over every file changed since round 8 found no bug. PixelArtEditor.started is now readonly. Comments and docs: - Ten comments told history ("since #190", "used to", "any more"). They now describe what is. The bundle is md5-identical before and after, and the four e2e files minify to the same bytes. - README: joint and shader function in the asset list, joints on the Physics row, what the Shaders page covers, two CI runners. - e2e 04 keeps the number of asset types once, as ASSET_TYPE_COUNT. - screenshot.png in the README is a new picture of the editor (3840x2160 instead of 1825x1140), taken by the user. Tests: - Gate on melektaus-atx: 229 files, 3514 tests, CHECK EXIT 0 (+3 for emptyHost, +1 for the unit quad). - e2e 1-4, 31, 63 on melektaus-atx: ALL PASSED, 195 checks. That is the + menu of 04, the field shader of 31 and the camera shader of 63, both through ShaderPicture. - No new e2e check, so the sum stays 1728. - Counter-checks, each put back and compared with cmp: reaches that never goes on fails 1 test, listensTo reading the wrong parameter fails 1, emptyHost dropping what a panel hands in fails 1, folding without a relayout fails 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a526e1b40f
commit
808e1eefc2
43 files changed
+314
-224
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# Game Maker
|
||||
|
||||
[](https://git.nockal.com/meleki/game-maker/actions?workflow=ci.yml)
|
||||
|
||||
A browser-based tool "one step before a game engine": create **assets** (scenes, cameras,
|
||||
sprites), place them into scenes, tweak their properties in an inspector and press **Play** –
|
||||
all in one program, all drawn inside a single WebGL canvas.
|
||||
@@ -15,9 +17,9 @@ way in.
|
||||
## In one screen
|
||||
|
||||
- An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, variables, struct,
|
||||
collider, rigidbody, container, clip area, texture, vector art, pixel art, palette, animation,
|
||||
curve, tile, tilemap, state machine, shader, font, sound, particles) with properties and no
|
||||
position.
|
||||
collider, rigidbody, joint, container, clip area, texture, vector art, pixel art, palette,
|
||||
animation, curve, tile, tilemap, state machine, shader, shader function, font, sound, particles)
|
||||
with properties and no position.
|
||||
- A **scene object** places an asset: position, sparse **overrides** of its properties, an optional
|
||||
parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑.
|
||||
- A **container** is a reusable group edited once and placed many times; its content is resolved
|
||||
@@ -52,7 +54,7 @@ node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
```
|
||||
|
||||
Every push runs all of it on the Gitea runner – the gate as one job, build and suite as another
|
||||
Every push runs all of it on the Gitea runners – the gate as one job, build and suite as another
|
||||
beside it – so locally take the gate plus the step your change touches and leave the full run to
|
||||
CI. Details, the GPU requirement and the detached runner are in
|
||||
[Development](https://git.nockal.com/meleki/game-maker/wiki/Development).
|
||||
@@ -68,7 +70,7 @@ CI. Details, the GPU requirement and the detached runner are in
|
||||
| [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing |
|
||||
| [Structs](https://git.nockal.com/meleki/game-maker/wiki/Structs) | Struct assets: a named shape that struct variables, fields and `Pack`/`Expand` follow |
|
||||
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events, blocking movement, one-way colliders and raycasts |
|
||||
| [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step |
|
||||
| [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step, joints |
|
||||
| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits |
|
||||
| [Vector art](https://git.nockal.com/meleki/game-maker/wiki/Vector-Art) | Pictures drawn from shapes: the pen, curves, gradients and texture fills, combining shapes |
|
||||
| [Pixel art](https://git.nockal.com/meleki/game-maker/wiki/Pixel-Art) | Pictures painted pixel by pixel: the palette, the tools, layers and frames |
|
||||
@@ -76,7 +78,7 @@ CI. Details, the GPU requirement and the detached runner are in
|
||||
| [Sounds](https://git.nockal.com/meleki/game-maker/wiki/Sounds) | Sound assets and `Play sound` |
|
||||
| [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab |
|
||||
| [Curves](https://git.nockal.com/meleki/game-maker/wiki/Curves) | Easings drawn by hand, the Curve tab, using one on a key or a `Lerp` |
|
||||
| [Shaders](https://git.nockal.com/meleki/game-maker/wiki/Shaders) | Shader graphs a sprite wears, their node set, the properties they declare |
|
||||
| [Shaders](https://git.nockal.com/meleki/game-maker/wiki/Shaders) | Shader graphs that sprites, texts, particle fields and cameras wear, their node set, passes, shader functions |
|
||||
| [Particles](https://git.nockal.com/meleki/game-maker/wiki/Particles) | Particle graphs: a particle as a function of its age |
|
||||
| [State machines](https://git.nockal.com/meleki/game-maker/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` |
|
||||
| [Containers](https://git.nockal.com/meleki/game-maker/wiki/Containers) | Templates, instances, screens |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 557 KiB |
@@ -82,7 +82,7 @@ export function colliderRefusal(project: Project, holder: ObjectHolder, target:
|
||||
* frame's size, so it is scaled up to the sprite, and a sprite stretched out of the frame's
|
||||
* proportions keeps the box, since an outline cannot be stretched. Anything else: schema defaults.
|
||||
*/
|
||||
export function newColliderProps(project: Project, targetAsset: Asset, target: SceneObject): PropertyBag {
|
||||
function newColliderProps(project: Project, targetAsset: Asset, target: SceneObject): PropertyBag {
|
||||
if (targetAsset.type !== 'sprite') return {};
|
||||
const size = spriteSize(project, targetAsset, target);
|
||||
const frame = textOf(resolveSceneObjectProps(project, targetAsset, target).texture ?? '');
|
||||
|
||||
@@ -53,7 +53,7 @@ import { requireVectorArt, requireVectorItem, requireVectorShape } from './requi
|
||||
* which layer. `edit` is one of the functions of `model/vectorTree.ts`; it hands back new layers, so
|
||||
* the undo is simply the layers that were there.
|
||||
*/
|
||||
export class EditVectorTreeCommand implements Command {
|
||||
class EditVectorTreeCommand implements Command {
|
||||
private previous: VectorLayer[] = [];
|
||||
|
||||
constructor(
|
||||
|
||||
+32
-34
@@ -383,48 +383,46 @@ function functionCallProblem(node: GraphNode, lookup: GraphLookup, ownerId: stri
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The ids the nodes of one type in that graph name in `param`, picked or not found alike. */
|
||||
function namedBy(graph: Graph, type: string, param: string): string[] {
|
||||
return graph.nodes
|
||||
.filter((n) => n.type === type)
|
||||
.map((n) => textOf(n.params[param] ?? ''))
|
||||
.filter((id) => id !== '');
|
||||
}
|
||||
|
||||
/** Whether `from` is `target` or leads to it through `next`, however many steps lie between. */
|
||||
function reaches(
|
||||
from: string,
|
||||
target: string,
|
||||
next: (id: string) => string[],
|
||||
seen = new Set<string>(),
|
||||
): boolean {
|
||||
if (from === target) return true;
|
||||
if (seen.has(from)) return false;
|
||||
seen.add(from);
|
||||
return next(from).some((id) => reaches(id, target, next, seen));
|
||||
}
|
||||
|
||||
/** The functions the calls in that graph name, picked or not found alike. */
|
||||
export function calledFunctions(graph: Graph): string[] {
|
||||
return graph.nodes
|
||||
.filter((n) => n.type === SHADER_CALL)
|
||||
.map((n) => textOf(n.params[FUNCTION_PARAM] ?? ''))
|
||||
.filter((id) => id !== '');
|
||||
return namedBy(graph, SHADER_CALL, FUNCTION_PARAM);
|
||||
}
|
||||
|
||||
/** Whether `from` is `target` or calls it, however many functions lie between. */
|
||||
export function callsFunction(
|
||||
lookup: GraphLookup,
|
||||
from: string,
|
||||
target: string,
|
||||
seen: Set<string> = new Set(),
|
||||
): boolean {
|
||||
if (from === target) return true;
|
||||
if (seen.has(from)) return false;
|
||||
seen.add(from);
|
||||
const called = lookup.shaderFunction(from);
|
||||
return called ? calledFunctions(called.graph).some((id) => callsFunction(lookup, id, target, seen)) : false;
|
||||
}
|
||||
|
||||
/** The behaviors the `On behavior` nodes of that graph name, picked or not found alike. */
|
||||
export function heardBehaviors(graph: Graph): string[] {
|
||||
return graph.nodes
|
||||
.filter((n) => n.type === ON_BEHAVIOR)
|
||||
.map((n) => textOf(n.params[BEHAVIOR_PARAM] ?? ''))
|
||||
.filter((id) => id !== '');
|
||||
export function callsFunction(lookup: GraphLookup, from: string, target: string): boolean {
|
||||
return reaches(from, target, (id) => {
|
||||
const called = lookup.shaderFunction(id);
|
||||
return called ? calledFunctions(called.graph) : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether `from` is `target` or hears it, however many behaviors lie between. */
|
||||
export function listensTo(
|
||||
lookup: GraphLookup,
|
||||
from: string,
|
||||
target: string,
|
||||
seen: Set<string> = new Set(),
|
||||
): boolean {
|
||||
if (from === target) return true;
|
||||
if (seen.has(from)) return false;
|
||||
seen.add(from);
|
||||
const program = lookup.behavior(from);
|
||||
return program ? heardBehaviors(program.graph).some((id) => listensTo(lookup, id, target, seen)) : false;
|
||||
function listensTo(lookup: GraphLookup, from: string, target: string): boolean {
|
||||
return reaches(from, target, (id) => {
|
||||
const program = lookup.behavior(id);
|
||||
return program ? namedBy(program.graph, ON_BEHAVIOR, BEHAVIOR_PARAM) : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -225,7 +225,7 @@ export function frameSlice(frame: TextureFrame): FrameSlice {
|
||||
|
||||
/**
|
||||
* Writes the slice back, dropping the keys again once they mean nothing: a frame nobody sliced
|
||||
* reads and saves exactly as it did before #41, so undo of the first slice restores the old shape.
|
||||
* reads and saves without them, so undo of the first slice restores the old shape.
|
||||
*/
|
||||
export function setFrameSlice(frame: TextureFrame, slice: FrameSlice): void {
|
||||
const border = normalizeBorders(slice.border, frame.w, frame.h);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Scaling, turning and mirroring vector art shapes (#291). A transformation is baked into the points:
|
||||
* the model keeps no matrix, so everything that reads points – combining, the picture a sprite wears,
|
||||
* a press in the tab – goes on reading them as before. This module says what a shape reads after an
|
||||
* a press in the tab – reads them as they are. This module says what a shape reads after an
|
||||
* affine map, how far a selection reaches, and which map a pull on the frame's handles means.
|
||||
*
|
||||
* The outline's width is left alone: a line of two pixels is two pixels at every size of its shape.
|
||||
|
||||
@@ -73,7 +73,7 @@ const NUMBER_STEP = 0.1;
|
||||
* direction and the speed of one particle are not the same draw. Mixing `salt` in is what separates
|
||||
* the nodes; the node's id is hashed into it, so an edit elsewhere in the graph does not reshuffle it.
|
||||
*/
|
||||
export function random01(seed: number, salt: number): number {
|
||||
function random01(seed: number, salt: number): number {
|
||||
let h = (seed ^ salt) >>> 0;
|
||||
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
|
||||
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
|
||||
@@ -498,6 +498,6 @@ export function nextParticlePropertyName(taken: ReadonlySet<string>): string {
|
||||
for (let n = 2; ; n++) if (!taken.has(`value${n}`)) return `value${n}`;
|
||||
}
|
||||
|
||||
export function paramNameOf(params: PropertyBag): string {
|
||||
function paramNameOf(params: PropertyBag): string {
|
||||
return textOf(params[PROPERTY_NAME_PARAM] ?? '');
|
||||
}
|
||||
@@ -386,7 +386,7 @@ export function isSortProp(key: string): boolean {
|
||||
* Which cameras see a drawn object (#302). Without a choice of its own it is on its parent's layer,
|
||||
* so a HUD container on `UI` takes everything in it along.
|
||||
*/
|
||||
export const RENDER_LAYER_ROW: FlatDef<'layer'> = {
|
||||
const RENDER_LAYER_ROW: FlatDef<'layer'> = {
|
||||
key: RENDER_LAYER_KEY,
|
||||
label: 'Render layer',
|
||||
kind: 'layer',
|
||||
|
||||
@@ -60,7 +60,7 @@ function num(value: unknown, fallback: unknown): number {
|
||||
return Number.isFinite(n) ? n : Number(fallback);
|
||||
}
|
||||
|
||||
export function jointTypeOf(props: PropertyBag): JointType {
|
||||
function jointTypeOf(props: PropertyBag): JointType {
|
||||
const type = props[JOINT_TYPE_KEY];
|
||||
return (JOINT_TYPES as readonly unknown[]).includes(type) ? (type as JointType) : 'hinge';
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function shaderParamDefs(graph: Graph): ShaderParamDef[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function propertyColor(params: PropertyBag): number {
|
||||
function propertyColor(params: PropertyBag): number {
|
||||
return colorOf(params[PROPERTY_DEFAULT_PARAM] ?? DEFAULT_PROPERTY_COLOR, DEFAULT_PROPERTY_COLOR);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export type FunctionLookup = GraphLookup['shaderFunction'];
|
||||
export const NO_FUNCTIONS: FunctionLookup = () => null;
|
||||
|
||||
/** A function takes and gives values only: a picture stays the caller's. */
|
||||
export const FUNCTION_PORT_KINDS = ['number', 'color'] as const;
|
||||
const FUNCTION_PORT_KINDS = ['number', 'color'] as const;
|
||||
const DEFAULT_COLOR = 0xffffff;
|
||||
|
||||
/** A declared port of a function as the nodes on either side of it see it. */
|
||||
|
||||
@@ -135,7 +135,7 @@ export function paddingOf(params: PropertyBag): number {
|
||||
* How many cells per axis the wearer's quad is cut into (#79) – a setting of the Vertex output node,
|
||||
* because how fine the grid has to be is the effect's to say. 1 is the plain quad.
|
||||
*/
|
||||
export const SUBDIVISIONS_PARAM = 'subdivisions';
|
||||
const SUBDIVISIONS_PARAM = 'subdivisions';
|
||||
export const DEFAULT_SUBDIVISIONS = 8;
|
||||
export const MAX_SUBDIVISIONS = 64;
|
||||
|
||||
@@ -289,7 +289,7 @@ export const ShaderOutput: ShaderNodeDef = {
|
||||
};
|
||||
|
||||
/** How large a Pass's picture is, as a share of the wearer's picture per side. */
|
||||
export const PASS_RESOLUTION_PARAM = 'resolution';
|
||||
const PASS_RESOLUTION_PARAM = 'resolution';
|
||||
const PASS_RESOLUTIONS: Record<string, number> = { full: 1, half: 0.5, quarter: 0.25 };
|
||||
/** Pictures a shader may draw before its Output: each is one more draw of the wearer's picture. */
|
||||
export const MAX_PASSES = 8;
|
||||
@@ -395,7 +395,7 @@ export const ShaderSample: ShaderNodeDef = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MAX_BLUR_RADIUS = 8;
|
||||
const MAX_BLUR_RADIUS = 8;
|
||||
const BLUR = 'blur';
|
||||
const BLUR_KIND_PARAM = 'kind';
|
||||
const BLUR_DIRECTION_PARAM = 'direction';
|
||||
|
||||
@@ -12,7 +12,7 @@ export function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
||||
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
|
||||
}
|
||||
|
||||
export function lerpVec(a: Vec2, b: Vec2, t: number): Vec2 {
|
||||
function lerpVec(a: Vec2, b: Vec2, t: number): Vec2 {
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
|
||||
|
||||
@@ -136,10 +136,6 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
|
||||
this.snap = on;
|
||||
}
|
||||
|
||||
get snapping(): boolean {
|
||||
return this.snap;
|
||||
}
|
||||
|
||||
/** Where the preview stands: the share of time, the value the curve gives it, its two dots and its knob. */
|
||||
previewState(): { t: number; v: number; dot: Vec2; bar: Vec2; knob: Vec2 } | null {
|
||||
const asset = this.asset();
|
||||
|
||||
@@ -147,7 +147,7 @@ export function renderHooks({ app, state, editor }: HookContext) {
|
||||
* where a deadline is only the harness hoping it has.
|
||||
*/
|
||||
afterFrames: (count = 2, timeoutMs = 2000) => afterFrames(count, timeoutMs),
|
||||
/** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */
|
||||
/** Visible nodes carrying a filter – one extra render pass each; a shader is none. */
|
||||
filterCount: () => {
|
||||
let n = 0;
|
||||
const walk = (node: Container): void => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||
/** The Vector art tab (#129). */
|
||||
export function vectorArtHooks({ state, editor }: HookContext) {
|
||||
const { vectorArtEditor, inspector } = editor;
|
||||
/** The two lists, scrolled into view first: since #190 the layers stand over the shapes. */
|
||||
/** The two lists, scrolled into view first: the layers stand over the shapes. */
|
||||
const shapeList = () => {
|
||||
const list = currentShapeList();
|
||||
if (list) inspector.reveal(list);
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* game, and what the editor does around them. Averaged per frame over half a second, the same
|
||||
* window the fps readout counts over.
|
||||
*/
|
||||
export const FRAME_AREAS = ['behaviors', 'physics', 'animation', 'drawing', 'editor'] as const;
|
||||
const FRAME_AREAS = ['behaviors', 'physics', 'animation', 'drawing', 'editor'] as const;
|
||||
export type FrameArea = (typeof FRAME_AREAS)[number];
|
||||
export type FrameTimes = Record<FrameArea, number>;
|
||||
|
||||
/** Seconds one average runs over. */
|
||||
export const FRAME_WINDOW = 0.5;
|
||||
const FRAME_WINDOW = 0.5;
|
||||
|
||||
const LABELS: Record<FrameArea, string> = {
|
||||
behaviors: 'Behaviors',
|
||||
|
||||
@@ -17,7 +17,7 @@ export type EdgePlate = Rect & { edgeId: string };
|
||||
/** Straight pieces a curve is tested as; at 24 the error stays far below a pixel of tolerance. */
|
||||
const CURVE_STEPS = 24;
|
||||
|
||||
export function pathPoints(path: EdgePath): Point[] {
|
||||
function pathPoints(path: EdgePath): Point[] {
|
||||
if (path.kind === 'line') return [path.start, path.end];
|
||||
const at = (t: number) =>
|
||||
path.kind === 'loop'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import type { FederatedPointerEvent } from 'pixi.js';
|
||||
import { Label, Panel, pressCount, ScrollContainer, SectionStack, Theme, ui, type Widget } from '../../ui';
|
||||
import { Label, Panel, pressCount, ScrollContainer, ui, type Widget } from '../../ui';
|
||||
import { activateAsset } from '../actions';
|
||||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
|
||||
import { buildEdgeSection, edgeSignature, refreshEdgeSection } from './edgeSection';
|
||||
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
|
||||
import type { InspectorHost } from './host';
|
||||
import { emptyHost, type InspectorHost } from './host';
|
||||
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
|
||||
import { buildShapeSection, refreshShapeSection, shapeSignature } from './shapeSection';
|
||||
import {
|
||||
@@ -95,19 +95,11 @@ export class Inspector extends Panel {
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
const body = new SectionStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
body.onFold = () => this.scroll.relayoutContent();
|
||||
return {
|
||||
state: this.state,
|
||||
// Read through the state on every call: load and play mode replace the project object.
|
||||
project: () => this.state.project,
|
||||
return emptyHost(this.state, this.scroll, {
|
||||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||||
followReference: (assetId, e) => this.followReference(assetId, e),
|
||||
listLimit: INLINE_LIMIT,
|
||||
body,
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { Label, SectionHeader, TextField, type TextFieldOptions, type VStack } from '../../ui';
|
||||
import {
|
||||
Label,
|
||||
type ScrollContainer,
|
||||
SectionHeader,
|
||||
SectionStack,
|
||||
TextField,
|
||||
type TextFieldOptions,
|
||||
Theme,
|
||||
type VStack,
|
||||
} from '../../ui';
|
||||
import { foldKey, isFolded, setFolded } from '../sectionFolds';
|
||||
import type { EditorState } from '../state';
|
||||
import { type FieldContext, type FieldRow, labelledRow } from './fieldFactory';
|
||||
@@ -18,6 +27,31 @@ export interface InspectorHost extends FieldContext {
|
||||
readonly foldScope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host with an empty body that sits in `scroll`, which lays itself out anew when a group folds.
|
||||
* `own` is what sets a panel apart: its fold scope, the live values it shows, how its links act.
|
||||
*/
|
||||
export function emptyHost(
|
||||
state: EditorState,
|
||||
scroll: ScrollContainer,
|
||||
own: Pick<InspectorHost, 'foldScope' | 'followReference' | 'listLimit'> & {
|
||||
liveVariables?: InspectorHost['liveVariables'];
|
||||
} = {},
|
||||
): InspectorHost {
|
||||
const body = new SectionStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
body.onFold = () => scroll.relayoutContent();
|
||||
return {
|
||||
state,
|
||||
// Read through the state on every call: load and play mode replace the project object.
|
||||
project: () => state.project,
|
||||
liveVariables: () => null,
|
||||
body,
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
...own,
|
||||
};
|
||||
}
|
||||
|
||||
/** The band that opens a group of rows; controls go to its right end with `add()`. Not yet in the body. */
|
||||
export function sectionHeader(host: InspectorHost, text: string): SectionHeader {
|
||||
const key = foldKey(host.foldScope ?? 'inspector', text);
|
||||
|
||||
@@ -100,7 +100,7 @@ function stepLabel(verb: string, count: number): string {
|
||||
* Takes the selected shapes through the map `toMap` makes of their frame, as one undo step. False
|
||||
* when there is no frame or the map changes nothing. Exported for the buttons and the tests.
|
||||
*/
|
||||
export function transformSelection(
|
||||
function transformSelection(
|
||||
state: EditorState,
|
||||
assetId: string,
|
||||
shapeIds: readonly string[],
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
Menu,
|
||||
Panel,
|
||||
ScrollContainer,
|
||||
SectionStack,
|
||||
SortableList,
|
||||
Theme,
|
||||
ui,
|
||||
@@ -39,7 +38,7 @@ import {
|
||||
import type { EditorState, TilemapFocus, TilemapTool } from '../state';
|
||||
import { TilePalette } from '../tile/TilePalette';
|
||||
import { labelledRow } from '../inspector/fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from '../inspector/host';
|
||||
import { addNote, addTitle, emptyHost, type InspectorHost } from '../inspector/host';
|
||||
import { listFold } from '../sectionFolds';
|
||||
|
||||
const FOCUS: { focus: TilemapFocus; label: string }[] = [
|
||||
@@ -218,17 +217,7 @@ export class TilemapPanel extends Panel {
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
const body = new SectionStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
body.onFold = () => this.scroll.relayoutContent();
|
||||
return {
|
||||
state: this.state,
|
||||
project: () => this.state.project,
|
||||
liveVariables: () => null,
|
||||
body,
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
foldScope: FOLD_SCOPE,
|
||||
};
|
||||
return emptyHost(this.state, this.scroll, { foldScope: FOLD_SCOPE });
|
||||
}
|
||||
|
||||
private currentSignature(): string {
|
||||
|
||||
@@ -21,7 +21,7 @@ export class PixelArtEditor extends AssetTabEditor<PixelArtAsset, PixelArtCanvas
|
||||
/** Assigned by `body`; public for the check scripts, which press everything on it. */
|
||||
side!: PixelArtSide;
|
||||
splitter!: Splitter;
|
||||
private started = performance.now();
|
||||
private readonly started = performance.now();
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
|
||||
@@ -42,7 +42,7 @@ const PARAM_VALUE_WIDTH = 44;
|
||||
type ShaderTabAsset = ShaderAsset | ShaderFunctionAsset;
|
||||
|
||||
/** The panel's line while a function is open: it is seen in the shaders that call it. */
|
||||
export const FUNCTION_SUBJECT = 'A function shows in the shaders calling it';
|
||||
const FUNCTION_SUBJECT = 'A function shows in the shaders calling it';
|
||||
|
||||
/**
|
||||
* The nodes of one shader asset, on the graph canvas the Behavior tab uses, and beside them a picture
|
||||
@@ -150,11 +150,6 @@ export class ShaderEditor extends AssetTabEditor<ShaderTabAsset, GraphCanvas> {
|
||||
return this.sliders.get(key)?.slider;
|
||||
}
|
||||
|
||||
/** The colour field of one colour property, for the check scripts. */
|
||||
colorFieldOf(key: string): ColorField | undefined {
|
||||
return this.colors.get(key);
|
||||
}
|
||||
|
||||
override selectedId(): string {
|
||||
return this.state.selectedShaderId ?? '';
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import { isVariableOwner } from '../../core/model/Asset';
|
||||
import { findObject } from '../../core/model/hierarchy';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { objectName } from '../../core/model/SceneObject';
|
||||
import { Label, ScrollContainer, SectionStack, Theme, ui, VStack, HStack } from '../../ui';
|
||||
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
|
||||
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
|
||||
import type { InspectorHost } from '../inspector/host';
|
||||
import { emptyHost, type InspectorHost } from '../inspector/host';
|
||||
import { viewOf } from '../inspector/objectQueries';
|
||||
import {
|
||||
buildOwnRows,
|
||||
@@ -101,17 +101,10 @@ export class VariablesEditor extends VStack {
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
const body = new SectionStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
body.onFold = () => this.scroll.relayoutContent();
|
||||
return {
|
||||
state: this.state,
|
||||
project: () => this.state.project,
|
||||
return emptyHost(this.state, this.scroll, {
|
||||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||||
body,
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
foldScope: 'variables',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private currentSignature(): string {
|
||||
|
||||
@@ -22,11 +22,11 @@ import { knobAt, type SizeGrip, sizeGripAt } from '../sizeGizmo';
|
||||
/** Screen pixels between what is framed and the frame. More than a point's reach plus a handle's. */
|
||||
export const FRAME_PAD = 15;
|
||||
/** Screen pixels around a handle that grab it. */
|
||||
export const FRAME_GRIP = 6;
|
||||
const FRAME_GRIP = 6;
|
||||
/** How far the knob stands out from the top edge, in screen pixels. */
|
||||
export const FRAME_STEM = 26;
|
||||
/** Degrees a turn snaps to while Shift is held. */
|
||||
export const TURN_SNAP = 15;
|
||||
const TURN_SNAP = 15;
|
||||
|
||||
export interface FrameBox {
|
||||
/** Top left, top right, bottom right, bottom left – in the pixels `toLocal` hands out. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { drawSvg } from '../svg';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
/** An SVG file on its own, centred in its box – where a row used to hold a symbol as text (#214). */
|
||||
/** An SVG file on its own, centred in its box (#214). */
|
||||
export class Icon extends Widget {
|
||||
private readonly picture = new Graphics();
|
||||
private drawn = false;
|
||||
|
||||
@@ -49,7 +49,7 @@ const VALUE_WIDTH = 44;
|
||||
/** Height of a header that only holds the plus; one with a title is a band (see `sections.ts`). */
|
||||
const HEADER_HEIGHT = 24;
|
||||
/** How far a row stands in for each group it is inside of. */
|
||||
export const SORTABLE_INDENT = 14;
|
||||
const SORTABLE_INDENT = 14;
|
||||
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
|
||||
@@ -444,7 +444,8 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
||||
const shade = this.cameraShade ?? new CameraShade();
|
||||
const clear = this.paintsBackground() ? background : null;
|
||||
const ppu = view.scale * renderer.resolution;
|
||||
if (!camera || !shade.draw(this.shaded, camera, shaders, ppu, renderer, clear)) {
|
||||
const mesh = camera ? shade.draw(this.shaded, camera, shaders, ppu, renderer, clear) : null;
|
||||
if (!camera || !mesh) {
|
||||
if (shade !== this.cameraShade) shade.destroy();
|
||||
this.unshadeCamera();
|
||||
return;
|
||||
@@ -452,8 +453,7 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
||||
this.cameraShade = shade;
|
||||
if (!this.keepsWorldAroundShader() && this.shaded.parent !== this.offstage)
|
||||
this.offstage.addChild(this.shaded);
|
||||
if (shade.mesh.parent !== this.world)
|
||||
this.world.addChildAt(shade.mesh, this.world.getChildIndex(this.overlay));
|
||||
if (mesh.parent !== this.world) this.world.addChildAt(mesh, this.world.getChildIndex(this.overlay));
|
||||
if (wornShader(camera.props, shaders)?.time) this.animated = true;
|
||||
}
|
||||
|
||||
|
||||
+28
-43
@@ -1,23 +1,24 @@
|
||||
import { type Container, Matrix, type Mesh, RenderTexture, type Renderer } from 'pixi.js';
|
||||
import type { Container, Mesh, Renderer } from 'pixi.js';
|
||||
import { shaderOf } from '../core/properties/shaders';
|
||||
import type { ActiveCamera } from './activeCamera';
|
||||
import { cameraPicture } from './cameraPicture';
|
||||
import { ShaderPicture } from './shaderPicture';
|
||||
import { type ShaderSource, syncShader } from './shaders';
|
||||
import { disposeSpriteMesh, newSpriteMesh, placeNode } from './symbols';
|
||||
import { ADDRESS_MODE } from './TextureLibrary';
|
||||
|
||||
/**
|
||||
* A camera's shader (#299): the world as the camera sees it is drawn into a picture of its frame, and
|
||||
* the shader draws that picture where the frame stands. `mesh` is what a view attaches, in world units.
|
||||
* the shader draws that picture where the frame stands.
|
||||
*/
|
||||
export class CameraShade {
|
||||
readonly mesh: Mesh = newSpriteMesh();
|
||||
private picture: RenderTexture | null = null;
|
||||
private mesh: Mesh | null = null;
|
||||
private readonly picture = new ShaderPicture();
|
||||
|
||||
/**
|
||||
* Draws `world`, which is in world units, into the camera's picture and lays the shader over it.
|
||||
* `clearColor` is the scene's background, or null for a see-through overlay camera. False – and
|
||||
* nothing drawn – while the camera wears no shader that compiles.
|
||||
* `clearColor` is the scene's background, or null for a see-through overlay camera. Returns what a
|
||||
* view attaches, in world units – or null, and nothing drawn, while the camera wears no shader that
|
||||
* compiles.
|
||||
*/
|
||||
draw(
|
||||
world: Container,
|
||||
@@ -26,54 +27,38 @@ export class CameraShade {
|
||||
devicePixelsPerUnit: number,
|
||||
renderer: Renderer,
|
||||
clearColor: number | null,
|
||||
): boolean {
|
||||
): Mesh | null {
|
||||
const id = shaderOf(camera.props);
|
||||
const compiled = id ? shaders.compiled(id) : null;
|
||||
if (!compiled) return false;
|
||||
if (!compiled) return null;
|
||||
const shape = cameraPicture(camera, devicePixelsPerUnit);
|
||||
const previous = this.picture;
|
||||
const fits = previous && previous.width === shape.pixels.w && previous.height === shape.pixels.h;
|
||||
const picture = fits ? previous : RenderTexture.create({ width: shape.pixels.w, height: shape.pixels.h });
|
||||
const t = shape.toPicture;
|
||||
renderer.render({
|
||||
container: world,
|
||||
target: picture,
|
||||
clear: true,
|
||||
clearColor: clearColor === null ? [0, 0, 0, 0] : clearColor,
|
||||
transform: new Matrix(t.a, t.b, t.c, t.d, t.tx, t.ty),
|
||||
});
|
||||
// The picture is this camera's alone, so filter and edge are set on it rather than on a variant.
|
||||
const style = picture.source.style;
|
||||
const addressMode = ADDRESS_MODE[compiled.edge];
|
||||
const scaleMode = shape.blocky ? 'nearest' : 'linear';
|
||||
if (style.addressMode !== addressMode || style.scaleMode !== scaleMode) {
|
||||
style.addressMode = addressMode;
|
||||
style.scaleMode = scaleMode;
|
||||
style.update();
|
||||
}
|
||||
placeNode(
|
||||
this.mesh,
|
||||
{ a: 1, b: 0, c: 0, d: 1, tx: camera.x, ty: camera.y },
|
||||
shape.frame.w,
|
||||
shape.frame.h,
|
||||
const picture = this.picture.draw(
|
||||
renderer,
|
||||
world,
|
||||
shape.pixels,
|
||||
shape.toPicture,
|
||||
clearColor,
|
||||
compiled.edge,
|
||||
scaleMode,
|
||||
);
|
||||
this.mesh.texture = picture;
|
||||
const mesh = (this.mesh ??= newSpriteMesh());
|
||||
placeNode(mesh, { a: 1, b: 0, c: 0, d: 1, tx: camera.x, ty: camera.y }, shape.frame.w, shape.frame.h);
|
||||
mesh.texture = picture;
|
||||
// The frame is the edge, so neither Padding nor Vertex output moves it; a pixel counts as a camera pixel.
|
||||
syncShader(this.mesh, camera.props, shaders, null, id, shape.pixelScale);
|
||||
this.picture = picture;
|
||||
// Only now: the shader kept the old picture bound until `syncShader` handed it the new one.
|
||||
if (previous && previous !== picture) previous.destroy(true);
|
||||
return true;
|
||||
syncShader(mesh, camera.props, shaders, null, id, shape.pixelScale);
|
||||
this.picture.release();
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/** The picture's size in pixels while one is drawn (test hook). */
|
||||
get pixels(): { w: number; h: number } | null {
|
||||
return this.picture ? { w: this.picture.width, h: this.picture.height } : null;
|
||||
return this.picture.pixels;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
disposeSpriteMesh(this.mesh);
|
||||
this.picture?.destroy(true);
|
||||
this.picture = null;
|
||||
if (this.mesh) disposeSpriteMesh(this.mesh);
|
||||
this.mesh = null;
|
||||
this.picture.destroy();
|
||||
}
|
||||
}
|
||||
+14
-36
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
Container,
|
||||
Matrix,
|
||||
type Mesh,
|
||||
Particle,
|
||||
ParticleContainer,
|
||||
RenderTexture,
|
||||
type Renderer,
|
||||
type Texture,
|
||||
} from 'pixi.js';
|
||||
import { Container, type Mesh, Particle, ParticleContainer, type Renderer, type Texture } from 'pixi.js';
|
||||
import { type CompiledParticles, emptyParticleOut, type ParticleOut } from '../core/particles/compile';
|
||||
import { birthTime, type EmitterSettings, fieldTime, liveRange } from '../core/particles/emission';
|
||||
import type { ParticleEnv } from '../core/particles/nodes';
|
||||
@@ -30,9 +21,9 @@ import {
|
||||
TIME_SCALE_KEY,
|
||||
} from '../core/properties/particles';
|
||||
import { fieldFrame } from './fieldFrame';
|
||||
import { ShaderPicture } from './shaderPicture';
|
||||
import { disposeParticleShader, type ShaderSource, syncParticleShader, syncShader } from './shaders';
|
||||
import { disposeSpriteMesh, newSpriteMesh, placeNode } from './symbols';
|
||||
import { ADDRESS_MODE } from './TextureLibrary';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
@@ -69,7 +60,7 @@ export class ParticleField {
|
||||
/** Where the particles are drawn from while a field shader is worn: on no stage at all. */
|
||||
private readonly offstage = new Container();
|
||||
private fieldMesh: Mesh | null = null;
|
||||
private fieldPicture: RenderTexture | null = null;
|
||||
private readonly fieldPicture = new ShaderPicture();
|
||||
private readonly out: ParticleOut = emptyParticleOut();
|
||||
private readonly pool: Particle[] = [];
|
||||
/** Seconds, on the library's clock, at which this source last started emitting. */
|
||||
@@ -248,37 +239,25 @@ export class ParticleField {
|
||||
const frame = fieldFrame(m, size.w, size.h, pixelsPerUnit);
|
||||
mesh.visible = !bounds || rectsIntersect(frame.extent, bounds);
|
||||
if (!mesh.visible) return;
|
||||
const previous = this.fieldPicture;
|
||||
const fits = previous && previous.width === frame.pixels.w && previous.height === frame.pixels.h;
|
||||
const picture = fits ? previous : RenderTexture.create({ width: frame.pixels.w, height: frame.pixels.h });
|
||||
const t = frame.toPicture;
|
||||
renderer.render({
|
||||
container: this.offstage,
|
||||
target: picture,
|
||||
clear: true,
|
||||
transform: new Matrix(t.a, t.b, t.c, t.d, t.tx, t.ty),
|
||||
});
|
||||
// The picture is this field's alone, so the shader's edge is set on it rather than on a variant.
|
||||
const style = picture.source.style;
|
||||
const addressMode = ADDRESS_MODE[compiled.edge];
|
||||
if (style.addressMode !== addressMode) {
|
||||
style.addressMode = addressMode;
|
||||
style.update();
|
||||
}
|
||||
const picture = this.fieldPicture.draw(
|
||||
renderer,
|
||||
this.offstage,
|
||||
frame.pixels,
|
||||
frame.toPicture,
|
||||
null,
|
||||
compiled.edge,
|
||||
);
|
||||
placeNode(mesh, m, size.w, size.h);
|
||||
mesh.texture = picture;
|
||||
// Nothing to count pixels in: the frame is the edge, so neither Padding nor Vertex output moves it.
|
||||
// A pixel the shader reads counts as a unit of the frame, not of the screen.
|
||||
syncShader(mesh, props, shaders, null, id, frame.unitsPerPixel);
|
||||
this.fieldPicture = picture;
|
||||
// Only now: the shader kept the old picture bound until `syncShader` handed it the new one.
|
||||
if (previous && previous !== picture) previous.destroy(true);
|
||||
this.fieldPicture.release();
|
||||
}
|
||||
|
||||
/** The field shader's picture in device pixels while one is drawn, else null (test hook). */
|
||||
get fieldPixels(): { w: number; h: number } | null {
|
||||
const picture = this.fieldPicture;
|
||||
return picture && this.fieldMesh?.visible ? { w: picture.width, h: picture.height } : null;
|
||||
return this.fieldMesh?.visible ? this.fieldPicture.pixels : null;
|
||||
}
|
||||
|
||||
private newFieldMesh(): Mesh {
|
||||
@@ -293,8 +272,7 @@ export class ParticleField {
|
||||
if (this.container.parent !== this.node) this.node.addChild(this.container);
|
||||
if (this.fieldMesh) disposeSpriteMesh(this.fieldMesh);
|
||||
this.fieldMesh = null;
|
||||
this.fieldPicture?.destroy(true);
|
||||
this.fieldPicture = null;
|
||||
this.fieldPicture.destroy();
|
||||
}
|
||||
|
||||
/** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */
|
||||
|
||||
@@ -19,13 +19,18 @@ export function quadPadding(padding: number, w: number, h: number): QuadPadding
|
||||
return { u: padding / w, v: padding / h };
|
||||
}
|
||||
|
||||
/** The unit quad with that margin around it; 0..1 is still exactly the picture. */
|
||||
export function paddedQuad(pad: QuadPadding): {
|
||||
/** What a mesh geometry is built from. */
|
||||
interface QuadArrays {
|
||||
positions: Float32Array;
|
||||
uvs: Float32Array;
|
||||
indices: Uint32Array;
|
||||
} {
|
||||
const corners = [-pad.u, -pad.v, 1 + pad.u, -pad.v, 1 + pad.u, 1 + pad.v, -pad.u, 1 + pad.v];
|
||||
}
|
||||
|
||||
/** The unit quad with that margin around it, or none; 0..1 is still exactly the picture. */
|
||||
export function paddedQuad(pad: QuadPadding | null): QuadArrays {
|
||||
const [u, v] = [pad?.u ?? 0, pad?.v ?? 0];
|
||||
// `0 - u` rather than `-u`: without a margin that is the plain quad, not one of -0.
|
||||
const corners = [0 - u, 0 - v, 1 + u, 0 - v, 1 + u, 1 + v, 0 - u, 1 + v];
|
||||
return {
|
||||
positions: new Float32Array(corners),
|
||||
uvs: new Float32Array(corners),
|
||||
@@ -37,10 +42,7 @@ export function paddedQuad(pad: QuadPadding): {
|
||||
* The same rectangle cut into `cells` by `cells` (#79), so a shader's Vertex output has corners to
|
||||
* move inside it. Positions and UVs stay one and the same, as on the quad.
|
||||
*/
|
||||
export function gridQuad(
|
||||
pad: QuadPadding | null,
|
||||
cells: number,
|
||||
): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } {
|
||||
export function gridQuad(pad: QuadPadding | null, cells: number): QuadArrays {
|
||||
const n = Math.max(1, Math.round(cells));
|
||||
const [u, v] = [pad?.u ?? 0, pad?.v ?? 0];
|
||||
const corners = new Float32Array((n + 1) * (n + 1) * 2);
|
||||
|
||||
@@ -34,12 +34,6 @@ void main()
|
||||
}
|
||||
`;
|
||||
|
||||
const unitQuad = () => ({
|
||||
positions: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
|
||||
uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
|
||||
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
|
||||
});
|
||||
|
||||
/** What `Mesh.shader` asks of a shader. */
|
||||
class PassShader extends Shader {
|
||||
texture: Texture = Texture.WHITE;
|
||||
@@ -58,7 +52,7 @@ export class PassPictures {
|
||||
private readonly stage = new Container();
|
||||
private targets: RenderTexture[] = [];
|
||||
/** One quad all passes draw with: they share the frame. */
|
||||
private geometry = new MeshGeometry(unitQuad());
|
||||
private geometry = new MeshGeometry(paddedQuad(null));
|
||||
private geometryKey = '';
|
||||
/** What the pictures were last drawn from; empty forces a draw. */
|
||||
private drawn = '';
|
||||
@@ -175,7 +169,7 @@ export class PassPictures {
|
||||
const key = paddingKey(pad);
|
||||
if (key === this.geometryKey) return;
|
||||
const previous = this.geometry;
|
||||
this.geometry = new MeshGeometry(pad ? paddedQuad(pad) : unitQuad());
|
||||
this.geometry = new MeshGeometry(paddedQuad(pad));
|
||||
this.geometryKey = key;
|
||||
for (const mesh of this.meshes) mesh.geometry = this.geometry;
|
||||
previous.destroy();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type Container, Matrix, RenderTexture, type Renderer } from 'pixi.js';
|
||||
import type { Mat } from '../core/model/matrix';
|
||||
import type { EdgeMode } from '../core/shader/nodes';
|
||||
import { ADDRESS_MODE } from './TextureLibrary';
|
||||
|
||||
/**
|
||||
* The picture a shader is laid over when it shades more than one thing at once: a camera's frame
|
||||
* (#299), a particle source's field (#170). It is drawn anew every time, into the same render
|
||||
* texture while its size holds. The picture is its wearer's alone, so edge and filter are set on it
|
||||
* rather than on a variant.
|
||||
*/
|
||||
export class ShaderPicture {
|
||||
private picture: RenderTexture | null = null;
|
||||
/** The picture a resize replaced, still bound to the shader until `release`. */
|
||||
private replaced: RenderTexture | null = null;
|
||||
|
||||
/**
|
||||
* Draws `container` into a picture of `pixels`, mapped by `toPicture`. `clearColor` is what is
|
||||
* under it, null for see-through.
|
||||
*/
|
||||
draw(
|
||||
renderer: Renderer,
|
||||
container: Container,
|
||||
pixels: { w: number; h: number },
|
||||
toPicture: Mat,
|
||||
clearColor: number | null,
|
||||
edge: EdgeMode,
|
||||
scaleMode: 'nearest' | 'linear' = 'linear',
|
||||
): RenderTexture {
|
||||
let picture = this.picture;
|
||||
if (!picture || picture.width !== pixels.w || picture.height !== pixels.h) {
|
||||
this.release();
|
||||
this.replaced = picture;
|
||||
picture = RenderTexture.create({ width: pixels.w, height: pixels.h });
|
||||
this.picture = picture;
|
||||
}
|
||||
const t = toPicture;
|
||||
renderer.render({
|
||||
container,
|
||||
target: picture,
|
||||
clear: true,
|
||||
clearColor: clearColor ?? [0, 0, 0, 0],
|
||||
transform: new Matrix(t.a, t.b, t.c, t.d, t.tx, t.ty),
|
||||
});
|
||||
const style = picture.source.style;
|
||||
const addressMode = ADDRESS_MODE[edge];
|
||||
if (style.addressMode !== addressMode || style.scaleMode !== scaleMode) {
|
||||
style.addressMode = addressMode;
|
||||
style.scaleMode = scaleMode;
|
||||
style.update();
|
||||
}
|
||||
return picture;
|
||||
}
|
||||
|
||||
/** Lets go of the picture the last `draw` replaced: call it once the shader holds the new one. */
|
||||
release(): void {
|
||||
this.replaced?.destroy(true);
|
||||
this.replaced = null;
|
||||
}
|
||||
|
||||
/** The picture's size in pixels while one is drawn (test hook). */
|
||||
get pixels(): { w: number; h: number } | null {
|
||||
return this.picture ? { w: this.picture.width, h: this.picture.height } : null;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.release();
|
||||
this.picture?.destroy(true);
|
||||
this.picture = null;
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,7 @@ export function placeNode(node: Container, m: Mat, w = 1, h = 1): void {
|
||||
* One quad for every sprite, sized by the mesh's scale. A frame is a texture of its own
|
||||
* (`TextureLibrary`), so the 0..1 UVs cover exactly the frame.
|
||||
*/
|
||||
const UNIT_QUAD = new MeshGeometry({
|
||||
positions: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
|
||||
uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
|
||||
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
|
||||
});
|
||||
const UNIT_QUAD = new MeshGeometry(paddedQuad(null));
|
||||
|
||||
/**
|
||||
* A sprite's picture: a unit quad, centred by its pivot and sized by its scale. It is a sibling of
|
||||
|
||||
@@ -187,7 +187,7 @@ function sameSource(built: MeshSource | undefined, from: MeshSource): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole layer is keyed by its id, as before; a row of a layer sorted by Y by id and row. */
|
||||
/** A whole layer is keyed by its id; a row of a layer sorted by Y by id and row. */
|
||||
function rowKey(layerId: string, row: number | null): string {
|
||||
return row === null ? layerId : `${layerId}#${row}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The empty host the Inspector, the Variables tab and the Tilemap panel build their rows into: what
|
||||
* each panel hands in reaches the rows, and folding a group lays the scroll out anew.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import { emptyHost } from '../../src/editor/inspector/host';
|
||||
import { EditorState } from '../../src/editor/state';
|
||||
import type { ScrollContainer } from '../../src/ui';
|
||||
|
||||
function setup() {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const scroll = { relayoutContent: vi.fn() };
|
||||
return { state, scroll, asScroll: scroll as unknown as ScrollContainer };
|
||||
}
|
||||
|
||||
describe('an empty inspector host', () => {
|
||||
it('carries what the panel hands in, and nothing live without it', () => {
|
||||
const { state, asScroll } = setup();
|
||||
const live = { hp: 3 };
|
||||
const follow = vi.fn();
|
||||
const host = emptyHost(state, asScroll, {
|
||||
foldScope: 'variables',
|
||||
listLimit: 7,
|
||||
liveVariables: () => live,
|
||||
followReference: follow,
|
||||
});
|
||||
expect(host.foldScope).toBe('variables');
|
||||
expect(host.listLimit).toBe(7);
|
||||
expect(host.liveVariables('any')).toBe(live);
|
||||
expect(host.followReference).toBe(follow);
|
||||
|
||||
const plain = emptyHost(state, asScroll);
|
||||
expect(plain.foldScope).toBeUndefined();
|
||||
expect(plain.liveVariables('any')).toBeNull();
|
||||
host.body.destroy({ children: true });
|
||||
plain.body.destroy({ children: true });
|
||||
});
|
||||
|
||||
it('reads the project through the state, which a load replaces', () => {
|
||||
const { state, asScroll } = setup();
|
||||
const host = emptyHost(state, asScroll);
|
||||
state.loadProject(createEmptyProject('q', 'Q'), 1);
|
||||
expect(host.project().id).toBe('q');
|
||||
host.body.destroy({ children: true });
|
||||
});
|
||||
|
||||
it('lays the scroll out anew when a group folds', () => {
|
||||
const { state, scroll, asScroll } = setup();
|
||||
const host = emptyHost(state, asScroll);
|
||||
(host.body as unknown as { onFold: () => void }).onFold();
|
||||
expect(scroll.relayoutContent).toHaveBeenCalledTimes(1);
|
||||
host.body.destroy({ children: true });
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,12 @@ describe('paddedQuad', () => {
|
||||
expect([...uvs]).toEqual([...positions]);
|
||||
expect([...indices]).toEqual([0, 1, 2, 0, 2, 3]);
|
||||
});
|
||||
|
||||
it('is the plain unit quad without a margin', () => {
|
||||
const { positions, uvs } = paddedQuad(null);
|
||||
expect([...positions]).toEqual([0, 0, 1, 0, 1, 1, 0, 1]);
|
||||
expect([...uvs]).toEqual([...positions]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a sliced sprite with padding', () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { colourDistance } from '../lib/harness.mjs';
|
||||
import { inView } from './lib/scaffold.mjs';
|
||||
|
||||
/** One per entry of `ASSET_TYPES` – a new asset type counts here too. */
|
||||
const ASSET_TYPE_COUNT = 26;
|
||||
|
||||
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
|
||||
export async function assets(t) {
|
||||
const {
|
||||
@@ -97,9 +100,9 @@ export async function assets(t) {
|
||||
await redo();
|
||||
check('redo puts it back', (await objects()).length === 8);
|
||||
|
||||
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
|
||||
// what can be placed in a scene, a line, then what only lives in the library. With the Joint
|
||||
// (#154) its 25 types no longer fit under the button on a canvas 800 high: there it scrolls.
|
||||
// 4c. Create a new asset through the + menu. The menu is two alphabetical groups (#43) – what
|
||||
// can be placed in a scene, a line, then what only lives in the library. Its types do not
|
||||
// all fit under the button on a canvas 800 high: there it scrolls.
|
||||
await clickText('+');
|
||||
const rows = await json('__gm.menuRows()');
|
||||
const cut = rows.indexOf(null);
|
||||
@@ -112,7 +115,7 @@ export async function assets(t) {
|
||||
);
|
||||
check(
|
||||
'every asset type is offered once, each group sorted by label',
|
||||
placeable.length + library.length === 25 &&
|
||||
placeable.length + library.length === ASSET_TYPE_COUNT &&
|
||||
String(placeable) === String([...placeable].sort()) &&
|
||||
String(library) === String([...library].sort()),
|
||||
JSON.stringify({ placeable, library }),
|
||||
@@ -486,7 +489,7 @@ export async function assets(t) {
|
||||
async function addMenuOnRightClick(t) {
|
||||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||||
const addRows = (rows) =>
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === 25 && rows.includes('Sprite');
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === ASSET_TYPE_COUNT && rows.includes('Sprite');
|
||||
const heading = await json("__gm.assetRowShown('group:scene')");
|
||||
const sceneFolded = async () =>
|
||||
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
|
||||
|
||||
@@ -1003,7 +1003,7 @@ export async function tilemap(t) {
|
||||
);
|
||||
|
||||
// 40j-ii. A frame of the run draws the game view once and leaves a still map's meshes alone
|
||||
// (#284): the animated grass used to have the view drawn a second time each frame, and
|
||||
// (#284): the animated grass must not have the view drawn a second time each frame, nor
|
||||
// every geometry of the map built anew with each drawing. Counted in the page over the
|
||||
// same frames, so a slow machine changes neither ratio.
|
||||
const drawing = JSON.parse(
|
||||
|
||||
@@ -465,7 +465,7 @@ export async function vectorArt(t) {
|
||||
);
|
||||
|
||||
// A spare shape for the two checks below: the duplicate mark makes one and selects it. The
|
||||
// inspector has no button that adds an empty shape any more (#293) – the Pen draws a new one.
|
||||
// inspector has no button that adds an empty shape (#293) – the Pen draws a new one.
|
||||
const before47e = (await shapeIds()).length;
|
||||
const spareCopy = async () => {
|
||||
const known = await shapeIds();
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function devicePixels(t) {
|
||||
|
||||
// 62b. Both lines of every band of the inspector, read down a column of device pixels: one row
|
||||
// each, in the border's own colour, and only the band's colour between them. A band on an odd
|
||||
// layout pixel starts half-way between two device pixels, which is the case that used to blur.
|
||||
// layout pixel starts half-way between two device pixels, which is the case that blurs.
|
||||
const colours = {
|
||||
border: await json(`__gm.themeColor('border')`),
|
||||
header: await json(`__gm.themeColor('panelHeader')`),
|
||||
|
||||
Reference in new issue
Block a user