Skip to content

Commit 62fd1f5

Browse files
authored
Websocket GMCP (#368)
# Description This is very basic support for receiving GMCP over the websocket (and sending it in a non breaking format). ## Changes - Allowing gmcp to go out to websocket. - Created a special `GmcpWebPayload` - Added a capture of the payload in the webclient, and logging it to the console. - Updates a global GMCP structure, only the node path that's sent (`Room.Info` etc.) - Create basic implementation demonstrating how additional window/popups can be added. - Healthbars - Simple map ## Example <img width="1728" alt="image" src="https://github.com/user-attachments/assets/ff7438ba-7e0d-4c7a-8a4f-50a9416830bd" />
1 parent 84037aa commit 62fd1f5

File tree

5 files changed

+748
-13
lines changed

5 files changed

+748
-13
lines changed

.jshintignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
_datafiles/html/public/static/js/xterm.4.19.0.js
22
_datafiles/html/public/static/js/xterm-addon-fit.js
3-
_datafiles/html/admin/static/js/htmx.2.0.3.js
3+
_datafiles/html/public/static/js/winbox.bundle.min.js
4+
_datafiles/html/admin/static/js/htmx.2.0.3.js
5+
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
class RoomGridSVG {
2+
constructor(selector, options = {}) {
3+
// ── Configurable options & defaults ───────────────────────────────
4+
this.cellSize = options.cellSize || 100;
5+
this.cellMargin = options.cellMargin || 20;
6+
this.spacing = this.cellSize + this.cellMargin;
7+
this.zoomStep = options.zoomStep || 1.2;
8+
this.zoomLevel = options.initialZoom || 1;
9+
this.onRoomClick = options.onRoomClick || (() => {});
10+
this.zoomButtonSize = options.zoomButtonSize || 25;
11+
this.controlsMargin = options.controlsMargin || 10;
12+
this.roomEdgeColor = options.roomEdgeColor || "#1c6b60";
13+
this.visitingColor = options.visitingColor || "#c20000";
14+
// ── Internal state ────────────────────────────────────────────────
15+
// rooms: Map<RoomId, { room, group, defaultColor }>
16+
this.rooms = new Map();
17+
this.drawnEdges = new Set(); // to avoid dup lines
18+
this.currentCenterId = null; // for highlight
19+
20+
// ── Build container & SVG ─────────────────────────────────────────
21+
this.container = document.querySelector(selector);
22+
this.container.style.position = 'relative';
23+
24+
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
25+
this.svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
26+
this.svg.style.width = '100%';
27+
this.svg.style.height = '100%';
28+
this.container.appendChild(this.svg);
29+
30+
// Connections under rooms:
31+
this.connectionsGroup = document.createElementNS(this.svg.namespaceURI, 'g');
32+
this.svg.appendChild(this.connectionsGroup);
33+
// Rooms on top:
34+
this.roomsGroup = document.createElementNS(this.svg.namespaceURI, 'g');
35+
this.svg.appendChild(this.roomsGroup);
36+
37+
// Default tiny viewBox until rooms exist:
38+
this.svg.setAttribute('viewBox', '0 0 1 1');
39+
40+
// ── HTML overlay zoom controls ────────────────────────────────────
41+
this._createHTMLControls();
42+
}
43+
44+
// ── Public API ───────────────────────────────────────────────────────
45+
46+
/**
47+
* Add or update a room.
48+
* - Pre-adds any Exits given as {RoomId,x,y,…}
49+
* - If room already exists, updates its position, color, text, & redraws edges.
50+
*/
51+
addRoom(room) {
52+
const id = room.RoomId;
53+
54+
// 1) Pre-add exit-defined rooms
55+
if (Array.isArray(room.Exits)) {
56+
room.Exits.forEach(e => {
57+
if (e && typeof e === 'object' && e.RoomId != null) {
58+
59+
if (this.rooms.has(e.RoomId)) return;
60+
61+
this.addRoom({
62+
RoomId: e.RoomId,
63+
Text: e.Text != null ? e.Text : String(e.RoomId),
64+
x: e.x,
65+
y: e.y,
66+
Exits: Array.isArray(e.Exits) ? e.Exits : []
67+
});
68+
}
69+
});
70+
}
71+
72+
// prepare defaults
73+
const defaultColor = room.Color || '#fff';
74+
const displayText = room.Text != null ?
75+
room.Text :
76+
String(room.RoomId);
77+
78+
// 2) UPDATE existing
79+
if (this.rooms.has(id)) {
80+
const entry = this.rooms.get(id);
81+
// update stored data
82+
entry.room.x = room.x;
83+
entry.room.y = room.y;
84+
entry.room.Exits = Array.isArray(room.Exits) ? room.Exits : [];
85+
entry.room.Color = room.Color;
86+
entry.room.Text = room.Text;
87+
entry.defaultColor = defaultColor;
88+
89+
// move & recolor rect
90+
const rect = this.svg.querySelector(`rect[data-room-rect="${id}"]`);
91+
rect.setAttribute('x', room.x * this.spacing);
92+
rect.setAttribute('y', room.y * this.spacing);
93+
if (this.currentCenterId === id) {
94+
rect.setAttribute('fill', this.visitingColor);
95+
} else {
96+
rect.setAttribute('fill', defaultColor);
97+
}
98+
99+
// move & update label
100+
const txtEl = this.svg.querySelector(`g[data-room-id="${id}"] text`);
101+
txtEl.setAttribute('x', room.x * this.spacing + this.cellSize / 2);
102+
txtEl.setAttribute('y', room.y * this.spacing + this.cellSize / 2 + 5);
103+
txtEl.textContent = displayText;
104+
105+
// redraw any new edges
106+
this._drawEdgesForRoom(id);
107+
108+
// refresh bounds & view
109+
this._updateBounds();
110+
this._applyZoom();
111+
return;
112+
}
113+
114+
// 3) NEW room → draw group
115+
const g = document.createElementNS(this.svg.namespaceURI, 'g');
116+
g.setAttribute('data-room-id', id);
117+
118+
// square
119+
const rect = document.createElementNS(this.svg.namespaceURI, 'rect');
120+
rect.setAttribute('width', this.cellSize);
121+
rect.setAttribute('height', this.cellSize);
122+
rect.setAttribute('x', room.x * this.spacing);
123+
rect.setAttribute('y', room.y * this.spacing);
124+
rect.setAttribute('stroke', this.roomEdgeColor);
125+
rect.setAttribute('stroke-width', '4');
126+
rect.setAttribute('rx', this.cellSize / 10); // corner radius X
127+
rect.setAttribute('ry', this.cellSize / 10); // corner radius Y
128+
rect.setAttribute('data-room-rect', id);
129+
rect.setAttribute('fill', defaultColor);
130+
rect.style.cursor = 'pointer';
131+
rect.addEventListener('click', () => this.onRoomClick(room));
132+
g.appendChild(rect);
133+
134+
// label
135+
const label = document.createElementNS(this.svg.namespaceURI, 'text');
136+
label.setAttribute('x', room.x * this.spacing + this.cellSize / 2);
137+
label.setAttribute('y', room.y * this.spacing + this.cellSize / 2 + 5);
138+
label.setAttribute('text-anchor', 'middle');
139+
label.setAttribute('font-size', this.cellSize * 0.3);
140+
label.textContent = displayText;
141+
g.appendChild(label);
142+
143+
this.roomsGroup.appendChild(g);
144+
this.rooms.set(id, {
145+
room,
146+
group: g,
147+
defaultColor
148+
});
149+
150+
// draw edges for this new room
151+
this._drawEdgesForRoom(id);
152+
153+
// refresh bounds & view
154+
this._updateBounds();
155+
this._applyZoom();
156+
}
157+
158+
/**
159+
* Bulk‐set rooms (wipes existing).
160+
*/
161+
setRooms(arr) {
162+
this.reset();
163+
arr.forEach(r => this.addRoom(r));
164+
}
165+
166+
/**
167+
* Clear everything.
168+
*/
169+
reset() {
170+
this.rooms.clear();
171+
this.drawnEdges.clear();
172+
this.currentCenterId = null;
173+
this.zoomLevel = 1;
174+
this.svg.setAttribute('viewBox', '0 0 1 1');
175+
this.roomsGroup.innerHTML = '';
176+
this.connectionsGroup.innerHTML = '';
177+
}
178+
179+
/**
180+
* Center & highlight a room. Previous one reverts to its default color.
181+
*/
182+
centerOnRoom(id) {
183+
const entry = this.rooms.get(id);
184+
if (!entry) return;
185+
186+
// un-highlight previous
187+
if (this.currentCenterId != null) {
188+
const prevRect = this.svg.querySelector(
189+
`rect[data-room-rect="${this.currentCenterId}"]`
190+
);
191+
if (prevRect) {
192+
const prevEntry = this.rooms.get(this.currentCenterId);
193+
prevRect.setAttribute('fill', prevEntry.defaultColor);
194+
}
195+
}
196+
197+
// compute new view center
198+
this.center = {
199+
x: entry.room.x * this.spacing + this.cellSize / 2,
200+
y: entry.room.y * this.spacing + this.cellSize / 2
201+
};
202+
this._applyZoom();
203+
204+
// highlight new
205+
const newRect = this.svg.querySelector(
206+
`rect[data-room-rect="${id}"]`
207+
);
208+
if (newRect) newRect.setAttribute('fill', this.visitingColor);
209+
210+
this.currentCenterId = id;
211+
}
212+
213+
zoomIn() {
214+
this.zoomLevel *= this.zoomStep;
215+
this._applyZoom();
216+
}
217+
zoomOut() {
218+
this.zoomLevel /= this.zoomStep;
219+
this._applyZoom();
220+
}
221+
222+
drawConnection(a, b) {
223+
if (!this.rooms.has(a) || !this.rooms.has(b)) return;
224+
this._drawEdge(a, b);
225+
this._applyZoom();
226+
}
227+
228+
// ── Private draw helpers ───────────────────────────────────────────────
229+
230+
_createHTMLControls() {
231+
const div = document.createElement('div');
232+
div.style.cssText = `
233+
position:absolute;
234+
top:${this.controlsMargin}px;
235+
right:${this.controlsMargin}px;
236+
display:flex; gap:5px;
237+
`;
238+
const mk = (lbl, cb) => {
239+
const b = document.createElement('button');
240+
b.textContent = lbl;
241+
b.style.cssText = `
242+
width:${this.zoomButtonSize}px;
243+
height:${this.zoomButtonSize}px;
244+
font-size:${this.zoomButtonSize*0.6}px;
245+
line-height:1;
246+
`;
247+
b.addEventListener('click', cb);
248+
return b;
249+
};
250+
div.append(mk('−', () => this.zoomOut()), mk('+', () => this.zoomIn()));
251+
this.container.appendChild(div);
252+
}
253+
254+
_drawEdgesForRoom(id) {
255+
const me = this.rooms.get(id)
256+
.room;
257+
const exits = Array.isArray(me.Exits) ? me.Exits : [];
258+
259+
// draw its own exits
260+
exits.forEach(e => {
261+
const to = (typeof e === 'object') ? e.RoomId : e;
262+
if (this.rooms.has(to)) this._drawEdge(id, to);
263+
});
264+
265+
// draw others’ exits back to it
266+
this.rooms.forEach(({
267+
room
268+
}, otherId) => {
269+
if (otherId === id) return;
270+
const oe = Array.isArray(room.Exits) ? room.Exits : [];
271+
if (oe.some(x => ((typeof x === 'object') ? x.RoomId : x) === id)) {
272+
this._drawEdge(otherId, id);
273+
}
274+
});
275+
}
276+
277+
_drawEdge(a, b) {
278+
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
279+
if (this.drawnEdges.has(key)) return;
280+
this.drawnEdges.add(key);
281+
282+
const ra = this.rooms.get(a)
283+
.room;
284+
const rb = this.rooms.get(b)
285+
.room;
286+
const x1 = ra.x * this.spacing + this.cellSize / 2;
287+
const y1 = ra.y * this.spacing + this.cellSize / 2;
288+
const x2 = rb.x * this.spacing + this.cellSize / 2;
289+
const y2 = rb.y * this.spacing + this.cellSize / 2;
290+
291+
const line = document.createElementNS(this.svg.namespaceURI, 'line');
292+
line.setAttribute('x1', x1);
293+
line.setAttribute('y1', y1);
294+
line.setAttribute('x2', x2);
295+
line.setAttribute('y2', y2);
296+
line.setAttribute('stroke', this.roomEdgeColor);
297+
line.setAttribute('stroke-width', '20');
298+
this.connectionsGroup.appendChild(line);
299+
}
300+
301+
_updateBounds() {
302+
if (!this.rooms.size) {
303+
this.bounds = {
304+
minX: 0,
305+
maxX: 0,
306+
minY: 0,
307+
maxY: 0
308+
};
309+
} else {
310+
const xs = [...this.rooms.values()].map(e => e.room.x);
311+
const ys = [...this.rooms.values()].map(e => e.room.y);
312+
this.bounds = {
313+
minX: Math.min(...xs),
314+
maxX: Math.max(...xs),
315+
minY: Math.min(...ys),
316+
maxY: Math.max(...ys)
317+
};
318+
}
319+
this.worldWidth = (this.bounds.maxX - this.bounds.minX + 1) * this.spacing;
320+
this.worldHeight = (this.bounds.maxY - this.bounds.minY + 1) * this.spacing;
321+
322+
if (!this.center && this.rooms.size) {
323+
this.center = {
324+
x: this.bounds.minX * this.spacing + this.worldWidth / 2,
325+
y: this.bounds.minY * this.spacing + this.worldHeight / 2
326+
};
327+
}
328+
}
329+
330+
_applyZoom() {
331+
const hw = this.worldWidth / (2 * this.zoomLevel);
332+
const hh = this.worldHeight / (2 * this.zoomLevel);
333+
const x0 = (this.center ? this.center.x : this.worldWidth / 2) - hw;
334+
const y0 = (this.center ? this.center.y : this.worldHeight / 2) - hh;
335+
this.svg.setAttribute('viewBox', `${x0} ${y0} ${hw*2} ${hh*2}`);
336+
}
337+
}

0 commit comments

Comments
 (0)