This commit is contained in:
Bob Farrell 2025-04-25 21:41:24 +01:00
parent 58403986d7
commit fa76b75c12
3 changed files with 151 additions and 87 deletions

View File

@ -120,3 +120,16 @@ body {
text-align: center; text-align: center;
vertical-align: center; vertical-align: center;
} }
#grid {
transition: background-color 0.5s ease;
}
.flash-animation {
animation: flashRed 0.5s ease;
}
@keyframes flashRed {
0% { background-color: #e55; }
100% { background-color: white; }
}

View File

@ -21,7 +21,7 @@ pub const Channel = struct {
game.evaluate(); game.evaluate();
if (game.victor != null) { if (game.victor != null) {
try channel.publish(.{ .err = "Game is already over." }); try channel.invoke(.game_over, .{});
return; return;
} else { } else {
try movePlayer(channel, &game, cells, cell); try movePlayer(channel, &game, cells, cell);

View File

@ -5,13 +5,23 @@
stateChangedCallbacks: [], stateChangedCallbacks: [],
messageCallbacks: [], messageCallbacks: [],
invokeCallbacks: {}, invokeCallbacks: {},
elementMap: {},
transformerMap: {},
transformers: {},
onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); }, onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); },
onMessage: function(callback) { this.messageCallbacks.push(callback); }, onMessage: function(callback) { this.messageCallbacks.push(callback); },
receive: function(event, callback) { transform: function(ref, callback) {
if (Object.hasOwn(this.invokeCallbacks, event)) { if (Object.hasOwn(this.transformers, ref)) {
this.invokeCallbacks[event].push(callback); this.transformers[ref].push(callback);
} else { } else {
this.invokeCallbacks[event] = [callback]; this.transformers[ref] = [callback];
}
},
receive: function(ref, callback) {
if (Object.hasOwn(this.invokeCallbacks, ref)) {
this.invokeCallbacks[ref].push(callback);
} else {
this.invokeCallbacks[ref] = [callback];
} }
}, },
publish: function(data) { publish: function(data) {
@ -21,76 +31,122 @@
} }
}, },
}; };
</script>
(() => {
@if (context.request) |request| @if (context.request) |request|
@if (request.headers.get("host")) |host| const transform = (value, state, element) => {
<script> const id = element.getAttribute('jetzig-id');
channel.websocket = new WebSocket('ws://{{host}}{{request.path.base_path}}'); const key = id && channel.transformerMap[id];
channel.websocket.addEventListener("message", (event) => { const transformers = key && channel.transformers[key];
const state_tag = "__jetzig_channel_state__:"; if (transformers) {
const actions_tag = "__jetzig_actions__:"; return transformers.reduce((acc, val) => val(acc), value);
const event_tag = "__jetzig_event__:"; } else {
return value === undefined || value == null ? '' : `${value}`
}
};
if (event.data.startsWith(state_tag)) { @if (request.headers.get("host")) |host|
const state = JSON.parse(event.data.slice(state_tag.length)); channel.websocket = new WebSocket('ws://{{host}}{{request.path.base_path}}');
channel.stateChangedCallbacks.forEach((callback) => { channel.websocket.addEventListener("message", (event) => {
callback(state); const state_tag = "__jetzig_channel_state__:";
}); const actions_tag = "__jetzig_actions__:";
} else if (event.data.startsWith(event_tag)) { const event_tag = "__jetzig_event__:";
const data = JSON.parse(event.data.slice(event_tag.length));
if (Object.hasOwn(channel.invokeCallbacks, data.method)) { if (event.data.startsWith(state_tag)) {
channel.invokeCallbacks[data.method].forEach(callback => { const state = JSON.parse(event.data.slice(state_tag.length));
Object.entries(channel.elementMap).forEach(([ref, elements]) => {
const value = reduceState(ref, state);
elements.forEach(element => element.innerHTML = transform(value, state, element));
});
channel.stateChangedCallbacks.forEach((callback) => {
callback(state);
});
} else if (event.data.startsWith(event_tag)) {
const data = JSON.parse(event.data.slice(event_tag.length));
if (Object.hasOwn(channel.invokeCallbacks, data.method)) {
channel.invokeCallbacks[data.method].forEach(callback => {
callback(data);
});
}
} else if (event.data.startsWith(actions_tag)) {
const data = JSON.parse(event.data.slice(actions_tag.length));
data.actions.forEach(action => {
channel.actions[action.name] = (...params) => {
if (action.params.length != params.length) {
throw new Error(`Invalid params for action '${action.name}'`);
}
[...action.params].forEach((param, index) => {
const map = {
s: "string",
b: "boolean",
i: "number",
f: "number",
};
if (map[param] !== typeof params[index]) {
throw new Error(`Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${map[param]}, found ${typeof params[index]}`);
}
});
channel.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`);
};
});
} else {
const data = JSON.parse(event.data);
channel.messageCallbacks.forEach((callback) => {
callback(data); callback(data);
}); });
} }
} else if (event.data.startsWith(actions_tag)) {
const data = JSON.parse(event.data.slice(actions_tag.length));
data.actions.forEach(action => {
channel.actions[action.name] = (...params) => {
if (action.params.length != params.length) {
throw new Error(`Invalid params for action '${action.name}'`);
}
[...action.params].forEach((param, index) => {
const map = {
s: "string",
b: "boolean",
i: "number",
f: "number",
};
if (map[param] !== typeof params[index]) {
throw new Error(`Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${map[param]}, found ${typeof params[index]}`);
}
});
channel.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`); });
};
});
} else {
const data = JSON.parse(event.data);
channel.messageCallbacks.forEach((callback) => {
callback(data);
});
}
}); const reduceState = (ref, state) => {
@// channel.websocket.addEventListener("open", (event) => { if (!ref.startsWith('$.')) throw new Error(`Unexpected ref format: ${ref}`);
@// // TODO const args = ref.split('.');
@// channel.publish("websockets", {}); args.shift();
@// }); const isNumeric = (string) => [...string].every(char => '0123456789'.includes(char));
</script> const isObject = (object) => object && typeof object === 'object';
return args.reduce((acc, arg) => {
if (isNumeric(arg)) {
if (acc && Array.isArray(acc) && acc.length > arg) return acc[parseInt(arg)];
return null;
} else {
if (acc && isObject(acc)) return acc[arg];
return null;
}
}, state);
};
window.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[jetzig-connect]').forEach(element => {
const ref = element.getAttribute('jetzig-connect');
if (!channel.elementMap[ref]) channel.elementMap[ref] = [];
const id = `jetzig-${crypto.randomUUID()}`;
element.setAttribute('jetzig-id', id);
channel.elementMap[ref].push(element);
channel.transformerMap[id] = element.getAttribute('jetzig-transform');
});
});
@// channel.websocket.addEventListener("open", (event) => {
@// // TODO
@// channel.publish("websockets", {});
@// });
@end @end
@end @end
})();
</script>
<div id="results-wrapper"> <div id="results-wrapper">
<span class="trophy">&#127942;</span> <span class="trophy">&#127942;</span>
<div id="results"> <div id="results">
<div>Player</div> <div>Player</div>
<div id="player-wins"></div> <div id="player-wins" jetzig-connect="$.results.player"></div>
<div>CPU</div> <div>CPU</div>
<div id="cpu-wins"></div> <div id="cpu-wins" jetzig-connect="$.results.cpu"></div>
<div>Tie</div> <div>Tie</div>
<div id="ties"></div> <div id="ties" jetzig-connect="$.results.tie"></div>
</div> </div>
<span class="trophy">&#127942;</span> <span class="trophy">&#127942;</span>
</div> </div>
@ -98,15 +154,9 @@
<div id="party-container"></div> <div id="party-container"></div>
<div class="board" id="board"> <div class="board" id="board">
<div class="cell" jetzig-connect="$.cells.0" id="tic-tac-toe-cell-0" data-cell="0"></div> @for (0..9) |index| {
<div class="cell" jetzig-connect="$.cells.1" id="tic-tac-toe-cell-1" data-cell="1"></div> <div class="cell" jetzig-connect="$.cells.{{index}}" jetzig-transform="cell" id="tic-tac-toe-cell-{{index}}" data-cell="{{index}}"></div>
<div class="cell" jetzig-connect="$.cells.2" id="tic-tac-toe-cell-2" data-cell="2"></div> }
<div class="cell" jetzig-connect="$.cells.3" id="tic-tac-toe-cell-3" data-cell="3"></div>
<div class="cell" jetzig-connect="$.cells.4" id="tic-tac-toe-cell-4" data-cell="4"></div>
<div class="cell" jetzig-connect="$.cells.5" id="tic-tac-toe-cell-5" data-cell="5"></div>
<div class="cell" jetzig-connect="$.cells.6" id="tic-tac-toe-cell-6" data-cell="6"></div>
<div class="cell" jetzig-connect="$.cells.7" id="tic-tac-toe-cell-7" data-cell="7"></div>
<div class="cell" jetzig-connect="$.cells.8" id="tic-tac-toe-cell-8" data-cell="8"></div>
</div> </div>
<button id="reset-button">Reset Game</button> <button id="reset-button">Reset Game</button>
@ -118,19 +168,10 @@
<script> <script>
channel.onStateChanged(state => { channel.onStateChanged(state => {
document.querySelector("#player-wins").innerText = state.results.player;
document.querySelector("#cpu-wins").innerText = state.results.cpu;
document.querySelector("#ties").innerText = state.results.tie;
if (!state.victor) { if (!state.victor) {
const elem = document.querySelector("#victor"); const element = document.querySelector("#victor");
elem.style.visibility = 'hidden'; element.style.visibility = 'hidden';
} }
Object.entries(state.cells).forEach(([cell, state]) => {
const element = document.querySelector(`#tic-tac-toe-cell-${cell}`);
element.innerHTML = { player: "&#9992;&#65039;", cpu: "&#129422;" }[state] || "";
});
}); });
channel.onMessage(message => { channel.onMessage(message => {
@ -140,25 +181,35 @@
}); });
channel.receive("victor", (data) => { channel.receive("victor", (data) => {
console.log(data); const element = document.querySelector("#victor");
const elem = document.querySelector("#victor");
const emoji = { const emoji = {
player: "&#9992;&#65039;", player: "&#9992;&#65039;",
cpu: "&#129422;", cpu: "&#129422;",
tie: "&#129309;" tie: "&#129309;"
}[data.params.type] || ""; }[data.params.type] || "";
elem.innerHTML = `&#127942; ${emoji} &#127942;`; element.innerHTML = `&#127942; ${emoji} &#127942;`;
elem.style.visibility = 'visible'; element.style.visibility = 'visible';
triggerPartyAnimation(); triggerPartyAnimation();
}); });
channel.receive("game_over", (data) => {
const element = document.querySelector("#board");
element.classList.remove('flash-animation');
void element.offsetWidth;
element.classList.add('flash-animation');
});
channel.transform("cell", (value) => (
{ player: "&#9992;&#65039;", cpu: "&#129422;" }[value] || ""
));
document.querySelectorAll("#board div.cell").forEach(element => {
element.addEventListener("click", () => {
channel.actions.move(parseInt(element.dataset.cell));
});
});
document.querySelector("#reset-button").addEventListener("click", () => { document.querySelector("#reset-button").addEventListener("click", () => {
channel.actions.reset(); channel.actions.reset();
}); });
document.querySelectorAll("#board div.cell").forEach(element => {
element.addEventListener("click", () => {
channel.actions.move(parseInt(element.dataset.cell));
});
});
</script> </script>