From b40805c02ec7d3407ae7205bcbae4bf9ee7915a7 Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Sat, 26 Sep 2020 02:55:39 -0600 Subject: [PATCH] Take a rough swing at phone support --- index.html | 5 +- js/main.js | 147 ++++++++++++++++++++++++++++++++++++++++++++++++----- style.css | 92 ++++++++++++++++++++++++++++++--- 3 files changed, 220 insertions(+), 24 deletions(-) diff --git a/index.html b/index.html index ea5bf21..509301c 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@ +
@@ -116,10 +117,10 @@
- + - +
diff --git a/js/main.js b/js/main.js index 8c865e2..ada6025 100644 --- a/js/main.js +++ b/js/main.js @@ -500,6 +500,7 @@ class Player extends PrimaryView { let key_target = document.body; this.previous_input = new Set; // actions that were held last tic this.previous_action = null; // last direction we were moving, if any + this.using_touch = false; // true if using touch controls this.current_keys = new Set; // keys that are currently held // TODO this could all probably be more rigorous but it's fine for now key_target.addEventListener('keydown', ev => { @@ -542,6 +543,7 @@ class Player extends PrimaryView { ev.stopPropagation(); ev.preventDefault(); + // TODO for demo compat, this should happen as part of input reading? if (this.state === 'waiting') { this.set_state('playing'); } @@ -560,13 +562,97 @@ class Player extends PrimaryView { ev.preventDefault(); } }); + // Similarly, grab touch events and translate them to directions + this.current_touches = {}; // ident => action + let touch_target = this.root.querySelector('.-main-area'); + let collect_touches = ev => { + ev.stopPropagation(); + ev.preventDefault(); + + // If state is anything other than playing/waiting, probably switch to playing, similar + // to pressing spacebar + if (ev.type === 'touchstart') { + if (this.state === 'paused') { + this.toggle_pause(); + return; + } + else if (this.state === 'stopped') { + if (this.level.state === 'success') { + // Advance to the next level + // TODO game ending? + // TODO this immediately begins it too, not sure why + this.conductor.change_level(this.conductor.level_index + 1); + } + else { + // Restart + this.restart_level(); + } + return; + } + } + + // Figure out where these touches are, relative to the game area + // TODO allow starting a level without moving? + let rect = touch_target.getBoundingClientRect(); + for (let touch of ev.changedTouches) { + // Normalize touch coordinates to [-1, 1] + let rx = (touch.clientX - rect.left) / rect.width * 2 - 1; + let ry = (touch.clientY - rect.top) / rect.height * 2 - 1; + // Divine a direction from the results + let action; + if (Math.abs(rx) > Math.abs(ry)) { + if (rx < 0) { + action = 'left'; + } + else { + action = 'right'; + } + } + else { + if (ry < 0) { + action = 'up'; + } + else { + action = 'down'; + } + } + this.current_touches[touch.identifier] = action; + } + + // TODO for demo compat, this should happen as part of input reading? + if (this.state === 'waiting') { + this.set_state('playing'); + } + }; + touch_target.addEventListener('touchstart', collect_touches); + touch_target.addEventListener('touchmove', collect_touches); + let dismiss_touches = ev => { + for (let touch of ev.changedTouches) { + delete this.current_touches[touch.identifier]; + } + }; + touch_target.addEventListener('touchend', dismiss_touches); + touch_target.addEventListener('touchcancel', dismiss_touches); // When we lose focus, act as though every key was released, and pause the game window.addEventListener('blur', ev => { this.current_keys.clear(); + this.current_touches = {}; if (this.state === 'playing' || this.state === 'rewinding') { - this.set_state('paused'); + this.autopause(); + } + }); + // Same when the window becomes hidden (especially important on phones, where this covers + // turning the screen off!) + document.addEventListener('visibilitychange', ev => { + if (document.visibilityState === 'hidden') { + this.current_keys.clear(); + this.current_touches = {}; + + if (this.state === 'playing' || this.state === 'rewinding') { + this.autopause(); + } } }); @@ -688,6 +774,9 @@ class Player extends PrimaryView { for (let key of this.current_keys) { input.add(this.key_mapping[key]); } + for (let action of Object.values(this.current_touches)) { + input.add(action); + } return input; } } @@ -907,6 +996,10 @@ class Player extends PrimaryView { } } + autopause() { + this.set_state('paused'); + } + // waiting: haven't yet pressed a key so the timer isn't going // playing: playing normally // paused: um, paused @@ -931,7 +1024,12 @@ class Player extends PrimaryView { else if (this.state === 'paused') { overlay_reason = 'paused'; overlay_bottom = "/// paused ///"; - overlay_keyhint = "press P to resume"; + if (this.using_touch) { + overlay_keyhint = "tap to resume"; + } + else { + overlay_keyhint = "press P to resume"; + } } else if (this.state === 'stopped') { if (this.level.state === 'failure') { @@ -939,7 +1037,13 @@ class Player extends PrimaryView { overlay_top = "whoops"; let obits = OBITUARIES[this.level.fail_reason] ?? OBITUARIES['generic']; overlay_bottom = random_choice(obits); - overlay_keyhint = "press space to try again, or Z to rewind"; + if (this.using_touch) { + // TODO touch gesture to rewind? + overlay_keyhint = "tap to try again, or tap undo/rewind above"; + } + else { + overlay_keyhint = "press space to try again, or Z to rewind"; + } } else { // We just beat the level! Hey, that's cool. @@ -1002,7 +1106,12 @@ class Player extends PrimaryView { "alphanumeric!", "nice dynamic typing!", ]); } - overlay_keyhint = "press space to move on"; + if (this.using_touch) { + overlay_keyhint = "tap to move on"; + } + else { + overlay_keyhint = "press space to move on"; + } overlay_middle = mk('dl.score-chart', mk('dt', "base score"), @@ -1134,14 +1243,23 @@ class Player extends PrimaryView { // Auto-size the game canvas to fit the screen, if possible adjust_scale() { // TODO make this optional - // The base size is the size of the canvas, i.e. the viewport size times the tile size -- - // but note that horizontally we have 4 extra tiles for the inventory - // TODO if there's ever a portrait view for phones, this will need adjusting - let base_x = this.conductor.tileset.size_x * (this.renderer.viewport_size_x + 4); - let base_y = this.conductor.tileset.size_y * this.renderer.viewport_size_y; - // The main UI is centered in a flex item with auto margins, so the - // extra space available is the size of those margins let style = window.getComputedStyle(this.root); + let is_portrait = !! style.getPropertyValue('--is-portrait'); + // The base size is the size of the canvas, i.e. the viewport size times the tile size -- + // but note that we have 2x4 extra tiles for the inventory depending on layout + // TODO if there's ever a portrait view for phones, this will need adjusting + let base_x, base_y; + if (is_portrait) { + base_x = this.conductor.tileset.size_x * this.renderer.viewport_size_x; + base_y = this.conductor.tileset.size_y * (this.renderer.viewport_size_y + 2); + } + else { + base_x = this.conductor.tileset.size_x * (this.renderer.viewport_size_x + 4); + base_y = this.conductor.tileset.size_y * this.renderer.viewport_size_y; + } + // The main UI is centered in a flex item with auto margins, so the extra space available is + // the size of those margins (which naturally discounts the size of the buttons and music + // title and whatnot, so those automatically reserve their own space) if (style['display'] === 'none') { // the computed margins can be 'auto' in this case return; @@ -1153,9 +1271,10 @@ class Player extends PrimaryView { let total_x = extra_x + this.renderer.canvas.offsetWidth + this.inventory_el.offsetWidth; let total_y = extra_y + this.renderer.canvas.offsetHeight; let dpr = window.devicePixelRatio || 1.0; - // Divide to find the biggest scale that still fits. But don't - // exceed 90% of the available space, or it'll feel cramped. - let scale = Math.floor(0.9 * dpr * Math.min(total_x / base_x, total_y / base_y)); + // Divide to find the biggest scale that still fits. But don't exceed 90% of the available + // space, or it'll feel cramped (except on small screens, where being too small HURTS). + let maxfrac = total_x < 800 ? 1 : 0.9; + let scale = Math.floor(maxfrac * dpr * Math.min(total_x / base_x, total_y / base_y)); if (scale <= 1) { scale = 1; } diff --git a/style.css b/style.css index 2d59920..89c3a00 100644 --- a/style.css +++ b/style.css @@ -281,7 +281,7 @@ body > header { align-items: center; gap: 0.5em; - margin: 0.25em; + padding: 0.5em; line-height: 1.125; } body > header h1 { @@ -291,14 +291,13 @@ body > header h2 { font-size: 1.33em; } body > header h3 { - font-size: 1.25em; + font-size: 1.75em; } body > header > nav { flex: 1; display: flex; justify-content: flex-end; gap: 0.5em; - margin: 0.25rem 0.5rem; } body > header button { font-size: 0.75em; @@ -317,8 +316,25 @@ body[data-mode=player] #editor-play { order: 3; color: #606060; } -#header-level { - font-size: 1.5em; + +@media (max-width: 800px) { + body > header { + padding: 0.25em; + } + /* All these headings are way too big on phones */ + body > header h1 { + font-size: 1.25em; + } + body > header h2 { + font-size: 1.125em; + } + body > header h3 { + font-size: 1.0625em; + } + body > header p { + /* "a game by eevee" takes up too much space :( */ + display: none; + } } /**************************************************************************************************/ @@ -379,6 +395,23 @@ body[data-mode=player] #editor-play { grid-area: yours; } +@media (max-width: 800px) { + #splash { + /* Grid layout doesn't fit, just stack everything */ + display: flex; + flex-direction: column; + /* 10% padding is way way too much */ + padding: 1em; + } + /* Shrink logo and title */ + #splash > header img { + width: 48px; + } + #splash > header h1 { + font-size: 2em; + } +} + button.level-pack-button { display: grid; grid: @@ -469,7 +502,7 @@ button.level-pack-button p { justify-content: center; align-items: center; - z-index: 1; + z-index: 2; font-size: calc(0.5 * var(--tile-width) * var(--scale)); padding: 2%; background: #0009; @@ -552,7 +585,6 @@ dl.score-chart .-sum { .bonus output { flex: 1; font-size: 2em; - padding: 0.125em; min-width: 2em; min-height: 1em; line-height: 1; @@ -584,9 +616,11 @@ dl.score-chart .-sum { } #player .bonus { visibility: hidden; + display: none; } #player.--bonus-visible .bonus { visibility: initial; + display: initial; } .message { @@ -644,11 +678,14 @@ dl.score-chart .-sum { #player-music { grid-area: music; display: flex; + gap: 1em; + margin: 0 1em; text-transform: lowercase; color: #909090; } #player-music #player-music-left { - flex: 1 0 auto; + /* allow me to wrap if need be */ + flex: 1 0 0px; } #player-music #player-music-right { text-align: right; @@ -709,6 +746,45 @@ main.--has-demo .demo-controls { } +@media (max-width: 800px) { + #player { + /* sentinel for js */ + --is-portrait: 1; + /* The play area isn't necessarily the biggest thing any more, and it's ugly when stretched */ + align-items: center; + } + #player > .-main-area { + /* Rearrange the grid to be vertical */ + grid: + "level level" + "chips inventory" + "time inventory" + "bonus inventory" + / min-content min-content + ; + row-gap: 0.5em; + column-gap: 1em; + + padding: 0.5em; + } + #player .inventory { + /* stick me in the center right */ + place-self: center end; + } + #player .message { + /* Overlay hints on the inventory area */ + grid-row: chips / bonus; + grid-column: level; + z-index: 1; + font-size: calc(var(--tile-height) * var(--scale) / 2.5); + } + #player-music { + /* Stack the title/artist on the volume, since they don't fit well side by side */ + font-size: 0.875em; + } +} + + /**************************************************************************************************/ /* Editor */