diff --git a/ZATACKA.html b/ZATACKA.html index dc55ddf..9edb4af 100644 --- a/ZATACKA.html +++ b/ZATACKA.html @@ -100,6 +100,7 @@ + @@ -107,6 +108,12 @@ + + + + + + diff --git a/Zatacka.css b/Zatacka.css index 38b5aa0..6e8caa3 100644 --- a/Zatacka.css +++ b/Zatacka.css @@ -60,6 +60,10 @@ input[type="checkbox"]:checked + label::before { background-repeat: no-repeat; } +.nocursor { + cursor: none; +} + #debug { background-color: black; border: 1px white solid; @@ -139,7 +143,7 @@ input[type="checkbox"]:checked + label::before { #lobby #controls { list-style-type: none; - margin-left: 81px; + margin-left: 80px; margin-top: 50px; } @@ -154,7 +158,7 @@ input[type="checkbox"]:checked + label::before { #lobby #controls .controls { background-image: url("resources/kurve-lobby-controls-ready.png"); - width: 159px; + width: 160px; } #lobby #controls .ready { @@ -168,17 +172,17 @@ input[type="checkbox"]:checked + label::before { } #lobby #controls .red.controls { background-position: 0px 0px; } -#lobby #controls .red.ready { background-position: -159px 0px; } +#lobby #controls .red.ready { background-position: -160px 0px; } #lobby #controls .yellow.controls { background-position: 0px -50px; } -#lobby #controls .yellow.ready { background-position: -159px -50px; } +#lobby #controls .yellow.ready { background-position: -160px -50px; } #lobby #controls .orange.controls { background-position: 0px -100px; } -#lobby #controls .orange.ready { background-position: -159px -100px; } +#lobby #controls .orange.ready { background-position: -160px -100px; } #lobby #controls .green.controls { background-position: 0px -150px; } -#lobby #controls .green.ready { background-position: -159px -150px; } +#lobby #controls .green.ready { background-position: -160px -150px; } #lobby #controls .pink.controls { background-position: 0px -200px; } -#lobby #controls .pink.ready { background-position: -159px -200px; } +#lobby #controls .pink.ready { background-position: -160px -200px; } #lobby #controls .blue.controls { background-position: 0px -250px; } -#lobby #controls .blue.ready { background-position: -159px -250px; } +#lobby #controls .blue.ready { background-position: -160px -250px; } #lobby footer { padding: 0 80px; @@ -191,7 +195,8 @@ input[type="checkbox"]:checked + label::before { .message { font-size: 8px; padding: 2px 80px; - background-color: rgba(0, 0, 0, 0.5); + text-align: center; + text-shadow: 0 0 2px black, 0 0 2px black, 0 0 4px black, 0 0 4px black, 0 0 4px black, 0 0 4px black, 0 0 8px black, 0 0 8px black, 0 0 8px black, 0 0 8px black; } .info.message { diff --git a/js/GUIController.js b/js/GUIController.js index 710967c..6a904fb 100644 --- a/js/GUIController.js +++ b/js/GUIController.js @@ -2,8 +2,9 @@ function GUIController(cfg) { - const CLASS_ACTIVE = "active"; - const CLASS_HIDDEN = "hidden"; + const CURSOR_VISIBLE = "visible"; + const CURSOR_HIDDEN_ON_CANVAS = "hidden_on_canvas"; + const CURSOR_HIDDEN = "hidden"; const config = cfg; const lobby = byID("lobby"); @@ -25,7 +26,12 @@ function GUIController(cfg) { function hideLobby() { log("Hiding lobby."); - lobby.classList.add(CLASS_HIDDEN); + lobby.classList.add(STRINGS.class_hidden); + } + + function showLobby() { + log("Showing lobby."); + lobby.classList.remove(STRINGS.class_hidden); } function isLobbyEntry(element) { @@ -40,6 +46,31 @@ function GUIController(cfg) { Array.from(scoreboard.children).forEach(resetScoreboardEntry); } + function resetResults() { + Array.from(results.children).forEach(resetScoreboardEntry); + } + + function setCursorBehavior(behavior) { + switch (behavior) { + case CURSOR_VISIBLE: + document.body.classList.remove(STRINGS.class_nocursor); + break; + case CURSOR_HIDDEN_ON_CANVAS: + canvas_main.classList.add(STRINGS.class_nocursor); + canvas_overlay.classList.add(STRINGS.class_nocursor); + break; + case CURSOR_HIDDEN: + document.body.classList.add(STRINGS.class_nocursor); + break; + default: + logError(`Cannot set cursor behavior to '${behavior}'.`); + } + } + + function resetCursorBehavior() { + setCursorBehavior(CURSOR_VISIBLE); + } + // PUBLIC API @@ -53,7 +84,7 @@ function GUIController(cfg) { if (!isLobbyEntry(entry)) { logWarning(`Cannot mark player ${id} as ready because controls.children[${index}] (${controls.children[index]}) is not a valid lobby entry.`); } else { - entry.children[1].classList.add(CLASS_ACTIVE); + entry.children[1].classList.add(STRINGS.class_active); } } @@ -63,7 +94,13 @@ function GUIController(cfg) { if (!isLobbyEntry(entry)) { logWarning(`Cannot mark player ${id} as unready because controls.children[${index}] (${controls.children[index]}) is not a valid lobby entry.`); } else { - entry.children[1].classList.remove(CLASS_ACTIVE); + entry.children[1].classList.remove(STRINGS.class_active); + } + } + + function allPlayersUnready() { + for (let id = 1; id <= controls.children.length; id++) { + playerUnready(id); } } @@ -71,11 +108,29 @@ function GUIController(cfg) { hideLobby(); } + function gameQuit() { + hideKonecHry(); + showLobby(); + clearMessages(); + resetScoreboard(); + resetResults(); + allPlayersUnready(); + resetCursorBehavior(); + } + function konecHry() { - KONEC_HRY.classList.remove("hidden"); + showKonecHry(); resetScoreboard(); } + function showKonecHry() { + KONEC_HRY.classList.remove(STRINGS.class_hidden); + } + + function hideKonecHry() { + KONEC_HRY.classList.add(STRINGS.class_hidden); + } + function showMessage(message) { if (!currentMessages.includes(message)) { currentMessages.push(message); @@ -135,15 +190,20 @@ function GUIController(cfg) { } return { + CURSOR_VISIBLE, + CURSOR_HIDDEN_ON_CANVAS, + CURSOR_HIDDEN, playerReady, playerUnready, gameStarted, + gameQuit, konecHry, updateScoreOfPlayer, updateMessages, showMessage, hideMessage, clearMessages, + setCursorBehavior, setEdgePadding }; diff --git a/js/Game.js b/js/Game.js index 645bf94..0d8023c 100644 --- a/js/Game.js +++ b/js/Game.js @@ -343,11 +343,6 @@ class Game { this.beginNewRound(); } - /** Quits the game. */ - quit() { - document.location.reload(); - } - /** Announce KONEC HRY, show results etc. */ konecHry() { log(this.constructor.KONEC_HRY); @@ -356,6 +351,11 @@ class Game { this.quitHintTimer = setTimeout(this.showQuitHint.bind(this), this.config.hintDelay); } + quit() { + clearTimeout(this.quitHintTimer); + clearTimeout(this.proceedHintTimer); + } + clearField() { this.pixels.fill(0); this.Render_clearField(); @@ -562,10 +562,7 @@ class Game { proceedKeyPressed() { this.hideProceedHint(); this.hideQuitHint(); - if (this.isEnded()) { - // The game is ended, so a proceed key press should quit: - this.quit(); - } else if (this.isGameOver()) { + if (this.isGameOver()) { // The game is over, so we should show KONEC HRY: this.konecHry(); } else if (this.isPostRound()) { @@ -574,10 +571,12 @@ class Game { } } - quitKeyPressed() { - if (this.isPostRound() && !this.isGameOver()) { - this.quit(); - } + shouldQuitOnQuitKey() { + return this.isPostRound() && !this.isGameOver(); + } + + shouldQuitOnProceedKey() { + return this.isEnded(); } diff --git a/js/Player.js b/js/Player.js index 1593c4e..7d81be0 100644 --- a/js/Player.js +++ b/js/Player.js @@ -90,6 +90,10 @@ class Player { || this.R_keys.includes(button)); } + usesAnyMouseButton() { + return MOUSE_BUTTONS.some((button) => this.hasMouseButton(button)); + } + hasKey(key) { return this.L_keys.includes(key) || this.R_keys.includes(key); diff --git a/js/Zatacka.js b/js/Zatacka.js index af3e317..9793abf 100644 --- a/js/Zatacka.js +++ b/js/Zatacka.js @@ -39,15 +39,40 @@ const Zatacka = ((window, document) => { mouse: new WarningMessage(text.hint_mouse), }), defaultPlayers: Object.freeze([ - { id: 1, name: "Red" , color: "#FF2800", keyL: KEY["1"] , keyR: KEY.Q }, - { id: 2, name: "Yellow", color: "#C3C300", keyL: KEY.CTRL , keyR: KEY.ALT }, - { id: 3, name: "Orange", color: "#FF7900", keyL: KEY.M , keyR: KEY.COMMA }, - { id: 4, name: "Green" , color: "#00CB00", keyL: KEY.LEFT_ARROW, keyR: KEY.DOWN_ARROW }, - { id: 5, name: "Pink" , color: "#DF51B6", keyL: KEY.DIVIDE , keyR: KEY.MULTIPLY }, - { id: 6, name: "Blue" , color: "#00A2CB", keyL: MOUSE.LEFT , keyR: MOUSE.RIGHT } + { id: 1, name: "Red" , color: "#FF2800", keyL: KEY["1"] , keyR: KEY.Q }, + { id: 2, name: "Yellow", color: "#C3C300", keyL: KEY.CTRL , keyR: KEY.ALT }, + { id: 3, name: "Orange", color: "#FF7900", keyL: KEY.M , keyR: KEY.COMMA }, + { id: 4, name: "Green" , color: "#00CB00", keyL: KEY.LEFT_ARROW , keyR: KEY.DOWN_ARROW }, + { id: 5, name: "Pink" , color: "#DF51B6", keyL: [ KEY.DIVIDE, KEY.END, KEY.PAGE_DOWN ], keyR: [ KEY.MULTIPLY, KEY.PAGE_UP ] }, + { id: 6, name: "Blue" , color: "#00A2CB", keyL: MOUSE.LEFT , keyR: MOUSE.RIGHT } ]) }); + const PREFERENCES = Object.freeze([ + { + type: MultichoicePreference, + key: STRINGS.pref_key_cursor, + values: [ + STRINGS.pref_value_cursor_always_visible, + STRINGS.pref_value_cursor_hidden_when_mouse_used_by_player, + STRINGS.pref_value_cursor_always_hidden + ], + default: STRINGS.pref_value_cursor_hidden_when_mouse_used_by_player + }, + { + type: MultichoicePreference, + key: STRINGS.pref_key_hints, + values: [ + STRINGS.pref_value_hints_all, + STRINGS.pref_value_hints_warnings_only, + STRINGS.pref_value_hints_none + ], + default: STRINGS.pref_value_hints_all + } + ]); + + const preferenceManager = new PreferenceManager(PREFERENCES); + function isProceedKey(key) { return config.keys.proceed.includes(key); } @@ -122,6 +147,23 @@ const Zatacka = ((window, document) => { getPaddedHoleConfig()); } + function applyCursorBehavior() { + const mouseIsBeingUsed = game.getPlayers().some(hasMouseButton); + let behavior; + switch (preferenceManager.get(STRINGS.pref_key_cursor)) { + case STRINGS.pref_value_cursor_hidden_when_mouse_used_by_player: + behavior = mouseIsBeingUsed ? guiController.CURSOR_HIDDEN : guiController.CURSOR_VISIBLE; + break; + case STRINGS.pref_value_cursor_always_hidden: + behavior = guiController.CURSOR_HIDDEN; + break; + default: + behavior = guiController.CURSOR_VISIBLE; + } + log(`Setting cursor behavior to ${behavior}.`); + guiController.setCursorBehavior(behavior); + } + function proceedKeyPressedInLobby() { const numberOfReadyPlayers = game.getNumberOfPlayers(); if (numberOfReadyPlayers > 0) { @@ -130,13 +172,14 @@ const Zatacka = ((window, document) => { guiController.clearMessages(); removeLobbyEventListeners(); addGameEventListeners(); + applyCursorBehavior(); game.setMode(numberOfReadyPlayers === 1 ? Game.PRACTICE : Game.COMPETITIVE); game.start(); } } function hasMouseButton(player) { - return Object.keys(MOUSE).some((buttonName) => player.hasMouseButton(MOUSE[buttonName])); + return player.usesAnyMouseButton(); } function checkForDangerousInput() { @@ -183,10 +226,18 @@ const Zatacka = ((window, document) => { } } + function defaultPlayerHasLeftKey(playerData, pressedKey) { + return pressedKey === playerData.keyL || (playerData.keyL instanceof Array && playerData.keyL.includes(pressedKey)); + } + + function defaultPlayerHasRightKey(playerData, pressedKey) { + return pressedKey === playerData.keyR || (playerData.keyR instanceof Array && playerData.keyR.includes(pressedKey)); + } + function addOrRemovePlayer(playerData, pressedKey) { - if (pressedKey === playerData.keyL) { + if (defaultPlayerHasLeftKey(playerData, pressedKey)) { addPlayer(playerData.id); - } else if (pressedKey === playerData.keyR) { + } else if (defaultPlayerHasRightKey(playerData, pressedKey)) { removePlayer(playerData.id); } } @@ -220,15 +271,27 @@ const Zatacka = ((window, document) => { mouseClickedInLobby(event.button); } + function quitGame() { + removeGameEventListeners(); + addLobbyEventListeners(); + game.quit(); + guiController.gameQuit(); + game = newGame(); + } + function gameKeyHandler(event) { const pressedKey = event.keyCode; if (shouldPreventDefault(pressedKey)) { event.preventDefault(); } if (isProceedKey(pressedKey)) { - game.proceedKeyPressed(); - } else if (isQuitKey(pressedKey)) { - game.quitKeyPressed(); + if (game.shouldQuitOnProceedKey()) { + quitGame(); + } else { + game.proceedKeyPressed(); + } + } else if (isQuitKey(pressedKey) && game.shouldQuitOnQuitKey()) { + quitGame(); } } @@ -273,8 +336,12 @@ const Zatacka = ((window, document) => { addLobbyEventListeners(); + function newGame() { + return new Game(config, Renderer(canvas_main, canvas_overlay), guiController); + } + const guiController = GUIController(config); - const game = new Game(config, Renderer(canvas_main, canvas_overlay), guiController); + let game = newGame(); let hintProceedTimer; let hintPickTimer = setTimeout(() => { diff --git a/js/lib/Utilities.js b/js/lib/Utilities.js index f36b9ef..bd0161f 100644 --- a/js/lib/Utilities.js +++ b/js/lib/Utilities.js @@ -28,6 +28,10 @@ const F_KEYS = Object.freeze([ KEY.F1, KEY.F2, KEY.F3, KEY.F4, KEY.F5, KEY.F6, KEY.F7, KEY.F8, KEY.F9, KEY.F10, KEY.F11, KEY.F12 ]); +const MOUSE_BUTTONS = Object.freeze([ + MOUSE.LEFT, MOUSE.RIGHT, MOUSE.MIDDLE, MOUSE.MOUSE4, MOUSE.MOUSE5 +]); + function isObject(obj) { return typeOf(obj) === "object"; } diff --git a/js/lib/preferences/BooleanPreference.js b/js/lib/preferences/BooleanPreference.js new file mode 100644 index 0000000..f0ec9a4 --- /dev/null +++ b/js/lib/preferences/BooleanPreference.js @@ -0,0 +1,19 @@ +"use strict"; + +class BooleanPreference extends MultichoicePreference { + constructor(data) { + super({ + key: data.key, + values: ["true", "false"], + default: data.default + }); + } + + static stringify(value) { + return value.toString(); + } + + static parse(stringifiedValue) { + return stringifiedValue === "true"; + } +} diff --git a/js/lib/preferences/IntegerRangePreference.js b/js/lib/preferences/IntegerRangePreference.js new file mode 100644 index 0000000..92459d4 --- /dev/null +++ b/js/lib/preferences/IntegerRangePreference.js @@ -0,0 +1,27 @@ +"use strict"; + +class IntegerRangePreference extends RangePreference { + constructor(data) { + if (!isInt(data.min) || !isInt(data.max)) { + throw new TypeError(`min and max must be integers (found ${data.min} and ${data.max} for preference '${data.key}').`); + } + super(data); + this.min = data.min; + this.max = data.max; + if (!this.isValidValue(data.default)) { + super.invalidValue(data.default); + } + } + + isValidValue(value) { + return isInt(value) && value >= this.min && value <= this.max; + } + + static stringify(value) { + return value.toString(); + } + + static parse(stringifiedValue) { + return parseInt(stringifiedValue); + } +} diff --git a/js/lib/preferences/MultichoicePreference.js b/js/lib/preferences/MultichoicePreference.js new file mode 100644 index 0000000..73ca245 --- /dev/null +++ b/js/lib/preferences/MultichoicePreference.js @@ -0,0 +1,30 @@ +"use strict"; + +class MultichoicePreference extends Preference { + constructor(data) { + if (!isNonEmptyStringArray(data.values)) { + throw new TypeError(`values must be a non-empty string array (found ${data.values} for preference '${data.key}').`); + } + super(data); + this.values = data.values; + if (!this.isValidValue(data.default)) { + super.invalidValue(data.default); + } + + function isNonEmptyStringArray(strings) { + return strings instanceof Array && strings.length > 0 && strings.every(isString); + } + } + + isValidValue(value) { + return this.values.indexOf(value) > -1; + } + + static stringify(value) { + return value; + } + + static parse(stringifiedValue) { + return stringifiedValue; + } +} diff --git a/js/lib/preferences/Preference.js b/js/lib/preferences/Preference.js new file mode 100644 index 0000000..d82f17e --- /dev/null +++ b/js/lib/preferences/Preference.js @@ -0,0 +1,33 @@ +"use strict"; + +class Preference { + constructor(data) { + if (!isString(data.key)) { + throw new TypeError(`key must be a string (found ${data.key}). More info: ${data}`); + } else if (data.default === undefined) { + throw new TypeError(`Preference '${data.key}' must have a default value.`); + } + this.key = data.key; + this.default = data.default; + } + + isValidValue(value) { + return isString(value); + } + + invalidValue(value) { + throw new TypeError(`${value} is not a valid value for preference '${this.key}'.`); + } + + static stringify(value) { + return value.toString(); + } + + static parse(stringifiedValue) { + return stringifiedValue; + } + + getDefaultValue() { + return this.default; + } +} diff --git a/js/lib/preferences/PreferenceManager.js b/js/lib/preferences/PreferenceManager.js new file mode 100644 index 0000000..3bf58b0 --- /dev/null +++ b/js/lib/preferences/PreferenceManager.js @@ -0,0 +1,93 @@ +"use strict"; + +function PreferenceManager(preferencesData) { + const LOCALSTORAGE_PREFIX = "pref_key_"; + + // Parse and validate preferences: + log("Validating preferences ..."); + const PREFERENCES = parsePreferences(preferencesData); + log("Done."); + + function parsePreferences(preferencesData) { + return preferencesData.map(parsePreference); + } + + function parsePreference(pref, index) { + if (!isString(pref.key)) { + throw new TypeError(`'The preference at index ${index} does not have a valid key (found ${pref.key}).`); + } else if (pref.type === undefined || !(pref.type.prototype instanceof Preference)) { + throw new TypeError(`Preference '${pref.key}' does not use a valid preference type (found ${pref.type}).`); + } else if (pref.default === undefined) { + throw new TypeError(`Preference '${pref.key}' has no default value.`); + } + return new (pref.type)(pref); + } + + function preferenceExists(key) { + return getPreference(key) !== undefined; + } + + function getPreference(key) { + return PREFERENCES.find((pref) => pref.key === key); + } + + function getKey(pref) { + return pref.key; + } + + function isValidPreferenceValue(key, value) { + return getPreference(key).isValidValue(value); + } + + function setToDefaultValue(key) { + set(key, getDefaultValue(key)); + } + + function getDefaultValue(key) { + if (!preferenceExists(key)) { + throw new Error(`Preference ${key} does not exist.`); + } + return getPreference(key).getDefaultValue(); + } + + function LS_prefix(key) { + return LOCALSTORAGE_PREFIX + key; + } + + function set(key, value) { + if (!preferenceExists(key)) { + throw new Error(`There is no preference with key '${key}'.`); + } + const pref = getPreference(key); + if (!isValidPreferenceValue(key, value)) { + pref.invalidValue(value); + } else { + log(`Setting preference ${key} to ${value}.`); + localStorage.setItem(LS_prefix(key), pref.stringify(value)); + } + } + + function get(key) { + if (!preferenceExists(key)) { + throw new Error(`There is no preference with key '${key}'.`); + } + const pref = getPreference(key); + const savedValue = localStorage.getItem(LS_prefix(key)); + return isValidPreferenceValue(key, savedValue) ? pref.parse(savedValue) : getDefaultValue(key); + } + + function setAllToDefault() { + log("Resetting all preferences ..."); + PREFERENCES.map(getKey).forEach(setToDefaultValue); + log("Done."); + } + + return { + isValidPreferenceValue, + set, + get, + setToDefaultValue, + getDefaultValue, + setAllToDefault + } +} \ No newline at end of file diff --git a/js/lib/preferences/RangePreference.js b/js/lib/preferences/RangePreference.js new file mode 100644 index 0000000..38826ee --- /dev/null +++ b/js/lib/preferences/RangePreference.js @@ -0,0 +1,29 @@ +"use strict"; + +class RangePreference extends Preference { + constructor(data) { + if (!isNumber(data.min) || !isNumber(data.max)) { + throw new TypeError(`min and max must be numbers (found ${data.min} and ${data.max} for preference '${data.key}').`); + } else if (data.min > data.max) { + throw new TypeError(`min cannot be greater than max (found ${data.min} and ${data.max} for preference '${data.key}', respectively).`); + } + super(data); + this.min = data.min; + this.max = data.max; + if (!this.isValidValue(data.default)) { + super.invalidValue(data.default) + } + } + + isValidValue(value) { + return isNumber(value) && value >= this.min && value <= this.max; + } + + static stringify(value) { + return value.toString(); + } + + static parse(stringifiedValue) { + return parseFloat(stringifiedValue); + } +} diff --git a/js/strings.js b/js/strings.js new file mode 100644 index 0000000..8bb4816 --- /dev/null +++ b/js/strings.js @@ -0,0 +1,17 @@ +"use strict"; + +const STRINGS = Object.freeze({ + class_hidden: "hidden", + class_active: "active", + class_nocursor: "nocursor", + + pref_key_cursor: "cursor", + pref_value_cursor_always_visible: "always_visible", + pref_value_cursor_hidden_when_mouse_used_by_player: "hidden_when_mouse_used_by_player", + pref_value_cursor_always_hidden: "always_hidden", + + pref_key_hints: "hints", + pref_value_hints_all: "all", + pref_value_hints_warnings_only: "warnings", + pref_value_hints_none: "none", +}); diff --git a/resources/kurve-lobby-controls-ready.png b/resources/kurve-lobby-controls-ready.png index c7b91d3..a231a00 100644 Binary files a/resources/kurve-lobby-controls-ready.png and b/resources/kurve-lobby-controls-ready.png differ