A behavior could move sprites around but had no way to look at or
change the tilemap it was standing on. Doors, breakable blocks and
"is there ground ahead?" checks all need that.
- New nodes Tile at, Set tile, World to cell and Cell to world. The
conversion honours position, rotation, scale and mirroring of the
placed map. A small Tile literal node lets a graph compare what it
read against a fixed tile.
- The layer is picked by name from all tilemap layers in the project
through a new field kind. "First layer" takes the map's first one.
Because layer is an input, a text can also be wired in.
- Each placement keeps only its edited cells in liveTilemap.ts,
layered over the asset's cells. A second placement of the same map
stays untouched, and the asset itself is never written.
- A read sees a Set tile from earlier in the same flow. Drawing picks
up the edit at once and neighbouring autotile edges adapt.
- Solids see the edits straight away, so a Move in the same frame
already hits the new cell. Rapier rebuilds its shapes on the next
step.
- After Stop the map is back to what was painted. The editor run shows
the edit in the scene view while it lasts and does not touch the
project.
- An unwired tilemap input means no map, like Get position. Cells
outside the grid are refused and done reports false.
Tests:
- ./check.sh: 155 files, 2271 tests, CHECK EXIT 0 (before 2245).
- tests/core/tile-access.test.ts (14), one physics test for a floor
set at runtime, catalog entries for the five nodes.
- e2e 39,40: all passed, 86 checks (before 81), five new in 40s.
- Counter-checks: collision ignoring edits fails 3 unit tests, the
solids cache missing same-tick edits fails only the "at once" test,
drawing ignoring edits fails the 2 drawing checks, the editor run
not reporting a new tile fails only its check.
- 40s uses a still tile. The animated fixture tile redraws every frame
and hid the last counter-check at first.
- Check total 1351 + 5 = 1356, computed. CI confirmed 1351 at cb32b4b.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.6 KiB
TypeScript
120 lines
4.6 KiB
TypeScript
/**
|
|
* The scene seen through its active camera, letterboxed into the widget: the running world while
|
|
* playing, the model as a still preview otherwise.
|
|
*/
|
|
import { type FederatedPointerEvent, Text } from 'pixi.js';
|
|
import { isSymbolType } from '../../core/model/AssetTypes';
|
|
import type { EditorState } from '../state';
|
|
import type { World } from '../../runtime/World';
|
|
import { textStyle } from '../../ui';
|
|
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
|
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
|
import { cameraView, isInside, screenToWorld, type View } from '../../viewport/view';
|
|
import { type Frame, WorldViewport } from './WorldViewport';
|
|
|
|
const LETTERBOX_COLOR = 0x000000;
|
|
|
|
export class GameViewport extends WorldViewport {
|
|
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
|
|
private readonly editorRunCaption = new Text({
|
|
text: 'Behaviors run here · keys and clicks go to the game',
|
|
style: textStyle('muted'),
|
|
});
|
|
private playWorld: World | null = null;
|
|
/** The editor run's world: it takes the mouse, but it is never what this view draws. */
|
|
private editorWorld: World | null = null;
|
|
|
|
constructor(state: EditorState) {
|
|
super(state);
|
|
this.hint.eventMode = 'none';
|
|
this.hint.anchor.set(0.5);
|
|
this.addChild(this.hint);
|
|
this.editorRunCaption.eventMode = 'none';
|
|
this.addChild(this.editorRunCaption);
|
|
|
|
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
|
if (e.button !== 0) return;
|
|
this.reportPointer(e);
|
|
this.pointerWorld()?.setPointerDown(true);
|
|
});
|
|
this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e));
|
|
this.on('pointerup', () => this.pointerWorld()?.setPointerDown(false));
|
|
this.on('pointerupoutside', () => this.pointerWorld()?.setPointerDown(false));
|
|
}
|
|
|
|
/** null = not playing. */
|
|
setWorld(world: World | null): void {
|
|
this.playWorld = world;
|
|
this.redraw();
|
|
}
|
|
|
|
/** null = no behavior is running outside play mode. */
|
|
setEditorWorld(world: World | null): void {
|
|
this.editorWorld = world;
|
|
this.redraw();
|
|
}
|
|
|
|
/** Where the mouse is reported: the running game, or the behaviors running without it. */
|
|
private pointerWorld(): World | null {
|
|
return this.playWorld ?? this.editorWorld;
|
|
}
|
|
|
|
/**
|
|
* Skipped while the tab is hidden: the preview costs a collect of the whole scene and would
|
|
* otherwise run on every selection change behind another tab. Showing the tab relayouts the
|
|
* stack, which resizes this widget and thereby redraws it.
|
|
*/
|
|
override redraw(): void {
|
|
if (!this.visible) return;
|
|
super.redraw();
|
|
}
|
|
|
|
protected override currentView(): View | null {
|
|
const camera = this.activeCamera();
|
|
return camera ? cameraView(camera, this.w, this.h) : null;
|
|
}
|
|
|
|
private activeCamera(): ActiveCamera | null {
|
|
const world = this.playWorld;
|
|
return world ? cameraOfWorld(world) : cameraOfItems(this.modelFrame().items);
|
|
}
|
|
|
|
private modelFrame(): Frame {
|
|
return collectHolderItems(this.state.project, this.state.activeScene, LETTERBOX_COLOR, this.editorWorld);
|
|
}
|
|
|
|
/** `camera`: object id of the camera the view is through, empty when none is switched on. */
|
|
override debugInfo(): ReturnType<WorldViewport['debugInfo']> & { camera: string } {
|
|
return { ...super.debugInfo(), camera: this.activeCamera()?.id ?? '' };
|
|
}
|
|
|
|
protected override collectFrame(): Frame {
|
|
if (this.playWorld) return collectWorldItems(this.playWorld);
|
|
const frame = this.modelFrame();
|
|
// Symbols are editor decoration, so the preview drops them: it shows what playing would show.
|
|
return { ...frame, items: frame.items.filter((item) => !isSymbolType(item.type)) };
|
|
}
|
|
|
|
protected override get baseColor(): number {
|
|
return LETTERBOX_COLOR;
|
|
}
|
|
|
|
protected override beforeItems(view: View | null): void {
|
|
this.hint.visible = view === null;
|
|
this.hint.position.set(this.w / 2, this.h / 2);
|
|
// Which tab holds the keyboard is otherwise nowhere to be seen.
|
|
this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld;
|
|
this.editorRunCaption.position.set(8, 6);
|
|
}
|
|
|
|
/** Outside the camera frame counts as "not in view": that region is letterbox, not game. */
|
|
private reportPointer(e: FederatedPointerEvent): void {
|
|
const world = this.pointerWorld();
|
|
const view = this.currentView();
|
|
if (!world || !view) return;
|
|
const local = this.toLocal(e.global);
|
|
const point = screenToWorld(view, local.x, local.y);
|
|
world.movePointer(point.x, point.y, isInside(view, local.x, local.y));
|
|
}
|
|
}
|