From 758043c4275498c94eeed26213536052349dd449 Mon Sep 17 00:00:00 2001 From: Tor Andersson Date: Sat, 1 Jul 2023 15:02:17 +0200 Subject: Add "snapshot" replay view during play. Snapshots store game state without undo and only log length. Combined with the final game state's log we can recreate the view from any snapshot quickly. Move replay code into separate script file, loaded only when used. Prefix system "setup", "resign", and "restore" actions with a period. --- public/common/play.css | 61 +++- public/common/play.js | 515 +++++++++++---------------------- public/common/replay.js | 342 ++++++++++++++++++++++ public/images/gui_arrow_down.svg | 3 + public/images/gui_arrow_left.svg | 3 + public/images/gui_arrow_right.svg | 3 + public/images/gui_arrow_right_stop.svg | 3 + public/images/gui_arrow_up.svg | 3 + public/images/gui_chevron_left.svg | 3 + public/images/gui_chevron_right.svg | 3 + public/images/gui_play.svg | 3 + public/images/gui_stop.svg | 3 + 12 files changed, 587 insertions(+), 358 deletions(-) create mode 100644 public/common/replay.js create mode 100644 public/images/gui_arrow_down.svg create mode 100644 public/images/gui_arrow_left.svg create mode 100644 public/images/gui_arrow_right.svg create mode 100644 public/images/gui_arrow_right_stop.svg create mode 100644 public/images/gui_arrow_up.svg create mode 100644 public/images/gui_chevron_left.svg create mode 100644 public/images/gui_chevron_right.svg create mode 100644 public/images/gui_play.svg create mode 100644 public/images/gui_stop.svg (limited to 'public') diff --git a/public/common/play.css b/public/common/play.css index 81bd358..5a145f4 100644 --- a/public/common/play.css +++ b/public/common/play.css @@ -77,7 +77,6 @@ header { align-items: center; border-bottom: 1px solid black; background-color: gainsboro; - padding-right: 8px; } #toolbar { @@ -93,6 +92,10 @@ header.your_turn { background-color: orange; } +header.replay { + background-image: repeating-linear-gradient(45deg, gainsboro, gainsboro 40px, silver 40px, silver 80px); +} + main { grid-column: 1; grid-row: 2; @@ -105,7 +108,7 @@ aside { grid-row: 2; display: grid; overflow: clip; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; width: 212px; border-left: 1px solid black; } @@ -230,22 +233,31 @@ header #actions { flex-wrap: wrap; justify-content: end; gap: 8px; - padding-left: 8px; + padding: 0 8px; margin: 4px 0; } -header .replay { +header #viewpoint_panel { display: flex; flex-wrap: wrap; justify-content: end; - padding-left: 8px; + padding: 0 8px; margin: 4px 0; } -header .replay button { +header .viewpoint_button { margin-right: 0; } +header .viewpoint_button.selected { + background-color: hsl(51,100%,60%); + border-color: hsl(51,100%,80%) hsl(51,100%,40%) hsl(51,100%,40%) hsl(51,100%,80%); +} + +header .viewpoint_button.selected:active:hover { + border-color: hsl(51,100%,40%) hsl(51,100%,80%) hsl(51,100%,80%) hsl(51,100%,40%); +} + #prompt { padding-left: 20px; font-size: 18px; @@ -255,6 +267,35 @@ header .replay button { overflow: hidden; } +#replay_panel { + display: flex; + height: 24px; + border-top: 1px solid black; + background-color: darkgray; +} + +.replay_button { + height: 24px; + flex-grow: 1; + background-repeat: no-repeat; + background-size: 16px 16px; + background-position: center; + opacity: 60%; +} + +.replay_button:hover { + background-color: lightgray; +} + +#replay_first { background-image: url(/images/gui_arrow_up.svg) } +#replay_prev { background-image: url(/images/gui_arrow_left.svg) } +#replay_step_prev { background-image: url(/images/gui_chevron_left.svg) } +#replay_step_next { background-image: url(/images/gui_chevron_right.svg) } +#replay_next { background-image: url(/images/gui_arrow_right.svg) } +#replay_last { background-image: url(/images/gui_arrow_down.svg) } +#replay_play { background-image: url(/images/gui_play.svg) } +#replay_stop { background-image: url(/images/gui_stop.svg) } + /* ROLES */ .role_info { @@ -431,6 +472,13 @@ header .replay button { white-space: normal; } + #replay_panel { + position: fixed; + z-index: 500; + bottom: 0; + width: 100%; + } + #chat_window, #notepad_window { position: static; grid-column: 1; @@ -456,6 +504,7 @@ header .replay button { width: auto !important; border-left: none; border-top: 1px solid black; + margin-bottom: 25px; /* space for replay panel */ } #log { height: 50vh; diff --git a/public/common/play.js b/public/common/play.js index 37708c0..e117dae 100644 --- a/public/common/play.js +++ b/public/common/play.js @@ -25,6 +25,12 @@ let chat = null let game_log = [] +let snap_active = [] +let snap_cache = [] +let snap_count = 0 +let snap_this = 0 +let snap_view = null + function scroll_with_middle_mouse(panel_sel, multiplier) { let panel = document.querySelector(panel_sel) let down_x, down_y, scroll_x, scroll_y @@ -331,6 +337,20 @@ function toggle_notepad() { show_notepad() } +function add_icon_button(parent, id, img, title, fn) { + let button = document.getElementById(id) + if (!button) { + button = document.createElement("div") + button.id = id + button.title = title + button.className = "icon_button" + button.innerHTML = '' + button.addEventListener("click", fn) + parent.appendChild(button) + } + return button +} + /* REMATCH BUTTON */ function remove_resign_menu() { @@ -349,20 +369,9 @@ function goto_replay() { } function on_game_over() { - function icon_button(id, img, title, fn) { - if (!document.getElementById(id)) { - let button = document.createElement("div") - button.id = id - button.title = title - button.className = "icon_button" - button.innerHTML = '' - button.addEventListener("click", fn) - document.querySelector("header").appendChild(button) - } - } - icon_button("replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay) + add_icon_button(document.querySelector("header"), "replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay) if (player !== "Observer") - icon_button("rematch_button", "cycle", "Propose a rematch!", goto_rematch) + add_icon_button(document.querySelector("header"), "rematch_button", "cycle", "Propose a rematch!", goto_rematch) remove_resign_menu() } @@ -376,7 +385,7 @@ function init_player_names(players) { } function send_message(cmd, arg) { - let data = JSON.stringify([cmd, arg]) + let data = JSON.stringify([ cmd, arg ]) console.log("SEND %s %s", cmd, arg) socket.send(data) } @@ -400,7 +409,7 @@ function connect_play() { socket = new WebSocket(url) - window.addEventListener('beforeunload', function () { + window.addEventListener("beforeunload", function () { socket.close(1000) }) @@ -429,19 +438,19 @@ function connect_play() { let [ cmd, arg ] = JSON.parse(evt.data) console.log("MESSAGE %s", cmd) switch (cmd) { - case 'error': + case "error": document.getElementById("prompt").textContent = arg break - case 'chat': + case "chat": update_chat(arg[0], arg[1], arg[2], arg[3]) break - case 'note': + case "note": update_notepad(arg) break - case 'players': + case "players": player = arg[0] document.querySelector("body").classList.add(player.replace(/ /g, "_")) if (player !== "Observer") { @@ -453,15 +462,20 @@ function connect_play() { init_player_names(arg[1]) break - case 'presence': - let list = Array.isArray(arg) ? arg : Object.keys(arg) - for (let i = 0; i < roles.length; ++i) { - let elt = document.getElementById(roles[i].id) - elt.classList.toggle("present", list.includes(roles[i].role)) + case "presence": + { + let list = Array.isArray(arg) ? arg : Object.keys(arg) + for (let i = 0; i < roles.length; ++i) { + let elt = document.getElementById(roles[i].id) + elt.classList.toggle("present", list.includes(roles[i].role)) + } } break - case 'state': + case "state": + if (snap_view) + on_snap_stop() + view = arg game_log.length = view.log_start @@ -469,19 +483,35 @@ function connect_play() { game_log.push(line) on_update_header() - if (typeof on_update === 'function') + if (typeof on_update === "function") on_update() on_update_log(view.log_start, game_log.length) if (view.game_over) on_game_over() break - case 'reply': - if (typeof on_reply === 'function') + case "snapsize": + snap_count = arg + if (snap_count === 0) + replay_panel.remove() + else + document.querySelector("aside").appendChild(replay_panel) + console.log("SNAPSIZE", snap_count) + break + + case "snap": + console.log("SNAP", arg[0]) + snap_active[arg[0]] = arg[1] + snap_cache[arg[0]] = arg[2] + show_snap(arg[0]) + break + + case "reply": + if (typeof on_reply === "function") on_reply(arg[0], arg[1]) break - case 'save': + case "save": window.localStorage[params.title_id + "/save"] = arg break } @@ -497,6 +527,10 @@ function on_update_header() { document.getElementById("prompt").textContent = view.prompt if (params.mode === "replay") return + if (snap_view) + document.querySelector("header").classList.add("replay") + else + document.querySelector("header").classList.remove("replay") if (view.actions) { document.querySelector("header").classList.add("your_turn") if (!is_your_turn || old_active !== view.active) @@ -649,13 +683,13 @@ function send_action(verb, noun) { let realnoun = Array.isArray(noun) ? noun[0] : noun if (view.actions && view.actions[verb] && view.actions[verb].includes(realnoun)) { view.actions = null - send_message("action", [verb, noun]) + send_message("action", [ verb, noun ]) return true } } else { if (view.actions && view.actions[verb]) { view.actions = null - send_message("action", [verb]) + send_message("action", [ verb ]) return true } } @@ -667,20 +701,13 @@ function confirm_action(message, verb, noun) { send_action(verb, noun) } -let replay_query = null - function send_query(q, param) { - if (param !== undefined) { - if (replay_query) - replay_query(q, param) - else - send_message("query", [q, param]) - } else { - if (replay_query) - replay_query(q, undefined) - else - send_message("query", q) - } + if (typeof replay_query === "function") + replay_query(q, param) + else if (snap_view) + send_message("querysnap", [ snap_this, q, param ]) + else + send_message("query", [ q, param ]) } function confirm_resign() { @@ -688,6 +715,14 @@ function confirm_resign() { send_message("resign") } +function send_save() { + send_message("save") +} + +function send_restore() { + send_message("restore", window.localStorage[params.title_id + "/save"]) +} + /* MOBILE PHONE LAYOUT */ let mobile_scroll_header = document.querySelector("header") @@ -706,316 +741,12 @@ window.addEventListener("scroll", function scroll_mobile_fix (evt) { } }) -/* DEBUGGING */ - -function send_save() { - send_message("save") -} - -function send_restore() { - send_message("restore", window.localStorage[params.title_id + "/save"]) -} - -function send_restart(scenario) { - send_message("restart", scenario) -} - /* REPLAY */ -function object_copy(original) { - if (Array.isArray(original)) { - let n = original.length - let copy = new Array(n) - for (let i = 0; i < n; ++i) { - let v = original[i] - if (typeof v === "object" && v !== null) - copy[i] = object_copy(v) - else - copy[i] = v - } - return copy - } else { - let copy = {} - for (let i in original) { - let v = original[i] - if (typeof v === "object" && v !== null) - copy[i] = object_copy(v) - else - copy[i] = v - } - return copy - } -} - -function adler32(data) { - let a = 1, b = 0 - for (let i = 0, n = data.length; i < n; ++i) { - a = (a + data.charCodeAt(i)) % 65521 - b = (b + a) % 65521 - } - return (b << 16) | a -} - -async function require(path) { - let cache = {} - - if (!path.endsWith(".js")) - path = path + ".js" - if (path.startsWith("./")) - path = path.substring(2) - - console.log("REQUIRE", path) - - let response = await fetch(path) - let source = await response.text() - - for (let [_, subpath] of source.matchAll(/require\(['"]([^)]*)['"]\)/g)) - if (cache[subpath] === undefined) - cache[subpath] = await require(subpath) - - let module = { exports: {} } - Function("module", "exports", "require", source)(module, module.exports, path => cache[path]) - return module.exports -} - -let replay = null - -async function init_replay() { - remove_resign_menu() - - document.getElementById("prompt").textContent = "Loading replay..." - - console.log("LOADING RULES") - let rules = await require("rules.js") - - console.log("LOADING REPLAY") - let response = await fetch("/api/replay/" + params.game_id) - if (!response.ok) { - let text = await response.text() - document.getElementById("prompt").textContent = "ERROR " + response.status + ": " + text - return - } - - let body = await response.json() - replay = body.replay - - init_player_names(body.players) - - let viewpoint = "Observer" - let p = 0 - let s = {} - - function eval_action(item, p) { - let [ item_role, item_action, item_arguments ] = item - switch (item_action) { - case "setup": - s = rules.setup(item_arguments[0], item_arguments[1], item_arguments[2]) - break - case "resign": - if (params.mode === "debug") - s.log.push([p, item_role.substring(0,2), item_action, null]) - s = rules.resign(s, item_role) - break - default: - if (params.mode === "debug") - s.log.push([p, item_role.substring(0,2), item_action, item_arguments]) - s = rules.action(s, item_role, item_action, item_arguments) - break - } - } - - replay_query = function (query, params) { - let reply = rules.query(s, player, query, params) - on_reply(query, reply) - } - - let ss - for (p = 0; p < replay.length; ++p) { - if (rules.is_checkpoint) { - replay[p].is_checkpoint = p > 1 && rules.is_checkpoint(ss, s) - ss = object_copy(s) - } - - try { - eval_action(replay[p], p) - } catch (err) { - console.log("ERROR IN REPLAY %d %s %s/%s/%s", p, s.state, replay[p][0], replay[p][1], replay[p][2]) - console.log(err) - if (params.mode === "debug") - replay.length = p - else - replay.length = 0 - break - } - - if (params.mode !== "debug") { - replay[p].digest = adler32(JSON.stringify(s)) - for (let k = p-1; k > 0; --k) { - if (replay[k].digest === replay[p].digest && !replay[k].is_undone) { - for (let a = k+1; a <= p; ++a) - if (!replay[a].is_undone) - replay[a].is_undone = true - break - } - } - } - } - - replay = replay.filter(x => !x.is_undone) - - function set_hash(n) { - history.replaceState(null, "", window.location.pathname + window.location.search + "#" + n) - } - - let timer = 0 - function play_pause_replay(evt) { - if (timer === 0) { - evt.target.textContent = "Stop" - timer = setInterval(() => { - if (p < replay.length) - goto_replay(p+1) - else - play_pause_replay(evt) - }, 1000) - } else { - evt.target.textContent = "Run" - clearInterval(timer) - timer = 0 - } - } - - function prev() { - for (let i = p - 1; i > 1; --i) - if (replay[i].is_checkpoint) - return i - return 1 - } - - function next() { - for (let i = p + 1; i < replay.length; ++i) - if (replay[i].is_checkpoint) - return i - return replay.length - } - - function on_hash_change() { - goto_replay(parseInt(window.location.hash.slice(1)) || 1) - } - - function goto_replay(np) { - if (np < 1) - np = 1 - if (np > replay.length) - np = replay.length - set_hash(np) - if (p > np) - p = 0, s = {} - while (p < np) { - eval_action(replay[p], p) - ++p; - } - update_replay_view() - } - - function update_replay_view() { - player = viewpoint - - if (viewpoint === "Active") { - player = s.active - if (player === "All" || player === "Both" || player === "None" || !player) - player = "Observer" - } - - let body = document.querySelector("body") - body.classList.remove("Observer") - for (let i = 0; i < roles.length; ++i) - body.classList.remove(roles[i].role.replace(/ /g, "_")) - body.classList.add(player.replace(/ /g, "_")) - - view = rules.view(s, player) - if (params.mode !== "debug") - view.actions = null - - if (viewpoint === "Observer") - view.game_over = 1 - if (s.state === "game_over") - view.game_over = 1 - - if (replay.length > 0) { - if (document.querySelector("body").classList.contains("shift")) { - view.prompt = `[${p}/${replay.length}] ${s.active} / ${s.state}` - if (p < replay.length) - view.prompt += ` / ${replay[p][1]} ${replay[p][2]}` - } else { - view.prompt = "[" + p + "/" + replay.length + "] " + view.prompt - } - } - - if (game_log.length > view.log.length) - game_log.length = view.log.length - let log_start = game_log.length - for (let i = log_start; i < view.log.length; ++i) - game_log.push(view.log[i]) - - on_update_header() - on_update() - on_update_log(log_start, game_log.length) - } - - function text_button(div, txt, fn) { - let button = document.createElement("button") - button.addEventListener("click", fn) - button.textContent = txt - div.appendChild(button) - return button - } - - function set_viewpoint(vp) { - viewpoint = vp - update_replay_view() - } - - function short_role(name) { - return name.split(" ").map(n => n[0]).join("") - } - - let div = document.createElement("div") - div.className = "replay" - if (replay.length > 0) - text_button(div, "Active", () => set_viewpoint("Active")) - if (roles.length > 2) - for (let r of roles) - text_button(div, short_role(r.role), () => set_viewpoint(r.role)) - else - for (let r of roles) - text_button(div, r.role, () => set_viewpoint(r.role)) - text_button(div, "Observer", () => set_viewpoint("Observer")) - document.querySelector("header").appendChild(div) - - if (replay.length > 0) { - console.log("REPLAY READY") - - div = document.createElement("div") - div.className = "replay" - text_button(div, "<<<", () => goto_replay(1)) - text_button(div, "<<", () => goto_replay(prev())) - text_button(div, "<\xa0", () => goto_replay(p-1)) - text_button(div, "\xa0>", () => goto_replay(p+1)) - text_button(div, ">>", () => goto_replay(next())) - text_button(div, "Run", play_pause_replay).style.width = "65px" - document.querySelector("header").appendChild(div) - - if (window.location.hash === "") - set_hash(replay.length) - - on_hash_change() - - window.addEventListener("hashchange", on_hash_change) - } else { - console.log("REPLAY NOT AVAILABLE") - s = body.state - update_replay_view() - } +function init_replay() { + let script = document.createElement("script") + script.src = "/common/replay.js" + document.body.appendChild(script) } window.addEventListener("load", function () { @@ -1030,6 +761,8 @@ window.addEventListener("load", function () { document.getElementById("prompt").textContent = "Invalid mode: " + params.mode }) +/* MAIN MENU */ + function init_main_menu() { let popup = document.querySelector(".menu_popup") let sep = document.createElement("div") @@ -1065,3 +798,81 @@ if (params.mode === "play" && params.role !== "Observer") { } else { add_main_menu_item_link("Go home", "/") } + +/* SNAPSHOT VIEW */ + +var replay_panel = null + +function add_replay_button(parent, id, callback) { + let button = document.createElement("div") + button.className = "replay_button" + button.id = id + button.onclick = callback + parent.appendChild(button) + return button +} + +function init_snap() { + replay_panel = document.createElement("div") + replay_panel.id = "replay_panel" + add_replay_button(replay_panel, "replay_first", on_snap_first) + add_replay_button(replay_panel, "replay_prev", on_snap_prev) + add_replay_button(replay_panel, "replay_step_prev", null).classList.add("hide") + add_replay_button(replay_panel, "replay_step_next", null).classList.add("hide") + add_replay_button(replay_panel, "replay_next", on_snap_next) + add_replay_button(replay_panel, "replay_last", null).classList.add("hide") + add_replay_button(replay_panel, "replay_play", on_snap_stop) + add_replay_button(replay_panel, "replay_stop", null).classList.add("hide") +} + +init_snap() + +function request_snap(snap_id) { + if (snap_id >= 1 && snap_id <= snap_count) { + snap_this = snap_id + if (snap_cache[snap_id]) + show_snap(snap_id) + else + send_message("getsnap", snap_id) + } +} + +function show_snap(snap_id) { + if (snap_view === null) + snap_view = view + view = snap_cache[snap_id] + view.prompt = "Replay " + snap_id + " / " + snap_count + " \u2013 " + snap_active[snap_id] + on_update_header() + on_update() + on_update_log(view.log, view.log) +} + +function on_snap_first() { + request_snap(1) +} + +function on_snap_prev() { + if (!snap_view) + request_snap(snap_count) + else if (snap_this > 1) + request_snap(snap_this - 1) +} + +function on_snap_next() { + if (!snap_view) + on_snap_stop() + else if (snap_this < snap_count) + request_snap(snap_this + 1) + else + on_snap_stop() +} + +function on_snap_stop() { + if (snap_view) { + view = snap_view + snap_view = null + on_update_header() + on_update() + on_update_log(game_log.length, game_log.length) + } +} diff --git a/public/common/replay.js b/public/common/replay.js new file mode 100644 index 0000000..91170bf --- /dev/null +++ b/public/common/replay.js @@ -0,0 +1,342 @@ +/* POST-GAME REPLAY & DEBUG MODE */ + +"use strict" + +;(function () { + +/* global view, player, params, roles, game_log, replay_panel */ + +var rules = null +var replay = null +var viewpoint = params.role +var viewpoint_buttons = [] + +var replay_this = 0 +var replay_state = {} + +const CRC32C_TABLE = new Int32Array([ + 0x00000000, 0xf26b8303, 0xe13b70f7, 0x1350f3f4, 0xc79a971f, 0x35f1141c, 0x26a1e7e8, 0xd4ca64eb, + 0x8ad958cf, 0x78b2dbcc, 0x6be22838, 0x9989ab3b, 0x4d43cfd0, 0xbf284cd3, 0xac78bf27, 0x5e133c24, + 0x105ec76f, 0xe235446c, 0xf165b798, 0x030e349b, 0xd7c45070, 0x25afd373, 0x36ff2087, 0xc494a384, + 0x9a879fa0, 0x68ec1ca3, 0x7bbcef57, 0x89d76c54, 0x5d1d08bf, 0xaf768bbc, 0xbc267848, 0x4e4dfb4b, + 0x20bd8ede, 0xd2d60ddd, 0xc186fe29, 0x33ed7d2a, 0xe72719c1, 0x154c9ac2, 0x061c6936, 0xf477ea35, + 0xaa64d611, 0x580f5512, 0x4b5fa6e6, 0xb93425e5, 0x6dfe410e, 0x9f95c20d, 0x8cc531f9, 0x7eaeb2fa, + 0x30e349b1, 0xc288cab2, 0xd1d83946, 0x23b3ba45, 0xf779deae, 0x05125dad, 0x1642ae59, 0xe4292d5a, + 0xba3a117e, 0x4851927d, 0x5b016189, 0xa96ae28a, 0x7da08661, 0x8fcb0562, 0x9c9bf696, 0x6ef07595, + 0x417b1dbc, 0xb3109ebf, 0xa0406d4b, 0x522bee48, 0x86e18aa3, 0x748a09a0, 0x67dafa54, 0x95b17957, + 0xcba24573, 0x39c9c670, 0x2a993584, 0xd8f2b687, 0x0c38d26c, 0xfe53516f, 0xed03a29b, 0x1f682198, + 0x5125dad3, 0xa34e59d0, 0xb01eaa24, 0x42752927, 0x96bf4dcc, 0x64d4cecf, 0x77843d3b, 0x85efbe38, + 0xdbfc821c, 0x2997011f, 0x3ac7f2eb, 0xc8ac71e8, 0x1c661503, 0xee0d9600, 0xfd5d65f4, 0x0f36e6f7, + 0x61c69362, 0x93ad1061, 0x80fde395, 0x72966096, 0xa65c047d, 0x5437877e, 0x4767748a, 0xb50cf789, + 0xeb1fcbad, 0x197448ae, 0x0a24bb5a, 0xf84f3859, 0x2c855cb2, 0xdeeedfb1, 0xcdbe2c45, 0x3fd5af46, + 0x7198540d, 0x83f3d70e, 0x90a324fa, 0x62c8a7f9, 0xb602c312, 0x44694011, 0x5739b3e5, 0xa55230e6, + 0xfb410cc2, 0x092a8fc1, 0x1a7a7c35, 0xe811ff36, 0x3cdb9bdd, 0xceb018de, 0xdde0eb2a, 0x2f8b6829, + 0x82f63b78, 0x709db87b, 0x63cd4b8f, 0x91a6c88c, 0x456cac67, 0xb7072f64, 0xa457dc90, 0x563c5f93, + 0x082f63b7, 0xfa44e0b4, 0xe9141340, 0x1b7f9043, 0xcfb5f4a8, 0x3dde77ab, 0x2e8e845f, 0xdce5075c, + 0x92a8fc17, 0x60c37f14, 0x73938ce0, 0x81f80fe3, 0x55326b08, 0xa759e80b, 0xb4091bff, 0x466298fc, + 0x1871a4d8, 0xea1a27db, 0xf94ad42f, 0x0b21572c, 0xdfeb33c7, 0x2d80b0c4, 0x3ed04330, 0xccbbc033, + 0xa24bb5a6, 0x502036a5, 0x4370c551, 0xb11b4652, 0x65d122b9, 0x97baa1ba, 0x84ea524e, 0x7681d14d, + 0x2892ed69, 0xdaf96e6a, 0xc9a99d9e, 0x3bc21e9d, 0xef087a76, 0x1d63f975, 0x0e330a81, 0xfc588982, + 0xb21572c9, 0x407ef1ca, 0x532e023e, 0xa145813d, 0x758fe5d6, 0x87e466d5, 0x94b49521, 0x66df1622, + 0x38cc2a06, 0xcaa7a905, 0xd9f75af1, 0x2b9cd9f2, 0xff56bd19, 0x0d3d3e1a, 0x1e6dcdee, 0xec064eed, + 0xc38d26c4, 0x31e6a5c7, 0x22b65633, 0xd0ddd530, 0x0417b1db, 0xf67c32d8, 0xe52cc12c, 0x1747422f, + 0x49547e0b, 0xbb3ffd08, 0xa86f0efc, 0x5a048dff, 0x8ecee914, 0x7ca56a17, 0x6ff599e3, 0x9d9e1ae0, + 0xd3d3e1ab, 0x21b862a8, 0x32e8915c, 0xc083125f, 0x144976b4, 0xe622f5b7, 0xf5720643, 0x07198540, + 0x590ab964, 0xab613a67, 0xb831c993, 0x4a5a4a90, 0x9e902e7b, 0x6cfbad78, 0x7fab5e8c, 0x8dc0dd8f, + 0xe330a81a, 0x115b2b19, 0x020bd8ed, 0xf0605bee, 0x24aa3f05, 0xd6c1bc06, 0xc5914ff2, 0x37faccf1, + 0x69e9f0d5, 0x9b8273d6, 0x88d28022, 0x7ab90321, 0xae7367ca, 0x5c18e4c9, 0x4f48173d, 0xbd23943e, + 0xf36e6f75, 0x0105ec76, 0x12551f82, 0xe03e9c81, 0x34f4f86a, 0xc69f7b69, 0xd5cf889d, 0x27a40b9e, + 0x79b737ba, 0x8bdcb4b9, 0x988c474d, 0x6ae7c44e, 0xbe2da0a5, 0x4c4623a6, 0x5f16d052, 0xad7d5351 +]) + +function crc32c(data) { + let x = 0 + for (let i = 0, n = data.length; i < n; ++i) + x = CRC32C_TABLE[(x ^ data.charCodeAt(i)) & 0xff] ^ (x >>> 8) + return x ^ -1 +} + +async function require(path) { + let cache = {} + + if (!path.endsWith(".js")) + path = path + ".js" + if (path.startsWith("./")) + path = path.substring(2) + + console.log("REQUIRE", path) + + let response = await fetch(path) + let source = await response.text() + + for (let [_, subpath] of source.matchAll(/require\(['"]([^)]*)['"]\)/g)) + if (cache[subpath] === undefined) + cache[subpath] = await require(subpath) + + let module = { exports: {} } + Function("module", "exports", "require", source)(module, module.exports, path => cache[path]) + return module.exports +} + +function snap_from_state(state) { + // return JSON of game state without undo and with log replaced by log length + let save_undo = state.undo + let save_log = state.log + state.undo = undefined + state.log = save_log.length + let snap = JSON.stringify(state) + state.undo = save_undo + state.log = save_log + return snap +} + +function eval_action(s, item, p) { + let [ item_role, item_action, item_arguments ] = item + switch (item_action) { + case ".setup": + return rules.setup(item_arguments[0], item_arguments[1], item_arguments[2]) + case ".resign": + if (params.mode === "debug") + s.log.push([p, item_role.substring(0,2), item_action, null]) + return rules.resign(s, item_role) + default: + if (params.mode === "debug") + s.log.push([p, item_role.substring(0,2), item_action, item_arguments]) + return rules.action(s, item_role, item_action, item_arguments) + } +} + +function on_click_viewpoint(evt) { + for (let button of viewpoint_buttons) + button.classList.toggle("selected", button === evt.target) + viewpoint = evt.target.my_role + update_replay_view() +} + +function create_viewpoint_button(parent, text, role) { + let button = document.createElement("button") + if (role === viewpoint) + button.className = "viewpoint_button selected" + else + button.className = "viewpoint_button" + button.onclick = on_click_viewpoint + button.textContent = text + button.my_role = role + parent.appendChild(button) + viewpoint_buttons.push(button) +} + +function update_replay_view() { + player = viewpoint + + if (viewpoint === "Active") { + player = replay_state.active + if (player === "All" || player === "Both" || player === "None" || !player) + player = "Observer" + } + + let body = document.querySelector("body") + body.classList.remove("Observer") + for (let i = 0; i < roles.length; ++i) + body.classList.remove(roles[i].role.replace(/ /g, "_")) + body.classList.add(player.replace(/ /g, "_")) + + view = rules.view(replay_state, player) + if (params.mode !== "debug") + view.actions = null + + if (viewpoint === "Observer") + view.game_over = 1 + if (replay_state.state === "game_over") + view.game_over = 1 + + if (replay.length > 0) { + if (document.querySelector("body").classList.contains("shift")) { + view.prompt = `[${replay_this}/${replay.length}] ${replay_state.active} / ${replay_state.state}` + if (replay_this < replay.length) + view.prompt += ` / ${replay[replay_this][1]} ${replay[replay_this][2]}` + } else { + view.prompt = "[" + replay_this + "/" + replay.length + "] " + view.prompt + } + } + + if (game_log.length > view.log.length) + game_log.length = view.log.length + let log_start = game_log.length + for (let i = log_start; i < view.log.length; ++i) + game_log.push(view.log[i]) + + on_update_header() + on_update() + on_update_log(log_start, game_log.length) +} + +function replay_query(query, params) { + on_reply(query, rules.query(replay_state, player, query, params)) +} + +function goto_replay(np) { + if (np < 1) + np = 1 + if (np > replay.length) + np = replay.length + set_hash(np) + if (replay_this > np) + replay_this = 0, replay_state = {} + while (replay_this < np) { + replay_state = eval_action(replay_state, replay[replay_this], replay_this) + ++replay_this; + } + update_replay_view() +} + +function set_hash(n) { + history.replaceState(null, "", window.location.pathname + window.location.search + "#" + n) +} + +function on_hash_change() { + goto_replay(parseInt(window.location.hash.slice(1)) || 1) +} + +function on_replay_first() { + goto_replay(1) +} + +function on_replay_last() { + goto_replay(replay.length) +} + +function on_replay_step_prev() { + goto_replay(replay_this-1) +} + +function on_replay_step_next() { + goto_replay(replay_this+1) +} + +function on_replay_jump_prev() { + for (let i = replay_this - 1; i > 1; --i) + if (replay[i].is_checkpoint) + return goto_replay(i) + goto_replay(1) +} + +function on_replay_jump_next() { + for (let i = replay_this + 1; i < replay.length; ++i) + if (replay[i].is_checkpoint) + return goto_replay(i) + goto_replay(replay.length) +} + +let replay_timer = 0 +function on_replay_play_pause() { + if (replay_timer === 0) { + document.getElementById("replay_stop").classList.remove("hide") + document.getElementById("replay_play").classList.add("hide") + replay_timer = setInterval(() => { + if (replay_this < replay.length) + on_replay_step_next() + else + on_replay_play_pause() + }, 1000) + } else { + document.getElementById("replay_stop").classList.add("hide") + document.getElementById("replay_play").classList.remove("hide") + clearInterval(replay_timer) + replay_timer = 0 + } +} + +async function load_replay() { + document.getElementById("prompt").textContent = "Loading replay..." + + remove_resign_menu() + + console.log("LOADING RULES") + rules = await require("rules.js") + + console.log("LOADING REPLAY") + let response = await fetch("/api/replay/" + params.game_id) + if (!response.ok) { + let text = await response.text() + document.getElementById("prompt").textContent = "ERROR " + response.status + ": " + text + return + } + let body = await response.json() + replay = body.replay + + init_player_names(body.players) + + console.log("PROCESSING REPLAY") + let old_active = null + let s = null + for (let p = 0; p < replay.length; ++p) { + try { + s = eval_action(s, replay[p], p) + if (p + 1 < replay.length) + replay[p+1].is_checkpoint = (old_active !== s.active) + old_active = s.active + } catch (err) { + console.log("ERROR IN REPLAY", JSON.stringify(replay[p])) + console.log(err) + if (params.mode === "debug") + replay.length = p + else + replay.length = 0 + break + } + + if (params.mode !== "debug") { + replay[p].digest = crc32c(snap_from_state(s)) + for (let k = p - 1; k > 0; --k) { + if (replay[k].digest === replay[p].digest && !replay[k].remove) { + for (let a = k + 1; a <= p; ++a) + replay[a].remove = true + break + } + } + } + } + + replay = replay.filter(x => !x.remove) + + if (replay.length > 0) { + console.log("REPLAY READY") + if (window.location.hash === "") + set_hash(replay.length) + on_hash_change() + window.addEventListener("hashchange", on_hash_change) + document.querySelector("aside").appendChild(replay_panel) + } else { + console.log("REPLAY NOT AVAILABLE") + replay_state = body.state + update_replay_view() + } + + // Build viewpoint panel + let viewpoint_panel = document.createElement("div") + viewpoint_panel.id = "viewpoint_panel" + create_viewpoint_button(viewpoint_panel, "Active", "Active") + for (let r of roles) + create_viewpoint_button(viewpoint_panel, r.role, r.role) + create_viewpoint_button(viewpoint_panel, "Observer", "Observer") + document.querySelector("header").appendChild(viewpoint_panel) + + // Adjust replay panel + document.getElementById("replay_step_prev").classList.remove("hide") + document.getElementById("replay_step_next").classList.remove("hide") + document.getElementById("replay_last").classList.remove("hide") + + document.getElementById("replay_first").onclick = on_replay_first + document.getElementById("replay_prev").onclick = on_replay_jump_prev + document.getElementById("replay_step_prev").onclick = on_replay_step_prev + document.getElementById("replay_step_next").onclick = on_replay_step_next + document.getElementById("replay_next").onclick = on_replay_jump_next + document.getElementById("replay_last").onclick = on_replay_last + document.getElementById("replay_stop").onclick = on_replay_play_pause + document.getElementById("replay_play").onclick = on_replay_play_pause +} + +load_replay() + +})() diff --git a/public/images/gui_arrow_down.svg b/public/images/gui_arrow_down.svg new file mode 100644 index 0000000..b02c8ac --- /dev/null +++ b/public/images/gui_arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/gui_arrow_left.svg b/public/images/gui_arrow_left.svg new file mode 100644 index 0000000..cf11f54 --- /dev/null +++ b/public/images/gui_arrow_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/gui_arrow_right.svg b/public/images/gui_arrow_right.svg new file mode 100644 index 0000000..011f314 --- /dev/null +++ b/public/images/gui_arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/gui_arrow_right_stop.svg b/public/images/gui_arrow_right_stop.svg new file mode 100644 index 0000000..c97fe76 --- /dev/null +++ b/public/images/gui_arrow_right_stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/gui_arrow_up.svg b/public/images/gui_arrow_up.svg new file mode 100644 index 0000000..8e50d19 --- /dev/null +++ b/public/images/gui_arrow_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/gui_chevron_left.svg b/public/images/gui_chevron_left.svg new file mode 100644 index 0000000..512f417 --- /dev/null +++ b/public/images/gui_chevron_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/gui_chevron_right.svg b/public/images/gui_chevron_right.svg new file mode 100644 index 0000000..3a5bddc --- /dev/null +++ b/public/images/gui_chevron_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/gui_play.svg b/public/images/gui_play.svg new file mode 100644 index 0000000..20d917b --- /dev/null +++ b/public/images/gui_play.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/gui_stop.svg b/public/images/gui_stop.svg new file mode 100644 index 0000000..648de6e --- /dev/null +++ b/public/images/gui_stop.svg @@ -0,0 +1,3 @@ + + + -- cgit v1.2.3