Skip to content

Commit 8783745

Browse files
committed
Add logic category and show-variable-links option
Add a new "logic" object category button to the editor UI and a new project setting (project-show-variable-links) to toggle showing variable links. Update the export bundle and various editor/runtime modules to support logic-related objects and variable-linked behavior (editor bindings, object service/state, renderers, game state, item definitions, item catalog, sprites, state managers, share infra, encoders/decoders, and interaction/game engine). Add unit tests for logic gate evaluation and sharing (LogicGateEvaluator.test.ts, ShareLogicGates.test.ts) and update related tests. These changes enable authoring and sharing of logic-linked objects and ensure serialization/normalization and runtime handling are covered by tests.
1 parent b01bc1f commit 8783745

32 files changed

Lines changed: 1550 additions & 46 deletions

‎index.html‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ <h1 class="tabs-title">Tiny RPG Studio</h1>
195195
<div class="object-category-buttons">
196196
<button type="button" class="object-category-button active" data-object-category-filter="all" aria-pressed="true" data-text-key="objects.category.all"></button>
197197
<button type="button" class="object-category-button" data-object-category-filter="swords" aria-pressed="false" data-text-key="objects.category.swords"></button>
198+
<button type="button" class="object-category-button" data-object-category-filter="logic" aria-pressed="false" data-text-key="objects.category.logic"></button>
198199
</div>
199200
</div>
200201
<div id="object-types" class="object-types"></div>
@@ -407,6 +408,10 @@ <h2 class="project-group__title" data-text-key="project.group.appearance">Aparê
407408
<input type="checkbox" id="project-disable-pixel-font">
408409
<span data-text-key="project.disablePixelFont"></span>
409410
</label>
411+
<label class="project-test__field project-test__checkbox project-appearance__checkbox">
412+
<input type="checkbox" id="project-show-variable-links" checked>
413+
<span data-text-key="project.showVariableLinks"></span>
414+
</label>
410415
</div>
411416
</div>
412417

‎public/export.bundle.js‎

Lines changed: 19 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎public/pico8-ui.woff‎

0 Bytes
Binary file not shown.

‎src/__tests__/GameState.critical.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,51 @@ describe('GameState - Critical Path Tests', () => {
7171
});
7272
});
7373

74+
describe('Logic gate integration', () => {
75+
it('evaluates a chained gate network from a single setVariableValue without recursion', () => {
76+
const state = new GameState();
77+
// NOT(var-1) -> var-2 ; AND(var-2, var-3) -> var-4
78+
state.game.objects = [
79+
{ id: 'logic-gate-not-0', type: 'logic-gate-not', x: 1, y: 1, roomIndex: 0, inputVariableId: 'var-1', outputVariableId: 'var-2' },
80+
{ id: 'logic-gate-and-0', type: 'logic-gate-and', x: 2, y: 2, roomIndex: 0, inputVariableId: 'var-2', inputVariableId2: 'var-3', outputVariableId: 'var-4' },
81+
];
82+
83+
const setSpy = vi.spyOn(state, 'setVariableValue');
84+
// var-1 is false by default → NOT makes var-2 true; setting var-3 true cascades into var-4
85+
state.setVariableValue('var-3', true);
86+
87+
// The public setVariableValue must be called exactly once (no recursion through the hook)
88+
expect(setSpy).toHaveBeenCalledTimes(1);
89+
expect(state.isVariableOn('var-2')).toBe(true);
90+
expect(state.isVariableOn('var-4')).toBe(true);
91+
});
92+
93+
it('opens a variable-door driven by a gate output', () => {
94+
const state = new GameState();
95+
state.game.objects = [
96+
{ id: 'logic-gate-and-0', type: 'logic-gate-and', x: 1, y: 1, roomIndex: 0, inputVariableId: 'var-1', inputVariableId2: 'var-2', outputVariableId: 'var-3' },
97+
{ id: 'door-1', type: 'door-variable', x: 3, y: 3, roomIndex: 0, variableId: 'var-3' },
98+
];
99+
100+
state.setVariableValue('var-1', true);
101+
const [, openedDoor] = state.setVariableValue('var-2', true);
102+
103+
expect(state.isVariableOn('var-3')).toBe(true);
104+
expect(openedDoor).toBe(true);
105+
});
106+
107+
it('terminates without throwing on a cyclic gate network', () => {
108+
const state = new GameState();
109+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
110+
state.game.objects = [
111+
{ id: 'logic-gate-not-0', type: 'logic-gate-not', x: 1, y: 1, roomIndex: 0, inputVariableId: 'var-1', outputVariableId: 'var-1' },
112+
];
113+
114+
expect(() => state.setVariableValue('var-2', true)).not.toThrow();
115+
expect(warn).toHaveBeenCalled();
116+
});
117+
});
118+
74119
describe('Level-up flow', () => {
75120
it('triggers celebration and queues skill choices on level-up', () => {
76121
const state = new GameState();

‎src/__tests__/editor/EditorObjectRenderer.test.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ vi.mock('../../runtime/domain/state/StateObjectManager', () => ({
2525
StateObjectManager: {
2626
get PLAYER_END_TEXT_LIMIT() {
2727
return mockData.playerEndTextLimit;
28+
},
29+
get MULTI_INSTANCE_LIMIT() {
30+
return 4;
2831
}
2932
}
3033
}));
@@ -35,13 +38,20 @@ vi.mock('../../runtime/domain/definitions/ItemDefinitions', () => ({
3538
}
3639
}));
3740

41+
vi.mock('../../runtime/domain/services/ItemCatalog', () => ({
42+
itemCatalog: {
43+
allowsMultiplePerRoom: vi.fn(() => false)
44+
}
45+
}));
46+
3847
import { ITEM_TYPES } from '../../runtime/domain/constants/itemTypes';
3948
import { EditorObjectRenderer } from '../../editor/modules/renderers/EditorObjectRenderer';
4049

4150
type EditorObjectRendererService = ConstructorParameters<typeof EditorObjectRenderer>[0];
4251
type ObjectLabelDefinitions = Parameters<EditorObjectRenderer['getObjectLabel']>[1];
4352
type EditorObjectMock = {
4453
type: string;
54+
id?: string;
4555
roomIndex?: number;
4656
x?: number;
4757
y?: number;
@@ -105,6 +115,10 @@ function createFixture() {
105115
const gameEngine = {
106116
getObjectsForRoom: vi.fn((): EditorObjectMock[] => []),
107117
setObjectVariable: vi.fn(),
118+
setObjectVariableById: vi.fn(),
119+
setGateInputVariableById: vi.fn(),
120+
setGateOutputVariableById: vi.fn(),
121+
setObjectHiddenInGameById: vi.fn(),
108122
isVariableOn: vi.fn(() => false),
109123
renderer: {
110124
drawObjectSprite: vi.fn(),
@@ -274,7 +288,7 @@ describe('EditorObjectRenderer', () => {
274288
];
275289
fixture.gameEngine.isVariableOn.mockReturnValueOnce(false);
276290
fixture.gameEngine.getObjectsForRoom.mockReturnValue([
277-
{ type: ITEM_TYPES.SWITCH, roomIndex: 1, x: 1, y: 2, variableId: 'var-1', on: true },
291+
{ type: ITEM_TYPES.SWITCH, roomIndex: 1, x: 1, y: 2, variableId: 'var-1', on: true, id: 'switch-1' },
278292
{ type: ITEM_TYPES.DOOR_VARIABLE, roomIndex: 1, x: 2, y: 3, variableId: 'var-2' },
279293
{ type: ITEM_TYPES.DOOR, roomIndex: 1, x: 3, y: 4, opened: true },
280294
{ type: ITEM_TYPES.KEY, roomIndex: 1, x: 4, y: 5, collected: true },
@@ -307,7 +321,7 @@ describe('EditorObjectRenderer', () => {
307321
expect(selects).toHaveLength(2);
308322
(selects[0] as HTMLSelectElement).value = 'var-2';
309323
selects[0].dispatchEvent(new Event('change'));
310-
expect(fixture.gameEngine.setObjectVariable).toHaveBeenCalledWith(ITEM_TYPES.SWITCH, 1, 'var-2');
324+
expect(fixture.gameEngine.setObjectVariableById).toHaveBeenCalledWith('switch-1', 'var-2');
311325
expect(fixture.worldRenderer.renderWorldGrid).toHaveBeenCalled();
312326
expect(fixture.renderEditor).toHaveBeenCalled();
313327
expect(fixture.manager.updateJSON).toHaveBeenCalled();
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { StateVariableManager } from '../../runtime/domain/state/StateVariableManager';
3+
import { ITEM_TYPES } from '../../runtime/domain/constants/itemTypes';
4+
import type { ObjectEntry } from '../../runtime/domain/state/StateObjectManager';
5+
import type { GameDefinition, RuntimeState } from '../../types/gameState';
6+
7+
const makeGame = (): GameDefinition => ({
8+
title: 'Game',
9+
author: 'Author',
10+
palette: ['#000000', '#111111', '#222222'],
11+
roomSize: 8,
12+
world: { rows: 1, cols: 1 },
13+
rooms: [],
14+
start: { x: 1, y: 1, roomIndex: 0 },
15+
sprites: [],
16+
enemies: [],
17+
items: [],
18+
objects: [],
19+
variables: [],
20+
exits: [],
21+
tileset: { tiles: [], maps: [], map: { ground: [], overlay: [] } },
22+
});
23+
24+
const makeManager = () => {
25+
const game = makeGame();
26+
const state = { variables: [] } as unknown as RuntimeState;
27+
const manager = new StateVariableManager(game, state);
28+
game.variables = manager.normalizeVariables([]);
29+
state.variables = manager.cloneVariables(game.variables);
30+
return manager;
31+
};
32+
33+
const gate = (type: string, input: string | null, input2: string | null, output: string | null): ObjectEntry => ({
34+
id: `${type}-0`,
35+
type: type as ObjectEntry['type'],
36+
roomIndex: 0,
37+
x: 1,
38+
y: 1,
39+
isLogicGate: true,
40+
inputVariableId: input,
41+
inputVariableId2: input2,
42+
outputVariableId: output,
43+
});
44+
45+
afterEach(() => {
46+
vi.restoreAllMocks();
47+
});
48+
49+
describe('evaluateLogicGates - truth tables', () => {
50+
it('NOT inverts the single input', () => {
51+
const m = makeManager();
52+
const objects = [gate(ITEM_TYPES.LOGIC_GATE_NOT, 'var-1', null, 'var-2')];
53+
54+
m.setVariableValue('var-1', false);
55+
m.evaluateLogicGates(objects);
56+
expect(m.isVariableOn('var-2')).toBe(true);
57+
58+
m.setVariableValue('var-1', true);
59+
m.evaluateLogicGates(objects);
60+
expect(m.isVariableOn('var-2')).toBe(false);
61+
});
62+
63+
it.each([
64+
[false, false, false],
65+
[true, false, false],
66+
[false, true, false],
67+
[true, true, true],
68+
])('AND(%s, %s) = %s', (a, b, expected) => {
69+
const m = makeManager();
70+
const objects = [gate(ITEM_TYPES.LOGIC_GATE_AND, 'var-1', 'var-2', 'var-3')];
71+
m.setVariableValue('var-1', a);
72+
m.setVariableValue('var-2', b);
73+
m.evaluateLogicGates(objects);
74+
expect(m.isVariableOn('var-3')).toBe(expected);
75+
});
76+
77+
it.each([
78+
[false, false, false],
79+
[true, false, true],
80+
[false, true, true],
81+
[true, true, true],
82+
])('OR(%s, %s) = %s', (a, b, expected) => {
83+
const m = makeManager();
84+
const objects = [gate(ITEM_TYPES.LOGIC_GATE_OR, 'var-1', 'var-2', 'var-3')];
85+
m.setVariableValue('var-1', a);
86+
m.setVariableValue('var-2', b);
87+
m.evaluateLogicGates(objects);
88+
expect(m.isVariableOn('var-3')).toBe(expected);
89+
});
90+
91+
it.each([
92+
[false, false, true],
93+
[true, false, true],
94+
[false, true, true],
95+
[true, true, false],
96+
])('NAND(%s, %s) = %s', (a, b, expected) => {
97+
const m = makeManager();
98+
const objects = [gate(ITEM_TYPES.LOGIC_GATE_NAND, 'var-1', 'var-2', 'var-3')];
99+
m.setVariableValue('var-1', a);
100+
m.setVariableValue('var-2', b);
101+
m.evaluateLogicGates(objects);
102+
expect(m.isVariableOn('var-3')).toBe(expected);
103+
});
104+
105+
it.each([
106+
[false, false, true],
107+
[true, false, false],
108+
[false, true, false],
109+
[true, true, false],
110+
])('NOR(%s, %s) = %s', (a, b, expected) => {
111+
const m = makeManager();
112+
const objects = [gate(ITEM_TYPES.LOGIC_GATE_NOR, 'var-1', 'var-2', 'var-3')];
113+
m.setVariableValue('var-1', a);
114+
m.setVariableValue('var-2', b);
115+
m.evaluateLogicGates(objects);
116+
expect(m.isVariableOn('var-3')).toBe(expected);
117+
});
118+
});
119+
120+
describe('evaluateLogicGates - edge cases', () => {
121+
it('treats null inputs as false', () => {
122+
const m = makeManager();
123+
const objects = [gate(ITEM_TYPES.LOGIC_GATE_AND, null, null, 'var-3')];
124+
m.evaluateLogicGates(objects);
125+
expect(m.isVariableOn('var-3')).toBe(false);
126+
});
127+
128+
it('discards result when output is null (no error)', () => {
129+
const m = makeManager();
130+
const objects = [gate(ITEM_TYPES.LOGIC_GATE_NOT, 'var-1', null, null)];
131+
expect(() => m.evaluateLogicGates(objects)).not.toThrow();
132+
});
133+
134+
it('returns empty array when there are no gates', () => {
135+
const m = makeManager();
136+
expect(m.evaluateLogicGates([])).toEqual([]);
137+
});
138+
});
139+
140+
describe('evaluateLogicGates - chaining', () => {
141+
it('propagates through chained gates in one call', () => {
142+
const m = makeManager();
143+
// NOT(var-1) -> var-2 ; AND(var-2, var-3) -> var-4
144+
const objects = [
145+
gate(ITEM_TYPES.LOGIC_GATE_NOT, 'var-1', null, 'var-2'),
146+
gate(ITEM_TYPES.LOGIC_GATE_AND, 'var-2', 'var-3', 'var-4'),
147+
];
148+
m.setVariableValue('var-1', false); // NOT -> var-2 = true
149+
m.setVariableValue('var-3', true);
150+
m.evaluateLogicGates(objects);
151+
expect(m.isVariableOn('var-2')).toBe(true);
152+
expect(m.isVariableOn('var-4')).toBe(true);
153+
});
154+
});
155+
156+
describe('evaluateLogicGates - cycle protection', () => {
157+
it('stops at the iteration limit and warns without throwing', () => {
158+
const m = makeManager();
159+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
160+
// Oscillating cycle: NOT(var-1) -> var-1
161+
const objects = [gate(ITEM_TYPES.LOGIC_GATE_NOT, 'var-1', null, 'var-1')];
162+
expect(() => m.evaluateLogicGates(objects)).not.toThrow();
163+
expect(warn).toHaveBeenCalled();
164+
});
165+
});

‎src/__tests__/share/ShareConstants.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ShareConstants } from '../../runtime/infra/share/ShareConstants';
44

55
describe('ShareConstants', () => {
66
it('exposes current version and world metadata', () => {
7-
expect(ShareConstants.VERSION).toBe(ShareConstants.VERSION_28);
7+
expect(ShareConstants.VERSION).toBe(ShareConstants.VERSION_30);
88
expect(ShareConstants.WORLD_ROOM_COUNT).toBe(9);
99
expect(ShareConstants.MATRIX_SIZE).toBe(8);
1010
});

0 commit comments

Comments
 (0)