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>
This commit is contained in:
1 parent
2ff3a78607
commit
275e50af01
20 files changed
+1161
-57
No files matched your search
@@ -48,6 +48,7 @@ import {
|
||||
surfaceOf,
|
||||
} from '../systems/solids';
|
||||
import type { System } from '../systems/System';
|
||||
import { TileTriggers } from '../systems/tileTriggers';
|
||||
import {
|
||||
angleBetween,
|
||||
blend,
|
||||
@@ -148,6 +149,8 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
private passes = new Map<string, boolean>();
|
||||
private hooks: PhysicsHooks | null = null;
|
||||
private readonly solids = new Solids();
|
||||
/** The maps' trigger tiles (#160): found by overlap where the objects are drawn, not by the engine. */
|
||||
private readonly triggers = new TileTriggers();
|
||||
private backlog = 0;
|
||||
/** The length of the frame being played – the time a move of the graph's is spread over. */
|
||||
private frameDt = PHYSICS_STEP;
|
||||
@@ -188,6 +191,7 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
this.enters = [];
|
||||
this.stays = [];
|
||||
this.exits = [];
|
||||
this.triggers.stop();
|
||||
this.backlog = 0;
|
||||
this.frameDt = PHYSICS_STEP;
|
||||
this.hinted.clear();
|
||||
@@ -218,13 +222,22 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
this.backlog = steps === MAX_STEPS_PER_TICK ? 0 : Math.max(0, this.backlog);
|
||||
this.writeBack(this.backlog / PHYSICS_STEP);
|
||||
this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair);
|
||||
this.triggers.update(world);
|
||||
this.enters.push(...this.triggers.enters);
|
||||
this.stays.push(...this.triggers.stays);
|
||||
this.exits.push(...this.triggers.exits);
|
||||
}
|
||||
|
||||
/** Whether any collider of `a` is in contact with any collider of `b` as of the last step. */
|
||||
touching(world: World, a: Entity, b: Entity): boolean {
|
||||
for (const ca of partsOf(world, a))
|
||||
for (const cb of partsOf(world, b))
|
||||
if (this.current.has(`${ca.id}|${cb.id}`) || this.current.has(`${cb.id}|${ca.id}`)) return true;
|
||||
if (
|
||||
this.current.has(`${ca.id}|${cb.id}`) ||
|
||||
this.current.has(`${cb.id}|${ca.id}`) ||
|
||||
this.triggers.touching(world, ca, cb)
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -253,6 +266,8 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
|
||||
/** How a current pair touches as of the last step: read from the engine's contact. */
|
||||
contactOf(pair: CollisionPair): Contact | null {
|
||||
// A trigger tile is no engine collider: nothing presses there.
|
||||
if (pair.tile) return null;
|
||||
const handles = this.pairColliders.get(`${pair.a.id}|${pair.b.id}`);
|
||||
if (!handles || !this.parts.has(handles[0]) || !this.parts.has(handles[1])) return null;
|
||||
return this.measure(this.engine!.getCollider(handles[0]), this.engine!.getCollider(handles[1]));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
adoptDeclarations,
|
||||
asBoolean,
|
||||
buildSharedState,
|
||||
carriedForward,
|
||||
declaredVariables,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type Value,
|
||||
} from '../../core/graph';
|
||||
import { GraphCache } from '../../core/graph/graphIndex';
|
||||
import { PER_CELL_INPUT } from '../../core/graph/nodes/shared';
|
||||
import { keyName } from '../../core/keys';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
collisionStayPulses,
|
||||
type Instance,
|
||||
keyDownPulses,
|
||||
type PerCell,
|
||||
type Produced,
|
||||
type Pulse,
|
||||
pulsesOf,
|
||||
@@ -537,6 +540,8 @@ export class BehaviorSystem implements System {
|
||||
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
|
||||
};
|
||||
const { input, collisions, animations } = this;
|
||||
const perCell: PerCell = (i, node) =>
|
||||
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
|
||||
const kinds: (() => Pulse[])[] = [
|
||||
() =>
|
||||
ticking.flatMap((i) =>
|
||||
@@ -546,9 +551,9 @@ export class BehaviorSystem implements System {
|
||||
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => input.heldKeys.has(keyOf(n))),
|
||||
() => ticking.flatMap((i) => timerPulses(i, dt)),
|
||||
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionEnterPulses(i, collisions))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionEnterPulses(i, collisions, perCell))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt, perCell))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
|
||||
() =>
|
||||
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Solids,
|
||||
} from './solids';
|
||||
import type { System } from './System';
|
||||
import { TileTriggers } from './tileTriggers';
|
||||
|
||||
/**
|
||||
* Sorts collider overlaps into enter / stay / exit per tick, detection only; colliders of one body never
|
||||
@@ -26,7 +27,8 @@ import type { System } from './System';
|
||||
*
|
||||
* A placed tilemap is a solid body of its own: its blocking cells, merged into rectangles, stop a
|
||||
* move and report a collision with the tilemap as the other side. Two tilemaps never meet – they
|
||||
* are ground, not things that walk into each other.
|
||||
* are ground, not things that walk into each other. Its trigger tiles block nothing and report
|
||||
* the cells overlapped (`TileTriggers`, #160).
|
||||
*/
|
||||
export class CollisionSystem implements System, CollisionSource {
|
||||
enters: CollisionPair[] = [];
|
||||
@@ -34,6 +36,7 @@ export class CollisionSystem implements System, CollisionSource {
|
||||
exits: CollisionPair[] = [];
|
||||
private previous = new Map<string, CollisionPair>();
|
||||
private readonly solids = new Solids();
|
||||
private readonly triggers = new TileTriggers();
|
||||
|
||||
update(world: World): void {
|
||||
this.solids.reset();
|
||||
@@ -56,11 +59,17 @@ export class CollisionSystem implements System, CollisionSource {
|
||||
for (const [key, pair] of current) (this.previous.has(key) ? this.stays : this.enters).push(pair);
|
||||
for (const [key, pair] of this.previous) if (!current.has(key)) this.exits.push(pair);
|
||||
this.previous = current;
|
||||
// A map's trigger tiles (#160) are pairs of their own, told after the others.
|
||||
this.triggers.update(world);
|
||||
this.enters.push(...this.triggers.enters);
|
||||
this.stays.push(...this.triggers.stays);
|
||||
this.exits.push(...this.triggers.exits);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.previous = new Map();
|
||||
this.solids.reset();
|
||||
this.triggers.stop();
|
||||
this.enters = [];
|
||||
this.stays = [];
|
||||
this.exits = [];
|
||||
@@ -80,7 +89,9 @@ export class CollisionSystem implements System, CollisionSource {
|
||||
touching(world: World, a: Entity, b: Entity): boolean {
|
||||
for (const ca of partsOf(world, a)) {
|
||||
for (const cb of partsOf(world, b)) {
|
||||
if (ca !== cb && collidersMeet(ca, cb) && this.overlaps(world, ca, cb)) return true;
|
||||
if (ca === cb) continue;
|
||||
if (collidersMeet(ca, cb) && this.overlaps(world, ca, cb)) return true;
|
||||
if (this.triggers.touching(world, ca, cb)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { parentChain } from '../../core/model/walk';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { AnimationSystem } from './AnimationSystem';
|
||||
import type { Input } from './Input';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource, type Contact } from './solids';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids';
|
||||
|
||||
/** What one node last handed on, and when – the graph tab shows the newest of every instance. */
|
||||
export interface Produced {
|
||||
@@ -116,11 +116,32 @@ function sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function collisionEnterPulses(instance: Instance, collisions: CollisionSource | null): Pulse[] {
|
||||
/** Reads an event node's "per cell" input for its instance (#160). */
|
||||
export type PerCell = (instance: Instance, node: GraphNode) => boolean;
|
||||
|
||||
/**
|
||||
* What of the pairs one node hears. A trigger tile is told twice, per cell and per area (#160), and
|
||||
* the node's "per cell" input picks one; it is only read where a tile is among the pairs at all.
|
||||
*/
|
||||
function heardBy(instance: Instance, node: GraphNode, all: Side[], perCell: PerCell): Side[] {
|
||||
if (!all.some((side) => side.pair.tile)) return all;
|
||||
const cells = perCell(instance, node);
|
||||
return all.filter((side) => !side.pair.tile || side.pair.tile.perCell === cells);
|
||||
}
|
||||
|
||||
export function collisionEnterPulses(
|
||||
instance: Instance,
|
||||
collisions: CollisionSource | null,
|
||||
perCell: PerCell,
|
||||
): Pulse[] {
|
||||
const entered = sides(instance, collisions?.enters ?? []);
|
||||
return instance.interpreter
|
||||
.eventNodes('OnCollisionEnter')
|
||||
.flatMap((node) => entered.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) })));
|
||||
return instance.interpreter.eventNodes('OnCollisionEnter').flatMap((node) =>
|
||||
heardBy(instance, node, entered, perCell).map((side) => ({
|
||||
instance,
|
||||
node,
|
||||
outputs: sideOutputs(side, side.pair),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +153,7 @@ export function collisionStayPulses(
|
||||
instance: Instance,
|
||||
collisions: CollisionSource | null,
|
||||
dt: number,
|
||||
perCell: PerCell,
|
||||
): Pulse[] {
|
||||
if (!collisions) return [];
|
||||
const interpreter = instance.interpreter;
|
||||
@@ -146,10 +168,10 @@ export function collisionStayPulses(
|
||||
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
|
||||
const clocks = interpreter.nodeState.get(node.id) ?? {};
|
||||
const running: Record<string, number> = {};
|
||||
for (const side of stays) {
|
||||
for (const side of heardBy(instance, node, stays, perCell)) {
|
||||
let fires = 1;
|
||||
if (interval > 0) {
|
||||
const key = `${side.pair.a.id}|${side.pair.b.id}`;
|
||||
const key = pairKey(side.pair);
|
||||
let elapsed = (clocks[key] ?? 0) + dt;
|
||||
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
|
||||
running[key] = elapsed;
|
||||
@@ -163,11 +185,17 @@ export function collisionStayPulses(
|
||||
return pulses;
|
||||
}
|
||||
|
||||
export function collisionExitPulses(instance: Instance, collisions: CollisionSource | null): Pulse[] {
|
||||
export function collisionExitPulses(
|
||||
instance: Instance,
|
||||
collisions: CollisionSource | null,
|
||||
perCell: PerCell,
|
||||
): Pulse[] {
|
||||
const left = sides(instance, collisions?.exits ?? []);
|
||||
return instance.interpreter
|
||||
.eventNodes('OnCollisionExit')
|
||||
.flatMap((node) => left.map((side) => ({ instance, node, outputs: sideOutputs(side) })));
|
||||
.flatMap((node) =>
|
||||
heardBy(instance, node, left, perCell).map((side) => ({ instance, node, outputs: sideOutputs(side) })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +217,14 @@ export function animationFinishedPulses(instance: Instance, animations: Animatio
|
||||
|
||||
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
|
||||
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
|
||||
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
|
||||
const outputs: Record<string, Value> = {
|
||||
other: bodyOf(other),
|
||||
otherCollider: other,
|
||||
collider: own,
|
||||
tile: pair.tile?.tileId ?? '',
|
||||
cellX: pair.tile?.cell.x ?? -1,
|
||||
cellY: pair.tile?.cell.y ?? -1,
|
||||
};
|
||||
if (contact !== undefined) {
|
||||
// The pair's normal points towards `a`; seen from `b` it points the other way.
|
||||
const sign = own === pair.a ? 1 : -1;
|
||||
|
||||
@@ -72,6 +72,26 @@ export interface CollisionPair {
|
||||
normal?: Vec2;
|
||||
impulse?: number;
|
||||
point?: Vec2;
|
||||
/** Set where `b` is a map and `a` overlaps a trigger tile of it (#160) – a pair of its own, next to the map's. */
|
||||
tile?: TileContact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which trigger tile of a map a collider overlaps (#160). A pair stands either for one cell or for a
|
||||
* whole area of the tile; an area names the cell of it nearest to the collider.
|
||||
*/
|
||||
export interface TileContact {
|
||||
tileId: string;
|
||||
cell: { x: number; y: number };
|
||||
area: string;
|
||||
perCell: boolean;
|
||||
}
|
||||
|
||||
/** What a pair is known by from one tick to the next. */
|
||||
export function pairKey({ a, b, tile }: CollisionPair): string {
|
||||
const key = `${a.id}|${b.id}`;
|
||||
if (!tile) return key;
|
||||
return `${key}|${tile.tileId}|${tile.perCell ? `cell ${tile.cell.x},${tile.cell.y}` : `area ${tile.area}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
||||
import { liveTilemap } from '../../core/model/liveTilemap';
|
||||
import { apply } from '../../core/model/matrix';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { cellOrigin, tilemapColumns, tilemapRows } from '../../core/model/tilemap';
|
||||
import {
|
||||
cellPolygonShape,
|
||||
cellRectShape,
|
||||
cellsWithin,
|
||||
type TriggerCell,
|
||||
triggerCells,
|
||||
} from '../../core/model/tilemapCollision';
|
||||
import { shapeBounds, shapesOverlap } from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import { bodyOf, collidersMeet, type CollisionPair, pairKey, shapeOf } from './solids';
|
||||
|
||||
/** A collision scale above 1 lets a cell reach this far into its neighbours, in cells. */
|
||||
const REACH = 1;
|
||||
|
||||
/**
|
||||
* The trigger tiles of the placed maps (#160): which colliders overlap which of their cells, sorted
|
||||
* into enter / stay / exit like any other pair. Both collision systems ask this one, with the
|
||||
* editor's own overlap test, so a run in the editor and a played game tell the same.
|
||||
*
|
||||
* Every overlap is told twice, as two pairs: once per cell and once per area of the tile. An event
|
||||
* node hears the one it asks for.
|
||||
*/
|
||||
export class TileTriggers {
|
||||
enters: CollisionPair[] = [];
|
||||
stays: CollisionPair[] = [];
|
||||
exits: CollisionPair[] = [];
|
||||
private previous = new Map<string, CollisionPair>();
|
||||
|
||||
update(world: World): void {
|
||||
const current = new Map<string, CollisionPair>();
|
||||
const maps = world.entities.filter((e) => e.type === 'tilemap' && isHandleEnabled(e));
|
||||
const colliders = maps.length > 0 ? world.entities.filter((e) => e.type === 'collider') : [];
|
||||
for (const map of maps) {
|
||||
const cells = cellsOf(world, map);
|
||||
if (cells.size === 0) continue;
|
||||
for (const collider of colliders)
|
||||
for (const pair of pairsOf(collider, map, cells)) current.set(pairKey(pair), pair);
|
||||
}
|
||||
this.enters = [];
|
||||
this.stays = [];
|
||||
this.exits = [];
|
||||
for (const [key, pair] of current) (this.previous.has(key) ? this.stays : this.enters).push(pair);
|
||||
for (const [key, pair] of this.previous) if (!current.has(key)) this.exits.push(pair);
|
||||
this.previous = current;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.previous = new Map();
|
||||
this.enters = [];
|
||||
this.stays = [];
|
||||
this.exits = [];
|
||||
}
|
||||
|
||||
/** Whether a collider lies on any trigger tile of a map right now, whichever of the two is `a`. */
|
||||
touching(world: World, a: Entity, b: Entity): boolean {
|
||||
if (a.type === 'tilemap') return b.type !== 'tilemap' && this.touching(world, b, a);
|
||||
if (b.type !== 'tilemap' || !isHandleEnabled(b)) return false;
|
||||
return touched(a, b, cellsOf(world, b)).length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
function cellsOf(world: World, map: Entity): ReadonlyMap<string, readonly TriggerCell[]> {
|
||||
const stored = findAssetOf(world.project, map.assetId, 'tilemap');
|
||||
return stored ? triggerCells(world.project, map.props, liveTilemap(stored, map.tileEdits)) : new Map();
|
||||
}
|
||||
|
||||
/** The trigger cells of the map the collider overlaps – none for one switched off, or on layers that do not meet. */
|
||||
function touched(
|
||||
collider: Entity,
|
||||
map: Entity,
|
||||
cells: ReadonlyMap<string, readonly TriggerCell[]>,
|
||||
): TriggerCell[] {
|
||||
if (cells.size === 0 || !isHandleEnabled(collider)) return [];
|
||||
if (bodyOf(collider) === map || !collidersMeet(collider, map)) return [];
|
||||
const m = worldMatrixOf(map);
|
||||
const shape = shapeOf(collider);
|
||||
// Only the cells the collider can reach are looked at, as for a move or a ray (#184).
|
||||
const range = cellsWithin(map.props, m, shapeBounds(shape));
|
||||
const from = (edge: number) => Math.max(0, Math.floor(edge) - REACH);
|
||||
const to = (edge: number, count: number) => Math.min(count - 1, Math.floor(edge) + REACH);
|
||||
const found: TriggerCell[] = [];
|
||||
for (let y = from(range.top); y <= to(range.bottom, tilemapRows(map.props)); y++)
|
||||
for (let x = from(range.left); x <= to(range.right, tilemapColumns(map.props)); x++)
|
||||
for (const cell of cells.get(`${x},${y}`) ?? []) {
|
||||
const over =
|
||||
cell.rects.some((rect) => shapesOverlap(shape, cellRectShape(map.props, rect, m))) ||
|
||||
cell.polygons.some((polygon) => shapesOverlap(shape, cellPolygonShape(map.props, polygon, m)));
|
||||
if (over) found.push(cell);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** A pair per cell overlapped, and one per area: that one with the cell nearest to the collider's middle. */
|
||||
function pairsOf(
|
||||
collider: Entity,
|
||||
map: Entity,
|
||||
cells: ReadonlyMap<string, readonly TriggerCell[]>,
|
||||
): CollisionPair[] {
|
||||
const found = touched(collider, map, cells);
|
||||
if (found.length === 0) return [];
|
||||
const m = worldMatrixOf(map);
|
||||
const middle = shapeOf(collider);
|
||||
const distance = (cell: TriggerCell) => {
|
||||
const at = apply(m, cellOrigin(map.props, cell.x + 0.5, cell.y + 0.5));
|
||||
return Math.hypot(at.x - middle.x, at.y - middle.y);
|
||||
};
|
||||
const pair = (cell: TriggerCell, perCell: boolean): CollisionPair => ({
|
||||
a: collider,
|
||||
b: map,
|
||||
tile: { tileId: cell.tileId, cell: { x: cell.x, y: cell.y }, area: cell.area, perCell },
|
||||
});
|
||||
const nearest = new Map<string, TriggerCell>();
|
||||
for (const cell of found) {
|
||||
const key = `${cell.tileId}|${cell.area}`;
|
||||
const known = nearest.get(key);
|
||||
if (!known || distance(cell) < distance(known)) nearest.set(key, cell);
|
||||
}
|
||||
return [
|
||||
...found.map((cell) => pair(cell, true)),
|
||||
...[...nearest.values()].map((cell) => pair(cell, false)),
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user