Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.
- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
pulses (keys, timers, collisions, animations) moved to
behaviorPulses.ts. The NodeContext the nodes receive moved to
behaviorContext.ts. Imports from outside stay the same, TimeScaled
is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
its menu and the point and handle drawing that the curve and the
vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
that liveObjects and objectRefs did separately. withFieldRenamed
and withoutField replace three copies of renaming a struct field
and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
StaleRevisionError for all stores. The auth store turns a 401 into
its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
commands, shownObject in the inspector, selectedItem for the size
gizmo and the polygon corners, solidTiles in the tilemap collision,
requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
physics systems to Solids, the pointer wiring of the canvases and
the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
called since #128 and #131. EditorState.runtimeProject became unused
with GameRun in #266. The assetId getter of the make commands was
read only by a test.
- 60 values used only in their own file lost their export. The shader
and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
game link from #266.
Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
place, and they draw nothing. The delete dialog does not mention the
tilemaps. That was probably the job of the unused tilemapsUsing.
Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
stayed green. A new test in tests/core/surface.test.ts now fails
against it.
Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
the late timer and the time scale), 2 in the browser (Point button
always enabled fails 47, selectedItem without a hit fails 29).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
7.6 KiB
TypeScript
211 lines
7.6 KiB
TypeScript
/** Geometry for the Texture tab: no Pixi and no editor state, so it stays unit-testable. */
|
||
import type { FrameBorders } from '../../core/model/Asset';
|
||
import type { FrameRect } from '../../core/model/texture';
|
||
import type { Vec2 } from '../../core/model/Properties';
|
||
|
||
/** Image pixel `(0, 0)` sits at (`panX`, `panY`) in widget-local pixels, at `zoom` per pixel. */
|
||
export interface SheetView {
|
||
panX: number;
|
||
panY: number;
|
||
zoom: number;
|
||
}
|
||
|
||
export const MIN_SHEET_ZOOM = 0.05;
|
||
export const MAX_SHEET_ZOOM = 64;
|
||
|
||
/** Fraction of the widget the picture is fitted into, leaving a margin to grab and pan by. */
|
||
const FIT_MARGIN = 0.9;
|
||
|
||
export function imageToLocal(view: SheetView, x: number, y: number): Vec2 {
|
||
return { x: view.panX + x * view.zoom, y: view.panY + y * view.zoom };
|
||
}
|
||
|
||
export function localToImage(view: SheetView, x: number, y: number): Vec2 {
|
||
return { x: (x - view.panX) / view.zoom, y: (y - view.panY) / view.zoom };
|
||
}
|
||
|
||
export function fitView(imageWidth: number, imageHeight: number, w: number, h: number): SheetView {
|
||
if (imageWidth <= 0 || imageHeight <= 0 || w <= 0 || h <= 0) return { panX: 0, panY: 0, zoom: 1 };
|
||
const zoom = clampZoom(Math.min(w / imageWidth, h / imageHeight) * FIT_MARGIN || 1);
|
||
return {
|
||
panX: (w - imageWidth * zoom) / 2,
|
||
panY: (h - imageHeight * zoom) / 2,
|
||
zoom,
|
||
};
|
||
}
|
||
|
||
function clampZoom(zoom: number): number {
|
||
return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom));
|
||
}
|
||
|
||
/** Zooms about a fixed widget-local point, so the pixel under the cursor stays under it. */
|
||
export function zoomAbout(view: SheetView, factor: number, localX: number, localY: number): SheetView {
|
||
const zoom = clampZoom(view.zoom * factor);
|
||
const before = localToImage(view, localX, localY);
|
||
const after = { panX: localX - before.x * zoom, panY: localY - before.y * zoom, zoom };
|
||
return after;
|
||
}
|
||
|
||
/** `hx`/`hy` are −1/0/1, naming the edge each axis is pinned to; `'move'` is anywhere inside. */
|
||
export type FrameHandle = { hx: -1 | 0 | 1; hy: -1 | 0 | 1 } | 'move';
|
||
|
||
/** The nine anchor points of a rectangle; (0, 0) is its centre and is not a handle. */
|
||
const HANDLES: { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] = [
|
||
{ hx: -1, hy: -1 },
|
||
{ hx: 1, hy: -1 },
|
||
{ hx: -1, hy: 1 },
|
||
{ hx: 1, hy: 1 },
|
||
{ hx: 0, hy: -1 },
|
||
{ hx: 0, hy: 1 },
|
||
{ hx: -1, hy: 0 },
|
||
{ hx: 1, hy: 0 },
|
||
];
|
||
|
||
/** Where a handle sits, in image pixels. */
|
||
export function handlePoint(rect: FrameRect, handle: { hx: number; hy: number }): Vec2 {
|
||
return {
|
||
x: rect.x + ((handle.hx + 1) / 2) * rect.w,
|
||
y: rect.y + ((handle.hy + 1) / 2) * rect.h,
|
||
};
|
||
}
|
||
|
||
export function frameHandles(): readonly { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] {
|
||
return HANDLES;
|
||
}
|
||
|
||
/**
|
||
* What a press at `point` does to `rect`; `tolerance` is the grab radius in image pixels.
|
||
* Corners are tested before edges, so the corner wins on a frame small enough for both to overlap.
|
||
*/
|
||
export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): FrameHandle | null {
|
||
for (const handle of HANDLES) {
|
||
const p = handlePoint(rect, handle);
|
||
if (Math.abs(point.x - p.x) <= tolerance && Math.abs(point.y - p.y) <= tolerance) return handle;
|
||
}
|
||
const inside =
|
||
point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h;
|
||
return inside ? 'move' : null;
|
||
}
|
||
|
||
/**
|
||
* The rectangle a drag produces. It may come out inverted or outside the image: squaring it up
|
||
* is `normalizeFrameRect`'s job, shared with typed-in values.
|
||
*/
|
||
export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy: number): FrameRect {
|
||
if (handle === 'move') return { ...start, x: start.x + dx, y: start.y + dy };
|
||
const rect = { ...start };
|
||
if (handle.hx === -1) {
|
||
rect.x += dx;
|
||
rect.w -= dx;
|
||
} else if (handle.hx === 1) {
|
||
rect.w += dx;
|
||
}
|
||
if (handle.hy === -1) {
|
||
rect.y += dy;
|
||
rect.h -= dy;
|
||
} else if (handle.hy === 1) {
|
||
rect.h += dy;
|
||
}
|
||
return rect;
|
||
}
|
||
|
||
/** The four nine-slice border lines of a frame (#41), named by the frame edge each runs beside. */
|
||
export type BorderEdge = 'left' | 'top' | 'right' | 'bottom';
|
||
|
||
export const BORDER_EDGES: readonly BorderEdge[] = ['left', 'top', 'right', 'bottom'];
|
||
|
||
/** Where a border line runs, in image pixels; vertical for left/right, horizontal for top/bottom. */
|
||
export function borderLine(
|
||
rect: FrameRect,
|
||
border: FrameBorders,
|
||
edge: BorderEdge,
|
||
): { x1: number; y1: number; x2: number; y2: number } {
|
||
if (edge === 'left' || edge === 'right') {
|
||
const x = edge === 'left' ? rect.x + border.left : rect.x + rect.w - border.right;
|
||
return { x1: x, y1: rect.y, x2: x, y2: rect.y + rect.h };
|
||
}
|
||
const y = edge === 'top' ? rect.y + border.top : rect.y + rect.h - border.bottom;
|
||
return { x1: rect.x, y1: y, x2: rect.x + rect.w, y2: y };
|
||
}
|
||
|
||
/**
|
||
* Which border line a press at `point` grabs, or null. Tested only within the frame, and after the
|
||
* frame's own handles: a border still sitting on the frame's edge would otherwise take the corner
|
||
* that resizes the frame itself.
|
||
*/
|
||
export function borderAt(
|
||
rect: FrameRect,
|
||
border: FrameBorders,
|
||
point: Vec2,
|
||
tolerance: number,
|
||
): BorderEdge | null {
|
||
if (
|
||
point.x < rect.x - tolerance ||
|
||
point.x > rect.x + rect.w + tolerance ||
|
||
point.y < rect.y - tolerance ||
|
||
point.y > rect.y + rect.h + tolerance
|
||
)
|
||
return null;
|
||
for (const edge of BORDER_EDGES) {
|
||
const line = borderLine(rect, border, edge);
|
||
const distance = line.x1 === line.x2 ? Math.abs(point.x - line.x1) : Math.abs(point.y - line.y1);
|
||
if (distance <= tolerance) return edge;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The borders a drag of one line produces. Raw: keeping them inside the frame is
|
||
* `normalizeBorders`' job, shared with the numbers typed into the inspector.
|
||
*/
|
||
export function dragBorder(border: FrameBorders, edge: BorderEdge, dx: number, dy: number): FrameBorders {
|
||
const moved = { ...border };
|
||
if (edge === 'left') moved.left += dx;
|
||
else if (edge === 'right') moved.right -= dx;
|
||
else if (edge === 'top') moved.top += dy;
|
||
else moved.bottom -= dy;
|
||
return moved;
|
||
}
|
||
|
||
/** The Y a frame's sort line (#228) runs at, in image pixels. */
|
||
export function sortLineY(rect: FrameRect, line: number): number {
|
||
return rect.y + rect.h - line;
|
||
}
|
||
|
||
/**
|
||
* What a press grabs of the sort line: its knob left of the frame, reachable wherever the line sits,
|
||
* or the line itself inside the frame. The knob spans from `gap` to `reach` left of the frame, in
|
||
* image pixels. The press tests the knob before the frame's handles and the line after its border
|
||
* lines, which at 0 all lie on the bottom edge with it. So the knob takes its whole drawn width,
|
||
* even where the corner handle's tolerance reaches over it – bounding it by that tolerance instead
|
||
* left its very middle to the corner handle.
|
||
*/
|
||
export function sortLineAt(
|
||
rect: FrameRect,
|
||
line: number,
|
||
point: Vec2,
|
||
tolerance: number,
|
||
gap: number,
|
||
reach: number,
|
||
): 'knob' | 'line' | null {
|
||
if (Math.abs(point.y - sortLineY(rect, line)) > tolerance) return null;
|
||
if (point.x >= rect.x - reach && point.x < rect.x - gap) return 'knob';
|
||
return point.x >= rect.x && point.x <= rect.x + rect.w ? 'line' : null;
|
||
}
|
||
|
||
export function borderCursor(edge: BorderEdge): string {
|
||
return edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize';
|
||
}
|
||
|
||
/** The rectangle spanned by two points, in either order. */
|
||
export function rectBetween(a: Vec2, b: Vec2): FrameRect {
|
||
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
|
||
}
|
||
|
||
export function handleCursor(handle: FrameHandle): string {
|
||
if (handle === 'move') return 'move';
|
||
if (handle.hx === 0) return 'ns-resize';
|
||
if (handle.hy === 0) return 'ew-resize';
|
||
return handle.hx === handle.hy ? 'nwse-resize' : 'nesw-resize';
|
||
}
|