Skip to content

Commit e0264e5

Browse files
committed
feat(editor): always-on collision overlay + Marker pin gizmos
Two visibility polish passes for the object-authoring flow. (1) The tile-collision overlay now draws for EVERY collision (obstacle) layer whenever the flag is on, not only the selected one — a collision layer renders nothing, so its outlines ARE its content and must stay visible while you select/place other things. A selected non-collision tilemap still shows its per-tile collision as before. (2) Marker (point-object) entities render nothing either, so each now draws an always-on pin at its position (click to select), mirroring the camera/light gizmo chain. Both reuse the existing per-frame gizmo rAF + structural id sets; collision layers all share the one built-in palette model (no per-layer disk load). Shot-verified: obstacles stay visible with the Camera selected; two markers report visible, positioned pins.
1 parent 630b2b5 commit e0264e5

4 files changed

Lines changed: 145 additions & 14 deletions

File tree

desktop/src/engine/ViewportController.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ParticleEmitter, OneWayPlatform,
77
RevoluteJoint, DistanceJoint, PrismaticJoint, WeldJoint, WheelJoint, MotorJoint,
88
UINode, UICameraInfo, screenToUiWorld, uiWorldToScreen, uiPickAllWorld, type UICameraData,
9+
Marker,
910
TilemapLayer, TilemapAPI, decodeTilemapChunks, CHUNK_SIZE, tileCollisionOutlines,
1011
tileCellCenter, tileCellOutline, isNonOrthogonal,
1112
readColliderShapes, colliderShapeOutline, shapeCenter,
@@ -648,6 +649,29 @@ export const ViewportController = {
648649
return { cx: center.x, cy: center.y, kind: l.type, color, radiusPx, sdx, sdy, coneHalf, on, handle: handle ?? null };
649650
},
650651

652+
/** Ids of the scene's Marker (point-object) entities — the marker-pin gizmo set. A
653+
* Marker renders nothing, so it's drawn as an always-on pin at its position. */
654+
markerIds(): EntityId[] {
655+
const world = EngineHost.world;
656+
if (!world) return [];
657+
const out: EntityId[] = [];
658+
for (const e of world.getAllEntities()) {
659+
if (world.has(e, Marker) && world.has(e, Transform)) out.push(e);
660+
}
661+
return out;
662+
},
663+
664+
/** Screen-space position of a Marker's pin (its Transform world position → canvas px),
665+
* and its `type` label, or null when off-camera/removed. */
666+
getMarkerGizmo(id: EntityId): { cx: number; cy: number; type: string } | null {
667+
const world = EngineHost.world;
668+
if (!world || !world.valid(id) || !world.has(id, Marker) || !world.has(id, Transform)) return null;
669+
const t = world.get(id, Transform);
670+
const m = world.get(id, Marker) as { type?: string };
671+
const p = this.worldToClient(t.worldPosition.x, t.worldPosition.y);
672+
return p ? { cx: p.x, cy: p.y, type: typeof m.type === 'string' ? m.type : '' } : null;
673+
},
674+
651675
/** Ids of entities carrying ANY collider (box/circle/capsule/segment/polygon/chain) —
652676
* the collider-gizmo set. All six render through the shared shape-outline projection. */
653677
colliderIds(): EntityId[] {

desktop/src/panels/Viewport.tsx

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { memo, useEffect, useMemo, useRef, useState, useSyncExternalStore } from
44
import type { PointerEvent as ReactPointerEvent, DragEvent as ReactDragEvent, ReactNode } from 'react';
55
import {
66
MousePointer2, Move, RotateCw, Scale3d, Grid3x3, Frame,
7-
Camera, Check, ChevronDown, Loader2, TriangleAlert, Lightbulb, Sparkles, Globe, Crosshair, Smartphone, Monitor, Magnet, Axis3d, Hexagon, type LucideIcon,
7+
Camera, Check, ChevronDown, Loader2, TriangleAlert, Lightbulb, Sparkles, Globe, Crosshair, Smartphone, Monitor, Magnet, Axis3d, Hexagon, MapPin, type LucideIcon,
88
AlignStartVertical, AlignCenterVertical, AlignEndVertical,
99
AlignStartHorizontal, AlignCenterHorizontal, AlignEndHorizontal,
1010
AlignHorizontalDistributeCenter, AlignVerticalDistributeCenter,
@@ -19,7 +19,7 @@ import { useEditorMode } from '@/store/editorModeStore';
1919
import { RESOLUTION_PRESETS, RESOLUTION_PRESET_BY_ID, DESIGN_RESOLUTION_PRESETS, deviceDims } from '@/mode/resolutionPresets';
2020
import { buildStampGhost } from '@/tools/tileStampGhost';
2121
import { alignSelection, distributeSelection } from '@/tools/alignTools';
22-
import { TilemapAPI, tileIdOf, isNonOrthogonal, UINode, DimensionUnit, computeEffectiveOrthoSize, type TileCollisionPiece, type TilesetModel } from 'esengine';
22+
import { TilemapAPI, tileIdOf, isNonOrthogonal, isCollisionPaletteRef, buildCollisionPaletteModel, UINode, DimensionUnit, computeEffectiveOrthoSize, type TileCollisionPiece, type TilesetModel } from 'esengine';
2323
import { commands } from '@/commands';
2424
import { MOD_LABEL } from '@/commands/keybinding';
2525
import { EngineHost } from '@/engine/EngineHost';
@@ -865,6 +865,14 @@ export function Viewport() {
865865
() => (engine.status === 'ready' ? ViewportController.light2DIds() : []),
866866
[structRev, engine.status],
867867
);
868+
// Marker (point-object) entities render nothing — draw each as an always-on pin at its
869+
// position (click to select), so spawn points / waypoints / triggers are visible without
870+
// being selected. Same per-frame rAF + structural id set as the camera/light gizmos.
871+
const markerRefs = useRef(new Map<number, HTMLDivElement | null>());
872+
const markerIds = useMemo(
873+
() => (engine.status === 'ready' ? ViewportController.markerIds() : []),
874+
[structRev, engine.status],
875+
);
868876
// Physics colliders aren't drawn by the renderer — outline each (box polygon /
869877
// circle) as a gizmo so you can see/tune collider shapes without entering Play.
870878
const colliderRefs = useRef(new Map<number, SVGSVGElement | null>());
@@ -882,29 +890,55 @@ export function Viewport() {
882890
const tileColModelRef = useRef<{ key: string; model: TilesetModel | null }>({ key: '', model: null });
883891
useEffect(() => {
884892
const clear = () => { tileColPiecesRef.current = []; };
885-
if (engine.status !== 'ready' || !showTileCollision || !tilemapSelected || primaryId == null) {
893+
if (engine.status !== 'ready' || !showTileCollision) {
886894
clear();
887895
tileColModelRef.current = { key: '', model: null };
888896
return;
889897
}
890-
const refs = layerTilesetRefs(primaryId);
891-
const key = refs.join('|');
892-
const build = () => {
893-
const model = tileColModelRef.current.model;
894-
tileColPiecesRef.current = model ? ViewportController.tilemapColliderOutlines(primaryId, model) : [];
898+
// Collision (obstacle) layers ALWAYS contribute their outlines — the overlay IS their
899+
// content (they render nothing), so they stay visible unselected. They all share the
900+
// single built-in palette model (no per-layer disk load). A selected NON-collision
901+
// tilemap additionally shows its per-tile collision as a debug aid (selected-only).
902+
const collisionIds: number[] = [];
903+
for (const id of SceneModel.entityOrder()) {
904+
const e = SceneModel.entityBySource(id);
905+
if (e?.components.some((c) => c.type === 'TilemapLayer') && isCollisionPaletteRef(layerTilesetRefs(id))) {
906+
collisionIds.push(id);
907+
}
908+
}
909+
const paletteModel = collisionIds.length > 0 ? buildCollisionPaletteModel() : null;
910+
const selId = tilemapSelected && primaryId != null && !collisionIds.includes(primaryId) ? primaryId : null;
911+
912+
const rebuild = () => {
913+
const pieces: TileCollisionPiece[] = [];
914+
if (paletteModel) {
915+
for (const id of collisionIds) pieces.push(...ViewportController.tilemapColliderOutlines(id, paletteModel));
916+
}
917+
if (selId != null && tileColModelRef.current.model) {
918+
pieces.push(...ViewportController.tilemapColliderOutlines(selId, tileColModelRef.current.model));
919+
}
920+
tileColPiecesRef.current = pieces;
895921
};
896-
// Same tileset list as last time → reuse the cached model, just re-read the tiles.
897-
if (tileColModelRef.current.key === key && tileColModelRef.current.model) { build(); return; }
898-
// Tileset refs changed (or first show): reload the model, then build once it lands.
922+
923+
// Collision layers render immediately (sync). A selected .estileset tilemap may need an
924+
// async model load; show the collision layers now and fold it in once its model lands.
925+
if (selId == null) {
926+
tileColModelRef.current = { key: '', model: null };
927+
rebuild();
928+
return;
929+
}
930+
const refs = layerTilesetRefs(selId);
931+
const key = refs.join('|');
932+
if (tileColModelRef.current.key === key && tileColModelRef.current.model) { rebuild(); return; }
899933
let alive = true;
900-
clear();
934+
rebuild(); // collision layers appear at once; the selected layer joins on model load
901935
void loadLayerTilesetModel(refs).then((model) => {
902936
if (!alive) return;
903937
tileColModelRef.current = { key, model };
904-
build();
938+
rebuild();
905939
});
906940
return () => { alive = false; };
907-
}, [engine.status, showTileCollision, tilemapSelected, primaryId, dataRev]);
941+
}, [engine.status, showTileCollision, tilemapSelected, primaryId, dataRev, structRev]);
908942

909943
// Scene-authored joints are equally invisible — draw each as an anchor link (plus
910944
// axis/velocity direction). Keyed by entity + joint type; same physics show flag.
@@ -1669,6 +1703,19 @@ export function Viewport() {
16691703
}
16701704
}
16711705

1706+
// Marker pins — position each at its entity's world point (or hide it when the
1707+
// marker is off-camera/removed). Same edit-mode + gizmos-on gate as the other icons.
1708+
for (const [mid, wrap] of markerRefs.current) {
1709+
if (!wrap) continue;
1710+
const mg = camsOn ? ViewportController.getMarkerGizmo(mid) : null;
1711+
if (mg) {
1712+
wrap.style.visibility = 'visible';
1713+
wrap.style.transform = `translate(${mg.cx}px, ${mg.cy}px)`;
1714+
} else {
1715+
wrap.style.visibility = 'hidden'; // an invisible pin must not swallow clicks
1716+
}
1717+
}
1718+
16721719
// Particle-emitter gizmos — a clickable icon at the emitter + its spawn-shape
16731720
// outline (cone wedge / circle / box / point), so an otherwise-invisible emitter
16741721
// is placeable and aimable in edit mode. Same edit-mode + gizmos-on gate.
@@ -2218,6 +2265,34 @@ export function Viewport() {
22182265
);
22192266
})}
22202267

2268+
{markerIds.map((id) => {
2269+
const src = SceneModel.sourceFor(id);
2270+
const name = src != null ? SceneModel.entityBySource(src)?.name : undefined;
2271+
return (
2272+
<div
2273+
key={id}
2274+
ref={(el) => {
2275+
if (el) markerRefs.current.set(id, el);
2276+
else markerRefs.current.delete(id);
2277+
}}
2278+
className="viewport__marker-gizmo"
2279+
>
2280+
<span
2281+
className="viewport__marker-hit"
2282+
role="button"
2283+
title={name}
2284+
onPointerDown={(e) => {
2285+
if (e.button !== 0 || src == null) return;
2286+
e.stopPropagation();
2287+
useSelection.getState().select(src);
2288+
}}
2289+
>
2290+
<MapPin className="viewport__marker-icon" size={16} strokeWidth={2} />
2291+
</span>
2292+
</div>
2293+
);
2294+
})}
2295+
22212296
{/* Tile-collision overlay: ONE viewport-spanning SVG for the selected layer's whole
22222297
collision (solid outlines + dashed sensors + one-way arrows). The rAF writes the
22232298
combined path data each frame; empty when the flag's off or no tilemap is picked. */}

desktop/src/theme/app.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,37 @@ body.thumb-capture .viewport > :not(.viewport__stage):not(.viewport__play) {
415415
.viewport__light-hit:hover .viewport__light-icon {
416416
transform: scale(1.2);
417417
}
418+
/* Marker pin — a point-object's always-on icon; the pin TIP (bottom-centre) sits on the
419+
entity position, and the icon is the select-to-identify click target. */
420+
.viewport__marker-gizmo {
421+
position: absolute;
422+
left: 0;
423+
top: 0;
424+
z-index: 2;
425+
pointer-events: none;
426+
will-change: transform;
427+
color: var(--acc);
428+
}
429+
.viewport__marker-hit {
430+
position: absolute;
431+
left: 0;
432+
top: 0;
433+
margin: -22px 0 0 -11px;
434+
width: 22px;
435+
height: 24px;
436+
display: grid;
437+
place-items: end center;
438+
pointer-events: auto;
439+
cursor: pointer;
440+
}
441+
.viewport__marker-icon {
442+
display: block;
443+
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.85));
444+
transition: transform var(--t-fast);
445+
}
446+
.viewport__marker-hit:hover .viewport__marker-icon {
447+
transform: scale(1.2);
448+
}
418449
.viewport__light-svg {
419450
position: absolute;
420451
left: 0;

desktop/src/theme/viewport.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@
303303
.viewport--play .viewport__collider-gizmo,
304304
.viewport--play .viewport__tilecol-gizmo,
305305
.viewport--play .viewport__particle-gizmo,
306+
.viewport--play .viewport__marker-gizmo,
306307
.viewport--play .viewport__toolbar,
307308
.viewport--play .viewport__minimap,
308309
.viewport--play .ov-left {

0 commit comments

Comments
 (0)