diff --git a/README.md b/README.md index b7fac6aa..7ec182c4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Game Maker +[![game-maker](https://git.nockal.com/meleki/game-maker/actions/workflows/ci.yml/badge.svg?branch=main)](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 | diff --git a/screenshot.png b/screenshot.png index f3191383..3080d6e7 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index c96b1f22..ddb2f17b 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -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 ?? ''); diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index 9cc0c83e..18704c6e 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -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( diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index a0cba02b..dda57783 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -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(), +): 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 = 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 = 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) : []; + }); } /** diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 60d71c97..197d9578 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -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); diff --git a/src/core/model/vectorTransform.ts b/src/core/model/vectorTransform.ts index 2bd29c53..b3f2c7ef 100644 --- a/src/core/model/vectorTransform.ts +++ b/src/core/model/vectorTransform.ts @@ -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. diff --git a/src/core/particles/nodes.ts b/src/core/particles/nodes.ts index 2f02c36a..63d42ccc 100644 --- a/src/core/particles/nodes.ts +++ b/src/core/particles/nodes.ts @@ -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 { 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] ?? ''); } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index cb01fda8..191e088c 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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', diff --git a/src/core/properties/joint.ts b/src/core/properties/joint.ts index 93013e2b..19820e9f 100644 --- a/src/core/properties/joint.ts +++ b/src/core/properties/joint.ts @@ -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'; } diff --git a/src/core/properties/shaders.ts b/src/core/properties/shaders.ts index ed2988de..2edec01a 100644 --- a/src/core/properties/shaders.ts +++ b/src/core/properties/shaders.ts @@ -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); } diff --git a/src/core/shader/functions.ts b/src/core/shader/functions.ts index 29c2c56d..89ddae48 100644 --- a/src/core/shader/functions.ts +++ b/src/core/shader/functions.ts @@ -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. */ diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index be2973a2..ddb871b6 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -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 = { 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'; diff --git a/src/core/util/vec.ts b/src/core/util/vec.ts index 923e19f6..d646a126 100644 --- a/src/core/util/vec.ts +++ b/src/core/util/vec.ts @@ -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 }; } diff --git a/src/editor/curve/CurveCanvas.ts b/src/editor/curve/CurveCanvas.ts index f0ab50cd..380d058d 100644 --- a/src/editor/curve/CurveCanvas.ts +++ b/src/editor/curve/CurveCanvas.ts @@ -136,10 +136,6 @@ export class CurveCanvas extends SheetCanvas { 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(); diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts index b5676dd2..72949dbb 100644 --- a/src/editor/debug/renderHooks.ts +++ b/src/editor/debug/renderHooks.ts @@ -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 => { diff --git a/src/editor/debug/vectorArtHooks.ts b/src/editor/debug/vectorArtHooks.ts index 53bf2a05..b709838f 100644 --- a/src/editor/debug/vectorArtHooks.ts +++ b/src/editor/debug/vectorArtHooks.ts @@ -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); diff --git a/src/editor/frameMeter.ts b/src/editor/frameMeter.ts index 34791105..12e896d7 100644 --- a/src/editor/frameMeter.ts +++ b/src/editor/frameMeter.ts @@ -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; /** Seconds one average runs over. */ -export const FRAME_WINDOW = 0.5; +const FRAME_WINDOW = 0.5; const LABELS: Record = { behaviors: 'Behaviors', diff --git a/src/editor/graph/edgeHit.ts b/src/editor/graph/edgeHit.ts index 38300778..885c8b1d 100644 --- a/src/editor/graph/edgeHit.ts +++ b/src/editor/graph/edgeHit.ts @@ -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' diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 6c8c2b9f..dedcae33 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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, - }; + }); } /** diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts index 1e65311b..85a488f1 100644 --- a/src/editor/inspector/host.ts +++ b/src/editor/inspector/host.ts @@ -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 & { + 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); diff --git a/src/editor/inspector/shapeTransformRows.ts b/src/editor/inspector/shapeTransformRows.ts index e8120b71..614cf4da 100644 --- a/src/editor/inspector/shapeTransformRows.ts +++ b/src/editor/inspector/shapeTransformRows.ts @@ -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[], diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index cdada0a9..2b81d275 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -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 { diff --git a/src/editor/pixelart/PixelArtEditor.ts b/src/editor/pixelart/PixelArtEditor.ts index b1db916d..4a219089 100644 --- a/src/editor/pixelart/PixelArtEditor.ts +++ b/src/editor/pixelart/PixelArtEditor.ts @@ -21,7 +21,7 @@ export class PixelArtEditor extends AssetTabEditor { 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 ?? ''; } diff --git a/src/editor/variables/VariablesEditor.ts b/src/editor/variables/VariablesEditor.ts index f4941e2b..8282c098 100644 --- a/src/editor/variables/VariablesEditor.ts +++ b/src/editor/variables/VariablesEditor.ts @@ -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 { diff --git a/src/editor/vectorart/shapeFrame.ts b/src/editor/vectorart/shapeFrame.ts index ac4d1500..b4b6f3c9 100644 --- a/src/editor/vectorart/shapeFrame.ts +++ b/src/editor/vectorart/shapeFrame.ts @@ -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. */ diff --git a/src/ui/widgets/Icon.ts b/src/ui/widgets/Icon.ts index 157d6d8a..07b7ec2d 100644 --- a/src/ui/widgets/Icon.ts +++ b/src/ui/widgets/Icon.ts @@ -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; diff --git a/src/ui/widgets/SortableList.ts b/src/ui/widgets/SortableList.ts index 76e7f27d..9980ac4c 100644 --- a/src/ui/widgets/SortableList.ts +++ b/src/ui/widgets/SortableList.ts @@ -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; diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index d3f8112b..d93cc1cf 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -444,7 +444,8 @@ export abstract class WorldViewport 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 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; } diff --git a/src/viewport/cameraShade.ts b/src/viewport/cameraShade.ts index 5c6a3acf..1951355e 100644 --- a/src/viewport/cameraShade.ts +++ b/src/viewport/cameraShade.ts @@ -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(); } } diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index 6c3373c6..6ad7d491 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -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. */ diff --git a/src/viewport/quadPadding.ts b/src/viewport/quadPadding.ts index 093ecc3e..10dbbbd9 100644 --- a/src/viewport/quadPadding.ts +++ b/src/viewport/quadPadding.ts @@ -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); diff --git a/src/viewport/shaderPasses.ts b/src/viewport/shaderPasses.ts index f4862779..c0ba704f 100644 --- a/src/viewport/shaderPasses.ts +++ b/src/viewport/shaderPasses.ts @@ -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(); diff --git a/src/viewport/shaderPicture.ts b/src/viewport/shaderPicture.ts new file mode 100644 index 00000000..e91951e7 --- /dev/null +++ b/src/viewport/shaderPicture.ts @@ -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; + } +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 8fbe81e5..cb77e1bc 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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 diff --git a/src/viewport/tilemap.ts b/src/viewport/tilemap.ts index a3e6c4f0..04863061 100644 --- a/src/viewport/tilemap.ts +++ b/src/viewport/tilemap.ts @@ -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}`; } diff --git a/tests/editor/inspector-host.test.ts b/tests/editor/inspector-host.test.ts new file mode 100644 index 00000000..8faa278e --- /dev/null +++ b/tests/editor/inspector-host.test.ts @@ -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 }); + }); +}); diff --git a/tests/viewport/quad-padding.test.ts b/tests/viewport/quad-padding.test.ts index 34f15ef3..0638aec8 100644 --- a/tests/viewport/quad-padding.test.ts +++ b/tests/viewport/quad-padding.test.ts @@ -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', () => { diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index cf4360ce..b5cb11ff 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -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; diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index b72e4f74..f8ea027f 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -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( diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 28919aee..5c13cad6 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -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(); diff --git a/tools/e2e/62-device-pixels.mjs b/tools/e2e/62-device-pixels.mjs index c0f7ac5c..a9ef510f 100644 --- a/tools/e2e/62-device-pixels.mjs +++ b/tools/e2e/62-device-pixels.mjs @@ -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')`),