diff options
Diffstat (limited to 'public')
-rw-r--r-- | public/common/play.css | 61 | ||||
-rw-r--r-- | public/common/play.js | 515 | ||||
-rw-r--r-- | public/common/replay.js | 342 | ||||
-rw-r--r-- | public/images/gui_arrow_down.svg | 3 | ||||
-rw-r--r-- | public/images/gui_arrow_left.svg | 3 | ||||
-rw-r--r-- | public/images/gui_arrow_right.svg | 3 | ||||
-rw-r--r-- | public/images/gui_arrow_right_stop.svg | 3 | ||||
-rw-r--r-- | public/images/gui_arrow_up.svg | 3 | ||||
-rw-r--r-- | public/images/gui_chevron_left.svg | 3 | ||||
-rw-r--r-- | public/images/gui_chevron_right.svg | 3 | ||||
-rw-r--r-- | public/images/gui_play.svg | 3 | ||||
-rw-r--r-- | public/images/gui_stop.svg | 3 |
12 files changed, 587 insertions, 358 deletions
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 = '<img src="/images/' + img + '.svg">' + 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 = '<img src="/images/' + img + '.svg">' - 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 2 8 L 8 14 L 14 8 M 8 2 L 8 14" /> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 8 14 L 2 8 L 8 2 M 2 8 L 14 8" /> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 8 14 L 14 8 L 8 2 M 2 8 L 14 8" /> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 8,14 14,8 8,2 M 4,8 14,8 M 15,1 15,15" /> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 2 8 L 8 2 L 14 8 M 8 2 L 8 14" /> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 10 14 L 4 8 L 10 2" /> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 6 14 L 12 8 L 6 2" /> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 12,8 l -10,6 0,-12z"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 5,2 v 12 M 11,2 v 12"/> +</svg> |