summaryrefslogtreecommitdiff
path: root/public/common/client.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/common/client.js')
-rw-r--r--public/common/client.js1218
1 files changed, 1218 insertions, 0 deletions
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 = `
+ <div id="chat_header">Chat</div>
+ <div id="chat_x" onclick="toggle_chat()">\u274c</div>
+ <div id="chat_text"></div>
+ <form id="chat_form" action=""><input id="chat_input" autocomplete="off"></form>
+ `
+ 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 = `
+ <div id="notepad_header">Notepad: ${player}</div>
+ <div id="notepad_x" onclick="toggle_notepad()">\u274c</div>
+ <textarea id="notepad_input" cols="55" rows="10" maxlength="16000" oninput="dirty_notepad()"></textarea>
+ <div id="notepad_footer"><button id="notepad_save" onclick="save_notepad()" disabled>Save</button></div>
+ `
+ 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 = '<img src="/images/' + img + '.svg">'
+ 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 = `<a href="/user/${p.name}" target="_blank">${p.name}</a>`
+ 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
+})()