This commit is contained in:
Bob Farrell 2025-04-23 20:26:28 +01:00
parent e9802bf546
commit 58403986d7
5 changed files with 193 additions and 120 deletions

View File

@ -114,3 +114,9 @@ body {
.trophy {
font-size: 5rem;
}
#victor {
margin: 3rem;
font-size: 5rem;
text-align: center;
vertical-align: center;
}

83
demo/src/app/lib/Game.zig Normal file
View File

@ -0,0 +1,83 @@
const std = @import("std");
const jetzig = @import("jetzig");
grid: Grid,
victor: ?State = null,
pub const Grid = [9]State;
pub const State = enum { empty, player, cpu, tie };
const Game = @This();
pub fn gridFromValues(values: []*jetzig.data.Value) Grid {
var grid: [9]Game.State = undefined;
for (0..9) |id| {
if (values[id].* != .null) {
grid[id] = if (values[id].eql("player")) .player else .cpu;
} else {
grid[id] = .empty;
}
}
return grid;
}
pub fn movePlayer(game: *Game, cell: usize) bool {
if (cell >= game.grid.len) return false;
if (game.grid[cell] != .empty) return false;
game.grid[cell] = .player;
game.evaluate();
return true;
}
pub fn moveCpu(game: *Game) usize {
std.debug.assert(game.victor == null);
var available: [9]usize = undefined;
var available_len: usize = 0;
for (game.grid, 0..) |cell, cell_index| {
if (cell == .empty) {
available[available_len] = cell_index;
available_len += 1;
}
}
std.debug.assert(available_len > 0);
const choice = available[std.crypto.random.intRangeAtMost(usize, 0, available_len - 1)];
game.grid[choice] = .cpu;
game.evaluate();
return choice;
}
pub fn evaluate(game: *Game) void {
var full = true;
for (game.grid) |cell| {
if (cell == .empty) full = false;
}
if (full) {
game.victor = .tie;
return;
}
const patterns = [_][3]usize{
.{ 0, 1, 2 },
.{ 3, 4, 5 },
.{ 6, 7, 8 },
.{ 0, 3, 6 },
.{ 1, 4, 7 },
.{ 2, 5, 8 },
.{ 0, 4, 8 },
.{ 2, 4, 6 },
};
for (patterns) |pattern| {
var cpu_victor = true;
var player_victor = true;
for (pattern) |cell_index| {
if (game.grid[cell_index] != .cpu) cpu_victor = false;
if (game.grid[cell_index] != .player) player_victor = false;
}
std.debug.assert(!(cpu_victor and player_victor));
if (cpu_victor) game.victor = .cpu;
if (player_victor) game.victor = .player;
}
}

View File

@ -1,6 +1,8 @@
const std = @import("std");
const jetzig = @import("jetzig");
const Game = @import("../lib/Game.zig");
pub fn index(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}
@ -11,56 +13,22 @@ pub const Channel = struct {
try channel.sync();
}
pub fn receive(message: jetzig.channels.Message) !void {
const params = try message.params() orelse return;
if (params.remove("reset")) {
try resetGame(message.channel);
try message.channel.sync();
return;
}
const cell: usize = if (params.getT(.integer, "cell")) |integer|
@intCast(integer)
else
return;
const cells = message.channel.getT(.array, "cells") orelse return;
const items = cells.items();
var grid: [9]Game.State = undefined;
for (0..9) |id| {
if (items[id].* != .null) {
grid[id] = if (items[id].eql("player")) .player else .cpu;
} else {
grid[id] = .empty;
}
}
var game = Game{ .grid = grid };
game.evaluate();
if (game.winner != null) {
try message.channel.publish(.{ .err = "Game is already over." });
return;
}
if (game.movePlayer(cell)) {
items[cell] = message.data.string("player");
if (game.winner == null) {
items[game.moveCpu()] = message.data.string("cpu");
}
if (game.winner) |winner| {
try message.channel.put("winner", @tagName(winner));
var results = message.channel.getT(.object, "results") orelse return;
const count = results.getT(.integer, @tagName(winner)) orelse return;
try results.put(@tagName(winner), count + 1);
}
}
try message.channel.sync();
}
pub const Actions = struct {
pub fn move(channel: jetzig.channels.Channel, cell: usize) !void {
const cells = channel.getT(.array, "cells") orelse return;
const grid = Game.gridFromValues(cells.items());
var game = Game{ .grid = grid };
game.evaluate();
if (game.victor != null) {
try channel.publish(.{ .err = "Game is already over." });
return;
} else {
try movePlayer(channel, &game, cells, cell);
try channel.sync();
}
}
pub fn reset(channel: jetzig.channels.Channel) !void {
try resetGame(channel);
try channel.sync();
@ -68,7 +36,7 @@ pub const Channel = struct {
};
fn resetGame(channel: jetzig.channels.Channel) !void {
try channel.put("winner", null);
try channel.put("victor", null);
var cells = try channel.put("cells", .array);
for (0..9) |_| try cells.append(null);
}
@ -77,75 +45,31 @@ pub const Channel = struct {
var results = try channel.put("results", .object);
try results.put("cpu", 0);
try results.put("player", 0);
try results.put("ties", 0);
try results.put("tie", 0);
try resetGame(channel);
}
};
const Game = struct {
grid: [9]State,
winner: ?State = null,
pub const State = enum { empty, player, cpu, tie };
pub fn movePlayer(game: *Game, cell: usize) bool {
if (cell >= game.grid.len) return false;
if (game.grid[cell] != .empty) return false;
game.grid[cell] = .player;
game.evaluate();
return true;
fn movePlayer(
channel: jetzig.channels.Channel,
game: *Game,
cells: *const jetzig.data.Array,
cell: usize,
) !void {
const values = cells.items();
if (game.movePlayer(cell)) {
values[cell] = channel.data.string("player");
if (game.victor == null) {
values[game.moveCpu()] = channel.data.string("cpu");
}
if (game.victor) |victor| try setVictor(channel, victor);
}
}
pub fn moveCpu(game: *Game) usize {
std.debug.assert(game.winner == null);
var available: [9]usize = undefined;
var available_len: usize = 0;
for (game.grid, 0..) |cell, cell_index| {
if (cell == .empty) {
available[available_len] = cell_index;
available_len += 1;
}
}
std.debug.assert(available_len > 0);
const choice = available[std.crypto.random.intRangeAtMost(usize, 0, available_len - 1)];
game.grid[choice] = .cpu;
game.evaluate();
return choice;
}
fn evaluate(game: *Game) void {
var full = true;
for (game.grid) |cell| {
if (cell == .empty) full = false;
}
if (full) {
game.winner = .tie;
return;
}
const patterns = [_][3]usize{
.{ 0, 1, 2 },
.{ 3, 4, 5 },
.{ 6, 7, 8 },
.{ 0, 3, 6 },
.{ 1, 4, 7 },
.{ 2, 5, 8 },
.{ 0, 4, 8 },
.{ 2, 4, 6 },
};
for (patterns) |pattern| {
var cpu_winner = true;
var player_winner = true;
for (pattern) |cell_index| {
if (game.grid[cell_index] != .cpu) cpu_winner = false;
if (game.grid[cell_index] != .player) player_winner = false;
}
std.debug.assert(!(cpu_winner and player_winner));
if (cpu_winner) game.winner = .cpu;
if (player_winner) game.winner = .player;
}
fn setVictor(channel: jetzig.channels.Channel, victor: Game.State) !void {
try channel.put("victor", @tagName(victor));
var results = channel.getT(.object, "results") orelse return;
const count = results.getT(.integer, @tagName(victor)) orelse return;
try results.put(@tagName(victor), count + 1);
try channel.invoke(.victor, .{ .type = @tagName(victor) });
}
};

View File

@ -4,8 +4,16 @@
actions: {},
stateChangedCallbacks: [],
messageCallbacks: [],
invokeCallbacks: {},
onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); },
onMessage: function(callback) { this.messageCallbacks.push(callback); },
receive: function(event, callback) {
if (Object.hasOwn(this.invokeCallbacks, event)) {
this.invokeCallbacks[event].push(callback);
} else {
this.invokeCallbacks[event] = [callback];
}
},
publish: function(data) {
if (this.websocket) {
const json = JSON.stringify(data);
@ -22,12 +30,20 @@
channel.websocket.addEventListener("message", (event) => {
const state_tag = "__jetzig_channel_state__:";
const actions_tag = "__jetzig_actions__:";
const event_tag = "__jetzig_event__:";
if (event.data.startsWith(state_tag)) {
const state = JSON.parse(event.data.slice(state_tag.length));
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 => {
@ -95,19 +111,20 @@
<button id="reset-button">Reset Game</button>
<div id="victor"></div>
<script src="/party.js"></script>
<link rel="stylesheet" href="/party.css" />
<script>
channel.onStateChanged(state => {
console.log(state);
document.querySelector("#player-wins").innerText = state.results.player;
document.querySelector("#cpu-wins").innerText = state.results.cpu;
document.querySelector("#ties").innerText = state.results.ties;
document.querySelector("#ties").innerText = state.results.tie;
if (state.winner) {
triggerPartyAnimation();
if (!state.victor) {
const elem = document.querySelector("#victor");
elem.style.visibility = 'hidden';
}
Object.entries(state.cells).forEach(([cell, state]) => {
@ -122,13 +139,26 @@
}
});
channel.receive("victor", (data) => {
console.log(data);
const elem = document.querySelector("#victor");
const emoji = {
player: "&#9992;&#65039;",
cpu: "&#129422;",
tie: "&#129309;"
}[data.params.type] || "";
elem.innerHTML = `&#127942; ${emoji} &#127942;`;
elem.style.visibility = 'visible';
triggerPartyAnimation();
});
document.querySelector("#reset-button").addEventListener("click", () => {
channel.actions.reset();
});
document.querySelectorAll("#board div.cell").forEach(element => {
element.addEventListener("click", () => {
channel.publish({ cell: parseInt(element.dataset.cell) });
channel.actions.move(parseInt(element.dataset.cell));
});
});
</script>

View File

@ -23,6 +23,32 @@ pub fn RoutedChannel(Routes: type) type {
const writer = write_buffer.writer();
try std.json.stringify(data, .{}, writer);
try write_buffer.flush();
channel.websocket.logger.DEBUG(
"Published Channel message for `{s}`",
.{channel.websocket.route.path},
) catch {};
}
pub fn invoke(
channel: Channel,
comptime method: @TypeOf(.enum_literal),
args: anytype,
) !void {
// TODO: DRY
var stack_fallback = std.heap.stackFallback(4096, channel.allocator);
const allocator = stack_fallback.get();
var write_buffer = channel.websocket.connection.writeBuffer(allocator, .text);
defer write_buffer.deinit();
const writer = write_buffer.writer();
try writer.writeAll("__jetzig_event__:");
try std.json.stringify(.{ .method = method, .params = args }, .{}, writer);
try write_buffer.flush();
channel.websocket.logger.DEBUG(
"Invoked Javascript function `{s}` for `{s}`",
.{ @tagName(method), channel.websocket.route.path },
) catch {};
}
pub fn getT(
@ -45,6 +71,10 @@ pub fn RoutedChannel(Routes: type) type {
return try channel.state.put(key, value);
}
pub fn remove(channel: Channel, key: []const u8) bool {
return channel.state.remove(key);
}
pub fn sync(channel: Channel) !void {
try channel.websocket.syncState(channel);
}