├── .prettierrc.json ├── src ├── resources │ ├── js │ │ ├── update_history │ │ │ └── main_update_history.js │ │ ├── quick_match │ │ │ ├── quick_match_server_url.template.js │ │ │ ├── match_group.js │ │ │ └── quick_match.js │ │ ├── utils │ │ │ ├── hash_code.js │ │ │ ├── mod.js │ │ │ ├── input_conversion.js │ │ │ ├── is_local_storage_available.js │ │ │ └── generate_pushid.js │ │ ├── replay │ │ │ ├── main_replay.js │ │ │ ├── replay_saver.js │ │ │ ├── pikavolley_replay.js │ │ │ ├── replay_player.js │ │ │ └── ui_replay.js │ │ ├── data_channel │ │ │ ├── firebase_config.template.js │ │ │ ├── rtc_configuration.js │ │ │ ├── parse_candidate.js │ │ │ └── network_test.js │ │ ├── offline_version_js │ │ │ ├── utils │ │ │ │ ├── local_storage_wrapper.js │ │ │ │ ├── is_embedded_in_other_website.js │ │ │ │ └── dark_color_scheme.js │ │ │ ├── rand.js │ │ │ ├── assets_path.js │ │ │ ├── cloud_and_wave.js │ │ │ ├── audio.js │ │ │ ├── keyboard.js │ │ │ └── main.js │ │ ├── nickname_display.js │ │ ├── bad_words_filtering │ │ │ ├── chat_filter.js │ │ │ ├── bad_word_list.js │ │ │ └── ui.js │ │ ├── block_other_players │ │ │ ├── blocked_ip_list.js │ │ │ └── ui.js │ │ ├── chat_display.js │ │ ├── pikavolley_online.js │ │ ├── main_online.js │ │ └── keyboard_online.js │ └── assets │ │ ├── sounds │ │ ├── bgm.mp3 │ │ ├── WAVE140_1.wav │ │ ├── WAVE141_1.wav │ │ ├── WAVE142_1.wav │ │ ├── WAVE143_1.wav │ │ ├── WAVE144_1.wav │ │ ├── WAVE145_1.wav │ │ └── WAVE146_1.wav │ │ └── images │ │ ├── screenshot.png │ │ ├── IDI_PIKAICON-0.png │ │ ├── sprite_sheet.png │ │ ├── controls_online.png │ │ ├── controls_online_ko.png │ │ ├── IDI_PIKAICON-1_gap_filled.png │ │ ├── IDI_PIKAICON-1_gap_filled_192.png │ │ └── IDI_PIKAICON-1_gap_filled_512.png ├── index.html ├── ko │ ├── ko.js │ ├── replay │ │ └── index.html │ └── update-history │ │ └── index.html └── en │ ├── replay │ └── index.html │ └── update-history │ └── index.html ├── .gitignore ├── jsconfig.json ├── webpack.prod.js ├── webpack.dev.js ├── .vscode └── settings.json ├── .eslintrc.json ├── package.json ├── README.ko.md ├── README.md └── webpack.common.js /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /src/resources/js/update_history/main_update_history.js: -------------------------------------------------------------------------------- 1 | import '../../style.css'; 2 | -------------------------------------------------------------------------------- /src/resources/assets/sounds/bgm.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/sounds/bgm.mp3 -------------------------------------------------------------------------------- /src/resources/assets/sounds/WAVE140_1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/sounds/WAVE140_1.wav -------------------------------------------------------------------------------- /src/resources/assets/sounds/WAVE141_1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/sounds/WAVE141_1.wav -------------------------------------------------------------------------------- /src/resources/assets/sounds/WAVE142_1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/sounds/WAVE142_1.wav -------------------------------------------------------------------------------- /src/resources/assets/sounds/WAVE143_1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/sounds/WAVE143_1.wav -------------------------------------------------------------------------------- /src/resources/assets/sounds/WAVE144_1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/sounds/WAVE144_1.wav -------------------------------------------------------------------------------- /src/resources/assets/sounds/WAVE145_1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/sounds/WAVE145_1.wav -------------------------------------------------------------------------------- /src/resources/assets/sounds/WAVE146_1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/sounds/WAVE146_1.wav -------------------------------------------------------------------------------- /src/resources/assets/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/images/screenshot.png -------------------------------------------------------------------------------- /src/resources/assets/images/IDI_PIKAICON-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/images/IDI_PIKAICON-0.png -------------------------------------------------------------------------------- /src/resources/assets/images/sprite_sheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/images/sprite_sheet.png -------------------------------------------------------------------------------- /src/resources/assets/images/controls_online.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/images/controls_online.png -------------------------------------------------------------------------------- /src/resources/assets/images/controls_online_ko.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/images/controls_online_ko.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | firebase_config.js 4 | quick_match_server_url.js 5 | analytics.html 6 | .firebaserc 7 | firebase.json 8 | .firebase/ 9 | yarn.lock 10 | 11 | -------------------------------------------------------------------------------- /src/resources/assets/images/IDI_PIKAICON-1_gap_filled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/images/IDI_PIKAICON-1_gap_filled.png -------------------------------------------------------------------------------- /src/resources/assets/images/IDI_PIKAICON-1_gap_filled_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/images/IDI_PIKAICON-1_gap_filled_192.png -------------------------------------------------------------------------------- /src/resources/assets/images/IDI_PIKAICON-1_gap_filled_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorisanson/pikachu-volleyball-p2p-online/HEAD/src/resources/assets/images/IDI_PIKAICON-1_gap_filled_512.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es6", 4 | "target": "es6", 5 | "moduleResolution": "node", 6 | "checkJs": true 7 | }, 8 | "include": ["src/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'production', 6 | devtool: 'source-map', 7 | }); 8 | -------------------------------------------------------------------------------- /src/resources/js/quick_match/quick_match_server_url.template.js: -------------------------------------------------------------------------------- 1 | // Set the following to proper server URL, 2 | // and rename this file to "quick_match_server_url.js". 3 | export const serverURL = 'http://localhost:3000'; 4 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Redirecting to ./en/ 4 | 5 | 9 | -------------------------------------------------------------------------------- /src/resources/js/quick_match/match_group.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes 4 | export const MATCH_GROUP = { 5 | GLOBAL: 'GLOBAL', // For "global". This code is not in ISO 3166. 6 | KR: 'KR', 7 | TW: 'TW', 8 | }; 9 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'development', 6 | devtool: 'inline-source-map', 7 | devServer: { 8 | static: './dist', 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.tabSize": 2, 4 | "cSpell.words": [ 5 | "Btns", 6 | "firestore", 7 | "Pikachu", 8 | "Pikachus", 9 | "pikavolley", 10 | "pipikachu", 11 | "pixi", 12 | "preselection", 13 | "SACHI", 14 | "sachisoft", 15 | "Satoshi", 16 | "SAWAYAKAN", 17 | "seedrandom", 18 | "spritesheet", 19 | "Takenouchi" 20 | ], 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | } 23 | -------------------------------------------------------------------------------- /src/resources/js/utils/hash_code.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get hashCode for the string. 3 | * Referred to: https://stackoverflow.com/a/7616484/8581025 4 | * (original source: https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/) 5 | * 6 | * @param {string} s 7 | */ 8 | export function getHashCode(s) { 9 | let hash = 0; 10 | for (let i = 0; i < s.length; i++) { 11 | hash = (hash << 5) - hash + s.charCodeAt(i); 12 | hash |= 0; // Convert to 32bit integer 13 | } 14 | return hash; 15 | } 16 | -------------------------------------------------------------------------------- /src/resources/js/replay/main_replay.js: -------------------------------------------------------------------------------- 1 | import { ASSETS_PATH } from '../offline_version_js/assets_path.js'; 2 | import { setUpUI } from './ui_replay.js'; 3 | 4 | adjustAssetsPath(); 5 | setUpUI(); 6 | 7 | /** 8 | * Adjust game assets path to the path from the perspective of en/replay/index.html 9 | */ 10 | function adjustAssetsPath() { 11 | ASSETS_PATH.SPRITE_SHEET = '../' + ASSETS_PATH.SPRITE_SHEET; 12 | for (const prop in ASSETS_PATH.SOUNDS) { 13 | ASSETS_PATH.SOUNDS[prop] = '../' + ASSETS_PATH.SOUNDS[prop]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/resources/js/data_channel/firebase_config.template.js: -------------------------------------------------------------------------------- 1 | // Populate the following with 2 | // Firebase config object (https://firebase.google.com/docs/web/setup#config-object). 3 | // Then, rename this file to "firebase_config.js". 4 | export const firebaseConfig = { 5 | apiKey: 'api-key', 6 | authDomain: 'project-id.firebaseapp.com', 7 | databaseURL: 'https://project-id.firebaseio.com', 8 | projectId: 'project-id', 9 | storageBucket: 'project-id.appspot.com', 10 | messagingSenderId: 'sender-id', 11 | appId: 'app-id', 12 | measurementId: 'G-measurement-id', 13 | }; 14 | -------------------------------------------------------------------------------- /src/resources/js/data_channel/rtc_configuration.js: -------------------------------------------------------------------------------- 1 | // This configuration should contain at least two stun servers to test for symmetric nat in test_network.js 2 | export const rtcConfiguration = { 3 | iceServers: [ 4 | { 5 | urls: ['stun:stun1.l.google.com:19302'], 6 | }, 7 | { 8 | urls: ['stun:stun2.l.google.com:19302'], 9 | }, 10 | ], 11 | }; 12 | 13 | // Expose `rtcConfiguration` as a global variable so that it can be accessed in the browser console. 14 | // Refer to: 15 | // https://github.com/gorisanson/pikachu-volleyball-p2p-online/pull/27#issuecomment-1974752039 16 | // @ts-ignore 17 | window.rtcConfiguration = rtcConfiguration; 18 | -------------------------------------------------------------------------------- /src/ko/ko.js: -------------------------------------------------------------------------------- 1 | /** 2 | * If want to use message sprites translated to Korean, 3 | * execute this script before executing main.js (more precisely, before creating PikachuVolleyball object). 4 | */ 5 | 6 | import { ASSETS_PATH } from '../resources/js/offline_version_js/assets_path.js'; 7 | 8 | const TEXTURES = ASSETS_PATH.TEXTURES; 9 | 10 | TEXTURES.MARK = 'messages/ko/mark.png'; 11 | TEXTURES.POKEMON = 'messages/ko/pokemon.png'; 12 | TEXTURES.PIKACHU_VOLLEYBALL = 'messages/ko/pikachu_volleyball.png'; 13 | TEXTURES.FIGHT = 'messages/ko/fight.png'; 14 | TEXTURES.WITH_COMPUTER = 'messages/ko/with_computer.png'; 15 | TEXTURES.WITH_FRIEND = 'messages/ko/with_friend.png'; 16 | TEXTURES.GAME_START = 'messages/ko/game_start.png'; 17 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/utils/local_storage_wrapper.js: -------------------------------------------------------------------------------- 1 | export const localStorageWrapper = { 2 | /** 3 | * Get value corresponding to the key from localStorage 4 | * @param {string} key 5 | * @returns {string|null} 6 | */ 7 | get: (key) => { 8 | let value = null; 9 | try { 10 | value = localStorage.getItem(key); 11 | } catch (err) { 12 | console.error(err); 13 | } 14 | return value; 15 | }, 16 | 17 | /* 18 | * Set key-value pair to the localStorage 19 | * @param {string} key 20 | * @param {string} value 21 | */ 22 | set: (key, value) => { 23 | try { 24 | localStorage.setItem(key, value); 25 | } catch (err) { 26 | console.error(err); 27 | } 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/resources/js/utils/mod.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Return positive modulo n % m 5 | * @param {number} n 6 | * @param {number} m 7 | */ 8 | export function mod(n, m) { 9 | return ((n % m) + m) % m; 10 | } 11 | 12 | /** 13 | * is n in the range [n1, n2] modulo m 14 | * @param {number} n 15 | * @param {number} n1 16 | * @param {number} n2 17 | * @param {number} m 18 | */ 19 | export function isInModRange(n, n1, n2, m) { 20 | const _n = mod(n, m); 21 | const _n1 = mod(n1, m); 22 | const _n2 = mod(n2, m); 23 | if (_n1 <= _n2) { 24 | if (mod(n1, m) <= _n && _n <= mod(n2, m)) { 25 | return true; 26 | } 27 | } else { 28 | if (mod(n1, m) <= _n || _n <= mod(n2, m)) { 29 | return true; 30 | } 31 | } 32 | return false; 33 | } 34 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "globals": { 9 | "Atomics": "readonly", 10 | "SharedArrayBuffer": "readonly" 11 | }, 12 | "parserOptions": { 13 | "ecmaVersion": 2018, 14 | "sourceType": "module" 15 | }, 16 | "plugins": ["prettier"], 17 | "rules": { 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | "endOfLine": "auto" 22 | } 23 | ], 24 | "no-constant-condition": ["error", { "checkLoops": false }], 25 | "prefer-const": [ 26 | "error", 27 | { 28 | "destructuring": "any", 29 | "ignoreReadBeforeAssign": false 30 | } 31 | ], 32 | "spaced-comment": ["error", "always", { "block": { "balanced": true } }], 33 | "eqeqeq": ["error", "always"] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/utils/is_embedded_in_other_website.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Check if the page is embedded in other site. 5 | * Copied from: https://stackoverflow.com/a/326076/8581025 6 | */ 7 | const isEmbeddedInOtherWebsite = () => { 8 | try { 9 | return window.self !== window.top; 10 | } catch (e) { 11 | return true; 12 | } 13 | }; 14 | 15 | if (isEmbeddedInOtherWebsite()) { 16 | document 17 | .getElementById('flex-container') 18 | .classList.add('embedded-in-other-website'); 19 | Array.from( 20 | document.getElementsByClassName('if-embedded-in-other-website') 21 | ).forEach((elem) => elem.classList.remove('hidden')); 22 | Array.from( 23 | document.querySelectorAll('.if-embedded-in-other-website button') 24 | ).forEach((elem) => 25 | elem.addEventListener('click', () => { 26 | Array.from( 27 | document.getElementsByClassName('if-embedded-in-other-website') 28 | ).forEach((elem) => elem.classList.add('hidden')); 29 | }) 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/rand.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module contains random number generator used for the game 3 | */ 4 | 'use strict'; 5 | /** @typedef {function():number} RNG */ 6 | 7 | /** @type {RNG} custom RNG (random number generator) function which generates a random number in [0, 1] */ 8 | let customRng = null; 9 | 10 | /** 11 | * Return random integer in [0, 32767] 12 | * 13 | * The machine code of the original game use "_rand()" function in Visual Studio 1988 Library. 14 | * I could't find out how this function works exactly. 15 | * But, anyhow, it should be a function that generate a random number. 16 | * I decided to use custom rand function which generates random integer in [0, 32767] 17 | * which follows rand() function in Visual Studio 2017 Library. 18 | * 19 | * By default, it uses the function "Math.random" for generating a random number. 20 | * A custom RNG function can used by setting the "customRng" as the custom RNG function. 21 | * 22 | * @return {number} random integer 23 | */ 24 | export function rand() { 25 | if (customRng === null) { 26 | return Math.floor(32768 * Math.random()); 27 | } 28 | return Math.floor(32768 * customRng()); 29 | } 30 | 31 | /** 32 | * Set custom RNG function 33 | * @param {RNG} rng 34 | */ 35 | export function setCustomRng(rng) { 36 | customRng = rng; 37 | } 38 | -------------------------------------------------------------------------------- /src/resources/js/utils/input_conversion.js: -------------------------------------------------------------------------------- 1 | import { PikaUserInput } from '../offline_version_js/physics.js'; 2 | 3 | /** 4 | * Convert PikaUserInput object to a 5-bit number. 5 | * 6 | * Input is converted to 5-bit number (so fit in 1 byte = 8 bits). 7 | * input.xDirection: 2bits. 0: 00, 1: 01, -1: 11. 8 | * input.yDirection: 2bits. 0: 00, 1: 01, -1: 11. 9 | * input.powerHit: 1bits. 0: 0, 1: 1. 10 | * The bits order is input.powerHit, input.yDirection, input.xDirection. 11 | * 12 | * @param {PikaUserInput} input PikaUserInput object 13 | * @return {number} 5-bit number 14 | */ 15 | export function convertUserInputTo5bitNumber(input) { 16 | let n = 0; 17 | switch (input.xDirection) { 18 | case 1: 19 | n += 1; 20 | break; 21 | case -1: 22 | n += (1 << 1) + 1; 23 | break; 24 | } 25 | switch (input.yDirection) { 26 | case 1: 27 | n += 1 << 2; 28 | break; 29 | case -1: 30 | n += (1 << 3) + (1 << 2); 31 | break; 32 | } 33 | switch (input.powerHit) { 34 | case 1: 35 | n += 1 << 4; 36 | break; 37 | } 38 | return n; 39 | } 40 | 41 | /** 42 | * Convert 5-bit number to PikaUserInput object 43 | * @param {number} n 5-bit number 44 | */ 45 | export function convert5bitNumberToUserInput(n) { 46 | const input = new PikaUserInput(); 47 | switch (n % (1 << 2)) { 48 | case 0: 49 | input.xDirection = 0; 50 | break; 51 | case 1: 52 | input.xDirection = 1; 53 | break; 54 | case 3: 55 | input.xDirection = -1; 56 | break; 57 | } 58 | switch ((n >>> 2) % (1 << 2)) { 59 | case 0: 60 | input.yDirection = 0; 61 | break; 62 | case 1: 63 | input.yDirection = 1; 64 | break; 65 | case 3: 66 | input.yDirection = -1; 67 | break; 68 | } 69 | input.powerHit = n >>> 4; 70 | return input; 71 | } 72 | -------------------------------------------------------------------------------- /src/resources/js/utils/is_local_storage_available.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copied from Modernizr source code: 3 | * https://github.com/Modernizr/Modernizr/blob/28d969e85cd8ebe5854f6296fd6aace241f6bdf7/feature-detects/storage/localstorage.js 4 | * 5 | * Reference: https://stackoverflow.com/a/16427747/8581025 6 | * 7 | * The license is below the code. 8 | */ 9 | 10 | /** 11 | * Check if local storage is available 12 | * @returns {boolean} 13 | */ 14 | export function getIfLocalStorageIsAvailable() { 15 | try { 16 | localStorage.setItem('__test', 'test'); 17 | localStorage.removeItem('__test'); 18 | return true; 19 | } catch (e) { 20 | return false; 21 | } 22 | } 23 | 24 | /* 25 | The MIT License (MIT) 26 | 27 | Copyright (c) 2021 The Modernizr Team | Modernizr 4.0.0-alpha 28 | 29 | Permission is hereby granted, free of charge, to any person obtaining a copy 30 | of this software and associated documentation files (the "Software"), to deal 31 | in the Software without restriction, including without limitation the rights 32 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 33 | copies of the Software, and to permit persons to whom the Software is 34 | furnished to do so, subject to the following conditions: 35 | 36 | The above copyright notice and this permission notice shall be included in 37 | all copies or substantial portions of the Software. 38 | 39 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 40 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 41 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 42 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 43 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 44 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 45 | THE SOFTWARE. 46 | */ 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pikachu-volleyball-p2p-online", 3 | "version": "1.3.1", 4 | "description": "Pikachu Volleyball peer-to-peer online via WebRTC data channels", 5 | "private": true, 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack serve --config webpack.dev.js", 9 | "build": "webpack --config webpack.prod.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/gorisanson/pikachu-volleyball-p2p-online.git" 14 | }, 15 | "keywords": [ 16 | "game" 17 | ], 18 | "author": "Kyutae Lee", 19 | "license": "UNLICENSED", 20 | "bugs": { 21 | "url": "https://github.com/gorisanson/pikachu-volleyball-p2p-online/issues" 22 | }, 23 | "homepage": "https://github.com/gorisanson/pikachu-volleyball-p2p-online#readme", 24 | "devDependencies": { 25 | "clean-webpack-plugin": "^4.0.0", 26 | "copy-webpack-plugin": "^11.0.0", 27 | "css-loader": "^6.7.1", 28 | "eslint": "^8.17.0", 29 | "eslint-plugin-prettier": "^4.0.0", 30 | "html-webpack-plugin": "^5.5.0", 31 | "mini-css-extract-plugin": "^2.6.0", 32 | "prettier": "^2.6.2", 33 | "webpack": "^5.76.0", 34 | "webpack-cli": "^4.9.2", 35 | "webpack-dev-server": "^4.9.1", 36 | "webpack-merge": "^5.8.0" 37 | }, 38 | "dependencies": { 39 | "@pixi/canvas-display": "^6.4.2", 40 | "@pixi/canvas-prepare": "^6.4.2", 41 | "@pixi/canvas-renderer": "^6.4.2", 42 | "@pixi/canvas-sprite": "^6.4.2", 43 | "@pixi/constants": "^6.4.2", 44 | "@pixi/core": "^6.4.2", 45 | "@pixi/display": "^6.4.2", 46 | "@pixi/loaders": "^6.4.2", 47 | "@pixi/prepare": "^6.4.2", 48 | "@pixi/settings": "^6.4.2", 49 | "@pixi/sound": "^4.2.1", 50 | "@pixi/sprite": "^6.4.2", 51 | "@pixi/sprite-animated": "^6.4.2", 52 | "@pixi/spritesheet": "^6.4.2", 53 | "@pixi/ticker": "^6.4.2", 54 | "file-saver": "^2.0.5", 55 | "firebase": "^9.8.2", 56 | "seedrandom": "3.0.5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /README.ko.md: -------------------------------------------------------------------------------- 1 | # 피카츄 배구 P2P 온라인 2 | 3 | [_English_](README.md) | _✓_ _Korean(한국어)_ 4 | 5 | 피카츄 배구(対戦ぴかちゅ~ ビーチバレー編)는 "(C) SACHI SOFT / SAWAYAKAN Programmers"와 "(C) Satoshi Takenouchi"가 1997년에 만든 윈도우용 게임입니다. 6 | 7 | 피카츄 배구 P2P 온라인은 이 피카츄 배구 게임의 P2P (peer-to-peer) 온라인 버전입니다. 인터넷을 통해 다른 사람과 함께 플레이할 수 있습니다. 원조 게임을 리버스 엔지니어링해서 만든 [피카츄 배구 오프라인 웹 버전](https://github.com/gorisanson/pikachu-volleyball)에 [WebRTC](https://webrtc.org/) [data channels](https://webrtc.org/getting-started/data-channels)을 결합하여 개발했습니다. 8 | 9 | https://gorisanson.github.io/pikachu-volleyball-p2p-online/ko/ 에서 피카츄 배구 P2P 온라인을 플레이할 수 있습니다. 10 | 11 | 피카츄 배구 게임 스크린샷 12 | 13 | ## 구조 14 | 15 | - 오프라인 버전: 오프라인 웹 버전의 소스 코드 파일이 모두 [`src/resources/js/offline_version_js/`](src/resources/js/offline_version_js)에 담겨 있습니다. https://github.com/gorisanson/pikachu-volleyball/tree/main/src/resources/js 에 있는 소스 코드 파일과 동일한 것입니다. 이를 기반으로 온라인 버전을 만들었습니다. 16 | 17 | - WebRTC data channels: WebRTC data channels를 이용한 P2P 온라인 핵심 기능들이 [`src/resources/js/data_channel/data_channel.js`](src/resources/js/data_channel/data_channel.js)에 담겨 있습니다. (WebRTC로 P2P 연결을 맺기 위한 매개 수단으로 [Firebase Cloud Firestore](https://firebase.google.com/docs/firestore)를 사용합니다. 방장이 방장의 친구에게 보내는 방 ID가 서로 공유하는 Cloud Firestore document의 ID입니다. [Firebase + WebRTC Codelab](https://webrtc.org/getting-started/firebase-rtc-codelab) 및 [https://github.com/webrtc/FirebaseRTC](https://github.com/webrtc/FirebaseRTC)에서 사용한 방식을 거의 그대로 이용한 것입니다.) 18 | 19 | - 퀵 매치: 퀵 매치 서버와 통신하는 기능이 [`src/resources/js/quick_match/quick_match.js`](src/resources/js/quick_match/quick_match.js)에 담겨 있습니다. (퀵 매치 서버로는 [Google App Engine](https://cloud.google.com/appengine)을 사용합니다. 퀵 매치 서버는 퀵 매치를 위해 현재 대기하고 있는 사람의 방 ID를 새로 들어온 사람에게 보내주는 역할을 합니다.) 20 | 21 | 게임에서 사용되는 RNG (random number generator) 부분만을 제외하면 게임 상태는 오로지 사용자의 (키보드) 입력에 의해 결정됩니다. 따라서 네트워크 양편에 있는 두 사용자가 사용하는 RNG가 같다면, 사용자의 입력을 서로 주고 받는 것만으로도 두 사용자의 게임 상태를 동일하게 유지할 수 있습니다. 이 P2P 온라인 버전은 data channel open event가 발생할 때 이 두 사용자의 RNG를 같게 만들고 그 후 사용자의 입력을 네트워크를 통해 서로 주고 받습니다. 22 | 23 | 더 자세한 사항은 [`src/resources/js/main_online.js`](src/resources/js/main_online.js) 파일에 있는 주석에서 볼 수 있습니다. 24 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/assets_path.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages the paths (file locations) of the game assets. 3 | */ 4 | 'use strict'; 5 | 6 | export const ASSETS_PATH = { 7 | SPRITE_SHEET: '../resources/assets/images/sprite_sheet.json', 8 | TEXTURES: {}, 9 | SOUNDS: {}, 10 | }; 11 | 12 | const TEXTURES = ASSETS_PATH.TEXTURES; 13 | TEXTURES.PIKACHU = (i, j) => `pikachu/pikachu_${i}_${j}.png`; 14 | TEXTURES.BALL = (s) => `ball/ball_${s}.png`; 15 | TEXTURES.NUMBER = (n) => `number/number_${n}.png`; 16 | 17 | TEXTURES.SKY_BLUE = 'objects/sky_blue.png'; 18 | TEXTURES.MOUNTAIN = 'objects/mountain.png'; 19 | TEXTURES.GROUND_RED = 'objects/ground_red.png'; 20 | TEXTURES.GROUND_LINE = 'objects/ground_line.png'; 21 | TEXTURES.GROUND_LINE_LEFT_MOST = 'objects/ground_line_leftmost.png'; 22 | TEXTURES.GROUND_LINE_RIGHT_MOST = 'objects/ground_line_rightmost.png'; 23 | TEXTURES.GROUND_YELLOW = 'objects/ground_yellow.png'; 24 | TEXTURES.NET_PILLAR_TOP = 'objects/net_pillar_top.png'; 25 | TEXTURES.NET_PILLAR = 'objects/net_pillar.png'; 26 | TEXTURES.SHADOW = 'objects/shadow.png'; 27 | TEXTURES.BALL_HYPER = 'ball/ball_hyper.png'; 28 | TEXTURES.BALL_TRAIL = 'ball/ball_trail.png'; 29 | TEXTURES.BALL_PUNCH = 'ball/ball_punch.png'; 30 | TEXTURES.CLOUD = 'objects/cloud.png'; 31 | TEXTURES.WAVE = 'objects/wave.png'; 32 | TEXTURES.BLACK = 'objects/black.png'; 33 | 34 | TEXTURES.SACHISOFT = 'messages/common/sachisoft.png'; 35 | TEXTURES.READY = 'messages/common/ready.png'; 36 | TEXTURES.GAME_END = 'messages/common/game_end.png'; 37 | 38 | TEXTURES.MARK = 'messages/ja/mark.png'; 39 | TEXTURES.POKEMON = 'messages/ja/pokemon.png'; 40 | TEXTURES.PIKACHU_VOLLEYBALL = 'messages/ja/pikachu_volleyball.png'; 41 | TEXTURES.FIGHT = 'messages/ja/fight.png'; 42 | TEXTURES.WITH_COMPUTER = 'messages/ja/with_computer.png'; 43 | TEXTURES.WITH_FRIEND = 'messages/ja/with_friend.png'; 44 | TEXTURES.GAME_START = 'messages/ja/game_start.png'; 45 | 46 | TEXTURES.SITTING_PIKACHU = 'sitting_pikachu.png'; 47 | 48 | const SOUNDS = ASSETS_PATH.SOUNDS; 49 | SOUNDS.BGM = '../resources/assets/sounds/bgm.mp3'; 50 | SOUNDS.PIPIKACHU = '../resources/assets/sounds/WAVE140_1.wav'; 51 | SOUNDS.PIKA = '../resources/assets/sounds/WAVE141_1.wav'; 52 | SOUNDS.CHU = '../resources/assets/sounds/WAVE142_1.wav'; 53 | SOUNDS.PI = '../resources/assets/sounds/WAVE143_1.wav'; 54 | SOUNDS.PIKACHU = '../resources/assets/sounds/WAVE144_1.wav'; 55 | SOUNDS.POWERHIT = '../resources/assets/sounds/WAVE145_1.wav'; 56 | SOUNDS.BALLTOUCHESGROUND = '../resources/assets/sounds/WAVE146_1.wav'; 57 | -------------------------------------------------------------------------------- /src/resources/js/nickname_display.js: -------------------------------------------------------------------------------- 1 | import { channel } from './data_channel/data_channel'; 2 | import { filterBadWords } from './bad_words_filtering/chat_filter.js'; 3 | 4 | /** 5 | * Display nickname for the player 6 | * @param {boolean} isForPlayer2 7 | * @param {string} nickname 8 | */ 9 | export function displayNicknameFor(nickname, isForPlayer2) { 10 | let nicknameElm = null; 11 | if (!isForPlayer2) { 12 | nicknameElm = document.getElementById('player1-nickname'); 13 | } else { 14 | nicknameElm = document.getElementById('player2-nickname'); 15 | } 16 | nicknameElm.textContent = nickname; 17 | } 18 | 19 | /** 20 | * Display peer's nickname for the player 21 | * Added for filtering nickname 22 | * @param {boolean} isForPlayer2 23 | * @param {string} nickname 24 | */ 25 | export function displayPeerNicknameFor(nickname, isForPlayer2) { 26 | let nicknameElm = null; 27 | if (!isForPlayer2) { 28 | nicknameElm = document.getElementById('player1-nickname'); 29 | } else { 30 | nicknameElm = document.getElementById('player2-nickname'); 31 | } 32 | nicknameElm.textContent = filterBadWords(nickname); 33 | } 34 | 35 | /** 36 | * Display partial ip for the player 37 | * @param {boolean} isForPlayer2 38 | * @param {string} partialIP 39 | */ 40 | export function displayPartialIPFor(partialIP, isForPlayer2) { 41 | let partialIPElm = null; 42 | if (!isForPlayer2) { 43 | partialIPElm = document.getElementById('player1-partial-ip'); 44 | } else { 45 | partialIPElm = document.getElementById('player2-partial-ip'); 46 | } 47 | partialIPElm.textContent = partialIP; 48 | } 49 | 50 | export function displayMyAndPeerNicknameShownOrHidden() { 51 | const elem1 = document.getElementById('player1-hiding-peer-nickname'); 52 | const elem2 = document.getElementById('player2-hiding-peer-nickname'); 53 | const displayShown = (isNicknameShown, elem) => { 54 | if (isNicknameShown) { 55 | elem.classList.add('hidden'); 56 | } else { 57 | elem.classList.remove('hidden'); 58 | } 59 | }; 60 | 61 | if (channel.amIPlayer2 === null) { 62 | if (channel.amICreatedRoom) { 63 | displayShown(channel.myIsPeerNicknameVisible, elem1); 64 | displayShown(channel.peerIsPeerNicknameVisible, elem2); 65 | } else { 66 | displayShown(channel.myIsPeerNicknameVisible, elem2); 67 | displayShown(channel.peerIsPeerNicknameVisible, elem1); 68 | } 69 | } else if (channel.amIPlayer2 === false) { 70 | displayShown(channel.myIsPeerNicknameVisible, elem1); 71 | displayShown(channel.peerIsPeerNicknameVisible, elem2); 72 | } else if (channel.amIPlayer2 === true) { 73 | displayShown(channel.myIsPeerNicknameVisible, elem2); 74 | displayShown(channel.peerIsPeerNicknameVisible, elem1); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/cloud_and_wave.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module takes charge of the physical motion of clouds (on the sky) and wave (on the bottom of game screen) in the game. 3 | * It is also a Model in MVC pattern and also rendered by "view.js". 4 | * 5 | * It is gained by reverse engineering the original game. 6 | * The address of the original function is in the comment. 7 | * ex) FUN_00404770 means the function at the address 00404770 in the machine code. 8 | */ 9 | 'use strict'; 10 | import { rand } from './rand.js'; 11 | 12 | /** 13 | * Class represents a cloud 14 | */ 15 | export class Cloud { 16 | constructor() { 17 | this.topLeftPointX = -68 + (rand() % (432 + 68)); 18 | this.topLeftPointY = rand() % 152; 19 | this.topLeftPointXVelocity = 1 + (rand() % 2); 20 | this.sizeDiffTurnNumber = rand() % 11; 21 | } 22 | 23 | get sizeDiff() { 24 | // this same as return [0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0][this.sizeDiffTurnNumber] 25 | return 5 - Math.abs(this.sizeDiffTurnNumber - 5); 26 | } 27 | 28 | get spriteTopLeftPointX() { 29 | return this.topLeftPointX - this.sizeDiff; 30 | } 31 | 32 | get spriteTopLeftPointY() { 33 | return this.topLeftPointY - this.sizeDiff; 34 | } 35 | 36 | get spriteWidth() { 37 | return 48 + 2 * this.sizeDiff; 38 | } 39 | 40 | get spriteHeight() { 41 | return 24 + 2 * this.sizeDiff; 42 | } 43 | } 44 | 45 | /** 46 | * Class representing wave 47 | */ 48 | export class Wave { 49 | constructor() { 50 | this.verticalCoord = 0; 51 | this.verticalCoordVelocity = 2; 52 | this.yCoords = []; 53 | for (let i = 0; i < 432 / 16; i++) { 54 | this.yCoords.push(314); 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * FUN_00404770 61 | * Move clouds and wave 62 | * @param {Cloud[]} cloudArray 63 | * @param {Wave} wave 64 | */ 65 | export function cloudAndWaveEngine(cloudArray, wave) { 66 | for (let i = 0; i < 10; i++) { 67 | cloudArray[i].topLeftPointX += cloudArray[i].topLeftPointXVelocity; 68 | if (cloudArray[i].topLeftPointX > 432) { 69 | cloudArray[i].topLeftPointX = -68; 70 | cloudArray[i].topLeftPointY = rand() % 152; 71 | cloudArray[i].topLeftPointXVelocity = 1 + (rand() % 2); 72 | } 73 | cloudArray[i].sizeDiffTurnNumber = 74 | (cloudArray[i].sizeDiffTurnNumber + 1) % 11; 75 | } 76 | 77 | wave.verticalCoord += wave.verticalCoordVelocity; 78 | if (wave.verticalCoord > 32) { 79 | wave.verticalCoord = 32; 80 | wave.verticalCoordVelocity = -1; 81 | } else if (wave.verticalCoord < 0 && wave.verticalCoordVelocity < 0) { 82 | wave.verticalCoordVelocity = 2; 83 | wave.verticalCoord = -(rand() % 40); 84 | } 85 | 86 | for (let i = 0; i < 432 / 16; i++) { 87 | wave.yCoords[i] = 314 - wave.verticalCoord + (rand() % 3); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/utils/dark_color_scheme.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { localStorageWrapper } from './local_storage_wrapper'; 3 | const THEME_COLOR_LIGHT = '#FFFFFF'; 4 | const THEME_COLOR_DARK = '#202124'; 5 | 6 | setUpDarkColorSchemeCheckbox(); 7 | 8 | /** 9 | * Set up dark color scheme checkbox 10 | */ 11 | function setUpDarkColorSchemeCheckbox() { 12 | const darkColorSchemeCheckboxElements = Array.from( 13 | document.getElementsByClassName('dark-color-scheme-checkbox') 14 | ); 15 | const colorScheme = localStorageWrapper.get('colorScheme'); 16 | if (colorScheme === 'dark' || colorScheme === 'light') { 17 | darkColorSchemeCheckboxElements.forEach((elem) => { 18 | // @ts-ignore 19 | elem.checked = colorScheme === 'dark'; 20 | }); 21 | applyColorScheme(colorScheme); 22 | } else { 23 | const doesPreferDarkColorScheme = window.matchMedia( 24 | '(prefers-color-scheme: dark)' 25 | ).matches; 26 | // The following line is not for "document.documentElement.dataset.colorScheme = colorScheme;". 27 | // document.documentElement.dataset.colorScheme is not needed to be set for displaying dark color scheme, 28 | // since style.css has media query "@media (prefers-color-scheme: dark)" which deals with it without JavaScript. 29 | // The following line is for setting theme color and etc... 30 | applyColorScheme(doesPreferDarkColorScheme ? 'dark' : 'light'); 31 | darkColorSchemeCheckboxElements.forEach((elem) => { 32 | // @ts-ignore 33 | elem.checked = doesPreferDarkColorScheme; 34 | }); 35 | } 36 | darkColorSchemeCheckboxElements.forEach((elem) => { 37 | elem.addEventListener('change', () => { 38 | // @ts-ignore 39 | const colorScheme = elem.checked ? 'dark' : 'light'; 40 | applyColorScheme(colorScheme); 41 | localStorageWrapper.set('colorScheme', colorScheme); 42 | // For syncing states of other checkbox elements 43 | darkColorSchemeCheckboxElements.forEach((element) => { 44 | if (element !== elem) { 45 | // @ts-ignore 46 | element.checked = elem.checked; 47 | } 48 | }); 49 | }); 50 | }); 51 | } 52 | 53 | /** 54 | * Apply color scheme. (Display color scheme to the screen.) 55 | * @param {string} colorScheme 'dark' or 'light' 56 | */ 57 | function applyColorScheme(colorScheme) { 58 | document.documentElement.dataset.colorScheme = colorScheme; 59 | const themeColorMetaElement = document.querySelector( 60 | 'meta[name="theme-color"]' 61 | ); 62 | if (themeColorMetaElement !== null) { 63 | // The line below is for the status bar color, which is set by theme-color 64 | // meta tag content, of PWA in Apple devices. 65 | themeColorMetaElement.setAttribute( 66 | 'content', 67 | colorScheme === 'dark' ? THEME_COLOR_DARK : THEME_COLOR_LIGHT 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pikachu Volleyball P2P Online 2 | 3 | _✓_ _English_ | [_Korean(한국어)_](README.ko.md) 4 | 5 | Pikachu Volleyball (対戦ぴかちゅ~ ビーチバレー編) is an old Windows game which was developed by "(C) SACHI SOFT / SAWAYAKAN Programmers" and "(C) Satoshi Takenouchi" in 1997. 6 | 7 | Pikachu Volleyball P2P Online is an peer-to-peer online version of the game. You can play with other person through the internet. It is developed by integrating [WebRTC](https://webrtc.org/) [data channels](https://webrtc.org/getting-started/data-channels) to [the offline web version of Pikachu Volleyball](https://github.com/gorisanson/pikachu-volleyball) which is made by reverse engineering the original game. 8 | 9 | You can play Pikachu Volleyball P2P online on the website: https://gorisanson.github.io/pikachu-volleyball-p2p-online/en/ 10 | 11 | Pikachu Volleyball game screenshot 12 | 13 | ## Structure 14 | 15 | - Offline version: All the offline web version source code files is in the directory [`src/resources/js/offline_version_js/`](src/resources/js/offline_version_js). These are the same as the source code files in https://github.com/gorisanson/pikachu-volleyball/tree/main/src/resources/js. The online version is developed base on these. 16 | 17 | - WebRTC data channels: The peer-to-peer online functions utilizing WebRTC data channels are contained in [`src/resources/js/data_channel/data_channel.js`](src/resources/js/data_channel/data_channel.js). ([Firebase Cloud Firestore](https://firebase.google.com/docs/firestore) is used as a mediator for establishing a peer-to-peer communication via WebRTC. The room ID which the room creator sends to the joiner is the ID of a Cloud Firestore document which is shared between them. This method is originally from [Firebase + WebRTC Codelab](https://webrtc.org/getting-started/firebase-rtc-codelab) and [https://github.com/webrtc/FirebaseRTC](https://github.com/webrtc/FirebaseRTC).) 18 | 19 | - Quick Match: The communication with the quick match server is contained in [`src/resources/js/quick_match/quick_match.js`](src/resources/js/quick_match/quick_match.js). ([Google App Engine](https://cloud.google.com/appengine) is used as the quick match server. The quick match server sends the ID of the room, which is created by a person waiting for a quick match, to the new one who comes in.) 20 | 21 | The game state is deterministic on the user (keyboard) inputs except the RNG (random number generator) used in the game. So if the RNG is the same on both peers, only the user inputs need to be communicated to maintain the same game state between the peers. In this p2p online version, the RNG is set to the same thing on both peers at the data channel open event, then the user inputs are communicated via the data channel. 22 | 23 | Refer comments on [`src/resources/js/main_online.js`](src/resources/js/main_online.js) for other details. 24 | -------------------------------------------------------------------------------- /src/resources/js/bad_words_filtering/chat_filter.js: -------------------------------------------------------------------------------- 1 | import { badWordList } from './bad_word_list'; 2 | 3 | /** 4 | * @param {string} message 5 | * @returns {string} 6 | */ 7 | export function filterBadWords(message) { 8 | const badWords = badWordList.createWordArray(); // Sum of basic bad words and additional bad words 9 | const filteredBadWords = [...new Set(badWords)].filter( 10 | (word) => word.length > 0 11 | ); 12 | 13 | let resultChars = Array.from(message); // Original message not yet filtered 14 | let hasChanges = true; // Variable for detecting 'Is filtering ended?' 15 | 16 | const pattern = new RegExp(filteredBadWords.join('|'), 'gi'); 17 | 18 | const characterViews = [ 19 | { name: 'Letter+Emoji', regex: /\p{L}|\p{Emoji}/u }, // Only letter and emoji 20 | { name: 'Letter', regex: /\p{L}/u }, // Only letter 21 | { name: 'Korean', regex: /\p{Script=Hangul}/u }, // Only korean 22 | { name: 'English', regex: /\p{Script=Latin}/u }, // Only English 23 | { name: 'Kanji', regex: /\p{Script=Han}/u }, // Only kanji 24 | { name: 'Emoji', regex: /\p{Emoji}/u }, // Only Emoji 25 | ]; 26 | 27 | while (hasChanges) { 28 | // Repeat filtering while target doesn't exist 29 | hasChanges = false; 30 | let currentResult = Array.from(resultChars); 31 | 32 | for (const characterView of characterViews) { 33 | const { cleaned, mapToOriginal } = cleanMessage( 34 | currentResult.join(''), 35 | characterView.regex 36 | ); 37 | const matches = [...cleaned.matchAll(pattern)]; 38 | 39 | for (const m of matches) { 40 | const matchLength = Array.from(m[0]).length; // Count emojis as length 1 41 | const prefix = cleaned.slice(0, m.index); 42 | const arrayIndex = Array.from(prefix).length; 43 | 44 | // Substitute bad-words to '*' by index 45 | for (let i = 0; i < matchLength; i++) { 46 | const cleanedIndex = arrayIndex + i; 47 | const origIndex = mapToOriginal[cleanedIndex]; 48 | 49 | // Prevent re-filtering 50 | if (currentResult[origIndex] !== '*') { 51 | currentResult[origIndex] = '*'; 52 | hasChanges = true; 53 | } 54 | } 55 | } 56 | } 57 | 58 | if (hasChanges) { 59 | resultChars = currentResult; 60 | } 61 | } // End of while loop 62 | return resultChars.join(''); 63 | } 64 | 65 | /** 66 | * Remove characters not matching 'filterRegex' 67 | * @param {string} message 68 | * @param {RegExp} filterRegex - character which is wanted to remain 69 | * @returns {{cleaned: string, mapToOriginal: number[]}} 70 | */ 71 | function cleanMessage(message, filterRegex) { 72 | const cleanedChars = []; 73 | const mapToOriginal = []; 74 | const messageChars = Array.from(message); 75 | 76 | for (let i = 0; i < messageChars.length; i++) { 77 | const ch = messageChars[i]; 78 | 79 | if (filterRegex.test(ch)) { 80 | mapToOriginal.push(i); 81 | cleanedChars.push(ch.toLowerCase()); 82 | } 83 | } 84 | 85 | return { 86 | cleaned: cleanedChars.join(''), 87 | mapToOriginal: mapToOriginal, 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/resources/js/bad_words_filtering/bad_word_list.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Class representing a custom bad word added by the user. 3 | */ 4 | class CustomBadWord { 5 | /** 6 | * Create a CustomBadWord object 7 | * @param {string} word 8 | * @param {number} [addedTime] 9 | */ 10 | constructor(word, addedTime = Date.now()) { 11 | this.word = word.toLowerCase().trim(); 12 | this.addedTime = addedTime; 13 | } 14 | } 15 | 16 | /** 17 | * Class representing a list of custom bad words 18 | */ 19 | class BadWordList { 20 | /** 21 | * Create a BadWordList object 22 | * @param {number} maxLength 23 | */ 24 | constructor(maxLength) { 25 | this._badWords = []; 26 | this.maxLength = maxLength; 27 | this.willUseBasicBadWords = false; 28 | this.basicBadWords = [ 29 | 'fuck', 30 | 'fuckyou', 31 | 'shit', 32 | 'bitch', 33 | 'asshole', 34 | 'nigger', 35 | 'faggot', 36 | '개새', 37 | '느금', 38 | 'ㄴㄱㅁ', 39 | 'ㄴ금마', 40 | '니애미', 41 | 'ㄴㅇㅁ', 42 | '느그', 43 | '병신', 44 | '병ㅅ', 45 | 'ㅂㅅ', 46 | 'ㅂ신', 47 | 'ㅅㅂ', 48 | '새끼', 49 | 'ㅅㄲ', 50 | '시발', 51 | '씨발', 52 | 'ㅅ발', 53 | '애미', 54 | '애비', 55 | '어머니', 56 | '엄마', 57 | '아버지', 58 | '좆', 59 | 'ㅈ까', 60 | 'ㅈ밥', 61 | 'ㅈㅂ', 62 | 'ㅈ이', 63 | 'ㅄ', 64 | '씹', 65 | ]; 66 | } 67 | 68 | get length() { 69 | return this._badWords.length; 70 | } 71 | 72 | /** 73 | * Return if the list is full 74 | * @returns {boolean} 75 | */ 76 | isFull() { 77 | return this.length >= this.maxLength; 78 | } 79 | 80 | /** 81 | * Add the custom bad words into custom bad words list 82 | * @param {string} bad_Words 83 | */ 84 | AddBadWords(bad_Words) { 85 | if (this.isFull() || this._badWords.some((bw) => bw.word === bad_Words)) { 86 | return; 87 | } 88 | this._badWords.push(new CustomBadWord(bad_Words)); 89 | } 90 | 91 | /** 92 | * Remove a bad word at index from the list 93 | * @param {number} index 94 | */ 95 | removeAt(index) { 96 | this._badWords.splice(index, 1); 97 | } 98 | 99 | /** 100 | * Create a read-only 2D array [word, addedTime]. 101 | * @returns {[string, number][]} 102 | */ 103 | createArrayView() { 104 | return this._badWords.map((badWord) => [badWord.word, badWord.addedTime]); 105 | } 106 | 107 | /** 108 | * Read a 2D array and update this._badWords from it. 109 | * @param {[string, number][]} arrayView 110 | */ 111 | readArrayViewAndUpdate(arrayView) { 112 | this._badWords = []; 113 | arrayView = arrayView.slice(0, this.maxLength); 114 | this._badWords = arrayView.map( 115 | (value) => new CustomBadWord(value[0], value[1]) 116 | ); 117 | } 118 | 119 | /** 120 | * Create a read-only 1D array of words. 121 | * @returns {string[]} 122 | */ 123 | createWordArray() { 124 | if (this.willUseBasicBadWords) { 125 | return this.basicBadWords.concat( 126 | this._badWords.map((badWord) => badWord.word) 127 | ); 128 | } else { 129 | return this._badWords.map((badWord) => badWord.word); 130 | } 131 | } 132 | } 133 | 134 | export const badWordList = new BadWordList(50); // Limit of the number of bad words 135 | -------------------------------------------------------------------------------- /src/resources/js/utils/generate_pushid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This code is originated from a gist https://gist.github.com/mikelehen/3596a30bd69384624c11 3 | * I found the gist link at https://firebase.googleblog.com/2015/02/the-2120-ways-to-ensure-unique_68.html 4 | * 5 | * Modified the original code somewhat so that the generated id can be easily distinguishable by human eye 6 | * and Web Crypto API is used instead of Math.random if available. 7 | */ 8 | 'use strict'; 9 | 10 | /** 11 | * Fancy ID generator that creates 20-character string identifiers with the following properties: 12 | * 13 | * 1. They're based on timestamp so that they sort *after* any existing ids. 14 | * 2. They contain 50-bits of random data after the timestamp so that IDs won't collide with other clients' IDs. 15 | * 3. They sort *lexicographically* (so the timestamp is converted to characters that will sort properly). 16 | * 4. They're monotonically increasing. Even if you generate more than one in the same timestamp, the 17 | * latter ones will sort after the former ones. We do this by using the previous random bits 18 | * but "incrementing" them by 1 (only in the case of a timestamp collision). 19 | */ 20 | export const generatePushID = (function () { 21 | // Modeled after base32 web-safe chars, but ordered by ASCII. 22 | const PUSH_CHARS = '23456789abcdefghijkmnpqrstuvwxyz'; 23 | 24 | // Timestamp of last push, used to prevent local collisions if you push twice in one ms. 25 | let lastPushTime = 0; 26 | 27 | // We generate 50-bits of randomness which get turned into 10 characters (since 32 === 2^5, (2^5)^10 === 2^50 ) 28 | // and appended to the timestamp to prevent collisions with other clients. We store the last characters we 29 | // generated because in the event of a collision, we'll use those same characters except 30 | // "incremented" by one. 31 | const lastRandChars = []; 32 | 33 | return function () { 34 | let now = Date.now(); 35 | const duplicateTime = now === lastPushTime; 36 | lastPushTime = now; 37 | 38 | const timeStampChars = new Array(10); 39 | for (let i = 9; i >= 0; i--) { 40 | timeStampChars[i] = PUSH_CHARS.charAt(now % 32); 41 | // NOTE: Can't use << here because javascript will convert to int and lose the upper bits. 42 | now = Math.floor(now / 32); 43 | } 44 | if (now !== 0) 45 | throw new Error('We should have converted the entire timestamp.'); 46 | 47 | let id = timeStampChars.join(''); 48 | if (!duplicateTime) { 49 | let array; 50 | if ( 51 | typeof window.crypto !== 'undefined' && 52 | window.crypto.getRandomValues 53 | ) { 54 | array = new Uint32Array(10); 55 | window.crypto.getRandomValues(array); 56 | } 57 | for (let i = 0; i < 10; i++) { 58 | lastRandChars[i] = array 59 | ? array[i] % 32 60 | : Math.floor(Math.random() * 32); 61 | } 62 | } else { 63 | // If the timestamp hasn't changed since last push, use the same random number, except incremented by 1. 64 | let i; 65 | for (i = 9; i >= 0 && lastRandChars[i] === 31; i--) { 66 | lastRandChars[i] = 0; 67 | } 68 | lastRandChars[i]++; 69 | } 70 | for (let i = 0; i < 10; i++) { 71 | id += PUSH_CHARS.charAt(lastRandChars[i]); 72 | } 73 | if (id.length !== 20) throw new Error('Length should be 20.'); 74 | 75 | return id; 76 | }; 77 | })(); 78 | -------------------------------------------------------------------------------- /src/resources/js/block_other_players/blocked_ip_list.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Class representing a blocked IP 3 | */ 4 | class BlockedIP { 5 | /** 6 | * Create a BlockedIP object 7 | * @param {string} hashedIP 8 | * @param {string} partialIP 9 | * @param {number} [blockedTime] 10 | * @param {string} [remark] 11 | */ 12 | constructor(hashedIP, partialIP, blockedTime = Date.now(), remark = '') { 13 | this.hashedIP = hashedIP; 14 | this.partialIP = partialIP; 15 | this.blockedTime = blockedTime; 16 | this.remark = remark; 17 | } 18 | } 19 | 20 | /** 21 | * Class representing a list of blocked IPs 22 | */ 23 | class BlockedIPList { 24 | /** 25 | * Create a BlockedIPLIst object 26 | * @param {number} maxLength 27 | */ 28 | constructor(maxLength) { 29 | this._blockedIPs = []; 30 | this._peerHashedIP = null; 31 | this.maxLength = maxLength; 32 | } 33 | 34 | get length() { 35 | return this._blockedIPs.length; 36 | } 37 | 38 | /** 39 | * Return if the list is full 40 | * @returns {boolean} 41 | */ 42 | isFull() { 43 | return this.length >= this.maxLength; 44 | } 45 | 46 | /** 47 | * Stage the hashed IP of the peer 48 | * @param {string} peerHashedIP 49 | */ 50 | stagePeerHashedIP(peerHashedIP) { 51 | this._peerHashedIP = peerHashedIP; 52 | } 53 | 54 | /** 55 | * Return if a peer hashed IP is staged 56 | * @returns {boolean} 57 | */ 58 | isPeerHashedIPStaged() { 59 | return this._peerHashedIP !== null; 60 | } 61 | 62 | /** 63 | * Add the staged peer hashed IP to blocked IP list 64 | * @param {string} peerPartialIP 65 | */ 66 | AddStagedPeerHashedIPWithPeerPartialIP(peerPartialIP) { 67 | if (!this.isPeerHashedIPStaged() || this.isFull()) { 68 | return; 69 | } 70 | this._blockedIPs.push(new BlockedIP(this._peerHashedIP, peerPartialIP)); 71 | } 72 | 73 | /** 74 | * Remove a blocked IP at index from the list 75 | * @param {number} index 76 | */ 77 | removeAt(index) { 78 | this._blockedIPs.splice(index, 1); 79 | } 80 | 81 | /** 82 | * Edit remark of a BlockedIP object at index 83 | * @param {number} index 84 | * @param {string} newRemark 85 | */ 86 | editRemarkAt(index, newRemark) { 87 | this._blockedIPs[index].remark = newRemark; 88 | } 89 | 90 | /** 91 | * Create a read-only 2D array whose elements have a structure of [ip, blockedTime, remark]. 92 | * And the elements have same indices as in the this._blockedIPs. 93 | * @returns {[string, string, number, string][]} 94 | */ 95 | createArrayView() { 96 | return this._blockedIPs.map((blockedIP) => [ 97 | blockedIP.hashedIP, 98 | blockedIP.partialIP, 99 | blockedIP.blockedTime, 100 | blockedIP.remark, 101 | ]); 102 | } 103 | 104 | /** 105 | * Read a 2D array which has the same structure of an array created by {@link createArrayView} 106 | * and update this._blockedIPs from it. 107 | * @param {[string, string, number, string][]} arrayView 108 | */ 109 | readArrayViewAndUpdate(arrayView) { 110 | arrayView = arrayView.slice(0, this.maxLength); 111 | this._blockedIPs = arrayView.map( 112 | (value) => new BlockedIP(value[0], value[1], value[2], value[3]) 113 | ); 114 | } 115 | 116 | /** 117 | * Create a read-only 1D array whose elements are blocked hashed IP addresses. 118 | * @returns {[string]} 119 | */ 120 | createHashedIPArray() { 121 | // @ts-ignore 122 | return this._blockedIPs.map((blockedIP) => blockedIP.hashedIP); 123 | } 124 | } 125 | 126 | export const blockedIPList = new BlockedIPList(50); 127 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const CopyPlugin = require('copy-webpack-plugin'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | 7 | module.exports = { 8 | entry: { 9 | main: './src/resources/js/main_online.js', 10 | ko: './src/ko/ko.js', 11 | main_replay: './src/resources/js/replay/main_replay.js', 12 | main_update_history: 13 | './src/resources/js/update_history/main_update_history.js', 14 | dark_color_scheme: 15 | './src/resources/js/offline_version_js/utils/dark_color_scheme.js', 16 | is_embedded_in_other_website: 17 | './src/resources/js/offline_version_js/utils/is_embedded_in_other_website.js', 18 | }, 19 | output: { 20 | filename: '[name].[contenthash].js', 21 | path: path.resolve(__dirname, 'dist'), 22 | }, 23 | optimization: { 24 | runtimeChunk: { name: 'runtime' }, // this is for code-sharing between "main_online.js" and "ko.js" 25 | splitChunks: { 26 | chunks: 'all', 27 | }, 28 | }, 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.css$/i, 33 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 34 | }, 35 | ], 36 | }, 37 | plugins: [ 38 | new CleanWebpackPlugin(), 39 | new CopyPlugin({ 40 | patterns: [ 41 | { 42 | context: 'src/', 43 | from: 'resources/assets/**/*.+(json|png|mp3|wav)', 44 | }, 45 | { from: 'src/index.html', to: 'index.html' }, 46 | ], 47 | }), 48 | new MiniCssExtractPlugin({ 49 | filename: '[name].[contenthash].css', 50 | chunkFilename: '[id].[contenthash].css', 51 | }), 52 | new HtmlWebpackPlugin({ 53 | template: 'src/en/index.html', 54 | filename: 'en/index.html', 55 | chunks: [ 56 | 'runtime', 57 | 'main', 58 | 'dark_color_scheme', 59 | 'is_embedded_in_other_website', 60 | ], 61 | chunksSortMode: 'manual', 62 | minify: { 63 | collapseWhitespace: true, 64 | removeComments: true, 65 | }, 66 | }), 67 | new HtmlWebpackPlugin({ 68 | template: 'src/ko/index.html', 69 | filename: 'ko/index.html', 70 | chunks: [ 71 | 'runtime', 72 | 'ko', 73 | 'main', 74 | 'dark_color_scheme', 75 | 'is_embedded_in_other_website', 76 | ], 77 | chunksSortMode: 'manual', 78 | minify: { 79 | collapseWhitespace: true, 80 | removeComments: true, 81 | }, 82 | }), 83 | new HtmlWebpackPlugin({ 84 | template: 'src/en/replay/index.html', 85 | filename: 'en/replay/index.html', 86 | chunks: ['runtime', 'main_replay', 'dark_color_scheme'], 87 | chunksSortMode: 'manual', 88 | minify: { 89 | collapseWhitespace: true, 90 | removeComments: true, 91 | }, 92 | }), 93 | new HtmlWebpackPlugin({ 94 | template: 'src/ko/replay/index.html', 95 | filename: 'ko/replay/index.html', 96 | chunks: ['runtime', 'ko', 'main_replay', 'dark_color_scheme'], 97 | chunksSortMode: 'manual', 98 | minify: { 99 | collapseWhitespace: true, 100 | removeComments: true, 101 | }, 102 | }), 103 | new HtmlWebpackPlugin({ 104 | template: 'src/en/update-history/index.html', 105 | filename: 'en/update-history/index.html', 106 | chunks: ['main_update_history', 'dark_color_scheme'], 107 | chunksSortMode: 'manual', 108 | minify: { 109 | collapseWhitespace: true, 110 | removeComments: true, 111 | }, 112 | }), 113 | new HtmlWebpackPlugin({ 114 | template: 'src/ko/update-history/index.html', 115 | filename: 'ko/update-history/index.html', 116 | chunks: ['main_update_history', 'dark_color_scheme'], 117 | chunksSortMode: 'manual', 118 | minify: { 119 | collapseWhitespace: true, 120 | removeComments: true, 121 | }, 122 | }), 123 | ], 124 | }; 125 | -------------------------------------------------------------------------------- /src/resources/js/data_channel/parse_candidate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function is from https://github.com/otalk/sdp 3 | * (permalink: https://github.com/otalk/sdp/blob/3a8d369a9c159a691c5ee67d6a5f26b4887d26dc/sdp.js#L48) 4 | * (I see this function first from https://webrtchacks.com/symmetric-nat/) 5 | * The license of the code this function is below (MIT License) 6 | * 7 | **************************************** 8 | * Copyright (c) 2017 Philipp Hancke 9 | * 10 | * Permission is hereby granted, free of charge, to any person obtaining a copy 11 | * of this software and associated documentation files (the "Software"), to deal 12 | * in the Software without restriction, including without limitation the rights 13 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | * copies of the Software, and to permit persons to whom the Software is 15 | * furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all 18 | * copies or substantial portions of the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | * SOFTWARE. 27 | ********************************************* 28 | */ 29 | export function parseCandidate(line) { 30 | let parts; 31 | // Parse both variants. 32 | if (line.indexOf('a=candidate:') === 0) { 33 | parts = line.substring(12).split(' '); 34 | } else { 35 | parts = line.substring(10).split(' '); 36 | } 37 | 38 | const candidate = { 39 | foundation: parts[0], 40 | component: { 1: 'rtp', 2: 'rtcp' }[parts[1]], 41 | protocol: parts[2].toLowerCase(), 42 | priority: parseInt(parts[3], 10), 43 | ip: parts[4], 44 | address: parts[4], // address is an alias for ip. 45 | port: parseInt(parts[5], 10), 46 | // skip parts[6] == 'typ' 47 | type: parts[7], 48 | }; 49 | 50 | for (let i = 8; i < parts.length; i += 2) { 51 | switch (parts[i]) { 52 | case 'raddr': 53 | candidate.relatedAddress = parts[i + 1]; 54 | break; 55 | case 'rport': 56 | candidate.relatedPort = parseInt(parts[i + 1], 10); 57 | break; 58 | case 'tcptype': 59 | candidate.tcpType = parts[i + 1]; 60 | break; 61 | case 'ufrag': 62 | candidate.ufrag = parts[i + 1]; // for backward compatibility. 63 | candidate.usernameFragment = parts[i + 1]; 64 | break; 65 | default: 66 | // extension handling, in particular ufrag 67 | candidate[parts[i]] = parts[i + 1]; 68 | break; 69 | } 70 | } 71 | return candidate; 72 | } 73 | 74 | /** 75 | * Return public IP address extracted from the candidate. 76 | * If the candidate does not contain public IP address, return null. 77 | * @param {Object} candidate 78 | */ 79 | export function parsePublicIPFromCandidate(candidate) { 80 | // Parse the candidate 81 | const cand = parseCandidate(candidate); 82 | // Try to get and return the peer's public IP 83 | if (cand.type === 'srflx') { 84 | return cand.ip; 85 | } else if (cand.type === 'host') { 86 | if (!cand.ip.endsWith('.local')) { 87 | const privateIPReg = RegExp( 88 | '(^127.)|(^10.)|(^172.1[6-9].)|(^172.2[0-9].)|(^172.3[0-1].)|(^192.168.)' 89 | ); 90 | if (!privateIPReg.test(cand.ip)) { 91 | return cand.ip; 92 | } 93 | } 94 | } 95 | return null; 96 | } 97 | 98 | /** 99 | * Get partial public IP, for example, 123.222.*.* 100 | * @param {string} ip ip address, for example, 123.222.111.123 101 | */ 102 | export function getPartialIP(ip) { 103 | const index = ip.indexOf('.', ip.indexOf('.') + 1); 104 | if (index === -1) { 105 | // if ip is IPv6 address 106 | return ip.slice(0, 7); 107 | } else { 108 | // if ip is IPv4 address 109 | return `${ip.slice(0, index)}.*.*`; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/audio.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module takes charge of the game audio (or sounds) 3 | */ 4 | 'use strict'; 5 | import { sound, Sound, filters } from '@pixi/sound'; 6 | import { ASSETS_PATH } from './assets_path.js'; 7 | 8 | const SOUNDS = ASSETS_PATH.SOUNDS; 9 | 10 | /** @typedef {import('@pixi/loaders').LoaderResource} LoaderResource */ 11 | 12 | /** 13 | * Class representing audio 14 | */ 15 | export class PikaAudio { 16 | /** 17 | * Create a PikaAudio object 18 | * @param {Object.} resources loader.resources 19 | */ 20 | constructor(resources) { 21 | /** @type {Object.} sounds pack */ 22 | this.sounds = { 23 | bgm: new PikaStereoSound(resources[SOUNDS.BGM].sound), 24 | pipikachu: new PikaStereoSound(resources[SOUNDS.PIPIKACHU].sound), 25 | pika: new PikaStereoSound(resources[SOUNDS.PIKA].sound), 26 | chu: new PikaStereoSound(resources[SOUNDS.CHU].sound), 27 | pi: new PikaStereoSound(resources[SOUNDS.PI].sound), 28 | pikachu: new PikaStereoSound(resources[SOUNDS.PIKACHU].sound), 29 | powerHit: new PikaStereoSound(resources[SOUNDS.POWERHIT].sound), 30 | ballTouchesGround: new PikaStereoSound( 31 | resources[SOUNDS.BALLTOUCHESGROUND].sound 32 | ), 33 | }; 34 | 35 | this.sounds.bgm.loop = true; 36 | /** @constant @type {number} proper bgm volume */ 37 | this.properBGMVolume = 0.2; 38 | /** @constant @type {number} proper sfx volume */ 39 | this.properSFXVolume = 0.35; 40 | this.adjustVolume(); 41 | } 42 | 43 | /** 44 | * Adjust audio volume 45 | */ 46 | adjustVolume() { 47 | for (const prop in this.sounds) { 48 | if (prop === 'bgm') { 49 | this.sounds[prop].volume = this.properBGMVolume; 50 | } else { 51 | this.sounds[prop].volume = this.properSFXVolume; 52 | } 53 | } 54 | } 55 | 56 | /** 57 | * turn BGM volume 58 | * @param {boolean} turnOn turnOn? turnOff 59 | */ 60 | turnBGMVolume(turnOn) { 61 | let volume; 62 | if (turnOn) { 63 | volume = this.properBGMVolume; 64 | } else { 65 | volume = 0; 66 | } 67 | this.sounds.bgm.volume = volume; 68 | } 69 | 70 | /** 71 | * turn SFX volume 72 | * @param {boolean} turnOn turnOn? turnOff 73 | */ 74 | turnSFXVolume(turnOn) { 75 | let volume; 76 | if (turnOn) { 77 | volume = this.properSFXVolume; 78 | } else { 79 | volume = 0; 80 | } 81 | for (const prop in this.sounds) { 82 | if (prop !== 'bgm') { 83 | this.sounds[prop].volume = volume; 84 | } 85 | } 86 | } 87 | 88 | muteAll() { 89 | sound.muteAll(); 90 | } 91 | 92 | unmuteAll() { 93 | sound.unmuteAll(); 94 | } 95 | } 96 | 97 | /** 98 | * Class representing a stereo sound 99 | */ 100 | class PikaStereoSound { 101 | /** 102 | * create a PikaStereoSound object 103 | * @param {Sound} sound 104 | */ 105 | constructor(sound) { 106 | this.center = sound; 107 | this.left = Sound.from(sound.url); 108 | this.right = Sound.from(sound.url); 109 | 110 | const centerPanning = new filters.StereoFilter(0); 111 | const leftPanning = new filters.StereoFilter(-0.75); 112 | const rightPanning = new filters.StereoFilter(0.75); 113 | this.center.filters = [centerPanning]; 114 | this.left.filters = [leftPanning]; 115 | this.right.filters = [rightPanning]; 116 | } 117 | 118 | /** 119 | * @param {number} v volume: number in [0, 1] 120 | */ 121 | set volume(v) { 122 | this.center.volume = v; 123 | this.left.volume = v; 124 | this.right.volume = v; 125 | } 126 | 127 | /** 128 | * @param {boolean} bool 129 | */ 130 | set loop(bool) { 131 | this.center.loop = bool; 132 | this.left.loop = bool; 133 | this.right.loop = bool; 134 | } 135 | 136 | /** 137 | * play this stereo sound 138 | * @param {number} leftOrCenterOrRight -1: left, 0: center, 1: right 139 | */ 140 | play(leftOrCenterOrRight = 0) { 141 | if (leftOrCenterOrRight === 0) { 142 | this.center.play(); 143 | } else if (leftOrCenterOrRight === -1) { 144 | this.left.play(); 145 | } else if (leftOrCenterOrRight === 1) { 146 | this.right.play(); 147 | } 148 | } 149 | 150 | /** 151 | * stop this stereo sound 152 | */ 153 | stop() { 154 | this.center.stop(); 155 | this.left.stop(); 156 | this.right.stop(); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/resources/js/replay/replay_saver.js: -------------------------------------------------------------------------------- 1 | import { saveAs } from 'file-saver'; 2 | import { serialize } from '../utils/serialize.js'; 3 | import { getHashCode } from '../utils/hash_code.js'; 4 | import { convertUserInputTo5bitNumber } from '../utils/input_conversion.js'; 5 | import { getCommentText } from './ui_replay.js'; 6 | 7 | /** @typedef {import('../offline_version_js/physics.js').PikaUserInput} PikaUserInput */ 8 | /** @typedef {{speed: string, winningScore: number}} Options options communicated with the peer */ 9 | 10 | /** 11 | * Class representing replay saver 12 | */ 13 | class ReplaySaver { 14 | constructor() { 15 | this.frameCounter = 0; 16 | this.roomID = null; // used for set RNGs 17 | this.nicknames = ['', '']; // [0]: room creator's nickname, [1]: room joiner's nickname 18 | this.partialPublicIPs = ['*.*.*.*', '*.*.*.*']; // [0]: room creator's partial public IP address, [1]: room joiner's partial public IP address 19 | this.inputs = []; // number[], the number in the array represents player1, player2 input 20 | this.options = []; // [frameCounter, options][]; 21 | this.chats = []; // [frameCounter, playerIndex (1 or 2), chatMessage][] 22 | } 23 | 24 | /** 25 | * Record room ID for RNGs to be used for replay 26 | * @param {string} roomID 27 | */ 28 | recordRoomID(roomID) { 29 | this.roomID = roomID; 30 | } 31 | 32 | /** 33 | * Record nicknames 34 | * @param {string} roomCreatorNickname 35 | * @param {string} roomJoinerNickname 36 | */ 37 | recordNicknames(roomCreatorNickname, roomJoinerNickname) { 38 | this.nicknames[0] = roomCreatorNickname; 39 | this.nicknames[1] = roomJoinerNickname; 40 | } 41 | 42 | /** 43 | * Record partial public ips 44 | * @param {string} roomCreatorPartialPublicIP 45 | * @param {string} roomJoinerPartialPublicIP 46 | */ 47 | recordPartialPublicIPs( 48 | roomCreatorPartialPublicIP, 49 | roomJoinerPartialPublicIP 50 | ) { 51 | this.partialPublicIPs[0] = roomCreatorPartialPublicIP; 52 | this.partialPublicIPs[1] = roomJoinerPartialPublicIP; 53 | } 54 | 55 | /** 56 | * Record user inputs 57 | * @param {PikaUserInput} player1Input 58 | * @param {PikaUserInput} player2Input 59 | */ 60 | recordInputs(player1Input, player2Input) { 61 | const usersInputNumber = 62 | (convertUserInputTo5bitNumber(player1Input) << 5) + 63 | convertUserInputTo5bitNumber(player2Input); 64 | this.inputs.push(usersInputNumber); 65 | this.frameCounter++; 66 | } 67 | 68 | /** 69 | * Record game options 70 | * @param {Options} options 71 | */ 72 | recordOptions(options) { 73 | this.options.push([this.frameCounter, options]); 74 | } 75 | 76 | /** 77 | * Record a chat message 78 | * @param {string} chatMessage 79 | * @param {number} whichPlayerSide 1 or 2 80 | */ 81 | recordChats(chatMessage, whichPlayerSide) { 82 | this.chats.push([this.frameCounter, whichPlayerSide, chatMessage]); 83 | } 84 | 85 | /** 86 | * Save as a file 87 | */ 88 | saveAsFile() { 89 | const pack = { 90 | version: 'p2p-online', 91 | roomID: this.roomID, 92 | nicknames: this.nicknames, 93 | partialPublicIPs: this.partialPublicIPs, 94 | chats: this.chats, 95 | options: this.options, 96 | inputs: this.inputs, 97 | hash: 0, 98 | }; 99 | 100 | // This is for making it annoying to modify/fabricate the replay file. 101 | // I'm worried about fabricating the replay file and distributing it even if it is unlikely. 102 | // I doubt about the effect of inserting a hash code. But It would be better than doing nothing. 103 | const hash = getHashCode(serialize(pack)); 104 | pack.hash = hash; 105 | 106 | const packWithComment = { 107 | _comment: getCommentText(), 108 | pack: pack, 109 | }; 110 | 111 | const blob = new Blob([JSON.stringify(packWithComment)], { 112 | type: 'text/plain;charset=utf-8', 113 | }); 114 | const d = new Date(); 115 | // The code removing illegal characters in Windows by replace method is from: 116 | // https://stackoverflow.com/a/42210346/8581025 117 | const filename = `${d.getFullYear()}${('0' + (d.getMonth() + 1)).slice( 118 | -2 119 | )}${('0' + d.getDate()).slice(-2)}_${('0' + d.getHours()).slice(-2)}${( 120 | '0' + d.getMinutes() 121 | ).slice(-2)}_${this.nicknames[0]}_${this.partialPublicIPs[0].replace( 122 | '.*.*', 123 | '' 124 | )}_vs_${this.nicknames[1]}_${this.partialPublicIPs[1].replace( 125 | '.*.*', 126 | '' 127 | )}.txt`.replace(/[/\\?%*:|"<>]/g, '_'); 128 | saveAs(blob, filename, { autoBom: true }); 129 | } 130 | } 131 | 132 | export const replaySaver = new ReplaySaver(); 133 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/keyboard.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module takes charge of the user input via keyboard 3 | */ 4 | 'use strict'; 5 | import { PikaUserInput } from './physics.js'; 6 | 7 | /** 8 | * Class representing a keyboard used to control a player 9 | */ 10 | export class PikaKeyboard extends PikaUserInput { 11 | /** 12 | * Create a keyboard used for game controller 13 | * left, right, up, down, powerHit: KeyboardEvent.code value for each 14 | * Refer {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values} 15 | * @param {string} left KeyboardEvent.code value of the key to use for left 16 | * @param {string} right KeyboardEvent.code value of the key to use for right 17 | * @param {string} up KeyboardEvent.code value of the key to use for up 18 | * @param {string} down KeyboardEvent.code value of the key to use for down 19 | * @param {string} powerHit KeyboardEvent.code value of the key to use for power hit or selection 20 | * @param {string} downRight KeyboardEvent.code value of the key to use for having the same effect 21 | * when pressing down key and right key at the same time (Only player 1 22 | * has this key) 23 | */ 24 | constructor(left, right, up, down, powerHit, downRight = null) { 25 | super(); 26 | 27 | /** @type {boolean} */ 28 | this.powerHitKeyIsDownPrevious = false; 29 | 30 | /** @type {Key} */ 31 | this.leftKey = new Key(left); 32 | /** @type {Key} */ 33 | this.rightKey = new Key(right); 34 | /** @type {Key} */ 35 | this.upKey = new Key(up); 36 | /** @type {Key} */ 37 | this.downKey = new Key(down); 38 | /** @type {Key} */ 39 | this.powerHitKey = new Key(powerHit); 40 | /** @type {Key} */ 41 | this.downRightKey = new Key(downRight); 42 | } 43 | 44 | /** 45 | * Get xDirection, yDirection, powerHit input from the keyboard. 46 | * This method is for freezing the keyboard input during the process of one game frame. 47 | */ 48 | getInput() { 49 | if (this.leftKey.isDown) { 50 | this.xDirection = -1; 51 | } else if ( 52 | this.rightKey.isDown || 53 | (this.downRightKey && this.downRightKey.isDown) 54 | ) { 55 | this.xDirection = 1; 56 | } else { 57 | this.xDirection = 0; 58 | } 59 | 60 | if (this.upKey.isDown) { 61 | this.yDirection = -1; 62 | } else if ( 63 | this.downKey.isDown || 64 | (this.downRightKey && this.downRightKey.isDown) 65 | ) { 66 | this.yDirection = 1; 67 | } else { 68 | this.yDirection = 0; 69 | } 70 | 71 | const isDown = this.powerHitKey.isDown; 72 | if (!this.powerHitKeyIsDownPrevious && isDown) { 73 | this.powerHit = 1; 74 | } else { 75 | this.powerHit = 0; 76 | } 77 | this.powerHitKeyIsDownPrevious = isDown; 78 | } 79 | 80 | /** 81 | * Subscribe keydown, keyup event listeners for the keys of this keyboard 82 | */ 83 | subscribe() { 84 | this.leftKey.subscribe(); 85 | this.rightKey.subscribe(); 86 | this.upKey.subscribe(); 87 | this.downKey.subscribe(); 88 | this.powerHitKey.subscribe(); 89 | this.downRightKey.subscribe(); 90 | } 91 | 92 | /** 93 | * Unsubscribe keydown, keyup event listeners for the keys of this keyboard 94 | */ 95 | unsubscribe() { 96 | this.leftKey.unsubscribe(); 97 | this.rightKey.unsubscribe(); 98 | this.upKey.unsubscribe(); 99 | this.downKey.unsubscribe(); 100 | this.powerHitKey.unsubscribe(); 101 | this.downRightKey.unsubscribe(); 102 | } 103 | } 104 | 105 | /** 106 | * Class representing a key on a keyboard 107 | * referred to: https://github.com/kittykatattack/learningPixi 108 | */ 109 | class Key { 110 | /** 111 | * Create a key 112 | * Refer {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values} 113 | * @param {string} value KeyboardEvent.code value of this key 114 | */ 115 | constructor(value) { 116 | this.value = value; 117 | this.isDown = false; 118 | this.isUp = true; 119 | 120 | this.downListener = this.downHandler.bind(this); 121 | this.upListener = this.upHandler.bind(this); 122 | this.subscribe(); 123 | } 124 | 125 | /** 126 | * When key downed 127 | * @param {KeyboardEvent} event 128 | */ 129 | downHandler(event) { 130 | if (event.code === this.value) { 131 | this.isDown = true; 132 | this.isUp = false; 133 | event.preventDefault(); 134 | } 135 | } 136 | 137 | /** 138 | * When key upped 139 | * @param {KeyboardEvent} event 140 | */ 141 | upHandler(event) { 142 | if (event.code === this.value) { 143 | this.isDown = false; 144 | this.isUp = true; 145 | event.preventDefault(); 146 | } 147 | } 148 | 149 | /** 150 | * Subscribe event listeners 151 | */ 152 | subscribe() { 153 | // I think an event listener for keyup should be attached 154 | // before the one for keydown to prevent a buggy behavior. 155 | // If keydown event listener were attached first and 156 | // a key was downed and upped before keyup event listener were attached, 157 | // I think the value of this.isDown would be true (and the value of this.isUp would be false) 158 | // for a while before the user press this key again. 159 | window.addEventListener('keyup', this.upListener); 160 | window.addEventListener('keydown', this.downListener); 161 | } 162 | 163 | /** 164 | * Unsubscribe event listeners 165 | */ 166 | unsubscribe() { 167 | window.removeEventListener('keydown', this.downListener); 168 | window.removeEventListener('keyup', this.upListener); 169 | this.isDown = false; 170 | this.isUp = true; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/resources/js/chat_display.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages displaying of chat messages. 3 | * 4 | * The chat messages between peers appears somewhat random positions on the game screen. 5 | * The positions are random but one message should appears on the same (random) position for both peers. 6 | * It is achieved by setting the same RNG (random number generator) for each player's chat box. 7 | */ 8 | 'use strict'; 9 | import { 10 | channel, 11 | sendChatEnabledMessageToPeer, 12 | } from './data_channel/data_channel.js'; 13 | import { replaySaver } from './replay/replay_saver.js'; 14 | import { filterBadWords } from './bad_words_filtering/chat_filter.js'; 15 | 16 | /** @typedef {import('./pikavolley_online.js').PikachuVolleyballOnline} PikachuVolleyballOnline */ 17 | 18 | let getSpeechBubbleNeeded = null; // it is assigned a function after the game assets are loaded 19 | 20 | let player1ChatRng = null; 21 | let player2ChatRng = null; 22 | 23 | const canvasContainer = document.getElementById('game-canvas-container'); 24 | let player1ChatBox = document.getElementById('player1-chat-box'); 25 | let player2ChatBox = document.getElementById('player2-chat-box'); 26 | 27 | /** 28 | * Set getSpeechBubbleNeeded function 29 | * @param {PikachuVolleyballOnline} pikaVolley 30 | */ 31 | export function setGetSpeechBubbleNeeded(pikaVolley) { 32 | getSpeechBubbleNeeded = () => { 33 | if (document.querySelectorAll('.fade-in-box:not(.hidden)').length > 0) { 34 | return true; 35 | } 36 | if ( 37 | pikaVolley.state === pikaVolley.intro || 38 | pikaVolley.state === pikaVolley.menu 39 | ) { 40 | return true; 41 | } 42 | return false; 43 | }; 44 | } 45 | 46 | export function setChatRngs(rngForPlayer1Chat, rngForPlayer2Chat) { 47 | player1ChatRng = rngForPlayer1Chat; 48 | player2ChatRng = rngForPlayer2Chat; 49 | } 50 | 51 | /** 52 | * Enable/Disable chat 53 | * @param {boolean} turnOn 54 | */ 55 | export function enableChat(turnOn) { 56 | channel.myChatEnabled = turnOn; 57 | displayMyAndPeerChatEnabledOrDisabled(); 58 | sendChatEnabledMessageToPeer(channel.myChatEnabled); 59 | } 60 | 61 | export function displayMyAndPeerChatEnabledOrDisabled() { 62 | const elem1 = document.getElementById('player1-chat-disabled'); 63 | const elem2 = document.getElementById('player2-chat-disabled'); 64 | const displayEnabled = (isChatEnabled, elem) => { 65 | if (isChatEnabled) { 66 | elem.classList.add('hidden'); 67 | } else { 68 | elem.classList.remove('hidden'); 69 | } 70 | }; 71 | 72 | if (channel.amIPlayer2 === null) { 73 | if (channel.amICreatedRoom) { 74 | displayEnabled(channel.myChatEnabled, elem1); 75 | displayEnabled(channel.peerChatEnabled, elem2); 76 | } else { 77 | displayEnabled(channel.myChatEnabled, elem2); 78 | displayEnabled(channel.peerChatEnabled, elem1); 79 | } 80 | } else if (channel.amIPlayer2 === false) { 81 | displayEnabled(channel.myChatEnabled, elem1); 82 | displayEnabled(channel.peerChatEnabled, elem2); 83 | } else if (channel.amIPlayer2 === true) { 84 | displayEnabled(channel.myChatEnabled, elem2); 85 | displayEnabled(channel.peerChatEnabled, elem1); 86 | } 87 | } 88 | 89 | export function hideChat() { 90 | player1ChatBox.classList.add('hidden'); 91 | player2ChatBox.classList.add('hidden'); 92 | } 93 | 94 | export function displayMyChatMessage(message) { 95 | if (channel.amIPlayer2 === null) { 96 | if (channel.amICreatedRoom) { 97 | displayChatMessageAt(message, 1); 98 | } else { 99 | displayChatMessageAt(message, 2); 100 | } 101 | } else if (channel.amIPlayer2 === false) { 102 | displayChatMessageAt(message, 1); 103 | } else if (channel.amIPlayer2 === true) { 104 | displayChatMessageAt(message, 2); 105 | } 106 | } 107 | 108 | export function displayPeerChatMessage(message) { 109 | message = filterBadWords(message); // add chat_filter code only peer's chat 110 | if (channel.amIPlayer2 === null) { 111 | if (channel.amICreatedRoom) { 112 | displayChatMessageAt(message, 2); 113 | } else { 114 | displayChatMessageAt(message, 1); 115 | } 116 | } else if (channel.amIPlayer2 === false) { 117 | displayChatMessageAt(message, 2); 118 | } else if (channel.amIPlayer2 === true) { 119 | displayChatMessageAt(message, 1); 120 | } 121 | } 122 | 123 | export function displayChatMessageAt(message, whichPlayerSide) { 124 | if (!channel.myChatEnabled) { 125 | return; 126 | } 127 | 128 | replaySaver.recordChats(message, whichPlayerSide); 129 | 130 | if (whichPlayerSide === 1) { 131 | const newChatBox = player1ChatBox.cloneNode(); 132 | newChatBox.textContent = message; 133 | // @ts-ignore 134 | newChatBox.style.top = `${20 + 30 * player1ChatRng()}%`; 135 | // @ts-ignore 136 | newChatBox.style.right = `${55 + 25 * player1ChatRng()}%`; 137 | // @ts-ignore 138 | newChatBox.classList.remove('hidden'); 139 | if (getSpeechBubbleNeeded && !getSpeechBubbleNeeded()) { 140 | // If speech Bubble is not needed 141 | // @ts-ignore 142 | newChatBox.classList.remove('in-speech-bubble'); 143 | } else { 144 | // if speech bubble is not needed 145 | // @ts-ignore 146 | newChatBox.classList.add('in-speech-bubble'); 147 | } 148 | canvasContainer.replaceChild(newChatBox, player1ChatBox); 149 | // @ts-ignore 150 | player1ChatBox = newChatBox; 151 | } else if (whichPlayerSide === 2) { 152 | const newChatBox = player2ChatBox.cloneNode(); 153 | newChatBox.textContent = message; 154 | // @ts-ignore 155 | newChatBox.style.top = `${20 + 30 * player2ChatRng()}%`; 156 | // @ts-ignore 157 | newChatBox.style.left = `${55 + 25 * player2ChatRng()}%`; 158 | // @ts-ignore 159 | newChatBox.classList.remove('hidden'); 160 | if (getSpeechBubbleNeeded && !getSpeechBubbleNeeded()) { 161 | // If speech Bubble is not needed 162 | // @ts-ignore 163 | newChatBox.classList.remove('in-speech-bubble'); 164 | } else { 165 | // if speech bubble is not needed 166 | // @ts-ignore 167 | newChatBox.classList.add('in-speech-bubble'); 168 | } 169 | canvasContainer.replaceChild(newChatBox, player2ChatBox); 170 | // @ts-ignore 171 | player2ChatBox = newChatBox; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/resources/js/data_channel/network_test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { parseCandidate } from './parse_candidate.js'; 3 | import { rtcConfiguration } from './rtc_configuration.js'; 4 | 5 | let peerConnection = null; 6 | let dataChannel = null; 7 | 8 | /** 9 | * Major part of this function is copied from https://webrtchacks.com/symmetric-nat/ 10 | */ 11 | export async function testNetwork( 12 | callBack, 13 | callBackIfPassed, 14 | callBackIfDidNotGetSrflxAndDidNotGetHost, 15 | callBackIfDidNotGetSrflxAndHostAddressIsObfuscated, 16 | callBackIfDidNotGetSrflxAndHostAddressIsPrivateIPAddress, 17 | callBackIfBehindSymmetricNat 18 | ) { 19 | peerConnection = new RTCPeerConnection(rtcConfiguration); 20 | 21 | let isHostAddressObfuscated = false; 22 | let isHostAddressPublicIP = false; 23 | let gotHost = false; 24 | let gotSrflx = false; 25 | let isBehindSymmetricNat = false; 26 | const candidates = []; 27 | 28 | let isinternalCallBackIfGotFinalCandidateCalled = false; 29 | const internalCallBackIfGotFinalCandidate = (gotFinalCandidate = false) => { 30 | if (isinternalCallBackIfGotFinalCandidateCalled) { 31 | return; 32 | } 33 | isinternalCallBackIfGotFinalCandidateCalled = true; 34 | if (gotFinalCandidate) { 35 | console.log('Got final candidate!'); 36 | } else { 37 | console.log('Timed out so just proceed as if got final candidate...'); 38 | } 39 | if (dataChannel) { 40 | dataChannel.close(); 41 | } 42 | if (peerConnection) { 43 | peerConnection.close(); 44 | } 45 | if (Object.keys(candidates).length >= 1) { 46 | isBehindSymmetricNat = true; 47 | for (const ip in candidates) { 48 | var ports = candidates[ip]; 49 | if (ports.length === 1) { 50 | isBehindSymmetricNat = false; 51 | break; 52 | } 53 | } 54 | console.log(isBehindSymmetricNat ? 'symmetric nat' : 'normal nat'); 55 | } 56 | if (!gotSrflx && !isHostAddressPublicIP) { 57 | console.log('did not get srflx candidate'); 58 | if (!gotHost) { 59 | console.log('did not get host candidate'); 60 | callBackIfDidNotGetSrflxAndDidNotGetHost(); 61 | } else if (isHostAddressObfuscated) { 62 | console.log('host address is obfuscated'); 63 | callBackIfDidNotGetSrflxAndHostAddressIsObfuscated(); 64 | } else { 65 | console.log( 66 | 'host address is not obfuscated and is a private ip address' 67 | ); 68 | callBackIfDidNotGetSrflxAndHostAddressIsPrivateIPAddress(); 69 | } 70 | } else if (isBehindSymmetricNat) { 71 | console.log('behind symmetric nat'); 72 | callBackIfBehindSymmetricNat(); 73 | } else if (isHostAddressPublicIP || (gotSrflx && !isBehindSymmetricNat)) { 74 | console.log( 75 | '"host address is public IP" or "got srflx, not behind symmetric nat"' 76 | ); 77 | callBackIfPassed(); 78 | } 79 | callBack(); 80 | return; 81 | }; 82 | 83 | peerConnection.addEventListener('icecandidate', (event) => { 84 | // ICE gathering completion is indicated when `event.candidate === null`: 85 | // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/icecandidate_event#indicating_that_ice_gathering_is_complete 86 | if (!event.candidate) { 87 | internalCallBackIfGotFinalCandidate(true); 88 | return; 89 | } 90 | if (event.candidate.candidate === '') { 91 | // This if statement is for Firefox browser. 92 | return; 93 | } 94 | const cand = parseCandidate(event.candidate.candidate); 95 | if (cand.type === 'srflx') { 96 | gotSrflx = true; 97 | // The original version in https://webrtchacks.com/symmetric-nat/ 98 | // use cand.relatedPort instead of cand.ip here to differentiate 99 | // the associated host port.) 100 | // References: 101 | // https://stackoverflow.com/a/53880029/8581025 102 | // https://www.rfc-editor.org/rfc/rfc5245#appendix-B.3 103 | // 104 | // But as we can see here: 105 | // https://datatracker.ietf.org/doc/html/draft-ietf-mmusic-mdns-ice-candidates#section-3.1.2.2-3 106 | // rport is set to a constant value when mDNS is used to obfuscate host 107 | // address thus rport is not appropriate to be used here. 108 | // So I decided to use cand.ip here instead. 109 | // (For some network environment, a user device is assigned both IPv4 110 | // address and IPv6 address. And when cand.relatedPort was used, 111 | // the user device was falsely detected to be behind symmetric. 112 | // (I get to know about this bug while troubleshooting with 113 | // a user "sochew" who reported that their device suddenly reported to 114 | // be behind symmetric nat.) So I fix it by using cand.ip instead.) 115 | if (!candidates[cand.ip]) { 116 | candidates[cand.ip] = [cand.port]; 117 | // this is for the Firefox browser 118 | // Firefox browser trigger an event even if a candidate with 119 | // the same port after translation is received from another STUN server. 120 | } else if (candidates[cand.ip][0] !== cand.port) { 121 | candidates[cand.ip].push(cand.port); 122 | } 123 | setTimeout(internalCallBackIfGotFinalCandidate, 1500); 124 | } else if (cand.type === 'host') { 125 | gotHost = true; 126 | if (cand.ip.endsWith('.local')) { 127 | isHostAddressObfuscated = true; 128 | } else { 129 | const privateIPReg = RegExp( 130 | '(^127.)|(^10.)|(^172.1[6-9].)|(^172.2[0-9].)|(^172.3[0-1].)|(^192.168.)' 131 | ); 132 | if (!privateIPReg.test(cand.ip)) { 133 | isHostAddressPublicIP = true; 134 | } 135 | } 136 | setTimeout(internalCallBackIfGotFinalCandidate, 3000); 137 | } 138 | console.log('Got candidate: ', event.candidate); 139 | }); 140 | 141 | dataChannel = peerConnection.createDataChannel('test', { 142 | ordered: true, 143 | maxRetransmits: 0, 144 | }); 145 | const offer = await peerConnection.createOffer(); 146 | await peerConnection.setLocalDescription(offer); 147 | // Chrome Version 123.0.6312.105 (Official Build) (64-bit) in Ubuntu 148 | // has an issue where ICE gathering completion is indicated too late. 149 | // The following line and similar `setTimeout` lines above are a workaround for the issue. 150 | setTimeout(internalCallBackIfGotFinalCandidate, 5000); 151 | } 152 | -------------------------------------------------------------------------------- /src/resources/js/pikavolley_online.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { PikachuVolleyball } from './offline_version_js/pikavolley.js'; 3 | import { bufferLength, myKeyboard, OnlineKeyboard } from './keyboard_online.js'; 4 | import { SYNC_DIVISOR, channel } from './data_channel/data_channel'; 5 | import { mod } from './utils/mod.js'; 6 | import { askOneMoreGame } from './ui_online.js'; 7 | import { 8 | displayPartialIPFor, 9 | displayNicknameFor, 10 | displayPeerNicknameFor, 11 | displayMyAndPeerNicknameShownOrHidden, 12 | } from './nickname_display.js'; 13 | import { replaySaver } from './replay/replay_saver.js'; 14 | import { PikaUserInput } from './offline_version_js/physics.js'; 15 | import { displayMyAndPeerChatEnabledOrDisabled } from './chat_display.js'; 16 | 17 | /** @typedef GameState @type {function():void} */ 18 | 19 | /** 20 | * Class representing Pikachu Volleyball p2p online game 21 | */ 22 | // @ts-ignore 23 | export class PikachuVolleyballOnline extends PikachuVolleyball { 24 | constructor(stage, resources) { 25 | super(stage, resources); 26 | this.physics.player1.isComputer = false; 27 | this.physics.player2.isComputer = false; 28 | 29 | // @ts-ignore 30 | this.keyboardArray[0].unsubscribe(); 31 | // @ts-ignore 32 | this.keyboardArray[1].unsubscribe(); 33 | 34 | this.myKeyboard = myKeyboard; 35 | this.myOnlineKeyboard = new OnlineKeyboard(this.myKeyboard.inputQueue); 36 | this.peerOnlineKeyboard = new OnlineKeyboard(channel.peerInputQueue); 37 | 38 | this._amIPlayer2 = false; 39 | this.keyboardArray = [this.myOnlineKeyboard, this.peerOnlineKeyboard]; 40 | this._syncCounter = 0; 41 | this.noInputFrameTotal.menu = Infinity; 42 | 43 | this.isFirstGame = true; 44 | 45 | this.willSaveReplay = true; 46 | } 47 | 48 | /** 49 | * @return {boolean} 50 | */ 51 | get amIPlayer2() { 52 | return this._amIPlayer2; 53 | } 54 | 55 | /** 56 | * @param {boolean} bool Am I Player 2? Am I play on the right side? 57 | */ 58 | set amIPlayer2(bool) { 59 | this._amIPlayer2 = bool; 60 | channel.amIPlayer2 = bool; 61 | if (this._amIPlayer2 === true) { 62 | this.keyboardArray = [this.peerOnlineKeyboard, this.myOnlineKeyboard]; 63 | } else { 64 | this.keyboardArray = [this.myOnlineKeyboard, this.peerOnlineKeyboard]; 65 | } 66 | } 67 | 68 | get syncCounter() { 69 | return this._syncCounter; 70 | } 71 | 72 | set syncCounter(counter) { 73 | this._syncCounter = mod(counter, SYNC_DIVISOR); 74 | } 75 | 76 | /** 77 | * Override the "intro" method in the super class. 78 | * It is to ask for one more game with the peer after quick match game ends. 79 | * @type {GameState} 80 | */ 81 | intro() { 82 | if (this.frameCounter === 0) { 83 | this.selectedWithWho = 0; 84 | if (this.isFirstGame) { 85 | this.isFirstGame = false; 86 | this.amIPlayer2 = !channel.amICreatedRoom; 87 | } else if (channel.isQuickMatch) { 88 | askOneMoreGame(); 89 | } 90 | } 91 | super.intro(); 92 | } 93 | 94 | /** 95 | * Override the "menu" method in the super class. 96 | * It changes "am I player 1 or player 2" setting accordingly. 97 | * @type {GameState} 98 | */ 99 | menu() { 100 | const selectedWithWho = this.selectedWithWho; 101 | super.menu(); 102 | if (this.selectedWithWho !== selectedWithWho) { 103 | this.amIPlayer2 = !this.amIPlayer2; 104 | displayNicknameFor(channel.myNickname, this.amIPlayer2); 105 | displayPeerNicknameFor(channel.peerNickname, !this.amIPlayer2); 106 | displayPartialIPFor(channel.myPartialPublicIP, this.amIPlayer2); 107 | displayPartialIPFor(channel.peerPartialPublicIP, !this.amIPlayer2); 108 | displayMyAndPeerChatEnabledOrDisabled(); 109 | displayMyAndPeerNicknameShownOrHidden(); 110 | } 111 | } 112 | 113 | /** 114 | * Game loop 115 | * This function should be called at regular intervals ( interval = (1 / FPS) second ) 116 | */ 117 | gameLoop() { 118 | if (!(channel.gameStartAllowed && channel.isOpen)) { 119 | return; 120 | } 121 | 122 | // Sync game frame and user input with peer 123 | // 124 | // This must be before the slow-mo effect. 125 | // Otherwise, frame sync could be broken, 126 | // for example, if peer use other tap on the browser 127 | // so peer's game pause while my game goes on slow-mo. 128 | // This broken frame sync results into different game state between two peers. 129 | this.myKeyboard.getInputIfNeededAndSendToPeer(this.syncCounter); 130 | this.gameLoopFromGettingPeerInput(); 131 | } 132 | 133 | gameLoopFromGettingPeerInput() { 134 | const checkForPeerInputQueue = this.peerOnlineKeyboard.isInputOnQueue( 135 | this.syncCounter 136 | ); 137 | if (!checkForPeerInputQueue) { 138 | channel.callbackAfterPeerInputQueueReceived = 139 | this.gameLoopFromGettingPeerInput.bind(this); 140 | return; 141 | } 142 | const checkForMyInputQueue = this.myOnlineKeyboard.isInputOnQueue( 143 | this.syncCounter 144 | ); 145 | if (!checkForMyInputQueue) { 146 | return; 147 | } 148 | this.peerOnlineKeyboard.getInput(this.syncCounter); 149 | this.myOnlineKeyboard.getInput(this.syncCounter); 150 | this.syncCounter++; 151 | 152 | if (this.willSaveReplay) { 153 | const player1Input = new PikaUserInput(); 154 | player1Input.xDirection = this.keyboardArray[0].xDirection; 155 | player1Input.yDirection = this.keyboardArray[0].yDirection; 156 | player1Input.powerHit = this.keyboardArray[0].powerHit; 157 | const player2Input = new PikaUserInput(); 158 | player2Input.xDirection = this.keyboardArray[1].xDirection; 159 | player2Input.yDirection = this.keyboardArray[1].yDirection; 160 | player2Input.powerHit = this.keyboardArray[1].powerHit; 161 | replaySaver.recordInputs(player1Input, player2Input); 162 | } 163 | 164 | // slow-mo effect 165 | if (this.slowMotionFramesLeft > 0) { 166 | this.slowMotionNumOfSkippedFrames++; 167 | if ( 168 | this.slowMotionNumOfSkippedFrames % 169 | Math.round(this.normalFPS / this.slowMotionFPS) !== 170 | 0 171 | ) { 172 | return; 173 | } 174 | this.slowMotionFramesLeft--; 175 | this.slowMotionNumOfSkippedFrames = 0; 176 | } 177 | 178 | this.physics.player1.isComputer = false; 179 | this.physics.player2.isComputer = false; 180 | this.state(); 181 | 182 | // window.setTimeout(callback, 0) is used because it puts 183 | // the callback to the message queue of Javascript runtime event loop, 184 | // so the callback does not stack upon the stack of the caller function and 185 | // also does not block if the callbacks are called a bunch in a row. 186 | if (this.peerOnlineKeyboard.inputQueue.length > bufferLength) { 187 | if (this.myOnlineKeyboard.inputQueue.length > bufferLength) { 188 | window.setTimeout(this.gameLoopFromGettingPeerInput.bind(this), 0); 189 | } else { 190 | window.setTimeout(this.gameLoop.bind(this), 0); 191 | } 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/resources/js/bad_words_filtering/ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages UI relevant to bad words censorship 3 | */ 4 | 'use strict'; 5 | 6 | import { getIfLocalStorageIsAvailable } from '../utils/is_local_storage_available'; 7 | import { badWordList } from './bad_word_list.js'; 8 | const STORAGE_KEY_CUSTOM_LIST = 'stringifiedbadWordListArrayView'; 9 | const isLocalStorageAvailable = getIfLocalStorageIsAvailable(); 10 | 11 | const STORAGE_KEY_DEFAULT_FILTER_TOGGLE = 'isDefaultBadWordFilterEnabled'; 12 | 13 | const defaultFilterToggle = document.getElementById( 14 | 'basic-chat-filter-checkbox' 15 | ); 16 | const customBadWordsTableContainer = document.getElementById( 17 | 'blocked-bad-words-table-container' 18 | ); 19 | const customBadWordsTableTbody = document.querySelector( 20 | 'table.blocked-bad-words-table tbody' 21 | ); 22 | const deleteCustomWordBtn = document.querySelector( 23 | 'table.blocked-bad-words-table .delete-btn' 24 | ); 25 | const customBadWordsCountSpan = document.getElementById( 26 | 'number-of-bad-words-addresses' 27 | ); 28 | 29 | const addCustomWordBtn = document.getElementById('add-custom-word-btn'); 30 | const newCustomWordInput = document.getElementById('new-custom-word-input'); 31 | 32 | export function setUpUIForManagingBadWords() { 33 | if (!isLocalStorageAvailable) { 34 | defaultFilterToggle.parentElement.classList.add('hidden'); 35 | customBadWordsTableContainer.classList.add('hidden'); 36 | return; 37 | } 38 | 39 | setUpDefaultFilterToggle(); 40 | setUpCustomFilterManagement(); 41 | } 42 | 43 | /** 44 | * Set up toggle for using list of basic bad words at chat_filter.js 45 | */ 46 | function setUpDefaultFilterToggle() { 47 | let isEnabled = true; 48 | try { 49 | const storedToggleState = window.localStorage.getItem( 50 | STORAGE_KEY_DEFAULT_FILTER_TOGGLE 51 | ); 52 | if (storedToggleState !== null) { 53 | isEnabled = storedToggleState === 'true'; 54 | } 55 | } catch (err) { 56 | console.log(err); 57 | } 58 | // @ts-ignore 59 | defaultFilterToggle.checked = isEnabled; 60 | badWordList.willUseBasicBadWords = isEnabled; 61 | defaultFilterToggle.addEventListener('change', () => { 62 | try { 63 | // @ts-ignore 64 | window.localStorage.setItem( 65 | STORAGE_KEY_DEFAULT_FILTER_TOGGLE, 66 | // @ts-ignore 67 | String(defaultFilterToggle.checked) 68 | ); 69 | } catch (err) { 70 | console.log(err); 71 | } 72 | }); 73 | } 74 | 75 | /** 76 | * Set up table of bad words(delete, register) 77 | */ 78 | function setUpCustomFilterManagement() { 79 | // @ts-ignore 80 | deleteCustomWordBtn.disabled = true; 81 | if (!isLocalStorageAvailable) { 82 | return; 83 | } 84 | 85 | let stringifiedList = null; 86 | try { 87 | stringifiedList = window.localStorage.getItem(STORAGE_KEY_CUSTOM_LIST); 88 | } catch (err) { 89 | console.log(err); 90 | } 91 | 92 | if (stringifiedList !== null) { 93 | const arrayView = JSON.parse(stringifiedList); 94 | if (arrayView.length > 0 && arrayView[0].length !== 2) { 95 | window.localStorage.removeItem(STORAGE_KEY_CUSTOM_LIST); 96 | location.reload(); 97 | } else { 98 | badWordList.readArrayViewAndUpdate(arrayView); 99 | } 100 | } 101 | 102 | displayCustomBadWords(badWordList.createArrayView()); 103 | displayNumberOfCustomBadWords(); 104 | 105 | let lastSelectedIndex = -1; 106 | document.body.addEventListener('click', (event) => { 107 | const target = event.target; 108 | if ( 109 | // @ts-ignore 110 | customBadWordsTableTbody.contains(target) && 111 | // @ts-ignore 112 | target.tagName === 'TD' 113 | ) { 114 | // @ts-ignore 115 | const trElement = target.parentElement; 116 | const currentIndex = trElement.sectionRowIndex; 117 | 118 | if (event.shiftKey && lastSelectedIndex !== -1) { 119 | if (!event.ctrlKey) { 120 | clearSelection(); 121 | } 122 | const start = Math.min(lastSelectedIndex, currentIndex); 123 | const end = Math.max(lastSelectedIndex, currentIndex); 124 | const rows = customBadWordsTableTbody.getElementsByTagName('tr'); 125 | for (let i = start; i <= end; i++) { 126 | if (rows[i]) { 127 | rows[i].classList.add('selected'); 128 | } 129 | } 130 | } else { 131 | if (!event.ctrlKey) { 132 | clearSelection(); 133 | } 134 | trElement.classList.toggle('selected'); 135 | lastSelectedIndex = currentIndex; 136 | } 137 | } else { 138 | if (!event.ctrlKey && !event.shiftKey) { 139 | clearSelection(); 140 | } 141 | } 142 | 143 | // @ts-ignore 144 | deleteCustomWordBtn.disabled = 145 | !customBadWordsTableTbody.querySelector('.selected'); 146 | }); 147 | deleteCustomWordBtn.addEventListener('click', () => { 148 | lastSelectedIndex = -1; 149 | const selectedTRElements = 150 | customBadWordsTableTbody.querySelectorAll('.selected'); 151 | for (let i = selectedTRElements.length - 1; i >= 0; --i) { 152 | // @ts-ignore 153 | badWordList.removeAt(Number(selectedTRElements[i].dataset.index)); 154 | } 155 | try { 156 | window.localStorage.setItem( 157 | 'stringifiedbadWordListArrayView', 158 | JSON.stringify(badWordList.createArrayView()) 159 | ); 160 | } catch (err) { 161 | console.log(err); 162 | } 163 | displayCustomBadWords(badWordList.createArrayView()); 164 | displayNumberOfCustomBadWords(); 165 | }); 166 | addCustomWordBtn.addEventListener('click', () => { 167 | lastSelectedIndex = -1; 168 | // @ts-ignore 169 | const cleanWord = newCustomWordInput.value 170 | .toLowerCase() 171 | .replace(/[^\p{L}\p{Emoji}]/gu, ''); // Words or emojis will be saved 172 | if (!cleanWord || badWordList.isFull()) { 173 | return; 174 | } 175 | badWordList.AddBadWords(cleanWord); 176 | try { 177 | window.localStorage.setItem( 178 | STORAGE_KEY_CUSTOM_LIST, 179 | JSON.stringify(badWordList.createArrayView()) 180 | ); 181 | } catch (err) { 182 | console.log(err); 183 | } 184 | displayCustomBadWords(badWordList.createArrayView()); 185 | displayNumberOfCustomBadWords(); 186 | // @ts-ignore 187 | newCustomWordInput.value = ''; 188 | }); 189 | } 190 | 191 | /** 192 | * Display the given bad word list array view. 193 | * @param {[string, number][]} badWords 194 | */ 195 | function displayCustomBadWords(badWords) { 196 | while (customBadWordsTableTbody.firstChild) { 197 | customBadWordsTableTbody.removeChild(customBadWordsTableTbody.firstChild); 198 | } 199 | // Display the given list 200 | badWords.forEach((badWord, index) => { 201 | const trElement = document.createElement('tr'); 202 | const tdElementForWord = document.createElement('td'); 203 | const tdElementForTime = document.createElement('td'); 204 | trElement.appendChild(tdElementForWord); 205 | trElement.appendChild(tdElementForTime); 206 | trElement.dataset.index = String(index); 207 | tdElementForWord.textContent = badWord[0]; 208 | tdElementForTime.textContent = new Date(badWord[1]).toLocaleString(); 209 | customBadWordsTableTbody.appendChild(trElement); 210 | }); 211 | } 212 | 213 | /** 214 | * Display the number of bad words in the list 215 | */ 216 | function displayNumberOfCustomBadWords() { 217 | customBadWordsCountSpan.textContent = String(badWordList.length); 218 | } 219 | 220 | /** 221 | * Clear all selected rows in the bad words table 222 | */ 223 | function clearSelection() { 224 | Array.from( 225 | // @ts-ignore 226 | customBadWordsTableTbody.getElementsByTagName('tr') 227 | ).forEach((elem) => { 228 | elem.classList.remove('selected'); 229 | }); 230 | } 231 | -------------------------------------------------------------------------------- /src/resources/js/offline_version_js/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the main script which executes the game. 3 | * General explanations for the all source code files of the game are following. 4 | * 5 | ******************************************************************************************************************** 6 | * This web version of the Pikachu Volleyball is made by 7 | * reverse engineering the core part of the original Pikachu Volleyball game 8 | * which is developed by "1997 (C) SACHI SOFT / SAWAYAKAN Programmers" & "1997 (C) Satoshi Takenouchi". 9 | * 10 | * "physics.js", "cloud_and_wave.js", and some codes in "view.js" are the results of this reverse engineering. 11 | * Refer to the comments in each file for the machine code addresses of the original functions. 12 | ******************************************************************************************************************** 13 | * 14 | * This web version game is mainly composed of three parts which follows MVC pattern. 15 | * 1) "physics.js" (Model): The physics engine which takes charge of the dynamics of the ball and the players (Pikachus). 16 | * It is gained by reverse engineering the machine code of the original game. 17 | * 2) "view.js" (View): The rendering part of the game which depends on pixi.js (https://www.pixijs.com/, https://github.com/pixijs/pixi.js) library. 18 | * Some codes in this part is gained by reverse engineering the original machine code. 19 | * 3) "pikavolley.js" (Controller): Make the game work by controlling the Model and the View according to the user input. 20 | * 21 | * And explanations for other source files are below. 22 | * - "cloud_and_wave.js": This is also a Model part which takes charge of the clouds and wave motion in the game. Of course, it is also rendered by "view.js". 23 | * It is also gained by reverse engineering the original machine code. 24 | * - "keyboard.js": Support the Controller("pikavolley.js") to get a user input via keyboard. 25 | * - "audio.js": The game audio or sounds. It depends on pixi-sound (https://github.com/pixijs/pixi-sound) library. 26 | * - "rand.js": For the random function used in the Models ("physics.js", "cloud_and_wave.js"). 27 | * - "assets_path.js": For the assets (image files, sound files) locations. 28 | * - "ui.js": For the user interface (menu bar, buttons etc.) of the html page. 29 | */ 30 | 'use strict'; 31 | import { settings } from '@pixi/settings'; 32 | import { SCALE_MODES } from '@pixi/constants'; 33 | import { Renderer, BatchRenderer, autoDetectRenderer } from '@pixi/core'; 34 | import { Prepare } from '@pixi/prepare'; 35 | import { Container } from '@pixi/display'; 36 | import { Loader } from '@pixi/loaders'; 37 | import { SpritesheetLoader } from '@pixi/spritesheet'; 38 | import { Ticker } from '@pixi/ticker'; 39 | import { CanvasRenderer } from '@pixi/canvas-renderer'; 40 | import { CanvasSpriteRenderer } from '@pixi/canvas-sprite'; 41 | import { CanvasPrepare } from '@pixi/canvas-prepare'; 42 | import '@pixi/canvas-display'; 43 | import { PikachuVolleyball } from './pikavolley.js'; 44 | import { ASSETS_PATH } from './assets_path.js'; 45 | import { setUpUI } from './ui.js'; 46 | 47 | // Reference for how to use Renderer.registerPlugin: 48 | // https://github.com/pixijs/pixijs/blob/af3c0c6bb15aeb1049178c972e4a14bb4cabfce4/bundles/pixi.js/src/index.ts#L27-L34 49 | Renderer.registerPlugin('prepare', Prepare); 50 | Renderer.registerPlugin('batch', BatchRenderer); 51 | // Reference for how to use CanvasRenderer.registerPlugin: 52 | // https://github.com/pixijs/pixijs/blob/af3c0c6bb15aeb1049178c972e4a14bb4cabfce4/bundles/pixi.js-legacy/src/index.ts#L13-L19 53 | CanvasRenderer.registerPlugin('prepare', CanvasPrepare); 54 | CanvasRenderer.registerPlugin('sprite', CanvasSpriteRenderer); 55 | Loader.registerPlugin(SpritesheetLoader); 56 | 57 | // Set settings.RESOLUTION to 2 instead of 1 to make the game screen do not look 58 | // much blurry in case of the image rendering mode of 'image-rendering: auto', 59 | // which is like bilinear interpolation, which is used in "soft" game graphic option. 60 | settings.RESOLUTION = 2; 61 | settings.SCALE_MODE = SCALE_MODES.NEAREST; 62 | settings.ROUND_PIXELS = true; 63 | 64 | const renderer = autoDetectRenderer({ 65 | width: 432, 66 | height: 304, 67 | antialias: false, 68 | backgroundColor: 0x000000, 69 | backgroundAlpha: 1, 70 | // Decided to use only Canvas for compatibility reason. One player had reported that 71 | // on their browser, where pixi chooses to use WebGL renderer, the graphics are not fine. 72 | // And the issue had been fixed by using Canvas renderer. And also for the sake of testing, 73 | // it is more comfortable just to stick with Canvas renderer so that it is unnecessary to switch 74 | // between WebGL renderer and Canvas renderer. 75 | forceCanvas: true, 76 | }); 77 | 78 | const stage = new Container(); 79 | const ticker = new Ticker(); 80 | const loader = new Loader(); 81 | 82 | renderer.view.setAttribute('id', 'game-canvas'); 83 | document.getElementById('game-canvas-container').appendChild(renderer.view); 84 | renderer.render(stage); // To make the initial canvas painting stable in the Firefox browser. 85 | 86 | loader.add(ASSETS_PATH.SPRITE_SHEET); 87 | for (const prop in ASSETS_PATH.SOUNDS) { 88 | loader.add(ASSETS_PATH.SOUNDS[prop]); 89 | } 90 | 91 | setUpInitialUI(); 92 | 93 | /** 94 | * Set up the initial UI. 95 | */ 96 | function setUpInitialUI() { 97 | const loadingBox = document.getElementById('loading-box'); 98 | const progressBar = document.getElementById('progress-bar'); 99 | loader.onProgress.add(() => { 100 | progressBar.style.width = `${loader.progress}%`; 101 | }); 102 | loader.onComplete.add(() => { 103 | loadingBox.classList.add('hidden'); 104 | }); 105 | 106 | const aboutBox = document.getElementById('about-box'); 107 | const aboutBtn = document.getElementById('about-btn'); 108 | const closeAboutBtn = document.getElementById('close-about-btn'); 109 | const gameDropdownBtn = document.getElementById('game-dropdown-btn'); 110 | const optionsDropdownBtn = document.getElementById('options-dropdown-btn'); 111 | // @ts-ignore 112 | gameDropdownBtn.disabled = true; 113 | // @ts-ignore 114 | optionsDropdownBtn.disabled = true; 115 | const closeAboutBox = () => { 116 | if (!aboutBox.classList.contains('hidden')) { 117 | aboutBox.classList.add('hidden'); 118 | // @ts-ignore 119 | aboutBtn.disabled = true; 120 | } 121 | aboutBtn.getElementsByClassName('text-play')[0].classList.add('hidden'); 122 | aboutBtn.getElementsByClassName('text-about')[0].classList.remove('hidden'); 123 | aboutBtn.classList.remove('glow'); 124 | closeAboutBtn 125 | .getElementsByClassName('text-play')[0] 126 | .classList.add('hidden'); 127 | closeAboutBtn 128 | .getElementsByClassName('text-close')[0] 129 | .classList.remove('hidden'); 130 | closeAboutBtn.classList.remove('glow'); 131 | 132 | loader.load(setup); // setup is called after loader finishes loading 133 | loadingBox.classList.remove('hidden'); 134 | aboutBtn.removeEventListener('click', closeAboutBox); 135 | closeAboutBtn.removeEventListener('click', closeAboutBox); 136 | }; 137 | aboutBtn.addEventListener('click', closeAboutBox); 138 | closeAboutBtn.addEventListener('click', closeAboutBox); 139 | } 140 | 141 | /** 142 | * Set up the game and the full UI, and start the game. 143 | */ 144 | function setup() { 145 | const pikaVolley = new PikachuVolleyball(stage, loader.resources); 146 | setUpUI(pikaVolley, ticker); 147 | start(pikaVolley); 148 | } 149 | 150 | /** 151 | * Start the game. 152 | * @param {PikachuVolleyball} pikaVolley 153 | */ 154 | function start(pikaVolley) { 155 | ticker.maxFPS = pikaVolley.normalFPS; 156 | ticker.add(() => { 157 | pikaVolley.gameLoop(); 158 | renderer.render(stage); 159 | }); 160 | ticker.start(); 161 | } 162 | -------------------------------------------------------------------------------- /src/ko/replay/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 피카츄 배구 P2P 온라인 리플레이 재생기 8 | 9 | 10 | 11 | 17 | 18 | 19 |
20 |
21 |
22 |
23 |
24 |
25 | 26 | 27 |
28 |
29 | 30 | 31 |
32 |
33 | 41 |
42 | 46 |

47 | "파일 열기" 버튼을 누르거나 여기에 리플레이 파일을 끌어다 놓으세요 48 |

49 |
50 | 60 | 73 |
74 |
75 |
76 |
Z
77 |
R
78 |
D
79 |
V
80 |
G
81 |
82 |
83 |
Enter
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | 99 |
100 |
101 | 108 | 0:00 / 110 | 0:00 112 |
113 | 120 | 127 | 134 | 141 |
142 |
143 | 150 | 157 | 164 | 171 | 172 | 179 |
180 |
181 | 185 | 189 | 193 | 197 | 201 | 205 | 209 |
210 |
211 |
212 | 217 | 218 |
219 |
220 |
221 | 222 | 223 |
224 | 225 | 226 | -------------------------------------------------------------------------------- /src/resources/js/quick_match/quick_match.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages the communication with the quick match server. 3 | * 4 | * Major parts of fetch API code is copied from https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch 5 | */ 6 | 'use strict'; 7 | import { serverURL } from './quick_match_server_url.js'; 8 | import { createRoom, joinRoom } from '../data_channel/data_channel.js'; 9 | import { blockedIPList } from '../block_other_players/blocked_ip_list.js'; 10 | import { MATCH_GROUP } from './match_group.js'; 11 | import { 12 | printCommunicationCount, 13 | printQuickMatchState, 14 | printQuickMatchLog, 15 | printFailedToConnectToQuickMatchServer, 16 | printNumberOfSuccessfulQuickMatches, 17 | disableCancelQuickMatchBtnForAWhile, 18 | } from '../ui_online.js'; 19 | 20 | let roomIdToCreate = null; 21 | let matchGroup = null; 22 | let communicationCount = null; 23 | 24 | const MESSAGE_TO_SERVER = { 25 | initial: 'initial', 26 | roomCreated: 'roomCreated', 27 | quickMatchSuccess: 'quickMatchSuccess', 28 | withFriendSuccess: 'withFriendSuccess', 29 | cancel: 'cancel', 30 | }; 31 | 32 | export const MESSAGE_TO_CLIENT = { 33 | createRoom: 'createRoom', 34 | keepWait: 'keepWait', // keep sending wait packet 35 | connectToPeer: 'connectToPeer', 36 | connectToPeerAfterAWhile: 'connectToPeerAfterAWhile', 37 | waitPeerConnection: 'waitPeerConnection', // wait the peer to connect to you 38 | abandoned: 'abandoned', 39 | }; 40 | 41 | /** 42 | * Start request/response with quick match server 43 | * @param {string} roomIdToCreateIfNeeded 44 | * @param {string} matchGroupForQuickMatch 45 | */ 46 | export function startQuickMatch( 47 | roomIdToCreateIfNeeded, 48 | matchGroupForQuickMatch 49 | ) { 50 | roomIdToCreate = roomIdToCreateIfNeeded; 51 | matchGroup = matchGroupForQuickMatch; 52 | communicationCount = 0; 53 | postData( 54 | serverURL, 55 | objectToSendToServer( 56 | MESSAGE_TO_SERVER.initial, 57 | roomIdToCreate, 58 | matchGroup, 59 | blockedIPList.createHashedIPArray() 60 | ) 61 | ).then(callback); 62 | } 63 | 64 | /** 65 | * In quick match, the room creator send this quick match success message if data channel is opened. 66 | */ 67 | export function sendQuickMatchSuccessMessageToServer() { 68 | console.log('Send quick match success message to server'); 69 | postData( 70 | serverURL, 71 | objectToSendToServer( 72 | MESSAGE_TO_SERVER.quickMatchSuccess, 73 | roomIdToCreate, 74 | matchGroup 75 | ) 76 | ); 77 | } 78 | 79 | /** 80 | * In "with friend", the room creator send this packet if data channel is opened. 81 | */ 82 | export function sendWithFriendSuccessMessageToServer() { 83 | console.log('Send with friend success message to server'); 84 | postData( 85 | serverURL, 86 | objectToSendToServer(MESSAGE_TO_SERVER.withFriendSuccess, roomIdToCreate) 87 | ); 88 | } 89 | 90 | /** 91 | * In quick match, the room creator send this quick match cancel packet if they want to cancel quick match i.e. want to stop waiting. 92 | */ 93 | export function sendCancelQuickMatchMessageToServer() { 94 | console.log('Send cancel quick match message to server'); 95 | postData( 96 | serverURL, 97 | objectToSendToServer(MESSAGE_TO_SERVER.cancel, roomIdToCreate, matchGroup) 98 | ); 99 | } 100 | 101 | // Example POST method implementation: 102 | async function postData(url = '', data = {}) { 103 | try { 104 | // Default options are marked with * 105 | const response = await fetch(url, { 106 | method: 'POST', // *GET, POST, PUT, DELETE, etc. 107 | mode: 'cors', // no-cors, *cors, same-origin 108 | cache: 'default', // *default, no-cache, reload, force-cache, only-if-cached 109 | credentials: 'same-origin', // include, *same-origin, omit 110 | headers: { 111 | 'Content-Type': 'application/json', 112 | // 'Content-Type': 'application/x-www-form-urlencoded', 113 | }, 114 | redirect: 'follow', // manual, *follow, error 115 | referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url 116 | body: JSON.stringify(data), // body data type must match "Content-Type" header 117 | }); 118 | return response.json(); // parses JSON response into native JavaScript objects 119 | } catch (e) { 120 | printQuickMatchLog(e); 121 | printFailedToConnectToQuickMatchServer(); 122 | } 123 | } 124 | 125 | const callback = (data) => { 126 | // JSON data parsed by `response.json()` call 127 | 128 | if (data.numOfSuccess !== null) { 129 | const numOfSuccess = data.numOfSuccess; 130 | printNumberOfSuccessfulQuickMatches( 131 | numOfSuccess.withinLast24hours, 132 | numOfSuccess.withinLast1hour, 133 | numOfSuccess.withinLast10minutes 134 | ); 135 | } 136 | 137 | switch (data.message) { 138 | case MESSAGE_TO_CLIENT.createRoom: 139 | console.log('Create room!'); 140 | createRoom(roomIdToCreate).then(() => 141 | postData( 142 | serverURL, 143 | objectToSendToServer( 144 | MESSAGE_TO_SERVER.roomCreated, 145 | roomIdToCreate, 146 | matchGroup 147 | ) 148 | ).then(callback) 149 | ); 150 | break; 151 | case MESSAGE_TO_CLIENT.keepWait: 152 | console.log('Keep wait!'); 153 | window.setTimeout(() => { 154 | postData( 155 | serverURL, 156 | objectToSendToServer( 157 | MESSAGE_TO_SERVER.roomCreated, 158 | roomIdToCreate, 159 | matchGroup 160 | ) 161 | ).then(callback); 162 | }, 1000); 163 | break; 164 | case MESSAGE_TO_CLIENT.waitPeerConnection: 165 | console.log('Wait peer connection!'); 166 | disableCancelQuickMatchBtnForAWhile(); 167 | // If the following line is executed after data channel is opened, 168 | // peer hashed ip can be staged too late to enable blocking this peer button, 169 | // which checked if peer hashed ip is staged already. 170 | // But, since the blocking this peer button is rendered after ping test 171 | // which takes about 5 seconds, it will be almost never happened. 172 | blockedIPList.stagePeerHashedIP(data.hashedPeerIP); 173 | break; 174 | case MESSAGE_TO_CLIENT.connectToPeerAfterAWhile: 175 | console.log('Connect To Peer after 3 seconds...'); 176 | disableCancelQuickMatchBtnForAWhile(); 177 | window.setTimeout(() => { 178 | console.log('Connect To Peer!'); 179 | disableCancelQuickMatchBtnForAWhile(); 180 | blockedIPList.stagePeerHashedIP(data.hashedPeerIP); 181 | joinRoom(data.roomId); 182 | printQuickMatchState(MESSAGE_TO_CLIENT.connectToPeer); 183 | }, 3000); 184 | break; 185 | case MESSAGE_TO_CLIENT.connectToPeer: 186 | console.log('Connect To Peer!'); 187 | disableCancelQuickMatchBtnForAWhile(); 188 | blockedIPList.stagePeerHashedIP(data.hashedPeerIP); 189 | joinRoom(data.roomId); 190 | break; 191 | case MESSAGE_TO_CLIENT.abandoned: 192 | console.log('room id abandoned.. please retry quick match.'); 193 | break; 194 | case MESSAGE_TO_CLIENT.cancelAccepted: 195 | console.log('quick match cancel accepted'); 196 | // TODO: do something 197 | break; 198 | } 199 | 200 | communicationCount++; 201 | printCommunicationCount(communicationCount); 202 | printQuickMatchState(data.message); 203 | }; 204 | 205 | /** 206 | * Create an object to send to server by json 207 | * @param {string} message 208 | * @param {string} roomIdToCreate 209 | * @param {string[]} blockedHashedIPs 210 | * @param {string} matchGroup 211 | */ 212 | function objectToSendToServer( 213 | message, 214 | roomIdToCreate, 215 | matchGroup = MATCH_GROUP.GLOBAL, 216 | blockedHashedIPs = [] 217 | ) { 218 | return { 219 | message: message, 220 | roomId: roomIdToCreate, 221 | matchGroup: matchGroup, 222 | blockedHashedIPs: blockedHashedIPs, 223 | }; 224 | } 225 | -------------------------------------------------------------------------------- /src/en/replay/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pikachu Volleyball P2P Online Replay Viewer 8 | 12 | 13 | 14 | 20 | 21 | 22 |
23 |
24 |
25 |
26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 | 34 |
35 |
36 | 44 |
45 | 49 |

Use "Open File" button or drag a replay file here

50 |
51 | 61 | 74 |
75 |
76 |
77 |
Z
78 |
R
79 |
D
80 |
V
81 |
G
82 |
83 |
84 |
Enter
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | 100 |
101 |
102 | 109 | 0:00 / 111 | 0:00 113 |
114 | 121 | 128 | 135 | 142 |
143 |
144 | 151 | 158 | 165 | 172 | 173 | 180 |
181 |
182 | 186 | 190 | 194 | 198 | 202 | 206 | 210 |
211 |
212 |
213 | 218 | 219 |
220 |
221 |
222 | 223 | 224 |
225 | 226 | 227 | -------------------------------------------------------------------------------- /src/resources/js/main_online.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the main script which executes the p2p online version game. 3 | * General explanations for the all source code files of the game are following. 4 | * 5 | ******************************************************************************************************************** 6 | * This p2p online version of the Pikachu Volleyball is developed based on 7 | * the Pikachu Volleyball offline web version (https://github.com/gorisanson/pikachu-volleyball) 8 | * which is made by reverse engineering the core part of the original Pikachu Volleyball game 9 | * which is developed by "1997 (C) SACHI SOFT / SAWAYAKAN Programmers" & "1997 (C) Satoshi Takenouchi". 10 | ******************************************************************************************************************** 11 | * 12 | * This p2p online version game is mainly composed of two parts below. 13 | * 1) Offline version: All the offline web version source code files is in the directory "offline_version_js". 14 | * 2) WebRTC data channels: It utilizes WebRTC data channels to communicate with the peer. 15 | * The peer-to-peer online functions are contained in "data_channel.js" 16 | * 17 | * The game is deterministic on the user (keyboard) inputs except the RNG (random number generator) used in 18 | * "offline_version_js/physics.js" and "offline_version_js/cloud_and_wave.js". (The RNG is contained 19 | * in "offline_version_js/rand.js".) So if the RNG is the same on both peers, only the user inputs need 20 | * to be communicated to maintain the same game state between the peers. In this p2p online version, the RNG 21 | * is set to the same thing on both peers at the data channel open event (handled by the function 22 | * "dataChannelOpened" in "data_channel.js"), then the user inputs are communicated via the data channel. 23 | * 24 | * And explanations for other source files are below. 25 | * - "pikavolley_online.js": A wrapper for "offline_version_js/pikavolley.js". 26 | * - "keyboard_online.js": A wrapper for offline version "offline_version_js/keyboard.js". 27 | * This module gets user inputs and load them up onto the data channel to the peer. 28 | * - "generate_pushid.js": Generate a room ID easily distinguishable by human eye. 29 | * - "mod.js": To maintain game sync, sync counter is attached for each user input, and mod is used to 30 | * make sync counter cycle in a range [0, 255] which fits in a byte. 31 | * - "ui_online.js": For the user interface of the html page and inputs/outputs relevant to html elements. 32 | * - "chat_display.js": For displaying chat messages. 33 | * - "firebase_config.template.js": This p2p online version utilized firebase cloud firestore to establish 34 | * webRTC data channel connection between peers. Fill this template and 35 | * change the file name to "firebase_config.js". 36 | * - "rtc_configuration.js": Contains RTCPeerConnection configuration. 37 | * - "quick_match.js": It is for the quick match function. Manages communication with the quick match server. 38 | * - "quick_match_server_url.template.js": Fill this template the url of the quick match server and change 39 | * the file name to "quick_match_server_url.js" 40 | */ 41 | 'use strict'; 42 | import { settings } from '@pixi/settings'; 43 | import { SCALE_MODES } from '@pixi/constants'; 44 | import { Renderer, BatchRenderer, autoDetectRenderer } from '@pixi/core'; 45 | import { Prepare } from '@pixi/prepare'; 46 | import { Container } from '@pixi/display'; 47 | import { Loader } from '@pixi/loaders'; 48 | import { SpritesheetLoader } from '@pixi/spritesheet'; 49 | import { Ticker } from '@pixi/ticker'; 50 | import { CanvasRenderer } from '@pixi/canvas-renderer'; 51 | import { CanvasSpriteRenderer } from '@pixi/canvas-sprite'; 52 | import { CanvasPrepare } from '@pixi/canvas-prepare'; 53 | import '@pixi/canvas-display'; 54 | import { PikachuVolleyballOnline } from './pikavolley_online.js'; 55 | import { ASSETS_PATH } from './offline_version_js/assets_path.js'; 56 | import { channel } from './data_channel/data_channel.js'; 57 | import { setUpUI, setUpUIAfterLoadingGameAssets } from './ui_online.js'; 58 | import { setUpUIForBlockingOtherUsers } from './block_other_players/ui.js'; 59 | import { setUpUIForManagingBadWords } from './bad_words_filtering/ui.js'; 60 | import { setGetSpeechBubbleNeeded } from './chat_display.js'; 61 | import '../style.css'; 62 | 63 | // To show two "with friend" on the menu 64 | const TEXTURES = ASSETS_PATH.TEXTURES; 65 | TEXTURES.WITH_COMPUTER = TEXTURES.WITH_FRIEND; 66 | 67 | // Reference for how to use Renderer.registerPlugin: 68 | // https://github.com/pixijs/pixijs/blob/af3c0c6bb15aeb1049178c972e4a14bb4cabfce4/bundles/pixi.js/src/index.ts#L27-L34 69 | Renderer.registerPlugin('prepare', Prepare); 70 | Renderer.registerPlugin('batch', BatchRenderer); 71 | // Reference for how to use CanvasRenderer.registerPlugin: 72 | // https://github.com/pixijs/pixijs/blob/af3c0c6bb15aeb1049178c972e4a14bb4cabfce4/bundles/pixi.js-legacy/src/index.ts#L13-L19 73 | CanvasRenderer.registerPlugin('prepare', CanvasPrepare); 74 | CanvasRenderer.registerPlugin('sprite', CanvasSpriteRenderer); 75 | Loader.registerPlugin(SpritesheetLoader); 76 | 77 | settings.RESOLUTION = 2; 78 | settings.SCALE_MODE = SCALE_MODES.NEAREST; 79 | settings.ROUND_PIXELS = true; 80 | 81 | const renderer = autoDetectRenderer({ 82 | width: 432, 83 | height: 304, 84 | antialias: false, 85 | backgroundColor: 0x000000, 86 | backgroundAlpha: 1, 87 | // Decided to use only Canvas for compatibility reason. One player had reported that 88 | // on their browser, where pixi chooses to use WebGL renderer, the graphics are not fine. 89 | // And the issue had been fixed by using Canvas renderer. And also for the sake of testing, 90 | // it is more comfortable just to stick with Canvas renderer so that it is unnecessary to switch 91 | // between WebGL renderer and Canvas renderer. 92 | forceCanvas: true, 93 | }); 94 | 95 | const stage = new Container(); 96 | const ticker = new Ticker(); 97 | const loader = new Loader(); 98 | 99 | document.querySelector('#game-canvas-container').appendChild(renderer.view); 100 | renderer.render(stage); // To make the initial canvas painting stable in the Firefox browser. 101 | 102 | loader.add(ASSETS_PATH.SPRITE_SHEET); 103 | for (const prop in ASSETS_PATH.SOUNDS) { 104 | loader.add(ASSETS_PATH.SOUNDS[prop]); 105 | } 106 | setUpLoaderProgressBar(); 107 | channel.callbackAfterDataChannelOpened = () => { 108 | loader.load(setup); 109 | }; 110 | 111 | setUpUI(); 112 | setUpUIForBlockingOtherUsers(); 113 | setUpUIForManagingBadWords(); 114 | 115 | /** 116 | * Set up the loader progress bar. 117 | */ 118 | function setUpLoaderProgressBar() { 119 | const loadingBox = document.getElementById('loading-box'); 120 | const progressBar = document.getElementById('progress-bar'); 121 | 122 | loader.onProgress.add(() => { 123 | progressBar.style.width = `${loader.progress}%`; 124 | }); 125 | loader.onComplete.add(() => { 126 | loadingBox.classList.add('hidden'); 127 | }); 128 | } 129 | 130 | function setup() { 131 | const pikaVolley = new PikachuVolleyballOnline(stage, loader.resources); 132 | setUpUIAfterLoadingGameAssets(pikaVolley, ticker); 133 | setGetSpeechBubbleNeeded(pikaVolley); 134 | start(pikaVolley); 135 | } 136 | 137 | function start(pikaVolley) { 138 | ticker.maxFPS = pikaVolley.normalFPS; 139 | ticker.add(() => { 140 | // Rendering and gameLoop order is the opposite of 141 | // the offline web version (refer: ./offline_version_js/main.js). 142 | // It's for the smooth rendering for the online version 143 | // which gameLoop can not always succeed right on this "ticker.add"ed code 144 | // because of the transfer delay or connection status. (If gameLoop here fails, 145 | // it is recovered by the callback gameLoop which is called after peer input received.) 146 | // Now the rendering is delayed 40ms (when pikaVolley.normalFPS == 25) 147 | // behind gameLoop. 148 | renderer.render(stage); 149 | pikaVolley.gameLoop(); 150 | }); 151 | ticker.start(); 152 | } 153 | -------------------------------------------------------------------------------- /src/resources/js/block_other_players/ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages UI relevant to blocking other players 3 | */ 4 | 'use strict'; 5 | import { getIfLocalStorageIsAvailable } from '../utils/is_local_storage_available'; 6 | import { blockedIPList } from './blocked_ip_list'; 7 | import { channel } from '../data_channel/data_channel'; 8 | 9 | const MAX_REMARK_LENGTH = 20; 10 | 11 | const blockedIPAddressesTableTbody = document.querySelector( 12 | 'table.blocked-ip-addresses-table tbody' 13 | ); 14 | const deleteBtn = document.querySelector( 15 | 'table.blocked-ip-addresses-table .delete-btn' 16 | ); 17 | const blockThisPeerBtn = document.getElementById('block-this-peer-btn'); 18 | 19 | const isLocalStorageAvailable = getIfLocalStorageIsAvailable(); 20 | 21 | export function setUpUIForBlockingOtherUsers() { 22 | // @ts-ignore 23 | deleteBtn.disabled = true; 24 | // @ts-ignore 25 | blockThisPeerBtn.disabled = true; 26 | if (!isLocalStorageAvailable) { 27 | document 28 | .getElementById('blocked-ip-addresses-table-container') 29 | .classList.add('hidden'); 30 | return; 31 | } 32 | 33 | let stringifiedBlockedIPListArrayView = null; 34 | try { 35 | stringifiedBlockedIPListArrayView = window.localStorage.getItem( 36 | 'stringifiedBlockedIPListArrayView' 37 | ); 38 | } catch (err) { 39 | console.log(err); 40 | } 41 | if (stringifiedBlockedIPListArrayView !== null) { 42 | const arrayView = JSON.parse(stringifiedBlockedIPListArrayView); 43 | // TODO: Remove the if statement below can after 44 | // the previous version localStorage caches of clients 45 | // can be considered to be all expired. 46 | if (arrayView.length > 0 && arrayView[0].length !== 4) { 47 | window.localStorage.removeItem('stringifiedBlockedIPListArrayView'); 48 | location.reload(); 49 | } else { 50 | blockedIPList.readArrayViewAndUpdate(arrayView); 51 | } 52 | } 53 | displayBlockedIPs(blockedIPList.createArrayView()); 54 | displayNumberOfBlockedIPAddresses(); 55 | 56 | let lastSelectedIndex = -1; 57 | document.body.addEventListener('click', (event) => { 58 | const target = event.target; 59 | if ( 60 | // @ts-ignore 61 | blockedIPAddressesTableTbody.contains(target) && 62 | // @ts-ignore 63 | target.tagName === 'TD' 64 | ) { 65 | // @ts-ignore 66 | const trElement = target.parentElement; 67 | const currentIndex = trElement.sectionRowIndex; 68 | 69 | if (event.shiftKey && lastSelectedIndex !== -1) { 70 | if (!event.ctrlKey) { 71 | clearSelection(); 72 | } 73 | const start = Math.min(lastSelectedIndex, currentIndex); 74 | const end = Math.max(lastSelectedIndex, currentIndex); 75 | const rows = blockedIPAddressesTableTbody.getElementsByTagName('tr'); 76 | for (let i = start; i <= end; i++) { 77 | if (rows[i]) { 78 | rows[i].classList.add('selected'); 79 | } 80 | } 81 | } else { 82 | if (!event.ctrlKey) { 83 | clearSelection(); 84 | } 85 | trElement.classList.toggle('selected'); 86 | lastSelectedIndex = currentIndex; 87 | } 88 | } else { 89 | if (!event.ctrlKey && !event.shiftKey) { 90 | clearSelection(); 91 | } 92 | } 93 | 94 | // @ts-ignore 95 | deleteBtn.disabled = 96 | !blockedIPAddressesTableTbody.querySelector('.selected'); 97 | }); 98 | deleteBtn.addEventListener('click', () => { 99 | lastSelectedIndex = -1; 100 | const selectedTRElements = 101 | blockedIPAddressesTableTbody.querySelectorAll('.selected'); 102 | for (let i = selectedTRElements.length - 1; i >= 0; --i) { 103 | // @ts-ignore 104 | blockedIPList.removeAt(Number(selectedTRElements[i].dataset.index)); 105 | } 106 | try { 107 | window.localStorage.setItem( 108 | 'stringifiedBlockedIPListArrayView', 109 | JSON.stringify(blockedIPList.createArrayView()) 110 | ); 111 | } catch (err) { 112 | console.log(err); 113 | } 114 | displayBlockedIPs(blockedIPList.createArrayView()); 115 | displayNumberOfBlockedIPAddresses(); 116 | }); 117 | 118 | const askAddThisPeerToBlockedListBox = document.getElementById( 119 | 'ask-add-this-peer-to-blocked-list' 120 | ); 121 | const askAddThisPeerToBlockedListYesBtn = document.getElementById( 122 | 'ask-add-this-peer-to-blocked-list-yes-btn' 123 | ); 124 | const askAddThisPeerToBlockedListNoBtn = document.getElementById( 125 | 'ask-add-this-peer-to-blocked-list-no-btn' 126 | ); 127 | const noticeAddingThisPeerToBlockedListIsCompletedBox = 128 | document.getElementById( 129 | 'notice-adding-this-peer-to-blocked-list-is-completed' 130 | ); 131 | blockThisPeerBtn.addEventListener('click', () => { 132 | document.getElementById( 133 | 'partial-ip-address-of-this-peer-to-be-blocked' 134 | ).textContent = channel.peerPartialPublicIP; 135 | askAddThisPeerToBlockedListBox.classList.remove('hidden'); 136 | }); 137 | askAddThisPeerToBlockedListYesBtn.addEventListener('click', () => { 138 | // @ts-ignore 139 | blockThisPeerBtn.disabled = true; 140 | blockedIPList.AddStagedPeerHashedIPWithPeerPartialIP( 141 | channel.peerPartialPublicIP 142 | ); 143 | try { 144 | window.localStorage.setItem( 145 | 'stringifiedBlockedIPListArrayView', 146 | JSON.stringify(blockedIPList.createArrayView()) 147 | ); 148 | } catch (err) { 149 | console.log(err); 150 | } 151 | askAddThisPeerToBlockedListBox.classList.add('hidden'); 152 | noticeAddingThisPeerToBlockedListIsCompletedBox.classList.remove('hidden'); 153 | }); 154 | askAddThisPeerToBlockedListNoBtn.addEventListener('click', () => { 155 | askAddThisPeerToBlockedListBox.classList.add('hidden'); 156 | }); 157 | document 158 | .getElementById( 159 | 'notice-adding-this-peer-to-blocked-list-is-completed-exit-game-btn' 160 | ) 161 | .addEventListener('click', () => { 162 | location.reload(); 163 | }); 164 | document 165 | .getElementById( 166 | 'notice-adding-this-peer-to-blocked-list-is-completed-do-not-exit-game-btn' 167 | ) 168 | .addEventListener('click', () => { 169 | noticeAddingThisPeerToBlockedListIsCompletedBox.classList.add('hidden'); 170 | }); 171 | } 172 | 173 | /** 174 | * Show blockThisPeerBtn and enable it if a peer's full public IP is available. 175 | */ 176 | export function showBlockThisPeerBtn() { 177 | if (!isLocalStorageAvailable) { 178 | return; 179 | } 180 | blockThisPeerBtn.classList.remove('hidden'); 181 | if (blockedIPList.isPeerHashedIPStaged() && !blockedIPList.isFull()) { 182 | // @ts-ignore 183 | blockThisPeerBtn.disabled = false; 184 | } 185 | } 186 | 187 | /** 188 | * Display the given blocked IP list array view. 189 | * @param {[string, string, number, string][]} blockedIPs 190 | */ 191 | function displayBlockedIPs(blockedIPs) { 192 | // Clear the current displaying 193 | while (blockedIPAddressesTableTbody.firstChild) { 194 | blockedIPAddressesTableTbody.removeChild( 195 | blockedIPAddressesTableTbody.firstChild 196 | ); 197 | } 198 | // Display the given list 199 | blockedIPs.forEach((blockedIP, index) => { 200 | const trElement = document.createElement('tr'); 201 | const tdElementForIP = document.createElement('td'); 202 | const tdElementForTime = document.createElement('td'); 203 | const tdElementForRemark = document.createElement('td'); 204 | const inputElement = document.createElement('input'); 205 | tdElementForRemark.appendChild(inputElement); 206 | trElement.appendChild(tdElementForIP); 207 | trElement.appendChild(tdElementForTime); 208 | trElement.appendChild(tdElementForRemark); 209 | trElement.dataset.index = String(index); 210 | tdElementForIP.textContent = blockedIP[1]; 211 | tdElementForTime.textContent = new Date(blockedIP[2]).toLocaleString(); 212 | inputElement.value = blockedIP[3]; 213 | inputElement.addEventListener('input', (event) => { 214 | // @ts-ignore 215 | const newRemark = event.target.value.slice(0, MAX_REMARK_LENGTH); 216 | inputElement.value = newRemark; 217 | blockedIPList.editRemarkAt(index, newRemark); 218 | try { 219 | window.localStorage.setItem( 220 | 'stringifiedBlockedIPListArrayView', 221 | JSON.stringify(blockedIPList.createArrayView()) 222 | ); 223 | } catch (err) { 224 | console.log(err); 225 | } 226 | }); 227 | blockedIPAddressesTableTbody.appendChild(trElement); 228 | }); 229 | } 230 | 231 | /** 232 | * Display the number of blocked IPs in the list 233 | */ 234 | function displayNumberOfBlockedIPAddresses() { 235 | document.getElementById('number-of-blocked-ip-addresses').textContent = 236 | String(blockedIPList.length); 237 | } 238 | 239 | /** 240 | * Clear all selected rows in the blocked IP table 241 | */ 242 | function clearSelection() { 243 | Array.from( 244 | // @ts-ignore 245 | blockedIPAddressesTableTbody.getElementsByTagName('tr') 246 | ).forEach((elem) => { 247 | elem.classList.remove('selected'); 248 | }); 249 | } 250 | -------------------------------------------------------------------------------- /src/resources/js/replay/pikavolley_replay.js: -------------------------------------------------------------------------------- 1 | import seedrandom from 'seedrandom'; 2 | import { PikachuVolleyball } from '../offline_version_js/pikavolley.js'; 3 | import { setCustomRng } from '../offline_version_js/rand.js'; 4 | import { setChatRngs, displayChatMessageAt } from '../chat_display.js'; 5 | import { 6 | displayNicknameFor, 7 | displayPartialIPFor, 8 | } from '../nickname_display.js'; 9 | import { Cloud, Wave } from '../offline_version_js/cloud_and_wave.js'; 10 | import { PikaPhysics } from '../offline_version_js/physics.js'; 11 | import { convert5bitNumberToUserInput } from '../utils/input_conversion.js'; 12 | import { 13 | noticeEndOfReplay, 14 | moveScrubberTo, 15 | showKeyboardInputs, 16 | } from './ui_replay.js'; 17 | import { setTickerMaxFPSAccordingToNormalFPS } from './replay_player.js'; 18 | 19 | /** @typedef GameState @type {function():void} */ 20 | 21 | /** 22 | * Class representing Pikachu Volleyball Replay 23 | */ 24 | // @ts-ignore 25 | export class PikachuVolleyballReplay extends PikachuVolleyball { 26 | constructor( 27 | stage, 28 | resources, 29 | roomId, 30 | nicknames, 31 | partialPublicIPs, 32 | inputs, 33 | options, 34 | chats 35 | ) { 36 | super(stage, resources); 37 | this.noInputFrameTotal.menu = Infinity; 38 | 39 | this.roomId = roomId; 40 | this.nicknames = nicknames; 41 | this.partialPublicIPs = partialPublicIPs; 42 | this.inputs = inputs; 43 | this.options = options; 44 | this.chats = chats; 45 | this.player1Keyboard = { 46 | xDirection: 0, 47 | yDirection: 0, 48 | powerHit: 0, 49 | getInput: () => {}, 50 | }; 51 | this.player2Keyboard = { 52 | xDirection: 0, 53 | yDirection: 0, 54 | powerHit: 0, 55 | getInput: () => {}, 56 | }; 57 | this.keyboardArray = [this.player1Keyboard, this.player2Keyboard]; 58 | this.willDisplayChat = true; 59 | 60 | const fakeSound = { 61 | play: () => {}, 62 | stop: () => {}, 63 | }; 64 | const fakeBGM = { 65 | fake: true, 66 | center: { 67 | isPlaying: false, 68 | }, 69 | play: function () { 70 | this.center.isPlaying = true; 71 | }, 72 | stop: function () { 73 | this.center.isPlaying = false; 74 | }, 75 | }; 76 | this.fakeAudio = { 77 | sounds: { 78 | bgm: fakeBGM, 79 | pipikachu: fakeSound, 80 | pika: fakeSound, 81 | chu: fakeSound, 82 | pi: fakeSound, 83 | pikachu: fakeSound, 84 | powerHit: fakeSound, 85 | ballTouchesGround: fakeSound, 86 | }, 87 | }; 88 | 89 | this.initializeForReplay(); 90 | } 91 | 92 | /** 93 | * This is mainly for reinitialization for reusing the PikachuVolleyballReplay object 94 | */ 95 | initializeForReplay() { 96 | // Stop if sounds are playing 97 | for (const prop in this.audio.sounds) { 98 | this.audio.sounds[prop].stop(); 99 | } 100 | 101 | this.timeCurrent = 0; // unit: second 102 | this.timeBGM = 0; 103 | this.isBGMPlaying = false; 104 | this.replayFrameCounter = 0; 105 | this.chatCounter = 0; 106 | this.optionsCounter = 0; 107 | 108 | // Set the same RNG (used for the game) for both peers 109 | const customRng = seedrandom.alea(this.roomId.slice(10)); 110 | setCustomRng(customRng); 111 | 112 | // Set the same RNG (used for displaying chat messages) for both peers 113 | const rngForPlayer1Chat = seedrandom.alea(this.roomId.slice(10, 15)); 114 | const rngForPlayer2Chat = seedrandom.alea(this.roomId.slice(15)); 115 | setChatRngs(rngForPlayer1Chat, rngForPlayer2Chat); 116 | 117 | // Reinitialize things which needs exact RNG 118 | this.view.game.cloudArray = []; 119 | const NUM_OF_CLOUDS = 10; 120 | for (let i = 0; i < NUM_OF_CLOUDS; i++) { 121 | this.view.game.cloudArray.push(new Cloud()); 122 | } 123 | this.view.game.wave = new Wave(); 124 | this.view.intro.visible = false; 125 | this.view.menu.visible = false; 126 | this.view.game.visible = false; 127 | this.view.fadeInOut.visible = false; 128 | 129 | this.physics = new PikaPhysics(true, true); 130 | 131 | this.normalFPS = 25; 132 | this.slowMotionFPS = 5; 133 | this.SLOW_MOTION_FRAMES_NUM = 6; 134 | this.slowMotionFramesLeft = 0; 135 | this.slowMotionNumOfSkippedFrames = 0; 136 | this.selectedWithWho = 0; 137 | this.scores = [0, 0]; 138 | this.winningScore = 15; 139 | this.gameEnded = false; 140 | this.roundEnded = false; 141 | this.isPlayer2Serve = false; 142 | this.frameCounter = 0; 143 | this.noInputFrameCounter = 0; 144 | 145 | this.paused = false; 146 | this.isStereoSound = true; 147 | this._isPracticeMode = false; 148 | this.isRoomCreatorPlayer2 = false; 149 | this.state = this.intro; 150 | } 151 | 152 | /** 153 | * Override the "intro" method in the super class. 154 | * It is to ask for one more game with the peer after quick match game ends. 155 | * @type {GameState} 156 | */ 157 | intro() { 158 | if (this.frameCounter === 0) { 159 | this.selectedWithWho = 0; 160 | if (this.nicknames) { 161 | displayNicknameFor(this.nicknames[0], this.isRoomCreatorPlayer2); 162 | displayNicknameFor(this.nicknames[1], !this.isRoomCreatorPlayer2); 163 | } 164 | if (this.partialPublicIPs) { 165 | displayPartialIPFor( 166 | this.partialPublicIPs[0], 167 | this.isRoomCreatorPlayer2 168 | ); 169 | displayPartialIPFor( 170 | this.partialPublicIPs[1], 171 | !this.isRoomCreatorPlayer2 172 | ); 173 | } 174 | } 175 | super.intro(); 176 | } 177 | 178 | /** 179 | * Override the "menu" method in the super class. 180 | * It changes "am I player 1 or player 2" setting accordingly. 181 | * @type {GameState} 182 | */ 183 | menu() { 184 | const selectedWithWho = this.selectedWithWho; 185 | super.menu(); 186 | if (this.selectedWithWho !== selectedWithWho) { 187 | this.isRoomCreatorPlayer2 = !this.isRoomCreatorPlayer2; 188 | if (this.nicknames) { 189 | displayNicknameFor(this.nicknames[0], this.isRoomCreatorPlayer2); 190 | displayNicknameFor(this.nicknames[1], !this.isRoomCreatorPlayer2); 191 | } 192 | if (this.partialPublicIPs) { 193 | displayPartialIPFor( 194 | this.partialPublicIPs[0], 195 | this.isRoomCreatorPlayer2 196 | ); 197 | displayPartialIPFor( 198 | this.partialPublicIPs[1], 199 | !this.isRoomCreatorPlayer2 200 | ); 201 | } 202 | } 203 | } 204 | 205 | /** 206 | * Game loop which play no sound, display no chat, does not move scrubber 207 | */ 208 | gameLoopSilent() { 209 | const audio = this.audio; 210 | this.willDisplayChat = false; 211 | // @ts-ignore 212 | this.audio = this.fakeAudio; 213 | this.gameLoop(); 214 | this.willDisplayChat = true; 215 | this.audio = audio; 216 | } 217 | 218 | /** 219 | * Game loop 220 | * This function should be called at regular intervals ( interval = (1 / FPS) second ) 221 | */ 222 | gameLoop() { 223 | if (this.replayFrameCounter >= this.inputs.length) { 224 | noticeEndOfReplay(); 225 | return; 226 | } 227 | 228 | moveScrubberTo(this.replayFrameCounter); 229 | 230 | const usersInputNumber = this.inputs[this.replayFrameCounter]; 231 | const player1Input = convert5bitNumberToUserInput(usersInputNumber >>> 5); 232 | const player2Input = convert5bitNumberToUserInput( 233 | usersInputNumber % (1 << 5) 234 | ); 235 | this.player1Keyboard.xDirection = player1Input.xDirection; 236 | this.player1Keyboard.yDirection = player1Input.yDirection; 237 | this.player1Keyboard.powerHit = player1Input.powerHit; 238 | this.player2Keyboard.xDirection = player2Input.xDirection; 239 | this.player2Keyboard.yDirection = player2Input.yDirection; 240 | this.player2Keyboard.powerHit = player2Input.powerHit; 241 | showKeyboardInputs(player1Input, player2Input); 242 | 243 | let options = this.options[this.optionsCounter]; 244 | while (options && options[0] === this.replayFrameCounter) { 245 | if (options[1].speed) { 246 | switch (options[1].speed) { 247 | case 'slow': 248 | this.normalFPS = 20; 249 | break; 250 | case 'medium': 251 | this.normalFPS = 25; 252 | break; 253 | case 'fast': 254 | this.normalFPS = 30; 255 | break; 256 | } 257 | setTickerMaxFPSAccordingToNormalFPS(this.normalFPS); 258 | } 259 | if (options[1].winningScore) { 260 | switch (options[1].winningScore) { 261 | case 5: 262 | this.winningScore = 5; 263 | break; 264 | case 10: 265 | this.winningScore = 10; 266 | break; 267 | case 15: 268 | this.winningScore = 15; 269 | break; 270 | } 271 | } 272 | this.optionsCounter++; 273 | options = this.options[this.optionsCounter]; 274 | } 275 | this.timeCurrent += 1 / this.normalFPS; 276 | 277 | this.isBGMPlaying = this.audio.sounds.bgm.center.isPlaying; 278 | if (this.isBGMPlaying) { 279 | this.timeBGM = (this.timeBGM + 1 / this.normalFPS) % 83; // 83 is total duration of bgm 280 | } else { 281 | this.timeBGM = 0; 282 | } 283 | 284 | let chat = this.chats[this.chatCounter]; 285 | while (chat && chat[0] === this.replayFrameCounter) { 286 | if (this.willDisplayChat) { 287 | displayChatMessageAt(chat[2], chat[1]); 288 | } 289 | this.chatCounter++; 290 | chat = this.chats[this.chatCounter]; 291 | } 292 | 293 | this.replayFrameCounter++; 294 | this.physics.player1.isComputer = false; 295 | this.physics.player2.isComputer = false; 296 | super.gameLoop(); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/resources/js/replay/replay_player.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { settings } from '@pixi/settings'; 3 | import { SCALE_MODES } from '@pixi/constants'; 4 | import { Renderer, BatchRenderer, autoDetectRenderer } from '@pixi/core'; 5 | import { Prepare } from '@pixi/prepare'; 6 | import { Container } from '@pixi/display'; 7 | import { Loader } from '@pixi/loaders'; 8 | import { SpritesheetLoader } from '@pixi/spritesheet'; 9 | import { Ticker } from '@pixi/ticker'; 10 | import { CanvasRenderer } from '@pixi/canvas-renderer'; 11 | import { CanvasSpriteRenderer } from '@pixi/canvas-sprite'; 12 | import { CanvasPrepare } from '@pixi/canvas-prepare'; 13 | import '@pixi/canvas-display'; 14 | import { ASSETS_PATH } from '../offline_version_js/assets_path.js'; 15 | import { PikachuVolleyballReplay } from './pikavolley_replay.js'; 16 | import { setGetSpeechBubbleNeeded, hideChat } from '../chat_display.js'; 17 | import { 18 | setMaxForScrubberRange, 19 | adjustPlayPauseBtnIcon, 20 | showTotalTimeDuration, 21 | showTimeCurrent, 22 | enableReplayScrubberAndBtns, 23 | hideNoticeEndOfReplay, 24 | noticeFileOpenError, 25 | adjustFPSInputValue, 26 | } from './ui_replay.js'; 27 | import '../../style.css'; 28 | import { serialize } from '../utils/serialize.js'; 29 | import { getHashCode } from '../utils/hash_code.js'; 30 | 31 | class ReplayPlayer { 32 | constructor() { 33 | // Reference for how to use Renderer.registerPlugin: 34 | // https://github.com/pixijs/pixijs/blob/af3c0c6bb15aeb1049178c972e4a14bb4cabfce4/bundles/pixi.js/src/index.ts#L27-L34 35 | Renderer.registerPlugin('prepare', Prepare); 36 | Renderer.registerPlugin('batch', BatchRenderer); 37 | // Reference for how to use CanvasRenderer.registerPlugin: 38 | // https://github.com/pixijs/pixijs/blob/af3c0c6bb15aeb1049178c972e4a14bb4cabfce4/bundles/pixi.js-legacy/src/index.ts#L13-L19 39 | CanvasRenderer.registerPlugin('prepare', CanvasPrepare); 40 | CanvasRenderer.registerPlugin('sprite', CanvasSpriteRenderer); 41 | Loader.registerPlugin(SpritesheetLoader); 42 | settings.RESOLUTION = 2; 43 | settings.SCALE_MODE = SCALE_MODES.NEAREST; 44 | settings.ROUND_PIXELS = true; 45 | 46 | this.ticker = new Ticker(); 47 | this.ticker.minFPS = 1; 48 | this.renderer = autoDetectRenderer({ 49 | width: 432, 50 | height: 304, 51 | antialias: false, 52 | backgroundColor: 0x000000, 53 | backgroundAlpha: 1, 54 | forceCanvas: true, 55 | }); 56 | this.stage = new Container(); 57 | this.loader = new Loader(); 58 | this.pikaVolley = null; 59 | this.playBackSpeedTimes = 1; 60 | this.playBackSpeedFPS = null; 61 | } 62 | 63 | readFile(file) { 64 | // To show two "with friend" on the menu 65 | const TEXTURES = ASSETS_PATH.TEXTURES; 66 | TEXTURES.WITH_COMPUTER = TEXTURES.WITH_FRIEND; 67 | 68 | document 69 | .querySelector('#game-canvas-container') 70 | .appendChild(this.renderer.view); 71 | 72 | this.renderer.render(this.stage); // To make the initial canvas painting stable in the Firefox browser. 73 | this.ticker.add(() => { 74 | // Redering and gameLoop order is the opposite of 75 | // the offline web version (refer: ./offline_version_js/main.js). 76 | // It's for the smooth rendering for the online version 77 | // which gameLoop can not always succeed right on this "ticker.add"ed code 78 | // because of the transfer delay or connection status. (If gameLoop here fails, 79 | // it is recovered by the callback gameLoop which is called after peer input received.) 80 | // Now the rendering is delayed 40ms (when pikaVolley.normalFPS == 25) 81 | // behind gameLoop. 82 | this.renderer.render(this.stage); 83 | showTimeCurrent(this.pikaVolley.timeCurrent); 84 | this.pikaVolley.gameLoop(); 85 | }); 86 | 87 | this.loader.add(ASSETS_PATH.SPRITE_SHEET); 88 | for (const prop in ASSETS_PATH.SOUNDS) { 89 | this.loader.add(ASSETS_PATH.SOUNDS[prop]); 90 | } 91 | setUpLoaderProgressBar(this.loader); 92 | 93 | const reader = new FileReader(); 94 | reader.onload = (event) => { 95 | let packWithComment; 96 | let pack; 97 | try { 98 | // @ts-ignore 99 | packWithComment = JSON.parse(event.target.result); 100 | pack = packWithComment.pack; 101 | const hash = pack.hash; 102 | pack.hash = 0; 103 | if (hash !== getHashCode(serialize(pack))) { 104 | throw 'Error: The file content is not matching the hash code'; 105 | } 106 | } catch (err) { 107 | console.log(err); 108 | noticeFileOpenError(); 109 | return; 110 | } 111 | showTotalTimeDuration(getTotalTimeDuration(pack)); 112 | this.loader.load(() => { 113 | this.pikaVolley = new PikachuVolleyballReplay( 114 | this.stage, 115 | this.loader.resources, 116 | pack.roomID, 117 | pack.nicknames, 118 | pack.partialPublicIPs, 119 | pack.inputs, 120 | pack.options, 121 | pack.chats 122 | ); 123 | // @ts-ignore 124 | setGetSpeechBubbleNeeded(this.pikaVolley); 125 | setMaxForScrubberRange(pack.inputs.length); 126 | this.seekFrame(0); 127 | this.ticker.start(); 128 | adjustPlayPauseBtnIcon(); 129 | enableReplayScrubberAndBtns(); 130 | }); 131 | }; 132 | try { 133 | reader.readAsText(file); 134 | } catch (err) { 135 | console.log(err); 136 | noticeFileOpenError(); 137 | return; 138 | } 139 | } 140 | 141 | /** 142 | * Seek the specific frame 143 | * @param {number} frameNumber 144 | */ 145 | seekFrame(frameNumber) { 146 | hideChat(); 147 | hideNoticeEndOfReplay(); 148 | this.ticker.stop(); 149 | 150 | // Cleanup previous pikaVolley 151 | this.pikaVolley.initializeForReplay(); 152 | 153 | if (frameNumber > 0) { 154 | for (let i = 0; i < frameNumber; i++) { 155 | this.pikaVolley.gameLoopSilent(); 156 | } 157 | this.renderer.render(this.stage); 158 | } 159 | showTimeCurrent(this.pikaVolley.timeCurrent); 160 | } 161 | 162 | /** 163 | * Seek forward/backward the relative time (seconds). 164 | * @param {number} seconds plus value for seeking forward, minus value for seeking backward 165 | */ 166 | seekRelativeTime(seconds) { 167 | const seekFrameCounter = Math.max( 168 | 0, 169 | this.pikaVolley.replayFrameCounter + seconds * this.pikaVolley.normalFPS 170 | ); 171 | this.seekFrame(seekFrameCounter); 172 | } 173 | 174 | /** 175 | * Adjust playback speed by times 176 | * @param {number} times 177 | */ 178 | adjustPlaybackSpeedTimes(times) { 179 | this.playBackSpeedFPS = null; 180 | this.playBackSpeedTimes = times; 181 | this.ticker.maxFPS = this.pikaVolley.normalFPS * this.playBackSpeedTimes; 182 | adjustFPSInputValue(); 183 | } 184 | 185 | /** 186 | * Adjust playback speed by fps 187 | * @param {number} fps 188 | */ 189 | adjustPlaybackSpeedFPS(fps) { 190 | this.playBackSpeedTimes = null; 191 | this.playBackSpeedFPS = fps; 192 | this.ticker.maxFPS = this.playBackSpeedFPS; 193 | adjustFPSInputValue(); 194 | } 195 | 196 | stopBGM() { 197 | this.pikaVolley.audio.sounds.bgm.center.stop(); 198 | } 199 | 200 | playBGMProperly() { 201 | if (this.pikaVolley.isBGMPlaying) { 202 | this.pikaVolley.audio.sounds.bgm.center.play({ 203 | start: this.pikaVolley.timeBGM, 204 | }); 205 | } 206 | } 207 | } 208 | 209 | export const replayPlayer = new ReplayPlayer(); 210 | 211 | /** 212 | * Set ticker.maxFPS according to PikachuVolleyball object's normalFPS properly 213 | * @param {number} normalFPS 214 | */ 215 | export function setTickerMaxFPSAccordingToNormalFPS(normalFPS) { 216 | if (replayPlayer.playBackSpeedFPS) { 217 | replayPlayer.ticker.maxFPS = replayPlayer.playBackSpeedFPS; 218 | adjustFPSInputValue(); 219 | } else if (replayPlayer.playBackSpeedTimes) { 220 | replayPlayer.ticker.maxFPS = normalFPS * replayPlayer.playBackSpeedTimes; 221 | adjustFPSInputValue(); 222 | } 223 | } 224 | 225 | /** 226 | * Set up the loader progress bar. 227 | * @param {Loader} loader 228 | */ 229 | function setUpLoaderProgressBar(loader) { 230 | const loadingBox = document.getElementById('loading-box'); 231 | const progressBar = document.getElementById('progress-bar'); 232 | 233 | loader.onProgress.add(() => { 234 | progressBar.style.width = `${loader.progress}%`; 235 | }); 236 | loader.onComplete.add(() => { 237 | loadingBox.classList.add('hidden'); 238 | }); 239 | } 240 | 241 | /** 242 | * Get total time duration for the pack 243 | * @param {Object} pack 244 | */ 245 | function getTotalTimeDuration(pack) { 246 | const speedChangeRecord = []; 247 | 248 | let optionsCounter = 0; 249 | let options = pack.options[optionsCounter]; 250 | while (options) { 251 | if (options[1].speed) { 252 | let fpsFromNowOn = null; 253 | switch (options[1].speed) { 254 | case 'slow': 255 | fpsFromNowOn = 20; 256 | break; 257 | case 'medium': 258 | fpsFromNowOn = 25; 259 | break; 260 | case 'fast': 261 | fpsFromNowOn = 30; 262 | break; 263 | } 264 | const frameCounter = options[0]; 265 | speedChangeRecord.push([frameCounter, fpsFromNowOn]); 266 | } 267 | optionsCounter++; 268 | options = pack.options[optionsCounter]; 269 | } 270 | 271 | let timeDuration = 0; // unit: second 272 | let currentFrameCounter = 0; 273 | let currentFPS = 25; 274 | for (let i = 0; i < speedChangeRecord.length; i++) { 275 | const futureFrameCounter = speedChangeRecord[i][0]; 276 | const futureFPS = speedChangeRecord[i][1]; 277 | timeDuration += (futureFrameCounter - currentFrameCounter) / currentFPS; 278 | currentFrameCounter = futureFrameCounter; 279 | currentFPS = futureFPS; 280 | } 281 | timeDuration += (pack.inputs.length - currentFrameCounter) / currentFPS; 282 | 283 | return timeDuration; 284 | } 285 | -------------------------------------------------------------------------------- /src/ko/update-history/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 피카츄 배구 P2P 온라인 업데이트 기록 8 | 9 | 10 | 11 | 17 | 18 | 19 |
20 |
21 |

22 | 피카츄 배구 P2P 온라인 23 | 업데이트 기록 24 |

25 |
26 | 31 | 32 |
33 |

34 | English 35 | | ✓ Korean(한국어) 36 |

37 |

P2P 온라인 버전으로 돌아가기

38 |

주요 업데이트만 기록합니다.

39 |
40 |
41 |
42 |

2020-04-08 공개

43 |

44 | 2020-04-19 오프라인 웹 버전 변경 반영: 45 | 피카츄 다이빙 방향 오류 수정 (@djsjukr님이 수정함) 51 |

52 |

2020-04-29 퀵 매치 도입

53 |

54 | 2020-05-03 오프라인 웹 버전 변경 반영: 55 | 왼편 키보드 매핑을 원조 피카츄 배구 게임과 동일하도록 수정 (@repeat_c님의 제보) 61 |

62 |

63 | 2020-05-08 오프라인 웹 버전에서처럼 게임 64 | 속도와 다른 설정을 변경할 수 있도록 함 (@repeat_c님의 제안) 70 |

71 |

72 | 2020-05-17 맨 처음 연결 후 기다리는 동안에 73 | 게임 속도 및 다른 설정을 변경할 수 있도록 함 (@e6p77Bi8CW7zRBg님의 제안) 79 |

80 |

81 | 2020-05-23 "채팅 끄기" 버튼 추가 ("채팅" 82 | 버튼 오른편에 있음) (@막내님의 제안) 88 |

89 |

90 | 2020-05-23 팝업 텍스트 박스가 뜰 때, 91 | 내용을 더 편하게 볼 수 있도록, 배경을 블러 처리함. (@KNHui님의 제안) 97 |

98 |

99 | 2020-05-25 이 업데이트 기록 페이지를 만듦 100 | (@e6p77Bi8CW7zRBg님의 제안) 106 |

107 |

108 | 2020-05-26 닉네임과 공인 IP 앞부분 표시 109 | (@e6p77Bi8CW7zRBg님의 제안 115 | 및 116 | @repeat_c님의 제안) 122 |

123 |

124 | 2020-05-28 연결에 성공하거나 실패 시 125 | "피카츄" 소리로 알림 (@repeat_c님의 제안) 131 |

132 |

133 | 2020-06-02 리플레이 저장 기능 추가 (@e6p77Bi8CW7zRBg님의 제안) 139 |

140 |

141 | 2020-06-03 리플레이 재생기에서 키보드 142 | 입력을 볼 수 있음 (@repeat_c님의 제안) 148 |

149 |

150 | 2020-06-07 게임 속도 "빠르게"로 변경 신청 151 | 자동으로 할 수 있는 옵션 만듦. 배경음악 및 효과음 설정 변경 유지되게 152 | 함. 153 |

154 |

155 | 2020-06-17 "나가기" 버튼 추가. 리플레이에 156 | 관한 설명 추가. 157 |

158 |

159 | 2020-06-18 리플레이 자동 저장 옵션 만듦. 160 | (@e6p77Bi8CW7zRBg님의 제안) 166 |

167 |

168 | 2020-06-23 메인 페이지에서 "엔터" 키를 169 | 누르면 퀵 매치 진입 (@repeat_c님의 제안 참고함) 175 |

176 |

177 | 2020-06-28 방 ID를 클립보드에 편리하게 178 | 복사할 수 있도록 "복사" 버튼 추가 (@samslow님이 구현함) 184 |

185 |

186 | 2021-12-01 퀵 매치 차단 기능 추가. 퀵 매치 187 | 차단 목록에 등록된 IP로 접속하는 상대방과는 퀵 매치 상에서 매치되지 188 | 않음. 189 |

190 |

2022-01-17 다크 모드 도입

191 |

192 | 2023-02-19 퀵 매치에 매치 그룹 추가. 193 | 동일한 매치 그룹을 선택한 플레이어끼리 매치가 성사됩니다.(@DuckLL_tw님의 제안) 199 |

200 |

201 | 2023-02-26 네트워크 검사 버그 수정. 이 202 | 버그 수정이 적용되기 전에는, 실제로는 그렇지 않은데도 네트워크가 203 | symmetric NAT 하에 있다고 검사 결과가 잘못 나올 수 있었습니다. (@소츄님의 제보 덕분에 버그를 발견함) 209 |

210 |

211 | 2023-10-02 그래픽 옵션 ("예리하게", 212 | "부드럽게") 추가 (@DuckLL_tw님의 제안) 218 |

219 |

220 | 2025-03-03 상대방 닉네임 가리기 기능 추가 221 | (@훈령병님의 제안) 222 |

223 |

224 | 2025-11-23 채팅 및 닉네임 욕설 필터 추가 225 | (@uzaramen108님이 구현함) 231 |

232 |

233 | 2025-11-25 Ctrl 키와 Shift 키를 사용하여 234 | "퀵 매치 차단 목록" 및 "추가 금지어 목록"에서 여러 개의 항목을 235 | 선택할 수 있습니다. (@눈알님의 제안)

236 | 퀵 매치 취소 버튼을 상대방과 연결 시도 후 일정 시간이 지나면 다시 237 | 활성화합니다. (@마조리카님의 제안) 238 |

239 |
240 |
241 |
242 |
243 | 244 | 245 | -------------------------------------------------------------------------------- /src/resources/js/keyboard_online.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module takes charge of the online user input via keyboard 3 | * 4 | * The user inputs (inputQueue) are transmitted between peers. 5 | */ 6 | 'use strict'; 7 | import { PikaKeyboard } from './offline_version_js/keyboard.js'; 8 | import { PikaUserInput } from './offline_version_js/physics.js'; 9 | import { 10 | channel, 11 | SYNC_DIVISOR, 12 | sendInputQueueToPeer, 13 | } from './data_channel/data_channel.js'; 14 | import { mod, isInModRange } from './utils/mod.js'; 15 | 16 | /** @constant @type {number} communicated input queue buffer length */ 17 | export const bufferLength = 8; 18 | 19 | /** 20 | * Class representing modified version of PikaKeyboard Class 21 | */ 22 | class PikaKeyboardModified extends PikaKeyboard { 23 | /** 24 | * Override the method in the superclass 25 | */ 26 | getInput() { 27 | if (this.leftKey.isDown) { 28 | this.xDirection = -1; 29 | } else if ( 30 | this.rightKey.isDown || 31 | (!channel.amIPlayer2 && this.downRightKey && this.downRightKey.isDown) // this.downRightKey works as right key only for player 1 32 | ) { 33 | this.xDirection = 1; 34 | } else { 35 | this.xDirection = 0; 36 | } 37 | 38 | if (this.upKey.isDown) { 39 | this.yDirection = -1; 40 | } else if ( 41 | this.downKey.isDown || 42 | (this.downRightKey && this.downRightKey.isDown) 43 | ) { 44 | this.yDirection = 1; 45 | } else { 46 | this.yDirection = 0; 47 | } 48 | 49 | const isDown = this.powerHitKey.isDown; 50 | if (!this.powerHitKeyIsDownPrevious && isDown) { 51 | this.powerHit = 1; 52 | } else { 53 | this.powerHit = 0; 54 | } 55 | this.powerHitKeyIsDownPrevious = isDown; 56 | } 57 | } 58 | 59 | /** 60 | * Class representing my keyboard used for game controller. 61 | * User chooses a comfortable side, so it contains both sides 62 | * (player 1 side in offline version, player 2 side in offline version). 63 | */ 64 | class MyKeyboard { 65 | /** 66 | * Create a keyboard used for game controller 67 | * left, right, up, down, powerHit: KeyboardEvent.code value for each 68 | * Refer {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values} 69 | * @param {string} left KeyboardEvent.code value of the key to use for left 70 | * @param {string} right KeyboardEvent.code value of the key to use for right 71 | * @param {string} up KeyboardEvent.code value of the key to use for up 72 | * @param {string} down KeyboardEvent.code value of the key to use for down 73 | * @param {string} powerHit KeyboardEvent.code value of the key to use for power hit or selection 74 | * @param {string} downRight KeyboardEvent.code value of the key to use for having the same effect 75 | * when pressing down key and right key at the same time (Only player 1 76 | * has this key) 77 | * @param {string} left2 KeyboardEvent.code value of the key to use for left 78 | * @param {string} right2 KeyboardEvent.code value of the key to use for right 79 | * @param {string} up2 KeyboardEvent.code value of the key to use for up 80 | * @param {string} down2 KeyboardEvent.code value of the key to use for down 81 | * @param {string} powerHit2 KeyboardEvent.code value of the key to use for power hit or selection 82 | * @param {string} downRight2 KeyboardEvent.code value of the key to use for having the same effect 83 | * when pressing down key and right key at the same time (Only player 1 84 | * has this key) 85 | */ 86 | constructor( 87 | left, 88 | right, 89 | up, 90 | down, 91 | powerHit, 92 | downRight, 93 | left2, 94 | right2, 95 | up2, 96 | down2, 97 | powerHit2, 98 | downRight2 99 | ) { 100 | this.keyboard1 = new PikaKeyboardModified( 101 | left, 102 | right, 103 | up, 104 | down, 105 | powerHit, 106 | downRight 107 | ); 108 | this.keyboard2 = new PikaKeyboardModified( 109 | left2, 110 | right2, 111 | up2, 112 | down2, 113 | powerHit2, 114 | downRight2 115 | ); 116 | this._syncCounter = 0; 117 | /** @type {PikaUserInputWithSync[]} */ 118 | this.inputQueue = []; 119 | } 120 | 121 | get syncCounter() { 122 | return this._syncCounter; 123 | } 124 | 125 | set syncCounter(counter) { 126 | this._syncCounter = mod(counter, SYNC_DIVISOR); 127 | } 128 | 129 | /** 130 | * Subscribe keydown, keyup event listeners for the keys of this keyboard 131 | */ 132 | subscribe() { 133 | this.keyboard1.subscribe(); 134 | this.keyboard2.subscribe(); 135 | } 136 | 137 | /** 138 | * Unsubscribe keydown, keyup event listeners for the keys of this keyboard 139 | */ 140 | unsubscribe() { 141 | this.keyboard1.unsubscribe(); 142 | this.keyboard2.unsubscribe(); 143 | } 144 | 145 | /** 146 | * Get user input if needed (judged by the syncCounter), 147 | * then push it to the input queue and send the input queue to peer. 148 | * @param {number} syncCounter 149 | */ 150 | getInputIfNeededAndSendToPeer(syncCounter) { 151 | if ( 152 | this.inputQueue.length === 0 || 153 | isInModRange( 154 | this.syncCounter, 155 | syncCounter, 156 | syncCounter + bufferLength - 1, 157 | SYNC_DIVISOR 158 | ) 159 | ) { 160 | this.keyboard1.getInput(); 161 | this.keyboard2.getInput(); 162 | const xDirection = 163 | this.keyboard1.xDirection !== 0 164 | ? this.keyboard1.xDirection 165 | : this.keyboard2.xDirection; 166 | const yDirection = 167 | this.keyboard1.yDirection !== 0 168 | ? this.keyboard1.yDirection 169 | : this.keyboard2.yDirection; 170 | const powerHit = 171 | this.keyboard1.powerHit !== 0 172 | ? this.keyboard1.powerHit 173 | : this.keyboard2.powerHit; 174 | const userInputWithSync = new PikaUserInputWithSync( 175 | this.syncCounter, 176 | xDirection, 177 | yDirection, 178 | powerHit 179 | ); 180 | this.inputQueue.push(userInputWithSync); 181 | this.syncCounter++; 182 | } 183 | sendInputQueueToPeer(this.inputQueue); 184 | } 185 | } 186 | 187 | /** This MyKeyboard instance is used among the modules */ 188 | export const myKeyboard = new MyKeyboard( 189 | 'KeyD', 190 | 'KeyG', 191 | 'KeyR', 192 | 'KeyV', 193 | 'KeyZ', 194 | 'KeyF', 195 | 'ArrowLeft', 196 | 'ArrowRight', 197 | 'ArrowUp', 198 | 'ArrowDown', 199 | 'Enter', 200 | 'ArrowDown' 201 | ); 202 | 203 | /** 204 | * Class representing the online keyboard which gets input from input queue 205 | */ 206 | export class OnlineKeyboard { 207 | /** 208 | * 209 | * @param {PikaUserInputWithSync[]} inputQueue 210 | */ 211 | constructor(inputQueue) { 212 | this.xDirection = 0; 213 | this.yDirection = 0; 214 | this.powerHit = 0; 215 | this.inputQueue = inputQueue; 216 | this.isHistoryBufferFilled = false; 217 | } 218 | 219 | /** 220 | * Check whether an input which corresponds syncCounter is on inputQueue 221 | * @param {number} syncCounter 222 | * @return {boolean} get input from peer succeed? 223 | */ 224 | isInputOnQueue(syncCounter) { 225 | if (this.inputQueue.length === 0) { 226 | return false; 227 | } 228 | if (this.isHistoryBufferFilled) { 229 | if (this.inputQueue.length > bufferLength) { 230 | if (this.inputQueue[bufferLength].syncCounter !== syncCounter) { 231 | console.log('Something in OnlineKeyboard is wrong...'); 232 | return false; 233 | } 234 | return true; 235 | } 236 | return false; 237 | } else { 238 | for (let i = 0; i < this.inputQueue.length; i++) { 239 | if (this.inputQueue[i].syncCounter === syncCounter) { 240 | return true; 241 | } 242 | } 243 | return false; 244 | } 245 | } 246 | 247 | /** 248 | * It should be called after checking the isInputOnQueue return value 249 | * is true. The input gotten by this method should be used, not discarded, 250 | * since this method does this.inputQueue.shift if it is needed. 251 | * @param {number} syncCounter 252 | */ 253 | getInput(syncCounter) { 254 | if (this.inputQueue.length === 0) { 255 | console.log('Something in getInput method is wrong...0'); 256 | return; 257 | } 258 | // Keep the history buffers (previous inputs) at the head of queue so that 259 | // the history buffers can be resent if lost. 260 | // The future buffers (now and upcoming inputs) and the history buffers 261 | // should have same length (bufferLength) to keep the sync connection. 262 | // So the maximum length of this.inputQueue is (2 * bufferLength). 263 | let input = null; 264 | if (this.isHistoryBufferFilled) { 265 | if (this.inputQueue.length > bufferLength) { 266 | input = this.inputQueue[bufferLength]; 267 | if (input.syncCounter !== syncCounter) { 268 | console.log('Something in getInput method is wrong...1'); 269 | return; 270 | } 271 | this.inputQueue.shift(); 272 | } else { 273 | console.log('Something in getInput method is wrong...2'); 274 | return; 275 | } 276 | } else { 277 | for (let i = 0; i < this.inputQueue.length; i++) { 278 | if (this.inputQueue[i].syncCounter === syncCounter) { 279 | input = this.inputQueue[i]; 280 | if (i === bufferLength) { 281 | this.isHistoryBufferFilled = true; 282 | this.inputQueue.shift(); 283 | } 284 | break; 285 | } 286 | } 287 | } 288 | if (input === null) { 289 | console.log('Something in getInput method is wrong...3'); 290 | return; 291 | } 292 | this.xDirection = input.xDirection; 293 | this.yDirection = input.yDirection; 294 | this.powerHit = input.powerHit; 295 | } 296 | } 297 | 298 | /** 299 | * Class representing a user input with a corresponding sync counter 300 | */ 301 | export class PikaUserInputWithSync extends PikaUserInput { 302 | constructor(syncCounter, xDirection, yDirection, powerHit) { 303 | super(); 304 | this.syncCounter = syncCounter; 305 | this.xDirection = xDirection; 306 | this.yDirection = yDirection; 307 | this.powerHit = powerHit; 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/en/update-history/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pikachu Volleyball P2P Online update history 8 | 12 | 13 | 14 | 20 | 21 | 22 |
23 |
24 |

25 | Pikachu Volleyball P2P Online 26 | update history 27 |

28 |
29 | 34 | 35 |
36 |

37 | ✓ English | 38 | Korean(한국어) 39 |

40 |

41 | Back to 42 | the peer-to-peer online version 43 |

44 |

Only major updates are recorded here.

45 |
46 |
47 |
48 |

2020-04-08 Release

49 |

50 | 2020-04-19 From the offline web version: 51 | fix Pikachu's diving direction. (Fixed by @djsjukr) 57 |

58 |

2020-04-29 Introduce Quick Match

59 |

60 | 2020-05-03 From the offline web version: 61 | fix left side keyboard mapping to the same as the original Pikachu 62 | Volleyball game. (Thanks to @repeat_c) 68 |

69 |

70 | 2020-05-08 Enable changing the game speed 71 | and other options like in the offline web version. (Suggested by @repeat_c) 77 |

78 |

79 | 2020-05-17 Enable changing game speed and 80 | other options on the initial waiting time. (Suggested by @e6p77Bi8CW7zRBg) 86 |

87 |

88 | 2020-05-23 Add "disable chat" button, 89 | which is on the right side of "chat" button. (Suggested by @막내) 95 |

96 |

97 | 2020-05-23 Add background blurring effect 98 | to the pop-up contents so that the text contents would be read 99 | easier. (Suggested by @KNHui) 105 |

106 |

107 | 2020-05-25 Create this update history 108 | page. (Suggested by @e6p77Bi8CW7zRBg) 114 |

115 |

116 | 2020-05-26 Show nickname and partial 117 | public IP address. (Suggested by @e6p77Bi8CW7zRBg 123 | and 124 | Suggested by @repeat_c) 130 |

131 |

132 | 2020-05-28 Notify by "Pikachu" sound when 133 | the connection is established or failed. (Suggested by @repeat_c) 139 |

140 |

141 | 2020-06-02 Enable saving replay (Suggested by @e6p77Bi8CW7zRBg) 147 |

148 |

149 | 2020-06-03 Users can see keyboard inputs 150 | in replay viewer (Suggested by @repeat_c) 156 |

157 |

158 | 2020-06-07 Create an option that 159 | automatically asks the peer about changing the game speed to "fast". 160 | Let BGM and SFX options change be kept. 161 |

162 |

163 | 2020-06-17 Add "Exit" button. Add 164 | explanation about replay. 165 |

166 |

167 | 2020-06-18 Create an option that 168 | automatically saves replay. (Suggested by @e6p77Bi8CW7zRBg) 174 |

175 |

176 | 2020-06-23 Enable starting Quick Match by 177 | pressing "Enter" key in main page (Referred a suggestion of 182 | @repeat_c) 184 |

185 |

186 | 2020-06-28 Add "Copy" button which can be 187 | used to copy the room ID to the clipboard. (Implemented by @samslow) 193 |

194 |

195 | 2021-12-01 Create quick match block list. 196 | In the quick match, you are not matched against the players whose IP 197 | addresses are in the list. 198 |

199 |

2022-01-17 Introduce dark mode.

200 |

201 | 2023-02-19 Create match groups for the 202 | quick match. Players who have selected the same match group will be 203 | matched. (Suggested by 204 | @DuckLL_tw) 210 |

211 |

212 | 2023-02-26 Fix bug in the network test. 213 | Before this bug fix, the test result could falsely say that the 214 | network is behind a symmetric NAT. (Found this bug thanks to a 215 | report by 216 | @소츄) 222 |

223 |

224 | 2023-10-02 Options for graphic — "sharp" 225 | and "soft" — are added. (Suggested by @DuckLL_tw) 231 |

232 |

233 | 2025-03-03 Add feature to hide peer's 234 | nickname. (Suggested by @훈령병) 235 |

236 |

237 | 2025-11-23 Add chat and nickname profanity 238 | filter. (Implemented by @uzaramen108) 244 |

245 |

246 | 2025-11-25 Support Ctrl key and Shift key 247 | to select multiple entries in "Quick Match Block List" and "Quick 248 | Match Block List". (Suggested by 249 | @눈알)

250 | Re-enable cancel quick match button a short time after initiating 251 | the connection attempt. (Suggested by 252 | @마조리카) 253 |

254 |
255 |
256 |
257 |
258 | 259 | 260 | -------------------------------------------------------------------------------- /src/resources/js/replay/ui_replay.js: -------------------------------------------------------------------------------- 1 | import { replayPlayer } from './replay_player.js'; 2 | import { enableChat, hideChat } from '../chat_display'; 3 | import '../../style.css'; 4 | 5 | /** @typedef {import('../offline_version_js/physics.js').PikaUserInput} PikaUserInput */ 6 | 7 | let pausedByBtn = false; 8 | 9 | const scrubberRangeInput = document.getElementById('scrubber-range-input'); 10 | const playPauseBtn = document.getElementById('play-pause-btn'); 11 | const seekBackward1Btn = document.getElementById('seek-backward-1'); 12 | const seekForward1Btn = document.getElementById('seek-forward-1'); 13 | const seekBackward3Btn = document.getElementById('seek-backward-3'); 14 | const seekForward3Btn = document.getElementById('seek-forward-3'); 15 | const speedBtn5FPS = document.getElementById('speed-btn-5-fps'); 16 | const speedBtnHalfTimes = document.getElementById('speed-btn-half-times'); 17 | const speedBtn1Times = document.getElementById('speed-btn-1-times'); 18 | const speedBtn2Times = document.getElementById('speed-btn-2-times'); 19 | 20 | export function setUpUI() { 21 | disableReplayScrubberAndBtns(); 22 | 23 | // File input code is from: https://developer.mozilla.org/en-US/docs/Web/API/File/Using_files_from_web_applications 24 | const fileInputElement = document.getElementById('file-input'); 25 | fileInputElement.addEventListener('change', (e) => { 26 | document.getElementById('loading-box').classList.remove('hidden'); 27 | dropbox.classList.add('hidden'); 28 | // @ts-ignore 29 | handleFiles(e.target.files); 30 | }); 31 | 32 | // Dropbox code is from: https://developer.mozilla.org/en-US/docs/Web/API/File/Using_files_from_web_applications 33 | const dropbox = document.getElementById('dropbox'); 34 | dropbox.addEventListener('dragenter', dragenter, false); 35 | dropbox.addEventListener('dragover', dragover, false); 36 | dropbox.addEventListener('drop', drop, false); 37 | function dragenter(e) { 38 | e.stopPropagation(); 39 | e.preventDefault(); 40 | } 41 | function dragover(e) { 42 | e.stopPropagation(); 43 | e.preventDefault(); 44 | } 45 | function drop(e) { 46 | e.stopPropagation(); 47 | e.preventDefault(); 48 | 49 | const dt = e.dataTransfer; 50 | const files = dt.files; 51 | 52 | document.getElementById('loading-box').classList.remove('hidden'); 53 | dropbox.classList.add('hidden'); 54 | 55 | handleFiles(files); 56 | } 57 | function handleFiles(files) { 58 | replayPlayer.readFile(files[0]); 59 | } 60 | 61 | scrubberRangeInput.addEventListener('touchstart', () => { 62 | if (replayPlayer.ticker.started) { 63 | replayPlayer.ticker.stop(); 64 | replayPlayer.stopBGM(); 65 | } 66 | }); 67 | scrubberRangeInput.addEventListener('mousedown', () => { 68 | if (replayPlayer.ticker.started) { 69 | replayPlayer.ticker.stop(); 70 | replayPlayer.stopBGM(); 71 | } 72 | }); 73 | scrubberRangeInput.addEventListener('touchend', () => { 74 | if (!pausedByBtn && !replayPlayer.ticker.started) { 75 | replayPlayer.ticker.start(); 76 | replayPlayer.playBGMProperly(); 77 | } 78 | }); 79 | scrubberRangeInput.addEventListener('mouseup', () => { 80 | if (!pausedByBtn && !replayPlayer.ticker.started) { 81 | replayPlayer.ticker.start(); 82 | replayPlayer.playBGMProperly(); 83 | } 84 | }); 85 | scrubberRangeInput.addEventListener('input', (e) => { 86 | // @ts-ignore 87 | replayPlayer.seekFrame(Number(e.currentTarget.value)); 88 | }); 89 | 90 | // @ts-ignore 91 | playPauseBtn.disabled = true; 92 | playPauseBtn.addEventListener('click', () => { 93 | if (replayPlayer.ticker.started) { 94 | replayPlayer.ticker.stop(); 95 | replayPlayer.stopBGM(); 96 | pausedByBtn = true; 97 | adjustPlayPauseBtnIcon(); 98 | } else { 99 | replayPlayer.ticker.start(); 100 | replayPlayer.playBGMProperly(); 101 | pausedByBtn = false; 102 | adjustPlayPauseBtnIcon(); 103 | } 104 | }); 105 | 106 | seekBackward1Btn.addEventListener('click', () => { 107 | replayPlayer.seekRelativeTime(-1); 108 | if (!pausedByBtn && !replayPlayer.ticker.started) { 109 | replayPlayer.ticker.start(); 110 | replayPlayer.playBGMProperly(); 111 | } 112 | }); 113 | seekForward1Btn.addEventListener('click', () => { 114 | replayPlayer.seekRelativeTime(1); 115 | if (!pausedByBtn && !replayPlayer.ticker.started) { 116 | replayPlayer.ticker.start(); 117 | replayPlayer.playBGMProperly(); 118 | } 119 | }); 120 | seekBackward3Btn.addEventListener('click', () => { 121 | replayPlayer.seekRelativeTime(-3); 122 | if (!pausedByBtn && !replayPlayer.ticker.started) { 123 | replayPlayer.ticker.start(); 124 | replayPlayer.playBGMProperly(); 125 | } 126 | }); 127 | seekForward3Btn.addEventListener('click', () => { 128 | replayPlayer.seekRelativeTime(3); 129 | if (!pausedByBtn && !replayPlayer.ticker.started) { 130 | replayPlayer.ticker.start(); 131 | replayPlayer.playBGMProperly(); 132 | } 133 | }); 134 | 135 | speedBtn5FPS.addEventListener('click', (e) => { 136 | processSelected(e); 137 | replayPlayer.adjustPlaybackSpeedFPS(5); 138 | }); 139 | speedBtnHalfTimes.addEventListener('click', (e) => { 140 | processSelected(e); 141 | replayPlayer.adjustPlaybackSpeedTimes(0.5); 142 | }); 143 | speedBtn1Times.addEventListener('click', (e) => { 144 | processSelected(e); 145 | replayPlayer.adjustPlaybackSpeedTimes(1); 146 | }); 147 | speedBtn2Times.addEventListener('click', (e) => { 148 | processSelected(e); 149 | replayPlayer.adjustPlaybackSpeedTimes(2); 150 | }); 151 | function processSelected(e) { 152 | unselectSpeedBtns(); 153 | // @ts-ignore 154 | e.currentTarget.classList.add('selected'); 155 | } 156 | function unselectSpeedBtns() { 157 | for (const btn of [ 158 | speedBtn5FPS, 159 | speedBtnHalfTimes, 160 | speedBtn1Times, 161 | speedBtn2Times, 162 | ]) { 163 | btn.classList.remove('selected'); 164 | } 165 | } 166 | 167 | const fpsInput = document.getElementById('fps-input'); 168 | fpsInput.addEventListener('change', (e) => { 169 | // @ts-ignore 170 | let value = e.target.value; 171 | if (value < 0) { 172 | value = 0; 173 | } else if (value > 60) { 174 | value = 60; 175 | } 176 | replayPlayer.adjustPlaybackSpeedFPS(value); 177 | unselectSpeedBtns(); 178 | }); 179 | 180 | const noticeBoxEndOfReplayOKBtn = document.getElementById( 181 | 'notice-end-of-replay-ok-btn' 182 | ); 183 | noticeBoxEndOfReplayOKBtn.addEventListener('click', () => { 184 | location.reload(); 185 | }); 186 | 187 | const noticeBoxFileErrorOKBtn = document.getElementById( 188 | 'notice-file-open-error-ok-btn' 189 | ); 190 | noticeBoxFileErrorOKBtn.addEventListener('click', () => { 191 | location.reload(); 192 | }); 193 | 194 | const keyboardContainer = document.getElementById('keyboard-container'); 195 | const showKeyboardCheckbox = document.getElementById( 196 | 'show-keyboard-checkbox' 197 | ); 198 | showKeyboardCheckbox.addEventListener('change', () => { 199 | // @ts-ignore 200 | if (showKeyboardCheckbox.checked) { 201 | keyboardContainer.classList.remove('hidden'); 202 | } else { 203 | keyboardContainer.classList.add('hidden'); 204 | } 205 | }); 206 | 207 | const showChatCheckbox = document.getElementById('show-chat-checkbox'); 208 | showChatCheckbox.addEventListener('change', () => { 209 | // @ts-ignore 210 | if (showChatCheckbox.checked) { 211 | enableChat(true); 212 | } else { 213 | enableChat(false); 214 | hideChat(); 215 | } 216 | }); 217 | 218 | const showNicknamesCheckbox = document.getElementById( 219 | 'show-nicknames-checkbox' 220 | ); 221 | const player1NicknameElem = document.getElementById('player1-nickname'); 222 | const player2NicknameElem = document.getElementById('player2-nickname'); 223 | showNicknamesCheckbox.addEventListener('change', () => { 224 | // @ts-ignore 225 | if (showNicknamesCheckbox.checked) { 226 | player1NicknameElem.classList.remove('hidden'); 227 | player2NicknameElem.classList.remove('hidden'); 228 | } else { 229 | player1NicknameElem.classList.add('hidden'); 230 | player2NicknameElem.classList.add('hidden'); 231 | } 232 | }); 233 | 234 | const showIPsCheckbox = document.getElementById('show-ip-addresses-checkbox'); 235 | const player1IPElem = document.getElementById('player1-partial-ip'); 236 | const player2IPElem = document.getElementById('player2-partial-ip'); 237 | showIPsCheckbox.addEventListener('change', () => { 238 | // @ts-ignore 239 | if (showIPsCheckbox.checked) { 240 | player1IPElem.classList.remove('hidden'); 241 | player2IPElem.classList.remove('hidden'); 242 | } else { 243 | player1IPElem.classList.add('hidden'); 244 | player2IPElem.classList.add('hidden'); 245 | } 246 | }); 247 | 248 | const turnOnBGMCheckbox = document.getElementById('turn-on-bgm-checkbox'); 249 | turnOnBGMCheckbox.addEventListener('change', () => { 250 | if (replayPlayer.pikaVolley === null) { 251 | return; 252 | } 253 | // @ts-ignore 254 | if (turnOnBGMCheckbox.checked) { 255 | replayPlayer.pikaVolley.audio.turnBGMVolume(true); 256 | } else { 257 | replayPlayer.pikaVolley.audio.turnBGMVolume(false); 258 | } 259 | }); 260 | 261 | const turnOnSFXCheckbox = document.getElementById('turn-on-sfx-checkbox'); 262 | turnOnSFXCheckbox.addEventListener('change', () => { 263 | if (replayPlayer.pikaVolley === null) { 264 | return; 265 | } 266 | // @ts-ignore 267 | if (turnOnSFXCheckbox.checked) { 268 | replayPlayer.pikaVolley.audio.turnSFXVolume(true); 269 | } else { 270 | replayPlayer.pikaVolley.audio.turnSFXVolume(false); 271 | } 272 | }); 273 | 274 | const graphicSharpCheckbox = document.getElementById( 275 | 'graphic-sharp-checkbox' 276 | ); 277 | graphicSharpCheckbox.addEventListener('change', () => { 278 | if (replayPlayer.pikaVolley === null) { 279 | return; 280 | } 281 | // @ts-ignore 282 | if (graphicSharpCheckbox.checked) { 283 | document 284 | .querySelector('#game-canvas-container>canvas') 285 | .classList.remove('graphic-soft'); 286 | } else { 287 | document 288 | .querySelector('#game-canvas-container>canvas') 289 | .classList.add('graphic-soft'); 290 | } 291 | }); 292 | 293 | window.addEventListener('keydown', (event) => { 294 | if (event.code === 'Space') { 295 | event.preventDefault(); 296 | playPauseBtn.click(); 297 | } else if (event.code === 'ArrowLeft') { 298 | event.preventDefault(); 299 | seekBackward3Btn.click(); 300 | } else if (event.code === 'ArrowRight') { 301 | event.preventDefault(); 302 | seekForward3Btn.click(); 303 | } 304 | }); 305 | } 306 | 307 | export function adjustFPSInputValue() { 308 | const fpsInput = document.getElementById('fps-input'); 309 | // @ts-ignore 310 | fpsInput.value = replayPlayer.ticker.maxFPS; 311 | } 312 | 313 | export function adjustPlayPauseBtnIcon() { 314 | const playPauseBtn = document.getElementById('play-pause-btn'); 315 | if (replayPlayer.ticker.started) { 316 | playPauseBtn.textContent = 317 | document.getElementById('pause-mark').textContent; 318 | } else { 319 | playPauseBtn.textContent = document.getElementById('play-mark').textContent; 320 | } 321 | } 322 | 323 | export function noticeEndOfReplay() { 324 | const noticeBoxEndOfReplay = document.getElementById('notice-end-of-replay'); 325 | noticeBoxEndOfReplay.classList.remove('hidden'); 326 | } 327 | 328 | export function hideNoticeEndOfReplay() { 329 | const noticeBoxEndOfReplay = document.getElementById('notice-end-of-replay'); 330 | noticeBoxEndOfReplay.classList.add('hidden'); 331 | } 332 | 333 | export function noticeFileOpenError() { 334 | const noticeBoxFileOpenError = document.getElementById( 335 | 'notice-file-open-error' 336 | ); 337 | noticeBoxFileOpenError.classList.remove('hidden'); 338 | } 339 | 340 | export function getCommentText() { 341 | return document.getElementById('replay-viewer-at').textContent; 342 | } 343 | 344 | export function setMaxForScrubberRange(max) { 345 | // @ts-ignore 346 | scrubberRangeInput.max = max; 347 | } 348 | 349 | export function moveScrubberTo(value) { 350 | // @ts-ignore 351 | scrubberRangeInput.value = value; 352 | } 353 | 354 | /** 355 | * 356 | * @param {number} timeCurrent unit: second 357 | */ 358 | export function showTimeCurrent(timeCurrent) { 359 | document.getElementById('time-current').textContent = 360 | getTimeText(timeCurrent); 361 | } 362 | 363 | /** 364 | * 365 | * @param {number} timeDuration unit: second 366 | */ 367 | export function showTotalTimeDuration(timeDuration) { 368 | document.getElementById('time-duration').textContent = 369 | getTimeText(timeDuration); 370 | } 371 | 372 | /** 373 | * Show Keyboard inputs 374 | * @param {PikaUserInput} player1Input 375 | * @param {PikaUserInput} player2Input 376 | */ 377 | export function showKeyboardInputs(player1Input, player2Input) { 378 | const zKey = document.getElementById('z-key'); 379 | const rKey = document.getElementById('r-key'); 380 | const vKey = document.getElementById('v-key'); 381 | const dKey = document.getElementById('d-key'); 382 | const gKey = document.getElementById('g-key'); 383 | 384 | const enterKey = document.getElementById('enter-key'); 385 | const upKey = document.getElementById('up-key'); 386 | const downKey = document.getElementById('down-key'); 387 | const leftKey = document.getElementById('left-key'); 388 | const rightKey = document.getElementById('right-key'); 389 | 390 | function pressKeyElm(keyElm) { 391 | keyElm.classList.add('pressed'); 392 | } 393 | 394 | function unpressKeyElm(keyElm) { 395 | keyElm.classList.remove('pressed'); 396 | } 397 | 398 | switch (player1Input.xDirection) { 399 | case 0: 400 | unpressKeyElm(dKey); 401 | unpressKeyElm(gKey); 402 | break; 403 | case -1: 404 | pressKeyElm(dKey); 405 | unpressKeyElm(gKey); 406 | break; 407 | case 1: 408 | unpressKeyElm(dKey); 409 | pressKeyElm(gKey); 410 | break; 411 | } 412 | switch (player1Input.yDirection) { 413 | case 0: 414 | unpressKeyElm(rKey); 415 | unpressKeyElm(vKey); 416 | break; 417 | case -1: 418 | pressKeyElm(rKey); 419 | unpressKeyElm(vKey); 420 | break; 421 | case 1: 422 | unpressKeyElm(rKey); 423 | pressKeyElm(vKey); 424 | break; 425 | } 426 | switch (player1Input.powerHit) { 427 | case 0: 428 | unpressKeyElm(zKey); 429 | break; 430 | case 1: 431 | pressKeyElm(zKey); 432 | break; 433 | } 434 | 435 | switch (player2Input.xDirection) { 436 | case 0: 437 | unpressKeyElm(leftKey); 438 | unpressKeyElm(rightKey); 439 | break; 440 | case -1: 441 | pressKeyElm(leftKey); 442 | unpressKeyElm(rightKey); 443 | break; 444 | case 1: 445 | unpressKeyElm(leftKey); 446 | pressKeyElm(rightKey); 447 | break; 448 | } 449 | switch (player2Input.yDirection) { 450 | case 0: 451 | unpressKeyElm(upKey); 452 | unpressKeyElm(downKey); 453 | break; 454 | case -1: 455 | pressKeyElm(upKey); 456 | unpressKeyElm(downKey); 457 | break; 458 | case 1: 459 | unpressKeyElm(upKey); 460 | pressKeyElm(downKey); 461 | break; 462 | } 463 | switch (player2Input.powerHit) { 464 | case 0: 465 | unpressKeyElm(enterKey); 466 | break; 467 | case 1: 468 | pressKeyElm(enterKey); 469 | break; 470 | } 471 | } 472 | 473 | export function enableReplayScrubberAndBtns() { 474 | // @ts-ignore 475 | scrubberRangeInput.disabled = false; 476 | // @ts-ignore 477 | playPauseBtn.disabled = false; 478 | // @ts-ignore 479 | seekBackward1Btn.disabled = false; 480 | // @ts-ignore 481 | seekForward1Btn.disabled = false; 482 | // @ts-ignore 483 | seekBackward3Btn.disabled = false; 484 | // @ts-ignore 485 | seekForward3Btn.disabled = false; 486 | // @ts-ignore 487 | speedBtn5FPS.disabled = false; 488 | // @ts-ignore 489 | speedBtnHalfTimes.disabled = false; 490 | // @ts-ignore 491 | speedBtn1Times.disabled = false; 492 | // @ts-ignore 493 | speedBtn2Times.disabled = false; 494 | } 495 | 496 | function disableReplayScrubberAndBtns() { 497 | // @ts-ignore 498 | scrubberRangeInput.disabled = true; 499 | // @ts-ignore 500 | playPauseBtn.disabled = true; 501 | // @ts-ignore 502 | seekBackward1Btn.disabled = true; 503 | // @ts-ignore 504 | seekForward1Btn.disabled = true; 505 | // @ts-ignore 506 | seekBackward3Btn.disabled = true; 507 | // @ts-ignore 508 | seekForward3Btn.disabled = true; 509 | // @ts-ignore 510 | speedBtn5FPS.disabled = true; 511 | // @ts-ignore 512 | speedBtnHalfTimes.disabled = true; 513 | // @ts-ignore 514 | speedBtn1Times.disabled = true; 515 | // @ts-ignore 516 | speedBtn2Times.disabled = true; 517 | } 518 | 519 | /** 520 | * 521 | * @param {number} time unit: second 522 | */ 523 | function getTimeText(time) { 524 | const seconds = Math.floor(time % 60); 525 | const minutes = Math.floor(time / 60) % 60; 526 | const hours = Math.floor(Math.floor(time / 60) / 60); 527 | 528 | if (hours > 0) { 529 | return `${String(hours)}:${('0' + minutes).slice(-2)}:${( 530 | '0' + seconds 531 | ).slice(-2)}`; 532 | } else { 533 | return `${String(minutes)}:${('0' + seconds).slice(-2)}`; 534 | } 535 | } 536 | --------------------------------------------------------------------------------