Files
feuerwerk/src/core/model/tile.ts
T
adminandClaude Opus 5.5 275e50af01 Let a tile report a touch instead of blocking it (#160)
A tile could be solid, one-way and scaled, but not report without
blocking. Water, lava, spikes or a goal zone needed a trigger object
of their own laid over the map. Now a tile has a switch Trigger, and
the collision events name the tile and the cell they met.

- A tile has the switch "Trigger (reports, never blocks)" under
  Solid. Its cells hold nothing up: no Move, no falling body, no
  ray. They raise On collision enter, stay and exit with the tilemap
  as other.
- The three events have the outputs tile, cell x and cell y. Without
  a tile, tile is empty and the cell is -1, -1.
- The three events have the input per cell. Off, the default, gives
  one event per area: cells of the same tile that touch side by
  side. On gives one event per cell.
- The scene view outlines trigger cells in yellow like a trigger
  collider, merged to rectangles but never with solid cells.
- Is touching with the map counts trigger tiles, and Set tile works
  while the game runs.
- Both systems find the touches with the same overlap check in
  runtime/systems/tileTriggers.ts, not through the physics engine.

Decided here:

- Trigger wins over Solid. A tile with both does not block, as with
  a collider.
- Water beside lava is two areas, and so are two ponds that touch
  only at a corner.
- An area names the cell nearest to the centre of the collider, and
  on exit the cell it named last.
- The solid cells of a map still do not name their tile.
- Normal, impulse and point are 0, as with a trigger collider.
- Collision scale and outlines shape a trigger cell like a solid
  one. One-way, friction and bounciness have no effect.

Tests:
- Gate on stein2: 205 files, 3104 tests (3067 + 37), exit 0
- e2e 39,40: 106 checks. e2e 1-8,39-43: 490 checks. Both on
  melektaus-atx. No full run, the sum of 1648 (1645 + 3) is
  reckoned and CI measures it.
- 37 unit tests in tests/core/tile-triggers.test.ts, nearly all
  against both systems
- Three e2e checks with a real mouse in block 40l-iv
- 24 unit counter-probes fail tests. Two did not at first, tests
  were added for them.
- Two browser counter-probes fail their checks
- Two screenshots looked at (yellow outline, the node with per cell)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:28:39 +02:00

199 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { TileAsset } from './Asset';
import { textOf } from './Properties';
import type { Project } from './Project';
import { type FrameRect, resolvePicture, type TextureRef } from './texture';
/**
* A tile is one picture per animation step, and every picture is a *block* the editor cuts into
* quarter tiles. `none` is the plain case: the block is the tile itself and every cell shows it as
* it is (#131). `floor` and `wall` are the two autotile blocks of the classic RPG-Maker sheets,
* `terrain` the 6 × 4 block of the common top-down sheets; their quarters are picked by the
* neighbours – that part is `model/autotile.ts`.
*
* Everything below counts in *quadrants* – quarter tiles – with (0, 0) at the block's top-left
* corner, so all kinds are drawn the same way: four quarters per tile.
*/
export const TILE_KINDS = ['none', 'floor', 'wall', 'terrain'] as const;
export type TileKind = (typeof TILE_KINDS)[number];
export const TILE_KIND_KEY = 'kind';
export const TILE_FPS_KEY = 'fps';
export const TILE_PLAYBACK_KEY = 'playback';
/** Whether a cell of this tile blocks movement in the running game (#132). */
export const TILE_SOLID_KEY = 'solid';
/** Whether a cell of this tile reports what overlaps it instead of blocking it: water, lava, a goal (#160). */
export const TILE_TRIGGER_KEY = 'trigger';
/** Whether a solid tile only blocks from above, as a platform one jumps through from below (#155). */
export const TILE_ONE_WAY_KEY = 'oneWay';
/** How far a contact may lean from straight up and still land on a one-way tile, in degrees (#159). */
export const TILE_ONE_WAY_ANGLE_KEY = 'oneWayAngle';
/** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */
export const TILE_COLLISION_SCALE_KEY = 'collisionScale';
/** The surface of a solid tile (#193): the same rows, and the same defaults, as a collider's material. */
export const TILE_FRICTION_KEY = 'friction';
export const TILE_BOUNCINESS_KEY = 'bounciness';
export const TILE_COLLISION_SCALE_MIN = 0.1;
export const TILE_COLLISION_SCALE_MAX = 2;
/**
* How many rows further down a cell of this tile is sorted on a layer sorted by Y (#228): a
* treetop at 1 sorts with the trunk below it.
*/
export const TILE_SORT_OFFSET_KEY = 'sortOffset';
export const TILE_SORT_OFFSET_LIMIT = 16;
/** Only the two that make sense for a tile that is always on screen; there is no end to hold. */
const TILE_PLAYBACKS = ['loop', 'pingpong'] as const;
export type TilePlayback = (typeof TILE_PLAYBACKS)[number];
/** Quadrant columns of a block: one tile for `none`, two for the RPG-Maker blocks, six for `terrain`. */
const BLOCK_COLUMNS: Record<TileKind, number> = { none: 2, floor: 4, wall: 4, terrain: 12 };
/** Quadrant rows of a block: `none` is one tile tall, `floor` three, `wall` two and `terrain` four. */
const BLOCK_ROWS: Record<TileKind, number> = { none: 2, floor: 6, wall: 4, terrain: 8 };
export function tileKind(asset: TileAsset): TileKind {
const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none');
return (TILE_KINDS as readonly string[]).includes(value) ? (value as TileKind) : 'none';
}
export function tilePlayback(asset: TileAsset): TilePlayback {
const value = textOf(asset.props[TILE_PLAYBACK_KEY] ?? 'loop');
return (TILE_PLAYBACKS as readonly string[]).includes(value) ? (value as TilePlayback) : 'loop';
}
/** A trigger never blocks, whatever Solid says: it is the collider's rule, where Trigger wins too. */
export function isTileSolid(asset: TileAsset): boolean {
return asset.props[TILE_SOLID_KEY] === true && !isTileTrigger(asset);
}
export function isTileTrigger(asset: TileAsset): boolean {
return asset.props[TILE_TRIGGER_KEY] === true;
}
export function isTileOneWay(asset: TileAsset): boolean {
return asset.props[TILE_ONE_WAY_KEY] === true;
}
/** The collision scale, held to the range the field offers; anything unreadable is the whole cell. */
export function tileCollisionScale(asset: TileAsset): number {
const scale = Number(asset.props[TILE_COLLISION_SCALE_KEY] ?? 1);
if (!Number.isFinite(scale)) return 1;
return Math.min(TILE_COLLISION_SCALE_MAX, Math.max(TILE_COLLISION_SCALE_MIN, scale));
}
export function tileFps(asset: TileAsset): number {
const fps = Number(asset.props[TILE_FPS_KEY]);
return Number.isFinite(fps) && fps > 0 ? fps : 0;
}
/** The sort offset in whole rows, held to the range the field offers; anything unreadable is 0. */
export function tileSortOffset(asset: TileAsset): number {
const rows = Math.round(Number(asset.props[TILE_SORT_OFFSET_KEY] ?? 0));
if (!Number.isFinite(rows)) return 0;
return Math.min(TILE_SORT_OFFSET_LIMIT, Math.max(-TILE_SORT_OFFSET_LIMIT, rows));
}
/** A quarter tile inside the block, in quadrant units from its top-left corner. */
export interface QuadPos {
x: number;
y: number;
}
/** One picture of a tile: the reference it was named by, and the block it covers. */
export interface TilePicture {
id: string;
ref: TextureRef;
block: FrameRect;
}
/**
* What the tab can draw and what stands in its way. `problem` is a sentence for the reader, never
* a reason to drop anything: a picture of the wrong size stays in the list and can be taken out
* again or the kind switched to the one it fits.
*/
export interface TileInfo {
kind: TileKind;
pictures: TilePicture[];
/** Side of one tile in image pixels, 0 while the tile has no usable picture. */
tile: number;
/** Side of one quarter tile – half a tile, and what the block is measured in. */
quad: number;
problem: string;
}
/**
* Reads a tile against the project: resolves every picture, derives the tile size from the first
* one and says what does not fit. The tile size is derived rather than stored – the block's width
* is a known number of tiles, so a second number could only ever disagree with the picture.
*/
export function readTile(project: Project, asset: TileAsset): TileInfo {
const kind = tileKind(asset);
const columns = BLOCK_COLUMNS[kind];
const rows = BLOCK_ROWS[kind];
const pictures: TilePicture[] = [];
const missing: string[] = [];
for (const id of asset.pictures) {
const ref = resolvePicture(project, id);
if (!ref) {
missing.push(id);
continue;
}
pictures.push({ id, ref, block: ref.rect ?? { x: 0, y: 0, w: ref.sourceWidth, h: ref.sourceHeight } });
}
const first = pictures[0];
const quad = first ? first.block.w / columns : 0;
// A plain tile is only cut to be drawn like the others, never along a line in the picture, so
// an odd side is fine there; an autotile block is cut along its quarters and must split evenly.
const whole = kind === 'none' || Number.isInteger(quad);
const fits = (rect: FrameRect) => rect.w === quad * columns && rect.h === quad * rows && whole && quad > 0;
const problem =
asset.pictures.length === 0
? 'No picture yet – add a texture or a frame that holds one block.'
: missing.length > 0
? `${missing.length} picture(s) no longer exist.`
: !first || !fits(first.block)
? blockProblem(kind, first?.block)
: pictures.some((picture) => !fits(picture.block))
? 'The pictures have different sizes; every step of an animation must hold the same block.'
: '';
return { kind, pictures, tile: problem === '' ? quad * 2 : 0, quad: problem === '' ? quad : 0, problem };
}
function blockProblem(kind: TileKind, block: FrameRect | undefined): string {
const size = block ? `, this one is ${block.w} × ${block.h} px` : '';
if (kind === 'none') return `A plain tile is one square tile${size}.`;
const columns = BLOCK_COLUMNS[kind];
const rows = BLOCK_ROWS[kind];
const shape = `${columns / 2} × ${rows / 2} tiles`;
return `A ${kind} block is ${shape}, so its sides must divide by ${columns} and ${rows}${size}.`;
}
/** The rectangle one quarter tile is cut from, in image pixels. */
export function quadrantRect(picture: TilePicture, quad: number, position: QuadPos): FrameRect {
return {
x: picture.block.x + position.x * quad,
y: picture.block.y + position.y * quad,
w: quad,
h: quad,
};
}
/** That quarter as a texture reference of its own – a cut of the sheet, never nine-sliced. */
export function quadrantRef(picture: TilePicture, quad: number, position: QuadPos): TextureRef {
return { ...picture.ref, rect: quadrantRect(picture, quad, position), slice: null };
}
/**
* Which picture is shown `time` seconds in. One picture is a still image whatever the settings
* say; ping-pong turns on the last one, so three pictures run 0 1 2 1 and not 0 1 2 2 1.
*/
export function pictureIndexAt(count: number, playback: TilePlayback, fps: number, time: number): number {
if (count <= 1) return 0;
if (!Number.isFinite(fps) || fps <= 0 || !Number.isFinite(time) || time <= 0) return 0;
const step = Math.floor(time * fps);
if (playback === 'loop') return step % count;
const span = 2 * (count - 1);
const phase = step % span;
return phase < count ? phase : span - phase;
}