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>
199 lines
8.8 KiB
TypeScript
199 lines
8.8 KiB
TypeScript
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;
|
||
}
|