From 126ed8cadc9b5754869b934f34aa4ec46cf96a05 Mon Sep 17 00:00:00 2001 From: Tor Andersson Date: Tue, 17 Oct 2023 18:42:32 +0200 Subject: Rename common "play" files to "client". Avoids confusion in javascript console which file is which. --- public/common/client.css | 573 ++++++++++++++++++++++ public/common/client.js | 1218 ++++++++++++++++++++++++++++++++++++++++++++++ public/common/play.css | 573 ---------------------- public/common/play.js | 1218 ---------------------------------------------- 4 files changed, 1791 insertions(+), 1791 deletions(-) create mode 100644 public/common/client.css create mode 100644 public/common/client.js delete mode 100644 public/common/play.css delete mode 100644 public/common/play.js (limited to 'public') diff --git a/public/common/client.css b/public/common/client.css new file mode 100644 index 0000000..d943146 --- /dev/null +++ b/public/common/client.css @@ -0,0 +1,573 @@ +/* COMMON GRID LAYOUT */ + +html { + image-rendering: -webkit-optimize-contrast; /* try to fix chromium's terrible image rescaling */ +} + +html, button, input, select, textarea { + font-family: "Source Sans", "Circled Numbers", "Dingbats", "Noto Emoji", "Verdana", sans-serif; + font-size: 16px; +} + +#chat_text, #chat_input, #notepad_input { + font-family: "Source Serif", "Circled Numbers", "Dingbats", "Noto Emoji", "Georgia", serif; +} + +#log, #turn_info { + font-family: "Source Serif SmText", "Circled Numbers", "Dingbats", "Noto Emoji", "Georgia", serif; +} + +.hide { + display: none; +} + +body:not(.shift) .debug { + display: none; +} + +body.Observer .resign { + display: none; +} + +.action { + cursor: pointer; +} + +/* BUTTON */ + +button { + box-sizing: border-box; + font-size: 16px; + height: 28px; + padding: 1px 12px 1px 12px; + background-color: gainsboro; + border: 2px solid; + outline: 1px solid black; + white-space: nowrap; +} + +button:disabled { + color: gray; + border-color: gainsboro; + outline-color: gray; +} + +button:enabled { + border-color: white darkgray darkgray white; +} + +button:enabled:active:hover { + border-color: darkgray white white darkgray; + padding: 2px 11px 0px 13px; +} + +/* MAIN GRID */ + +body { + margin: 0; + padding: 0; + display: grid; + overflow: clip; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr) auto; + width: 100dvw; + height: 100dvh; +} + +header { + grid-column: 1/3; + grid-row: 1; +} + +main { + position: relative; + grid-column: 1; + grid-row: 2/4; + overflow: auto; + scrollbar-width: none; +} + +aside { + grid-column: 2; + grid-row: 2; + display: grid; + overflow: clip; + grid-template-rows: auto minmax(0, 1fr); + width: 212px; + border-left: 1px solid black; +} + +#roles { + grid-column: 1; + grid-row: 1; +} + +#turn_info { + border-bottom: 1px solid black; + padding: 4px 8px; + white-space: pre-line; + font-style: italic; + font-size: 12px; + line-height: 18px; +} + +#log { + grid-column: 1; + grid-row: 2; + overflow-y: scroll; +} + +#log { + padding: 12px 0; + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; +} + +#log > * { + padding: 0 4px 0 8px; + min-height: 9px; +} + +footer { + position: fixed; + pointer-events: none; + z-index: 500; + bottom: 0; + background-color: white; + padding: 0 8px; +} + +/* MENU */ + +.menu { + user-select: none; +} +.menu_item img { + vertical-align: top; + height: 20px; +} +.menu_title img { + display: block; + height: 36px; + padding: 4px; +} +.menu:hover .menu_title { + background-color: black; + color: white; +} +.menu:hover .menu_title img { + filter: invert(100%); +} +.menu_popup { + display: none; + position: absolute; + min-width: 160px; + white-space: nowrap; + border: 1px solid black; + background-color: white; + z-index: 501; +} +.menu:hover .menu_popup { + display: block; +} +.menu_separator { + border-top: 1px solid black; +} +.menu_item { + padding: 4px 8px; + cursor: pointer; +} +.menu_item:hover { + background-color: black; + color: white; +} +.menu_item:hover img { + filter: invert(100%); +} +a.menu_item { + display: block; + text-decoration: none; + color: black; +} +.menu_item.disabled { + color: gray; +} + +/* TOOL BAR */ + +.icon_button { + user-select: none; +} +.icon_button img { + display: block; + height: 36px; + padding: 4px; +} +.icon_button:hover { + background-color: black; + color: white; +} +.icon_button:hover img { + filter: invert(100%); +} + +header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 8px; + border-bottom: 1px solid black; + background-color: gainsboro; +} + +#toolbar { + display: flex; +} + +header.disconnected { + background-color: red !important; +} + +header.your_turn { + background-color: orange; +} + +header.replay { + background-image: repeating-linear-gradient(45deg, gainsboro, gainsboro 40px, silver 40px, silver 80px); +} + +#actions, #viewpoint_panel { + display: flex; + justify-content: end; + align-items: center; +} + +#actions { + flex-wrap: wrap; + padding: 4px 8px; + gap: 8px; +} + +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: 12px; + font-size: 18px; + flex: 1 1 200px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +#replay_panel { + grid-column: 2; + grid-row: 3; + display: flex; + height: 24px; + border-top: 1px solid black; + border-left: 1px solid black; + background-color: silver; +} + +.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: #fffc; +} + +.replay_button:hover:active { + background-color: #fff8; +} + +#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 { + border-bottom: 1px solid black; +} + +.role_name { + border-bottom: 1px solid black; + padding-top: 3px; + padding-bottom: 3px; + padding-left: 4px; + padding-right: 4px; +} + +.role_user { + font-style: italic; + text-align: right; +} + +.role_user a { + text-decoration: none; + color: black; +} + +.role_user a:hover { + text-decoration: underline; +} + +.role_name::before { + content: "\25cb "; + opacity: 0.6; +} + +.role.present .role_name::before { + content: "\25cf "; +} + +/* MAP */ + +#mapwrap { + position: relative; + margin: 0 auto; +} + +#mapwrap.fit { + max-width: 100%; +} + +#mapwrap #map { + position: absolute; + isolation: isolate; + transform-origin: 0 0; +} + +/* CHAT WINDOW */ + +#chat_button.new { + filter: invert(100%); +} + +#chat_window { + left: 24px; + top: 68px; + width: 640px; + z-index: 499; +} + +#notepad_window { + left: 60px; + top: 200px; + height: auto; + z-index: 498; +} + +#chat_window, #notepad_window { + position: fixed; + display: grid; + grid-template-rows: min-content 1fr min-content; + grid-template-columns: 1fr 30px; + border: 1px solid black; + background-color: white; + box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.5); + visibility: hidden; +} + +#chat_window.show, #notepad_window.show { + visibility: visible; +} + +#chat_header, #notepad_header { + grid-row: 1; + grid-column: 1/3; + user-select: none; + cursor: move; + background-color: gainsboro; + border-bottom: 1px solid black; + padding: 4px 8px; +} + +#chat_x, #notepad_x { + grid-row: 1; + grid-column: 2; + user-select: none; + cursor: pointer; + margin: 5px 5px; + height: 24px; + text-align: right; +} + +#chat_x:hover, #notepad_x:hover { + background-color: black; + color: white; +} + +#chat_text, #notepad_input { + grid-row: 2; + grid-column: 1/3; + margin: 0; + font-size: 16px; + line-height: 24px; + min-height: 216px; + padding: 0px 4px; + overflow-y: scroll; +} + +#chat_text .date { + font-weight: bold; +} + +#chat_form { + grid-row: 3; + grid-column: 1/3; + margin: 0; + padding: 0; + border-top: 1px solid black; +} + +#chat_input { + box-sizing: border-box; + width: 100%; + outline: none; + border: none; + padding: 4px; + font-size: 16px; +} + +#notepad_input { + outline: none; + border: none; + resize: none; +} + +#notepad_footer { + grid-row: 3; + grid-column: 1/3; + display: flex; + justify-content: end; + padding: 8px; + background-color: gainsboro; + border-top: 1px solid black; +} + +/* MOBILE PHONE LAYOUT */ + +#fullscreen_button { + display: none; +} + +@media (pointer: coarse) and (max-width: 400px) { + #fullscreen_button { display: block; } + #zoom_button { display: none; } +} + +@media (pointer: coarse) { + #replay_panel, .replay_button { + height: 36px; + } +} + +@media (max-height: 600px) { + .role_name:not(:hover) .role_user { + display: none; + } +} + +@media (max-width: 800px) { + body { + grid-template-columns: 1fr min-content; + grid-template-rows: min-content 1fr min-content; + } + header { + grid-column: 1/3; + grid-row: 1; + } + main { + grid-column: 1; + grid-row: 2; + } + aside { + grid-column: 2; + grid-row: 2/4; + } + footer { + background-color: #fffc; + } + #replay_panel { + grid-column: 1; + grid-row: 3; + border-left: none; + z-index: 496; + } + header { + display: grid; + grid-template-columns: min-content auto; + } + #toolbar { + grid-row: 1; + grid-column: 1; + } + #prompt { + grid-row: 2; + grid-column: 1/3; + padding: 0 8px 3px 8px; + } + #prompt:hover { + white-space: normal; + } + #actions { + grid-row: 1; + grid-column: 2; + } + + #chat_window, #notepad_window { + position: static; + grid-column: 1; + grid-row: 2; + display: none; + width: auto; + box-shadow: none; + border: none; + } + #chat_window.show, #notepad_window.show { + display: grid; + } +} + +@media (max-width: 800px) { footer { bottom: 25px } } +@media (pointer: coarse) and (max-width: 800px) { footer { bottom: 37px } } + +@media (max-width: 400px) { + body { + grid-template-columns: 1fr; + } + header { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + } + aside { + grid-column: 1; + grid-row: 2/3; + z-index: 497; + width: 100vw; + border: none; + } + #toolbar { grid-row: 1; grid-column: 1; } + #actions { grid-row: 2; grid-column: 1; } + #prompt { grid-row: 3; grid-column: 1; } +} diff --git a/public/common/client.js b/public/common/client.js new file mode 100644 index 0000000..8aa1f65 --- /dev/null +++ b/public/common/client.js @@ -0,0 +1,1218 @@ +"use strict" + +let params = { + mode: "play", + title_id: window.location.pathname.split("/")[1], + game_id: 0, + role: "Observer", +} + +function init_params() { + let search = new URLSearchParams(window.location.search) + params.game_id = search.get("game") + params.role = search.get("role") || "Observer" + params.mode = search.get("mode") || "play" +} + +init_params() + +let roles = Array.from(document.querySelectorAll(".role")).map(x=>({id:x.id,role:x.id.replace(/^role_/,"").replace(/_/g," ")})) + +let view = null +let player = "Observer" +let socket = null +let chat = null + +let game_log = [] +let game_cookie = 0 + +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 + if (!multiplier) + multiplier = 1 + function md(e) { + if (e.button === 1) { + down_x = e.clientX + down_y = e.clientY + scroll_x = panel.scrollLeft + scroll_y = panel.scrollTop + window.addEventListener("mousemove", mm) + window.addEventListener("mouseup", mu) + e.preventDefault() + } + } + function mm(e) { + let dx = down_x - e.clientX + let dy = down_y - e.clientY + panel.scrollLeft = scroll_x + dx * multiplier + panel.scrollTop = scroll_y + dy * multiplier + e.preventDefault() + } + function mu(e) { + if (e.button === 1) { + window.removeEventListener("mousemove", mm) + window.removeEventListener("mouseup", mu) + e.preventDefault() + } + } + panel.addEventListener("mousedown", md) +} + +function drag_element_with_mouse(element_sel, grabber_sel) { + let element = document.querySelector(element_sel) + let grabber = document.querySelector(grabber_sel) || element + let save_x, save_y + function md(e) { + if (e.button === 0) { + save_x = e.clientX + save_y = e.clientY + window.addEventListener("mousemove", mm) + window.addEventListener("mouseup", mu) + e.preventDefault() + } + } + function mm(e) { + let dx = save_x - e.clientX + let dy = save_y - e.clientY + save_x = e.clientX + save_y = e.clientY + element.style.left = (element.offsetLeft - dx) + "px" + element.style.top = (element.offsetTop - dy) + "px" + e.preventDefault() + } + function mu(e) { + if (e.button === 0) { + window.removeEventListener("mousemove", mm) + window.removeEventListener("mouseup", mu) + e.preventDefault() + } + } + grabber.addEventListener("mousedown", md) +} + +/* TITLE BLINKER */ + +let blink_title = document.title +let blink_timer = 0 + +function start_blinker(message) { + let tick = false + if (blink_timer) + stop_blinker() + if (!document.hasFocus()) { + document.title = message + blink_timer = setInterval(function () { + document.title = tick ? message : blink_title + tick = !tick + }, 1000) + } +} + +function stop_blinker() { + document.title = blink_title + clearInterval(blink_timer) + blink_timer = 0 +} + +window.addEventListener("focus", stop_blinker) + +/* CHAT */ + +function init_chat() { + // only fetch new messages when we reconnect! + if (chat !== null) { + send_message("getchat", chat.log) + return + } + + let chat_window = document.createElement("div") + chat_window.id = "chat_window" + chat_window.innerHTML = ` +
Chat
+
\u274c
+
+
+ ` + document.querySelector("body").appendChild(chat_window) + + let chat_button = document.getElementById("chat_button") + chat_button.classList.remove("hide") + + chat = { + is_visible: false, + text_element: document.getElementById("chat_text"), + key: "chat/" + params.game_id, + last_day: null, + log: 0 + } + + chat.seen = window.localStorage.getItem(chat.key) | 0 + + drag_element_with_mouse("#chat_window", "#chat_header") + + document.getElementById("chat_form").addEventListener("submit", e => { + let input = document.getElementById("chat_input") + e.preventDefault() + if (input.value) { + send_message("chat", input.value) + input.value = "" + } else { + hide_chat() + } + }) + + document.querySelector("body").addEventListener("keydown", e => { + if (e.key === "Escape") { + if (chat.is_visible) { + e.preventDefault() + hide_chat() + } + } + if (e.key === "Enter") { + let chat_input = document.getElementById("chat_input") + let notepad_input = document.getElementById("notepad_input") + if (document.activeElement !== chat_input && document.activeElement !== notepad_input) { + e.preventDefault() + show_chat() + } + } + }) + + send_message("getchat", 0) +} + +function save_chat() { + window.localStorage.setItem(chat.key, chat.log) +} + +function update_chat(chat_id, raw_date, user, message) { + function format_time(date) { + let mm = date.getMinutes() + let hh = date.getHours() + if (mm < 10) mm = "0" + mm + if (hh < 10) hh = "0" + hh + return hh + ":" + mm + } + function add_date_line(date) { + let line = document.createElement("div") + line.className = "date" + line.textContent = "~ " + date + " ~" + chat.text_element.appendChild(line) + } + function add_chat_line(time, user, message) { + let line = document.createElement("div") + line.textContent = "[" + time + "] " + user + " \xbb " + message + chat.text_element.appendChild(line) + chat.text_element.scrollTop = chat.text_element.scrollHeight + } + if (chat_id > chat.log) { + chat.log = chat_id + let date = new Date(raw_date * 1000) + let day = date.toDateString() + if (day !== chat.last_day) { + add_date_line(day) + chat.last_day = day + } + add_chat_line(format_time(date), user, message) + } + if (chat_id > chat.seen) { + let button = document.getElementById("chat_button") + start_blinker("NEW MESSAGE") + if (!chat.is_visible) + button.classList.add("new") + else + save_chat() + } +} + +function show_chat() { + if (!chat.is_visible) { + document.getElementById("chat_button").classList.remove("new") + document.getElementById("chat_window").classList.add("show") + document.getElementById("chat_input").focus() + chat.is_visible = true + save_chat() + } +} + +function hide_chat() { + if (chat.is_visible) { + document.getElementById("chat_window").classList.remove("show") + document.getElementById("chat_input").blur() + chat.is_visible = false + } +} + +function toggle_chat() { + if (chat.is_visible) + hide_chat() + else + show_chat() +} + +/* NOTEPAD */ + +let notepad = null + +function init_notepad() { + if (notepad !== null) + return + + add_main_menu_item("Notepad", toggle_notepad) + + let notepad_window = document.createElement("div") + notepad_window.id = "notepad_window" + notepad_window.innerHTML = ` +
Notepad: ${player}
+
\u274c
+ + + ` + document.querySelector("body").appendChild(notepad_window) + + notepad = { + is_visible: false, + is_dirty: false, + } + + drag_element_with_mouse("#notepad_window", "#notepad_header") +} + +function dirty_notepad() { + if (!notepad.is_dirty) { + notepad.is_dirty = true + document.getElementById("notepad_save").disabled = false + } +} + +function save_notepad() { + if (notepad.is_dirty) { + let text = document.getElementById("notepad_input").value + send_message("putnote", text) + notepad.is_dirty = false + document.getElementById("notepad_save").disabled = true + } +} + +function load_notepad() { + send_message("getnote") +} + +function update_notepad(text) { + document.getElementById("notepad_input").value = text +} + +function show_notepad() { + if (!notepad.is_visible) { + load_notepad() + document.getElementById("notepad_window").classList.add("show") + document.getElementById("notepad_input").focus() + notepad.is_visible = true + } +} + +function hide_notepad() { + if (notepad.is_visible) { + save_notepad() + document.getElementById("notepad_window").classList.remove("show") + document.getElementById("notepad_input").blur() + notepad.is_visible = false + } +} + +function toggle_notepad() { + if (notepad.is_visible) + hide_notepad() + else + show_notepad() +} + +window.addEventListener("keydown", (evt) => { + if (document.activeElement === document.getElementById("chat_input")) + return + if (document.activeElement === document.getElementById("notepad_input")) + return + if (evt.key === "Shift") + document.querySelector("body").classList.add("shift") +}) + +window.addEventListener("keyup", (evt) => { + if (evt.key === "Shift") + document.querySelector("body").classList.remove("shift") +}) + +/* REMATCH BUTTON */ + +function add_icon_button(where, 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) + if (where) + document.getElementById("toolbar").appendChild(button) + else + document.querySelector(".menu").after(button) + } + return button +} + +function remove_resign_menu() { + document.querySelectorAll(".resign").forEach(x => x.remove()) +} + +function goto_rematch() { + window.location = "/rematch/" + params.game_id +} + +function goto_replay() { + let search = new URLSearchParams(window.location.search) + search.delete("role") + search.set("mode", "replay") + window.location.search = search +} + +function on_game_over() { + add_icon_button(1, "replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay) + if (player !== "Observer") + add_icon_button(1, "rematch_button", "cycle", "Propose a rematch!", goto_rematch) + remove_resign_menu() +} + +/* CONNECT TO GAME SERVER */ + +function init_player_names(players) { + for (let i = 0; i < roles.length; ++i) { + let p = players.find(p => p.role === roles[i].role) + if (p) + document.getElementById(roles[i].id).querySelector(".role_user").innerHTML = `${p.name}` + else + document.getElementById(roles[i].id).querySelector(".role_user").textContent = "NONE" + } +} + +function send_message(cmd, arg) { + let data = JSON.stringify([ cmd, arg ]) + console.log("SEND %s %s", cmd, arg) + socket.send(data) +} + +let reconnect_count = 0 +let reconnect_max = 10 + +function connect_play() { + if (reconnect_count >= reconnect_max) { + document.title = "DISCONNECTED" + document.getElementById("prompt").textContent = "Disconnected." + return + } + + let protocol = (window.location.protocol === "http:") ? "ws" : "wss" + let seen = document.getElementById("log").children.length + let url = `${protocol}://${window.location.host}/play-socket?title=${params.title_id}&game=${params.game_id}&role=${encodeURIComponent(params.role)}&seen=${seen}` + + console.log("CONNECTING", url) + document.getElementById("prompt").textContent = "Connecting... " + + socket = new WebSocket(url) + + window.addEventListener("beforeunload", function () { + socket.close(1000) + }) + + socket.onopen = function (evt) { + console.log("OPEN") + document.querySelector("header").classList.remove("disconnected") + reconnect_count = 0 + } + + socket.onclose = function (evt) { + console.log("CLOSE %d", evt.code) + game_cookie = 0 + if (evt.code === 1000 && evt.reason !== "") { + document.getElementById("prompt").textContent = "Disconnected: " + evt.reason + document.title = "DISCONNECTED" + } + if (evt.code !== 1000) { + document.querySelector("header").classList.add("disconnected") + document.getElementById("prompt").textContent = `Reconnecting soon... (${reconnect_count+1}/${reconnect_max})` + let wait = 1000 * (Math.random() + 0.5) * Math.pow(2, reconnect_count++) + console.log("WAITING %.1f TO RECONNECT", wait/1000) + setTimeout(connect_play, wait) + } + } + + socket.onmessage = function (evt) { + let msg_data = JSON.parse(evt.data) + let cmd = msg_data[0] + let arg = msg_data[1] + console.log("MESSAGE", cmd) + switch (cmd) { + case "warning": + document.getElementById("prompt").textContent = arg + document.querySelector("header").classList.add("disconnected") + setTimeout(() => { + document.querySelector("header").classList.remove("disconnected") + on_update_header() + }, 1000) + break + + case "error": + document.getElementById("prompt").textContent = arg + if (view) { + view.actions = null + on_update() + } + break + + case "chat": + update_chat(arg[0], arg[1], arg[2], arg[3]) + break + + case "note": + update_notepad(arg) + break + + case "players": + player = arg[0] + document.querySelector("body").classList.add(player.replace(/ /g, "_")) + if (player !== "Observer") { + init_chat() + init_notepad() + } else { + remove_resign_menu() + } + 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)) + } + } + break + + case "state": + game_cookie = msg_data[2] + + if (snap_view) + on_snap_stop() + + view = arg + + game_log.length = view.log_start + for (let line of view.log) + game_log.push(line) + + on_update_header() + 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 "snapsize": + snap_count = arg + if (snap_count === 0) + replay_panel.remove() + else + document.querySelector("body").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": + window.localStorage[params.title_id + "/save"] = arg + break + } + } +} + +/* HEADER */ + +let is_your_turn = false +let old_active = null + +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) + start_blinker("YOUR TURN") + is_your_turn = true + } else { + document.querySelector("header").classList.remove("your_turn") + is_your_turn = false + } + old_active = view.active +} + +/* LOG */ + +function on_update_log(change_start, end) { + let div = document.getElementById("log") + + let to_delete = div.children.length - change_start + while (to_delete-- > 0) + div.removeChild(div.lastChild) + + for (let i = div.children.length; i < end; ++i) { + let text = game_log[i] + if (params.mode === "debug" && typeof text === "object") { + let entry = document.createElement("a") + entry.href = "#" + text[0] + if (text[3] !== null) + entry.textContent = "\u25b6 " + text[1] + " " + text[2] + " " + text[3] + else + entry.textContent = "\u25b6 " + text[1] + " " + text[2] + entry.style.display = "block" + entry.style.textDecoration = "none" + div.appendChild(entry) + } else if (typeof on_log === "function") { + div.appendChild(on_log(text)) + } else { + let entry = document.createElement("div") + entry.textContent = text + div.appendChild(entry) + } + } + scroll_log_to_end() +} + +function scroll_log_to_end() { + let div = document.getElementById("log") + div.scrollTop = div.scrollHeight +} + +try { + new ResizeObserver(scroll_log_to_end).observe(document.getElementById("log")) +} catch (err) { + window.addEventListener("resize", scroll_log_to_end) +} + +/* ACTIONS */ + +function action_button_imp(action, label, callback) { + if (params.mode === "replay") + return + let id = action + "_button" + let button = document.getElementById(id) + if (!button) { + button = document.createElement("button") + button.id = id + button.textContent = label + button.addEventListener("click", callback) + document.getElementById("actions").appendChild(button) + } + if (view.actions && action in view.actions) { + button.classList.remove("hide") + if (view.actions[action]) { + if (label === undefined) + button.textContent = view.actions[action] + button.disabled = false + } else { + button.disabled = true + } + } else { + button.classList.add("hide") + } +} + +function action_button(action, label) { + action_button_imp(action, label, evt => send_action(action)) +} + +function confirm_action_button(action, label, message) { + action_button_imp(action, label, evt => confirm_action(message, action)) +} + +function send_action(verb, noun) { + if (params.mode === "replay" || params.mode === "debug") + return false + // Reset action list here so we don't send more than one action per server prompt! + if (noun !== undefined) { + 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, game_cookie ]) + return true + } + } else { + if (view.actions && view.actions[verb]) { + view.actions = null + send_message("action", [ verb, null, game_cookie ]) + return true + } + } + return false +} + +function confirm_action(message, verb, noun) { + if (window.confirm(message)) + send_action(verb, noun) +} + +function send_query(q, param) { + 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() { + if (window.confirm("Are you sure that you want to resign?")) + send_message("resign") +} + +function send_save() { + send_message("save") +} + +function send_restore() { + send_message("restore", window.localStorage[params.title_id + "/save"]) +} + +/* REPLAY */ + +function init_replay() { + let script = document.createElement("script") + script.src = "/common/replay.js" + document.body.appendChild(script) +} + +window.addEventListener("load", function () { + zoom_map() + if (params.mode === "debug") + init_replay() + else if (params.mode === "replay") + init_replay() + else if (params.mode === "play") + connect_play() + else + document.getElementById("prompt").textContent = "Invalid mode: " + params.mode +}) + +/* MAIN MENU */ + +add_icon_button(0, "chat_button", "chat-bubble", "Open chat", toggle_chat).classList.add("hide") +add_icon_button(0, "zoom_button", "magnifying-glass", "Zoom", () => toggle_zoom()) +add_icon_button(0, "log_button", "scroll-quill", "Hide log", toggle_log) +add_icon_button(0, "fullscreen_button", "expand", "Fullscreen", toggle_fullscreen) + +function init_main_menu() { + let popup = document.querySelector(".menu_popup") + let sep = document.createElement("div") + sep.className = "menu_separator" + sep.id = "main_menu_separator" + popup.insertBefore(sep, popup.firstChild) +} + +function add_main_menu_item(text, onclick) { + let popup = document.querySelector(".menu_popup") + let sep = document.getElementById("main_menu_separator") + let item = document.createElement("div") + item.className = "menu_item" + item.onclick = onclick + item.textContent = text + popup.insertBefore(item, sep) +} + +function add_main_menu_item_link(text, url) { + let popup = document.querySelector(".menu_popup") + let sep = document.getElementById("main_menu_separator") + let item = document.createElement("a") + item.className = "menu_item" + item.href = url + item.textContent = text + popup.insertBefore(item, sep) +} + +init_main_menu() +if (params.mode === "play" && params.role !== "Observer") { + add_main_menu_item_link("Go home", "/games/active") + add_main_menu_item_link("Go to next game", "/games/next") +} else { + add_main_menu_item_link("Go home", "/") +} + +function toggle_fullscreen() { + if (document.fullscreenElement) + document.exitFullscreen() + else + document.documentElement.requestFullscreen() +} + +/* 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) + } +} + +/* TOGGLE ZOOM MAP TO FIT */ + +function toggle_log() { + document.querySelector("aside").classList.toggle("hide") + zoom_map() +} + +var toggle_zoom = function () {} + +function zoom_map() { + let mapwrap = document.getElementById("mapwrap") + if (mapwrap) { + let main = document.querySelector("main") + let map = document.getElementById("map") + map.style.transform = null + mapwrap.style.width = null + mapwrap.style.height = null + if (mapwrap.classList.contains("fit")) { + let { width: gw, height: gh } = main.getBoundingClientRect() + let { width: ww, height: wh } = mapwrap.getBoundingClientRect() + let { width: cw, height: ch } = map.getBoundingClientRect() + let scale = Math.min(ww / cw, gh / ch) + if (scale < 1) { + map.style.transform = "scale(" + scale + ")" + mapwrap.style.width = (cw * scale) + "px" + mapwrap.style.height = (ch * scale) + "px" + } + } + } +} + +window.addEventListener("resize", zoom_map) + +/* PAN & ZOOM GAME BOARD */ + +;(function panzoom_init() { + const MIN_ZOOM = 0.5 + const MAX_ZOOM = 1.5 + const THRESHOLD = 0.0625 + const DECELERATION = 125 + + console.log("DPX", window.devicePixelRatio) + + const e_scroll = document.querySelector("main") + e_scroll.style.touchAction = "none" + + const e_inner = document.createElement("div") + e_inner.id = "pan_zoom_main" + e_inner.style.transformOrigin = "0 0" + e_inner.style.height = "120px" + while (e_scroll.firstChild) + e_inner.appendChild(e_scroll.firstChild) + + const e_outer = document.createElement("div") + e_outer.id = "pan_zoom_wrap" + e_outer.style.height = "120px" + e_outer.appendChild(e_inner) + + e_scroll.appendChild(e_outer) + + const mapwrap = document.getElementById("mapwrap") + const map = document.getElementById("map") || e_inner.firstChild + const map_w = mapwrap ? mapwrap.clientWidth : map.clientWidth + const map_h = mapwrap ? mapwrap.clientHeight : map.clientHeight + + console.log("MAP", map_w, map_h) + + var transform0 = { x: 0, y: 0, scale: 1 } + var transform1 = { x: 0, y: 0, scale: 1 } + var old_scale = 1 + + // touch finger tracking + var last_touch_x = {} + var last_touch_y = {} + var last_touch_length = 0 + + // momentum velocity tracking + var mom_last_t = null + var mom_last_x = null + var mom_last_y = null + + // momentum auto-scroll + var timer = 0 + var mom_time = 0 + var mom_vx = 0 + var mom_vy = 0 + + try { + new ResizeObserver(update_transform_resize).observe(document.getElementById("log")) + } catch (err) { + window.addEventListener("resize", function (evt) { + old_scale = 0 + update_transform() + }) + } + + function clamp_scale(scale) { + let win_w = e_scroll.clientWidth + let win_h = e_scroll.clientHeight + let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) + if (scale * transform0.scale > MAX_ZOOM) + scale = MAX_ZOOM / transform0.scale + if (scale * transform0.scale < real_min_zoom) + scale = real_min_zoom / transform0.scale + return scale + } + + function anchor_transform(touches) { + // in case it changed from outside + transform1.x = -e_scroll.scrollLeft + transform1.y = -e_scroll.scrollTop + + transform0.scale = transform1.scale + transform0.x = transform1.x + transform0.y = transform1.y + if (touches) { + for (let touch of touches) { + last_touch_x[touch.identifier] = touch.clientX + last_touch_y[touch.identifier] = touch.clientY + } + last_touch_length = touches.length + } else { + last_touch_length = 0 + } + } + + function toggle_zoom_imp() { + if (transform1.scale === 1) { + if (window.innerWidth >= 800) { + if (mapwrap) { + mapwrap.classList.toggle("fit") + zoom_map() + return + } + } + let win_w = e_scroll.clientWidth + let win_h = e_scroll.clientHeight + let min_z = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) + zoom_to(min_z) + } else { + zoom_to(1) + } + return false + } + + function disable_map_fit() { + if (mapwrap && mapwrap.classList.contains("fit")) { + mapwrap.classList.remove("fit") + zoom_map() + } + } + + function zoom_to(new_scale) { + let cx = e_scroll.clientWidth / 2 + let cy = 0 + + // in case changed from outside + transform1.x = -e_scroll.scrollLeft + transform1.y = -e_scroll.scrollTop + + transform1.x -= cx + transform1.y -= cy + transform1.x *= new_scale / transform1.scale + transform1.y *= new_scale / transform1.scale + transform1.scale = new_scale + transform1.x += cx + transform1.y += cy + + update_transform() + } + + function update_transform() { + let win_w = e_scroll.clientWidth + let win_h = e_scroll.clientHeight + + // clamp zoom + let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) + transform1.scale = Math.max(real_min_zoom, Math.min(MAX_ZOOM, transform1.scale)) + + e_scroll.scrollLeft = -transform1.x + e_scroll.scrollTop = -transform1.y + + if (transform1.scale !== old_scale) { + if (transform1.scale === 1) { + e_inner.style.transform = null + } else { + e_inner.style.transform = `scale(${transform1.scale})` + disable_map_fit() + } + e_inner.style.width = (win_w / transform1.scale) + "px" + e_outer.style.width = (e_inner.clientWidth * transform1.scale) + "px" + old_scale = transform1.scale + } + } + + function start_measure(time) { + mom_last_t = [ time, time, time ] + mom_last_x = [ transform1.x, transform1.x, transform1.x ] + mom_last_y = [ transform1.y, transform1.y, transform1.y ] + } + + function abort_measure(time) { + mom_last_t = mom_last_x = mom_last_y = null + } + + function move_measure(time) { + if (mom_last_t) { + mom_last_t[0] = time + mom_last_x[0] = transform1.x + mom_last_y[0] = transform1.y + if (mom_last_t[0] - mom_last_t[1] > 15) { + mom_last_t[2] = mom_last_t[1] + mom_last_x[2] = mom_last_x[1] + mom_last_y[2] = mom_last_y[1] + mom_last_t[1] = mom_last_t[0] + mom_last_x[1] = mom_last_x[0] + mom_last_y[1] = mom_last_y[0] + } + } + } + + function start_momentum() { + if (mom_last_t) { + let dt = mom_last_t[0] - mom_last_t[2] + if (dt > 5) { + mom_time = Date.now() + mom_vx = (mom_last_x[0] - mom_last_x[2]) / dt + mom_vy = (mom_last_y[0] - mom_last_y[2]) / dt + if (Math.hypot(mom_vx, mom_vy) < THRESHOLD) + mom_vx = mom_vy = 0 + if (mom_vx || mom_vy) + timer = requestAnimationFrame(update_momentum) + } + } + } + + function stop_momentum() { + cancelAnimationFrame(timer) + timer = 0 + } + + function update_momentum() { + var now = Date.now() + var dt = now - mom_time + mom_time = now + + transform1.x = transform1.x + mom_vx * dt + transform1.y = transform1.y + mom_vy * dt + update_transform() + + var decay = Math.pow(0.5, dt / DECELERATION) + mom_vx *= decay + mom_vy *= decay + + if (Math.hypot(mom_vx, mom_vy) < THRESHOLD) + mom_vx = mom_vy = 0 + + if (mom_vx || mom_vy) + timer = requestAnimationFrame(update_momentum) + } + + e_scroll.ontouchstart = function (evt) { + anchor_transform(evt.touches) + stop_momentum() + start_measure(evt.timeStamp) + } + + e_scroll.ontouchend = function (evt) { + anchor_transform(evt.touches) + if (evt.touches.length === 0) + start_momentum() + } + + e_scroll.ontouchmove = function (evt) { + if (evt.touches.length !== last_touch_length) + anchor_transform(evt.touches) + + if (evt.touches.length === 1 || evt.touches.length === 2) { + let a = evt.touches[0] + + let dx = a.clientX - last_touch_x[a.identifier] + let dy = a.clientY - last_touch_y[a.identifier] + + transform1.scale = transform0.scale + transform1.x = transform0.x + dx + transform1.y = transform0.y + dy + + if (evt.touches.length === 1) + move_measure(evt.timeStamp) + else + abort_measure() + + // zoom + if (evt.touches.length === 2) { + let b = evt.touches[1] + + let old_x = last_touch_x[a.identifier] - last_touch_x[b.identifier] + let old_y = last_touch_y[a.identifier] - last_touch_y[b.identifier] + let old = Math.sqrt(old_x * old_x + old_y * old_y) + + let cur_x = a.clientX - b.clientX + let cur_y = a.clientY - b.clientY + let cur = Math.sqrt(cur_x * cur_x + cur_y * cur_y) + + let scale = clamp_scale(cur / old) + + let cx = a.clientX + let cy = a.clientY + + transform1.x -= cx + transform1.y -= cy + + transform1.scale *= scale + transform1.x *= scale + transform1.y *= scale + + transform1.x += cx + transform1.y += cy + } + + update_transform() + } + } + + e_scroll.addEventListener( + "wheel", + function (evt) { + if (evt.ctrlKey) { + anchor_transform(null) + + let win_w = e_scroll.clientWidth + let win_h = e_scroll.clientHeight + let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) + + // one "click" of 120 units -> 10% change + let new_scale = Math.max(real_min_zoom, Math.min(MAX_ZOOM, transform1.scale + event.wheelDeltaY / 1200)) + + // snap to 1 if close + console.log("WHEEL ", new_scale, Math.abs(event.wheelDeltaY / 2400)) + if (Math.abs(1 - new_scale) < Math.abs(event.wheelDeltaY / 2400)) { + console.log("SNAP TO 1 ", Math.abs(event.wheelDeltaY / 2400)) + new_scale = 1 + } + + transform1.x -= event.clientX + transform1.y -= event.clientY + + transform1.x *= new_scale / transform1.scale + transform1.y *= new_scale / transform1.scale + transform1.scale = new_scale + + transform1.x += event.clientX + transform1.y += event.clientY + + update_transform() + evt.preventDefault() + } + }, + { passive: false } + ) + + toggle_zoom = toggle_zoom_imp +})() diff --git a/public/common/play.css b/public/common/play.css deleted file mode 100644 index d943146..0000000 --- a/public/common/play.css +++ /dev/null @@ -1,573 +0,0 @@ -/* COMMON GRID LAYOUT */ - -html { - image-rendering: -webkit-optimize-contrast; /* try to fix chromium's terrible image rescaling */ -} - -html, button, input, select, textarea { - font-family: "Source Sans", "Circled Numbers", "Dingbats", "Noto Emoji", "Verdana", sans-serif; - font-size: 16px; -} - -#chat_text, #chat_input, #notepad_input { - font-family: "Source Serif", "Circled Numbers", "Dingbats", "Noto Emoji", "Georgia", serif; -} - -#log, #turn_info { - font-family: "Source Serif SmText", "Circled Numbers", "Dingbats", "Noto Emoji", "Georgia", serif; -} - -.hide { - display: none; -} - -body:not(.shift) .debug { - display: none; -} - -body.Observer .resign { - display: none; -} - -.action { - cursor: pointer; -} - -/* BUTTON */ - -button { - box-sizing: border-box; - font-size: 16px; - height: 28px; - padding: 1px 12px 1px 12px; - background-color: gainsboro; - border: 2px solid; - outline: 1px solid black; - white-space: nowrap; -} - -button:disabled { - color: gray; - border-color: gainsboro; - outline-color: gray; -} - -button:enabled { - border-color: white darkgray darkgray white; -} - -button:enabled:active:hover { - border-color: darkgray white white darkgray; - padding: 2px 11px 0px 13px; -} - -/* MAIN GRID */ - -body { - margin: 0; - padding: 0; - display: grid; - overflow: clip; - grid-template-columns: minmax(0, 1fr) auto; - grid-template-rows: auto minmax(0, 1fr) auto; - width: 100dvw; - height: 100dvh; -} - -header { - grid-column: 1/3; - grid-row: 1; -} - -main { - position: relative; - grid-column: 1; - grid-row: 2/4; - overflow: auto; - scrollbar-width: none; -} - -aside { - grid-column: 2; - grid-row: 2; - display: grid; - overflow: clip; - grid-template-rows: auto minmax(0, 1fr); - width: 212px; - border-left: 1px solid black; -} - -#roles { - grid-column: 1; - grid-row: 1; -} - -#turn_info { - border-bottom: 1px solid black; - padding: 4px 8px; - white-space: pre-line; - font-style: italic; - font-size: 12px; - line-height: 18px; -} - -#log { - grid-column: 1; - grid-row: 2; - overflow-y: scroll; -} - -#log { - padding: 12px 0; - font-size: 12px; - line-height: 18px; - white-space: pre-wrap; -} - -#log > * { - padding: 0 4px 0 8px; - min-height: 9px; -} - -footer { - position: fixed; - pointer-events: none; - z-index: 500; - bottom: 0; - background-color: white; - padding: 0 8px; -} - -/* MENU */ - -.menu { - user-select: none; -} -.menu_item img { - vertical-align: top; - height: 20px; -} -.menu_title img { - display: block; - height: 36px; - padding: 4px; -} -.menu:hover .menu_title { - background-color: black; - color: white; -} -.menu:hover .menu_title img { - filter: invert(100%); -} -.menu_popup { - display: none; - position: absolute; - min-width: 160px; - white-space: nowrap; - border: 1px solid black; - background-color: white; - z-index: 501; -} -.menu:hover .menu_popup { - display: block; -} -.menu_separator { - border-top: 1px solid black; -} -.menu_item { - padding: 4px 8px; - cursor: pointer; -} -.menu_item:hover { - background-color: black; - color: white; -} -.menu_item:hover img { - filter: invert(100%); -} -a.menu_item { - display: block; - text-decoration: none; - color: black; -} -.menu_item.disabled { - color: gray; -} - -/* TOOL BAR */ - -.icon_button { - user-select: none; -} -.icon_button img { - display: block; - height: 36px; - padding: 4px; -} -.icon_button:hover { - background-color: black; - color: white; -} -.icon_button:hover img { - filter: invert(100%); -} - -header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0 8px; - border-bottom: 1px solid black; - background-color: gainsboro; -} - -#toolbar { - display: flex; -} - -header.disconnected { - background-color: red !important; -} - -header.your_turn { - background-color: orange; -} - -header.replay { - background-image: repeating-linear-gradient(45deg, gainsboro, gainsboro 40px, silver 40px, silver 80px); -} - -#actions, #viewpoint_panel { - display: flex; - justify-content: end; - align-items: center; -} - -#actions { - flex-wrap: wrap; - padding: 4px 8px; - gap: 8px; -} - -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: 12px; - font-size: 18px; - flex: 1 1 200px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - -#replay_panel { - grid-column: 2; - grid-row: 3; - display: flex; - height: 24px; - border-top: 1px solid black; - border-left: 1px solid black; - background-color: silver; -} - -.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: #fffc; -} - -.replay_button:hover:active { - background-color: #fff8; -} - -#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 { - border-bottom: 1px solid black; -} - -.role_name { - border-bottom: 1px solid black; - padding-top: 3px; - padding-bottom: 3px; - padding-left: 4px; - padding-right: 4px; -} - -.role_user { - font-style: italic; - text-align: right; -} - -.role_user a { - text-decoration: none; - color: black; -} - -.role_user a:hover { - text-decoration: underline; -} - -.role_name::before { - content: "\25cb "; - opacity: 0.6; -} - -.role.present .role_name::before { - content: "\25cf "; -} - -/* MAP */ - -#mapwrap { - position: relative; - margin: 0 auto; -} - -#mapwrap.fit { - max-width: 100%; -} - -#mapwrap #map { - position: absolute; - isolation: isolate; - transform-origin: 0 0; -} - -/* CHAT WINDOW */ - -#chat_button.new { - filter: invert(100%); -} - -#chat_window { - left: 24px; - top: 68px; - width: 640px; - z-index: 499; -} - -#notepad_window { - left: 60px; - top: 200px; - height: auto; - z-index: 498; -} - -#chat_window, #notepad_window { - position: fixed; - display: grid; - grid-template-rows: min-content 1fr min-content; - grid-template-columns: 1fr 30px; - border: 1px solid black; - background-color: white; - box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.5); - visibility: hidden; -} - -#chat_window.show, #notepad_window.show { - visibility: visible; -} - -#chat_header, #notepad_header { - grid-row: 1; - grid-column: 1/3; - user-select: none; - cursor: move; - background-color: gainsboro; - border-bottom: 1px solid black; - padding: 4px 8px; -} - -#chat_x, #notepad_x { - grid-row: 1; - grid-column: 2; - user-select: none; - cursor: pointer; - margin: 5px 5px; - height: 24px; - text-align: right; -} - -#chat_x:hover, #notepad_x:hover { - background-color: black; - color: white; -} - -#chat_text, #notepad_input { - grid-row: 2; - grid-column: 1/3; - margin: 0; - font-size: 16px; - line-height: 24px; - min-height: 216px; - padding: 0px 4px; - overflow-y: scroll; -} - -#chat_text .date { - font-weight: bold; -} - -#chat_form { - grid-row: 3; - grid-column: 1/3; - margin: 0; - padding: 0; - border-top: 1px solid black; -} - -#chat_input { - box-sizing: border-box; - width: 100%; - outline: none; - border: none; - padding: 4px; - font-size: 16px; -} - -#notepad_input { - outline: none; - border: none; - resize: none; -} - -#notepad_footer { - grid-row: 3; - grid-column: 1/3; - display: flex; - justify-content: end; - padding: 8px; - background-color: gainsboro; - border-top: 1px solid black; -} - -/* MOBILE PHONE LAYOUT */ - -#fullscreen_button { - display: none; -} - -@media (pointer: coarse) and (max-width: 400px) { - #fullscreen_button { display: block; } - #zoom_button { display: none; } -} - -@media (pointer: coarse) { - #replay_panel, .replay_button { - height: 36px; - } -} - -@media (max-height: 600px) { - .role_name:not(:hover) .role_user { - display: none; - } -} - -@media (max-width: 800px) { - body { - grid-template-columns: 1fr min-content; - grid-template-rows: min-content 1fr min-content; - } - header { - grid-column: 1/3; - grid-row: 1; - } - main { - grid-column: 1; - grid-row: 2; - } - aside { - grid-column: 2; - grid-row: 2/4; - } - footer { - background-color: #fffc; - } - #replay_panel { - grid-column: 1; - grid-row: 3; - border-left: none; - z-index: 496; - } - header { - display: grid; - grid-template-columns: min-content auto; - } - #toolbar { - grid-row: 1; - grid-column: 1; - } - #prompt { - grid-row: 2; - grid-column: 1/3; - padding: 0 8px 3px 8px; - } - #prompt:hover { - white-space: normal; - } - #actions { - grid-row: 1; - grid-column: 2; - } - - #chat_window, #notepad_window { - position: static; - grid-column: 1; - grid-row: 2; - display: none; - width: auto; - box-shadow: none; - border: none; - } - #chat_window.show, #notepad_window.show { - display: grid; - } -} - -@media (max-width: 800px) { footer { bottom: 25px } } -@media (pointer: coarse) and (max-width: 800px) { footer { bottom: 37px } } - -@media (max-width: 400px) { - body { - grid-template-columns: 1fr; - } - header { - grid-template-columns: 1fr; - grid-template-rows: auto auto auto; - } - aside { - grid-column: 1; - grid-row: 2/3; - z-index: 497; - width: 100vw; - border: none; - } - #toolbar { grid-row: 1; grid-column: 1; } - #actions { grid-row: 2; grid-column: 1; } - #prompt { grid-row: 3; grid-column: 1; } -} diff --git a/public/common/play.js b/public/common/play.js deleted file mode 100644 index 8aa1f65..0000000 --- a/public/common/play.js +++ /dev/null @@ -1,1218 +0,0 @@ -"use strict" - -let params = { - mode: "play", - title_id: window.location.pathname.split("/")[1], - game_id: 0, - role: "Observer", -} - -function init_params() { - let search = new URLSearchParams(window.location.search) - params.game_id = search.get("game") - params.role = search.get("role") || "Observer" - params.mode = search.get("mode") || "play" -} - -init_params() - -let roles = Array.from(document.querySelectorAll(".role")).map(x=>({id:x.id,role:x.id.replace(/^role_/,"").replace(/_/g," ")})) - -let view = null -let player = "Observer" -let socket = null -let chat = null - -let game_log = [] -let game_cookie = 0 - -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 - if (!multiplier) - multiplier = 1 - function md(e) { - if (e.button === 1) { - down_x = e.clientX - down_y = e.clientY - scroll_x = panel.scrollLeft - scroll_y = panel.scrollTop - window.addEventListener("mousemove", mm) - window.addEventListener("mouseup", mu) - e.preventDefault() - } - } - function mm(e) { - let dx = down_x - e.clientX - let dy = down_y - e.clientY - panel.scrollLeft = scroll_x + dx * multiplier - panel.scrollTop = scroll_y + dy * multiplier - e.preventDefault() - } - function mu(e) { - if (e.button === 1) { - window.removeEventListener("mousemove", mm) - window.removeEventListener("mouseup", mu) - e.preventDefault() - } - } - panel.addEventListener("mousedown", md) -} - -function drag_element_with_mouse(element_sel, grabber_sel) { - let element = document.querySelector(element_sel) - let grabber = document.querySelector(grabber_sel) || element - let save_x, save_y - function md(e) { - if (e.button === 0) { - save_x = e.clientX - save_y = e.clientY - window.addEventListener("mousemove", mm) - window.addEventListener("mouseup", mu) - e.preventDefault() - } - } - function mm(e) { - let dx = save_x - e.clientX - let dy = save_y - e.clientY - save_x = e.clientX - save_y = e.clientY - element.style.left = (element.offsetLeft - dx) + "px" - element.style.top = (element.offsetTop - dy) + "px" - e.preventDefault() - } - function mu(e) { - if (e.button === 0) { - window.removeEventListener("mousemove", mm) - window.removeEventListener("mouseup", mu) - e.preventDefault() - } - } - grabber.addEventListener("mousedown", md) -} - -/* TITLE BLINKER */ - -let blink_title = document.title -let blink_timer = 0 - -function start_blinker(message) { - let tick = false - if (blink_timer) - stop_blinker() - if (!document.hasFocus()) { - document.title = message - blink_timer = setInterval(function () { - document.title = tick ? message : blink_title - tick = !tick - }, 1000) - } -} - -function stop_blinker() { - document.title = blink_title - clearInterval(blink_timer) - blink_timer = 0 -} - -window.addEventListener("focus", stop_blinker) - -/* CHAT */ - -function init_chat() { - // only fetch new messages when we reconnect! - if (chat !== null) { - send_message("getchat", chat.log) - return - } - - let chat_window = document.createElement("div") - chat_window.id = "chat_window" - chat_window.innerHTML = ` -
Chat
-
\u274c
-
-
- ` - document.querySelector("body").appendChild(chat_window) - - let chat_button = document.getElementById("chat_button") - chat_button.classList.remove("hide") - - chat = { - is_visible: false, - text_element: document.getElementById("chat_text"), - key: "chat/" + params.game_id, - last_day: null, - log: 0 - } - - chat.seen = window.localStorage.getItem(chat.key) | 0 - - drag_element_with_mouse("#chat_window", "#chat_header") - - document.getElementById("chat_form").addEventListener("submit", e => { - let input = document.getElementById("chat_input") - e.preventDefault() - if (input.value) { - send_message("chat", input.value) - input.value = "" - } else { - hide_chat() - } - }) - - document.querySelector("body").addEventListener("keydown", e => { - if (e.key === "Escape") { - if (chat.is_visible) { - e.preventDefault() - hide_chat() - } - } - if (e.key === "Enter") { - let chat_input = document.getElementById("chat_input") - let notepad_input = document.getElementById("notepad_input") - if (document.activeElement !== chat_input && document.activeElement !== notepad_input) { - e.preventDefault() - show_chat() - } - } - }) - - send_message("getchat", 0) -} - -function save_chat() { - window.localStorage.setItem(chat.key, chat.log) -} - -function update_chat(chat_id, raw_date, user, message) { - function format_time(date) { - let mm = date.getMinutes() - let hh = date.getHours() - if (mm < 10) mm = "0" + mm - if (hh < 10) hh = "0" + hh - return hh + ":" + mm - } - function add_date_line(date) { - let line = document.createElement("div") - line.className = "date" - line.textContent = "~ " + date + " ~" - chat.text_element.appendChild(line) - } - function add_chat_line(time, user, message) { - let line = document.createElement("div") - line.textContent = "[" + time + "] " + user + " \xbb " + message - chat.text_element.appendChild(line) - chat.text_element.scrollTop = chat.text_element.scrollHeight - } - if (chat_id > chat.log) { - chat.log = chat_id - let date = new Date(raw_date * 1000) - let day = date.toDateString() - if (day !== chat.last_day) { - add_date_line(day) - chat.last_day = day - } - add_chat_line(format_time(date), user, message) - } - if (chat_id > chat.seen) { - let button = document.getElementById("chat_button") - start_blinker("NEW MESSAGE") - if (!chat.is_visible) - button.classList.add("new") - else - save_chat() - } -} - -function show_chat() { - if (!chat.is_visible) { - document.getElementById("chat_button").classList.remove("new") - document.getElementById("chat_window").classList.add("show") - document.getElementById("chat_input").focus() - chat.is_visible = true - save_chat() - } -} - -function hide_chat() { - if (chat.is_visible) { - document.getElementById("chat_window").classList.remove("show") - document.getElementById("chat_input").blur() - chat.is_visible = false - } -} - -function toggle_chat() { - if (chat.is_visible) - hide_chat() - else - show_chat() -} - -/* NOTEPAD */ - -let notepad = null - -function init_notepad() { - if (notepad !== null) - return - - add_main_menu_item("Notepad", toggle_notepad) - - let notepad_window = document.createElement("div") - notepad_window.id = "notepad_window" - notepad_window.innerHTML = ` -
Notepad: ${player}
-
\u274c
- - - ` - document.querySelector("body").appendChild(notepad_window) - - notepad = { - is_visible: false, - is_dirty: false, - } - - drag_element_with_mouse("#notepad_window", "#notepad_header") -} - -function dirty_notepad() { - if (!notepad.is_dirty) { - notepad.is_dirty = true - document.getElementById("notepad_save").disabled = false - } -} - -function save_notepad() { - if (notepad.is_dirty) { - let text = document.getElementById("notepad_input").value - send_message("putnote", text) - notepad.is_dirty = false - document.getElementById("notepad_save").disabled = true - } -} - -function load_notepad() { - send_message("getnote") -} - -function update_notepad(text) { - document.getElementById("notepad_input").value = text -} - -function show_notepad() { - if (!notepad.is_visible) { - load_notepad() - document.getElementById("notepad_window").classList.add("show") - document.getElementById("notepad_input").focus() - notepad.is_visible = true - } -} - -function hide_notepad() { - if (notepad.is_visible) { - save_notepad() - document.getElementById("notepad_window").classList.remove("show") - document.getElementById("notepad_input").blur() - notepad.is_visible = false - } -} - -function toggle_notepad() { - if (notepad.is_visible) - hide_notepad() - else - show_notepad() -} - -window.addEventListener("keydown", (evt) => { - if (document.activeElement === document.getElementById("chat_input")) - return - if (document.activeElement === document.getElementById("notepad_input")) - return - if (evt.key === "Shift") - document.querySelector("body").classList.add("shift") -}) - -window.addEventListener("keyup", (evt) => { - if (evt.key === "Shift") - document.querySelector("body").classList.remove("shift") -}) - -/* REMATCH BUTTON */ - -function add_icon_button(where, 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) - if (where) - document.getElementById("toolbar").appendChild(button) - else - document.querySelector(".menu").after(button) - } - return button -} - -function remove_resign_menu() { - document.querySelectorAll(".resign").forEach(x => x.remove()) -} - -function goto_rematch() { - window.location = "/rematch/" + params.game_id -} - -function goto_replay() { - let search = new URLSearchParams(window.location.search) - search.delete("role") - search.set("mode", "replay") - window.location.search = search -} - -function on_game_over() { - add_icon_button(1, "replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay) - if (player !== "Observer") - add_icon_button(1, "rematch_button", "cycle", "Propose a rematch!", goto_rematch) - remove_resign_menu() -} - -/* CONNECT TO GAME SERVER */ - -function init_player_names(players) { - for (let i = 0; i < roles.length; ++i) { - let p = players.find(p => p.role === roles[i].role) - if (p) - document.getElementById(roles[i].id).querySelector(".role_user").innerHTML = `${p.name}` - else - document.getElementById(roles[i].id).querySelector(".role_user").textContent = "NONE" - } -} - -function send_message(cmd, arg) { - let data = JSON.stringify([ cmd, arg ]) - console.log("SEND %s %s", cmd, arg) - socket.send(data) -} - -let reconnect_count = 0 -let reconnect_max = 10 - -function connect_play() { - if (reconnect_count >= reconnect_max) { - document.title = "DISCONNECTED" - document.getElementById("prompt").textContent = "Disconnected." - return - } - - let protocol = (window.location.protocol === "http:") ? "ws" : "wss" - let seen = document.getElementById("log").children.length - let url = `${protocol}://${window.location.host}/play-socket?title=${params.title_id}&game=${params.game_id}&role=${encodeURIComponent(params.role)}&seen=${seen}` - - console.log("CONNECTING", url) - document.getElementById("prompt").textContent = "Connecting... " - - socket = new WebSocket(url) - - window.addEventListener("beforeunload", function () { - socket.close(1000) - }) - - socket.onopen = function (evt) { - console.log("OPEN") - document.querySelector("header").classList.remove("disconnected") - reconnect_count = 0 - } - - socket.onclose = function (evt) { - console.log("CLOSE %d", evt.code) - game_cookie = 0 - if (evt.code === 1000 && evt.reason !== "") { - document.getElementById("prompt").textContent = "Disconnected: " + evt.reason - document.title = "DISCONNECTED" - } - if (evt.code !== 1000) { - document.querySelector("header").classList.add("disconnected") - document.getElementById("prompt").textContent = `Reconnecting soon... (${reconnect_count+1}/${reconnect_max})` - let wait = 1000 * (Math.random() + 0.5) * Math.pow(2, reconnect_count++) - console.log("WAITING %.1f TO RECONNECT", wait/1000) - setTimeout(connect_play, wait) - } - } - - socket.onmessage = function (evt) { - let msg_data = JSON.parse(evt.data) - let cmd = msg_data[0] - let arg = msg_data[1] - console.log("MESSAGE", cmd) - switch (cmd) { - case "warning": - document.getElementById("prompt").textContent = arg - document.querySelector("header").classList.add("disconnected") - setTimeout(() => { - document.querySelector("header").classList.remove("disconnected") - on_update_header() - }, 1000) - break - - case "error": - document.getElementById("prompt").textContent = arg - if (view) { - view.actions = null - on_update() - } - break - - case "chat": - update_chat(arg[0], arg[1], arg[2], arg[3]) - break - - case "note": - update_notepad(arg) - break - - case "players": - player = arg[0] - document.querySelector("body").classList.add(player.replace(/ /g, "_")) - if (player !== "Observer") { - init_chat() - init_notepad() - } else { - remove_resign_menu() - } - 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)) - } - } - break - - case "state": - game_cookie = msg_data[2] - - if (snap_view) - on_snap_stop() - - view = arg - - game_log.length = view.log_start - for (let line of view.log) - game_log.push(line) - - on_update_header() - 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 "snapsize": - snap_count = arg - if (snap_count === 0) - replay_panel.remove() - else - document.querySelector("body").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": - window.localStorage[params.title_id + "/save"] = arg - break - } - } -} - -/* HEADER */ - -let is_your_turn = false -let old_active = null - -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) - start_blinker("YOUR TURN") - is_your_turn = true - } else { - document.querySelector("header").classList.remove("your_turn") - is_your_turn = false - } - old_active = view.active -} - -/* LOG */ - -function on_update_log(change_start, end) { - let div = document.getElementById("log") - - let to_delete = div.children.length - change_start - while (to_delete-- > 0) - div.removeChild(div.lastChild) - - for (let i = div.children.length; i < end; ++i) { - let text = game_log[i] - if (params.mode === "debug" && typeof text === "object") { - let entry = document.createElement("a") - entry.href = "#" + text[0] - if (text[3] !== null) - entry.textContent = "\u25b6 " + text[1] + " " + text[2] + " " + text[3] - else - entry.textContent = "\u25b6 " + text[1] + " " + text[2] - entry.style.display = "block" - entry.style.textDecoration = "none" - div.appendChild(entry) - } else if (typeof on_log === "function") { - div.appendChild(on_log(text)) - } else { - let entry = document.createElement("div") - entry.textContent = text - div.appendChild(entry) - } - } - scroll_log_to_end() -} - -function scroll_log_to_end() { - let div = document.getElementById("log") - div.scrollTop = div.scrollHeight -} - -try { - new ResizeObserver(scroll_log_to_end).observe(document.getElementById("log")) -} catch (err) { - window.addEventListener("resize", scroll_log_to_end) -} - -/* ACTIONS */ - -function action_button_imp(action, label, callback) { - if (params.mode === "replay") - return - let id = action + "_button" - let button = document.getElementById(id) - if (!button) { - button = document.createElement("button") - button.id = id - button.textContent = label - button.addEventListener("click", callback) - document.getElementById("actions").appendChild(button) - } - if (view.actions && action in view.actions) { - button.classList.remove("hide") - if (view.actions[action]) { - if (label === undefined) - button.textContent = view.actions[action] - button.disabled = false - } else { - button.disabled = true - } - } else { - button.classList.add("hide") - } -} - -function action_button(action, label) { - action_button_imp(action, label, evt => send_action(action)) -} - -function confirm_action_button(action, label, message) { - action_button_imp(action, label, evt => confirm_action(message, action)) -} - -function send_action(verb, noun) { - if (params.mode === "replay" || params.mode === "debug") - return false - // Reset action list here so we don't send more than one action per server prompt! - if (noun !== undefined) { - 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, game_cookie ]) - return true - } - } else { - if (view.actions && view.actions[verb]) { - view.actions = null - send_message("action", [ verb, null, game_cookie ]) - return true - } - } - return false -} - -function confirm_action(message, verb, noun) { - if (window.confirm(message)) - send_action(verb, noun) -} - -function send_query(q, param) { - 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() { - if (window.confirm("Are you sure that you want to resign?")) - send_message("resign") -} - -function send_save() { - send_message("save") -} - -function send_restore() { - send_message("restore", window.localStorage[params.title_id + "/save"]) -} - -/* REPLAY */ - -function init_replay() { - let script = document.createElement("script") - script.src = "/common/replay.js" - document.body.appendChild(script) -} - -window.addEventListener("load", function () { - zoom_map() - if (params.mode === "debug") - init_replay() - else if (params.mode === "replay") - init_replay() - else if (params.mode === "play") - connect_play() - else - document.getElementById("prompt").textContent = "Invalid mode: " + params.mode -}) - -/* MAIN MENU */ - -add_icon_button(0, "chat_button", "chat-bubble", "Open chat", toggle_chat).classList.add("hide") -add_icon_button(0, "zoom_button", "magnifying-glass", "Zoom", () => toggle_zoom()) -add_icon_button(0, "log_button", "scroll-quill", "Hide log", toggle_log) -add_icon_button(0, "fullscreen_button", "expand", "Fullscreen", toggle_fullscreen) - -function init_main_menu() { - let popup = document.querySelector(".menu_popup") - let sep = document.createElement("div") - sep.className = "menu_separator" - sep.id = "main_menu_separator" - popup.insertBefore(sep, popup.firstChild) -} - -function add_main_menu_item(text, onclick) { - let popup = document.querySelector(".menu_popup") - let sep = document.getElementById("main_menu_separator") - let item = document.createElement("div") - item.className = "menu_item" - item.onclick = onclick - item.textContent = text - popup.insertBefore(item, sep) -} - -function add_main_menu_item_link(text, url) { - let popup = document.querySelector(".menu_popup") - let sep = document.getElementById("main_menu_separator") - let item = document.createElement("a") - item.className = "menu_item" - item.href = url - item.textContent = text - popup.insertBefore(item, sep) -} - -init_main_menu() -if (params.mode === "play" && params.role !== "Observer") { - add_main_menu_item_link("Go home", "/games/active") - add_main_menu_item_link("Go to next game", "/games/next") -} else { - add_main_menu_item_link("Go home", "/") -} - -function toggle_fullscreen() { - if (document.fullscreenElement) - document.exitFullscreen() - else - document.documentElement.requestFullscreen() -} - -/* 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) - } -} - -/* TOGGLE ZOOM MAP TO FIT */ - -function toggle_log() { - document.querySelector("aside").classList.toggle("hide") - zoom_map() -} - -var toggle_zoom = function () {} - -function zoom_map() { - let mapwrap = document.getElementById("mapwrap") - if (mapwrap) { - let main = document.querySelector("main") - let map = document.getElementById("map") - map.style.transform = null - mapwrap.style.width = null - mapwrap.style.height = null - if (mapwrap.classList.contains("fit")) { - let { width: gw, height: gh } = main.getBoundingClientRect() - let { width: ww, height: wh } = mapwrap.getBoundingClientRect() - let { width: cw, height: ch } = map.getBoundingClientRect() - let scale = Math.min(ww / cw, gh / ch) - if (scale < 1) { - map.style.transform = "scale(" + scale + ")" - mapwrap.style.width = (cw * scale) + "px" - mapwrap.style.height = (ch * scale) + "px" - } - } - } -} - -window.addEventListener("resize", zoom_map) - -/* PAN & ZOOM GAME BOARD */ - -;(function panzoom_init() { - const MIN_ZOOM = 0.5 - const MAX_ZOOM = 1.5 - const THRESHOLD = 0.0625 - const DECELERATION = 125 - - console.log("DPX", window.devicePixelRatio) - - const e_scroll = document.querySelector("main") - e_scroll.style.touchAction = "none" - - const e_inner = document.createElement("div") - e_inner.id = "pan_zoom_main" - e_inner.style.transformOrigin = "0 0" - e_inner.style.height = "120px" - while (e_scroll.firstChild) - e_inner.appendChild(e_scroll.firstChild) - - const e_outer = document.createElement("div") - e_outer.id = "pan_zoom_wrap" - e_outer.style.height = "120px" - e_outer.appendChild(e_inner) - - e_scroll.appendChild(e_outer) - - const mapwrap = document.getElementById("mapwrap") - const map = document.getElementById("map") || e_inner.firstChild - const map_w = mapwrap ? mapwrap.clientWidth : map.clientWidth - const map_h = mapwrap ? mapwrap.clientHeight : map.clientHeight - - console.log("MAP", map_w, map_h) - - var transform0 = { x: 0, y: 0, scale: 1 } - var transform1 = { x: 0, y: 0, scale: 1 } - var old_scale = 1 - - // touch finger tracking - var last_touch_x = {} - var last_touch_y = {} - var last_touch_length = 0 - - // momentum velocity tracking - var mom_last_t = null - var mom_last_x = null - var mom_last_y = null - - // momentum auto-scroll - var timer = 0 - var mom_time = 0 - var mom_vx = 0 - var mom_vy = 0 - - try { - new ResizeObserver(update_transform_resize).observe(document.getElementById("log")) - } catch (err) { - window.addEventListener("resize", function (evt) { - old_scale = 0 - update_transform() - }) - } - - function clamp_scale(scale) { - let win_w = e_scroll.clientWidth - let win_h = e_scroll.clientHeight - let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) - if (scale * transform0.scale > MAX_ZOOM) - scale = MAX_ZOOM / transform0.scale - if (scale * transform0.scale < real_min_zoom) - scale = real_min_zoom / transform0.scale - return scale - } - - function anchor_transform(touches) { - // in case it changed from outside - transform1.x = -e_scroll.scrollLeft - transform1.y = -e_scroll.scrollTop - - transform0.scale = transform1.scale - transform0.x = transform1.x - transform0.y = transform1.y - if (touches) { - for (let touch of touches) { - last_touch_x[touch.identifier] = touch.clientX - last_touch_y[touch.identifier] = touch.clientY - } - last_touch_length = touches.length - } else { - last_touch_length = 0 - } - } - - function toggle_zoom_imp() { - if (transform1.scale === 1) { - if (window.innerWidth >= 800) { - if (mapwrap) { - mapwrap.classList.toggle("fit") - zoom_map() - return - } - } - let win_w = e_scroll.clientWidth - let win_h = e_scroll.clientHeight - let min_z = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) - zoom_to(min_z) - } else { - zoom_to(1) - } - return false - } - - function disable_map_fit() { - if (mapwrap && mapwrap.classList.contains("fit")) { - mapwrap.classList.remove("fit") - zoom_map() - } - } - - function zoom_to(new_scale) { - let cx = e_scroll.clientWidth / 2 - let cy = 0 - - // in case changed from outside - transform1.x = -e_scroll.scrollLeft - transform1.y = -e_scroll.scrollTop - - transform1.x -= cx - transform1.y -= cy - transform1.x *= new_scale / transform1.scale - transform1.y *= new_scale / transform1.scale - transform1.scale = new_scale - transform1.x += cx - transform1.y += cy - - update_transform() - } - - function update_transform() { - let win_w = e_scroll.clientWidth - let win_h = e_scroll.clientHeight - - // clamp zoom - let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) - transform1.scale = Math.max(real_min_zoom, Math.min(MAX_ZOOM, transform1.scale)) - - e_scroll.scrollLeft = -transform1.x - e_scroll.scrollTop = -transform1.y - - if (transform1.scale !== old_scale) { - if (transform1.scale === 1) { - e_inner.style.transform = null - } else { - e_inner.style.transform = `scale(${transform1.scale})` - disable_map_fit() - } - e_inner.style.width = (win_w / transform1.scale) + "px" - e_outer.style.width = (e_inner.clientWidth * transform1.scale) + "px" - old_scale = transform1.scale - } - } - - function start_measure(time) { - mom_last_t = [ time, time, time ] - mom_last_x = [ transform1.x, transform1.x, transform1.x ] - mom_last_y = [ transform1.y, transform1.y, transform1.y ] - } - - function abort_measure(time) { - mom_last_t = mom_last_x = mom_last_y = null - } - - function move_measure(time) { - if (mom_last_t) { - mom_last_t[0] = time - mom_last_x[0] = transform1.x - mom_last_y[0] = transform1.y - if (mom_last_t[0] - mom_last_t[1] > 15) { - mom_last_t[2] = mom_last_t[1] - mom_last_x[2] = mom_last_x[1] - mom_last_y[2] = mom_last_y[1] - mom_last_t[1] = mom_last_t[0] - mom_last_x[1] = mom_last_x[0] - mom_last_y[1] = mom_last_y[0] - } - } - } - - function start_momentum() { - if (mom_last_t) { - let dt = mom_last_t[0] - mom_last_t[2] - if (dt > 5) { - mom_time = Date.now() - mom_vx = (mom_last_x[0] - mom_last_x[2]) / dt - mom_vy = (mom_last_y[0] - mom_last_y[2]) / dt - if (Math.hypot(mom_vx, mom_vy) < THRESHOLD) - mom_vx = mom_vy = 0 - if (mom_vx || mom_vy) - timer = requestAnimationFrame(update_momentum) - } - } - } - - function stop_momentum() { - cancelAnimationFrame(timer) - timer = 0 - } - - function update_momentum() { - var now = Date.now() - var dt = now - mom_time - mom_time = now - - transform1.x = transform1.x + mom_vx * dt - transform1.y = transform1.y + mom_vy * dt - update_transform() - - var decay = Math.pow(0.5, dt / DECELERATION) - mom_vx *= decay - mom_vy *= decay - - if (Math.hypot(mom_vx, mom_vy) < THRESHOLD) - mom_vx = mom_vy = 0 - - if (mom_vx || mom_vy) - timer = requestAnimationFrame(update_momentum) - } - - e_scroll.ontouchstart = function (evt) { - anchor_transform(evt.touches) - stop_momentum() - start_measure(evt.timeStamp) - } - - e_scroll.ontouchend = function (evt) { - anchor_transform(evt.touches) - if (evt.touches.length === 0) - start_momentum() - } - - e_scroll.ontouchmove = function (evt) { - if (evt.touches.length !== last_touch_length) - anchor_transform(evt.touches) - - if (evt.touches.length === 1 || evt.touches.length === 2) { - let a = evt.touches[0] - - let dx = a.clientX - last_touch_x[a.identifier] - let dy = a.clientY - last_touch_y[a.identifier] - - transform1.scale = transform0.scale - transform1.x = transform0.x + dx - transform1.y = transform0.y + dy - - if (evt.touches.length === 1) - move_measure(evt.timeStamp) - else - abort_measure() - - // zoom - if (evt.touches.length === 2) { - let b = evt.touches[1] - - let old_x = last_touch_x[a.identifier] - last_touch_x[b.identifier] - let old_y = last_touch_y[a.identifier] - last_touch_y[b.identifier] - let old = Math.sqrt(old_x * old_x + old_y * old_y) - - let cur_x = a.clientX - b.clientX - let cur_y = a.clientY - b.clientY - let cur = Math.sqrt(cur_x * cur_x + cur_y * cur_y) - - let scale = clamp_scale(cur / old) - - let cx = a.clientX - let cy = a.clientY - - transform1.x -= cx - transform1.y -= cy - - transform1.scale *= scale - transform1.x *= scale - transform1.y *= scale - - transform1.x += cx - transform1.y += cy - } - - update_transform() - } - } - - e_scroll.addEventListener( - "wheel", - function (evt) { - if (evt.ctrlKey) { - anchor_transform(null) - - let win_w = e_scroll.clientWidth - let win_h = e_scroll.clientHeight - let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) - - // one "click" of 120 units -> 10% change - let new_scale = Math.max(real_min_zoom, Math.min(MAX_ZOOM, transform1.scale + event.wheelDeltaY / 1200)) - - // snap to 1 if close - console.log("WHEEL ", new_scale, Math.abs(event.wheelDeltaY / 2400)) - if (Math.abs(1 - new_scale) < Math.abs(event.wheelDeltaY / 2400)) { - console.log("SNAP TO 1 ", Math.abs(event.wheelDeltaY / 2400)) - new_scale = 1 - } - - transform1.x -= event.clientX - transform1.y -= event.clientY - - transform1.x *= new_scale / transform1.scale - transform1.y *= new_scale / transform1.scale - transform1.scale = new_scale - - transform1.x += event.clientX - transform1.y += event.clientY - - update_transform() - evt.preventDefault() - } - }, - { passive: false } - ) - - toggle_zoom = toggle_zoom_imp -})() -- cgit v1.2.3