body { margin: 0; background-color: #000; background-image: radial-gradient(ellipse at top, #335476 0.0%, #31506e 11.1%, #304b67 22.2%, #2f4760 33.3%, #2d4359 44.4%, #2c3f51 55.6%, #2a3a4a 66.7%, #293643 77.8%, #28323d 88.9%, #262e36 100.0%); height: 100vh; overflow: hidden; font-family: monospace; font-weight: bold; letter-spacing: 0.06em; color: rgba(255, 255, 255, 0.75); } #c { display: block; touch-action: none; transform: translateZ(0); } /*///////////////////// // HUD // /////////////////////*/ .hud__score, .pause-btn { position: fixed; font-size: calc(14px + 2vw + 1vh); } .hud__score { top: 0.65em; left: 0.65em; pointer-events: none; user-select: none; } .cube-count-lbl { font-size: 0.46em; } .pause-btn { position: fixed; top: 0; right: 0; padding: 0.8em 0.65em; } .pause-btn > div { position: relative; width: 0.8em; height: 0.8em; opacity: 0.75; } .pause-btn > div::before, .pause-btn > div::after { content: ''; display: block; width: 34%; height: 100%; position: absolute; background-color: #fff; } .pause-btn > div::after { right: 0; } .slowmo { position: fixed; bottom: 0; width: 100%; pointer-events: none; opacity: 0; transition: opacity 0.4s; will-change: opacity; } .slowmo::before { content: 'SLOW-MO'; display: block; font-size: calc(8px + 1vw + 0.5vh); margin-left: 0.5em; margin-bottom: 8px; } .slowmo::after { content: ''; display: block; position: fixed; bottom: 0; width: 100%; height: 1.5vh; background-color: rgba(0, 0, 0, 0.25); z-index: -1; } .slowmo__bar { height: 1.5vh; background-color: rgba(255, 255, 255, 0.75); transform-origin: 0 0; } /*///////////////////// // MENUS // /////////////////////*/ .menus::before { content: ''; pointer-events: none; position: fixed; top: 0; right: 0; bottom: 0; left: 0; background-color: #000; opacity: 0; transition: opacity 0.2s; transition-timing-function: ease-in; } .menus.has-active::before { opacity: 0.08; transition-duration: 0.4s; transition-timing-function: ease-out; } .menus.interactive-mode::before { opacity: 0.02; } /* Menu containers */ .menu { pointer-events: none; position: fixed; top: 0; right: 0; bottom: 0; left: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; user-select: none; text-align: center; color: rgba(255, 255, 255, 0.9); opacity: 0; visibility: hidden; transform: translateY(30px); transition-property: opacity, visibility, transform; transition-duration: 0.2s; transition-timing-function: ease-in; } .menu.active { opacity: 1; visibility: visible; transform: translateY(0); transition-duration: 0.4s; transition-timing-function: ease-out; } .menus.interactive-mode .menu.active { opacity: 0.6; } .menus:not(.interactive-mode) .menu.active > * { pointer-events: auto; } /* Common menu elements */ h1 { font-size: 4rem; line-height: 0.95; text-align: center; font-weight: bold; margin: 0 0.65em 1em; } h2 { font-size: 1.2rem; line-height: 1; text-align: center; font-weight: bold; margin: -1em 0.65em 1em; } .final-score-lbl { font-size: 5rem; margin: -0.2em 0 0; } .high-score-lbl { font-size: 1.2rem; margin: 0 0 2.5em; } button { display: block; position: relative; width: 200px; padding: 12px 20px; background: transparent; border: none; outline: none; user-select: none; font-family: monospace; font-weight: bold; font-size: 1.4rem; color: #fff; opacity: 0.75; transition: opacity 0.3s; } button::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background-color: rgba(255, 255, 255, 0.15); transform: scale(0, 0); opacity: 0; transition: opacity 0.3s, transform 0.3s; } /* No `:focus` styles because this is a mouse/touch game! */ button:active { opacity: 1; } button:active::before { transform: scale(1, 1); opacity: 1; } .credits { position: fixed; width: 100%; left: 0; bottom: 20px; } a { color: white; } /* Only enable hover state on large screens */ @media (min-width: 1025px) { button:hover { opacity: 1; } button:hover::before { transform: scale(1, 1); opacity: 1; } }
// globalConfig.js // ============================================================================ // ============================================================================ // Provides global variables used by the entire program. // Most of this should be configuration. // Timing multiplier for entire game engine. let gameSpeed = 1; // Colors const BLUE = { r: 0x67, g: 0xd7, b: 0xf0 }; const GREEN = { r: 0xa6, g: 0xe0, b: 0x2c }; const PINK = { r: 0xfa, g: 0x24, b: 0x73 }; const ORANGE = { r: 0xfe, g: 0x95, b: 0x22 }; const allColors = [BLUE, GREEN, PINK, ORANGE]; // Gameplay const getSpawnDelay = () => { const spawnDelayMax = 1400; const spawnDelayMin = 550; const spawnDelay = spawnDelayMax - state.game.cubeCount * 3.1; return Math.max(spawnDelay, spawnDelayMin); } const doubleStrongEnableScore = 2000; // Number of cubes that must be smashed before activating a feature. const slowmoThreshold = 10; const strongThreshold = 25; const spinnerThreshold = 25; // Interaction state let pointerIsDown = false; // The last known position of the primary pointer in screen coordinates.` let pointerScreen = { x: 0, y: 0 }; // Same as `pointerScreen`, but converted to scene coordinates in rAF. let pointerScene = { x: 0, y: 0 }; // Minimum speed of pointer before "hits" are counted. const minPointerSpeed = 60; // The hit speed affects the direction the target post-hit. This number dampens that force. const hitDampening = 0.1; // Backboard receives shadows and is the farthest negative Z position of entities. const backboardZ = -400; const shadowColor = '#262e36'; // How much air drag is applied to standard objects const airDrag = 0.022; const gravity = 0.3; // Spark config const sparkColor = 'rgba(170,221,255,.9)'; const sparkThickness = 2.2; const airDragSpark = 0.1; // Track pointer positions to show trail const touchTrailColor = 'rgba(170,221,255,.62)'; const touchTrailThickness = 7; const touchPointLife = 120; const touchPoints = []; // Size of in-game targets. This affects rendered size and hit area. const targetRadius = 40; const targetHitRadius = 50; const makeTargetGlueColor = target => { // const alpha = (target.health - 1) / (target.maxHealth - 1); // return `rgba(170,221,255,${alpha.toFixed(3)})`; return 'rgb(170,221,255)'; }; // Size of target fragments const fragRadius = targetRadius / 3; // Game canvas element needed in setup.js and interaction.js const canvas = document.querySelector('#c'); // 3D camera config // Affects perspective const cameraDistance = 900; // Does not affect perspective const sceneScale = 1; // Objects that get too close to the camera will be faded out to transparent over this range. // const cameraFadeStartZ = 0.8*cameraDistance - 6*targetRadius; const cameraFadeStartZ = 0.45*cameraDistance; const cameraFadeEndZ = 0.65*cameraDistance; const cameraFadeRange = cameraFadeEndZ - cameraFadeStartZ; // Globals used to accumlate all vertices/polygons in each frame const allVertices = []; const allPolys = []; const allShadowVertices = []; const allShadowPolys = []; // state.js // ============================================================================ // ============================================================================ /////////// // Enums // /////////// // Game Modes const GAME_MODE_RANKED = Symbol('GAME_MODE_RANKED'); const GAME_MODE_CASUAL = Symbol('GAME_MODE_CASUAL'); // Available Menus const MENU_MAIN = Symbol('MENU_MAIN'); const MENU_PAUSE = Symbol('MENU_PAUSE'); const MENU_SCORE = Symbol('MENU_SCORE'); ////////////////// // Global State // ////////////////// const state = { game: { mode: GAME_MODE_RANKED, // Run time of current game. time: 0, // Player score. score: 0, // Total number of cubes smashed in game. cubeCount: 0 }, menus: { // Set to `null` to hide all menus active: MENU_MAIN } }; //////////////////////////// // Global State Selectors // //////////////////////////// const isInGame = () => !state.menus.active; const isMenuVisible = () => !!state.menus.active; const isCasualGame = () => state.game.mode === GAME_MODE_CASUAL; const isPaused = () => state.menus.active === MENU_PAUSE; /////////////////// // Local Storage // /////////////////// const highScoreKey = '__menja__highScore'; const getHighScore = () => { const raw = localStorage.getItem(highScoreKey); return raw ? parseInt(raw, 10) : 0; }; let _lastHighscore = getHighScore(); const setHighScore = score => { _lastHighscore = getHighScore(); localStorage.setItem(highScoreKey, String(score)); }; const isNewHighScore = () => state.game.score > _lastHighscore; // utils.js // ============================================================================ // ============================================================================ const invariant = (condition, message) => { if (!condition) throw new Error(message); }; ///////// // DOM // ///////// const $ = selector => document.querySelector(selector); const handleClick = (element, handler) => element.addEventListener('click', handler); const handlePointerDown = (element, handler) => { element.addEventListener('touchstart', handler); element.addEventListener('mousedown', handler); }; //////////////////////// // Formatting Helpers // //////////////////////// // Converts a number into a formatted string with thousand separators. const formatNumber = num => num.toLocaleString(); //////////////////// // Math Constants // //////////////////// const PI = Math.PI; const TAU = Math.PI * 2; const ETA = Math.PI * 0.5; ////////////////// // Math Helpers // ////////////////// // Clamps a number between min and max values (inclusive) const clamp = (num, min, max) => Math.min(Math.max(num, min), max); // Linearly interpolate between numbers a and b by a specific amount. // mix >= 0 && mix <= 1 const lerp = (a, b, mix) => (b - a) * mix + a; //////////////////// // Random Helpers // //////////////////// // Generates a random number between min (inclusive) and max (exclusive) const random = (min, max) => Math.random() * (max - min) + min; // Generates a random integer between and possibly including min and max values const randomInt = (min, max) => ((Math.random() * (max - min + 1)) | 0) + min; // Returns a random element from an array const pickOne = arr => arr[Math.random() * arr.length | 0]; /////////////////// // Color Helpers // /////////////////// // Converts an { r, g, b } color object to a 6-digit hex code. const colorToHex = color => { return '#' + (color.r | 0).toString(16).padStart(2, '0') + (color.g | 0).toString(16).padStart(2, '0') + (color.b | 0).toString(16).padStart(2, '0'); }; // Operates on an { r, g, b } color object. // Returns string hex code. // `lightness` must range from 0 to 1. 0 is pure black, 1 is pure white. const shadeColor = (color, lightness) => { let other, mix; if (lightness < 0.5) { other = 0; mix = 1 - (lightness * 2); } else { other = 255; mix = lightness * 2 - 1; } return '#' + (lerp(color.r, other, mix) | 0).toString(16).padStart(2, '0') + (lerp(color.g, other, mix) | 0).toString(16).padStart(2, '0') + (lerp(color.b, other, mix) | 0).toString(16).padStart(2, '0'); }; //////////////////// // Timing Helpers // //////////////////// const _allCooldowns = []; const makeCooldown = (rechargeTime, units=1) => { let timeRemaining = 0; let lastTime = 0; const initialOptions = { rechargeTime, units }; const updateTime = () => { const now = state.game.time; // Reset time remaining if time goes backwards. if (now < lastTime) { timeRemaining = 0; } else { // update... timeRemaining -= now-lastTime; if (timeRemaining < 0) timeRemaining = 0; } lastTime = now; }; const canUse = () => { updateTime(); return timeRemaining <= (rechargeTime * (units-1)); }; const cooldown = { canUse, useIfAble() { const usable = canUse(); if (usable) timeRemaining += rechargeTime; return usable; }, mutate(options) { if (options.rechargeTime) { // Apply recharge time delta so change takes effect immediately. timeRemaining -= rechargeTime-options.rechargeTime; if (timeRemaining < 0) timeRemaining = 0; rechargeTime = options.rechargeTime; } if (options.units) units = options.units; }, reset() { timeRemaining = 0; lastTime = 0; this.mutate(initialOptions); } }; _allCooldowns.push(cooldown); return cooldown; }; const resetAllCooldowns = () => _allCooldowns.forEach(cooldown => cooldown.reset()); const makeSpawner = ({ chance, cooldownPerSpawn, maxSpawns }) => { const cooldown = makeCooldown(cooldownPerSpawn, maxSpawns); return { shouldSpawn() { return Math.random() <= chance && cooldown.useIfAble(); }, mutate(options) { if (options.chance) chance = options.chance; cooldown.mutate({ rechargeTime: options.cooldownPerSpawn, units: options.maxSpawns }); } }; }; //////////////////// // Vector Helpers // //////////////////// const normalize = v => { const mag = Math.hypot(v.x, v.y, v.z); return { x: v.x / mag, y: v.y / mag, z: v.z / mag }; } // Curried math helpers const add = a => b => a + b; // Curried vector helpers const scaleVector = scale => vector => { vector.x *= scale; vector.y *= scale; vector.z *= scale; }; //////////////// // 3D Helpers // //////////////// // Clone array and all vertices. function cloneVertices(vertices) { return vertices.map(v => ({ x: v.x, y: v.y, z: v.z })); } // Copy vertex data from one array into another. // Arrays must be the same length. function copyVerticesTo(arr1, arr2) { const len = arr1.length; for (let i=0; i { const targetVertex = target[i]; // X axis rotation const x1 = v.x; const y1 = v.z*sinX + v.y*cosX; const z1 = v.z*cosX - v.y*sinX; // Y axis rotation const x2 = x1*cosY - z1*sinY; const y2 = y1; const z2 = x1*sinY + z1*cosY; // Z axis rotation const x3 = x2*cosZ - y2*sinZ; const y3 = x2*sinZ + y2*cosZ; const z3 = z2; // Scale, Translate, and set the transform. targetVertex.x = x3 * sX + tX; targetVertex.y = y3 * sY + tY; targetVertex.z = z3 * sZ + tZ; }); } // 3D projection on a single vertex. // Directly mutates the vertex. const projectVertex = v => { const focalLength = cameraDistance * sceneScale; const depth = focalLength / (cameraDistance - v.z); v.x = v.x * depth; v.y = v.y * depth; }; // 3D projection on a single vertex. // Mutates a secondary target vertex. const projectVertexTo = (v, target) => { const focalLength = cameraDistance * sceneScale; const depth = focalLength / (cameraDistance - v.z); target.x = v.x * depth; target.y = v.y * depth; }; // PERF.js // ============================================================================ // ============================================================================ // Dummy no-op functions. // I use these in a special build for custom performance profiling. const PERF_START = () => {}; const PERF_END = () => {}; const PERF_UPDATE = () => {}; // 3dModels.js // ============================================================================ // ============================================================================ // Define models once. The origin is the center of the model. // A simple cube, 8 vertices, 6 quads. // Defaults to an edge length of 2 units, can be influenced with `scale`. function makeCubeModel({ scale=1 }) { return { vertices: [ // top { x: -scale, y: -scale, z: scale }, { x: scale, y: -scale, z: scale }, { x: scale, y: scale, z: scale }, { x: -scale, y: scale, z: scale }, // bottom { x: -scale, y: -scale, z: -scale }, { x: scale, y: -scale, z: -scale }, { x: scale, y: scale, z: -scale }, { x: -scale, y: scale, z: -scale } ], polys: [ // z = 1 { vIndexes: [0, 1, 2, 3] }, // z = -1 { vIndexes: [7, 6, 5, 4] }, // y = 1 { vIndexes: [3, 2, 6, 7] }, // y = -1 { vIndexes: [4, 5, 1, 0] }, // x = 1 { vIndexes: [5, 6, 2, 1] }, // x = -1 { vIndexes: [0, 3, 7, 4] } ] }; } // Not very optimized - lots of duplicate vertices are generated. function makeRecursiveCubeModel({ recursionLevel, splitFn, color, scale=1 }) { const getScaleAtLevel = level => 1 / (3 ** level); // We can model level 0 manually. It's just a single, centered, cube. let cubeOrigins = [{ x: 0, y: 0, z: 0 }]; // Recursively replace cubes with smaller cubes. for (let i=1; i<=recursionLevel; i++) { const scale = getScaleAtLevel(i) * 2; const cubeOrigins2 = []; cubeOrigins.forEach(origin => { cubeOrigins2.push(...splitFn(origin, scale)); }); cubeOrigins = cubeOrigins2; } const finalModel = { vertices: [], polys: [] }; // Generate single cube model and scale it. const cubeModel = makeCubeModel({ scale: 1 }); cubeModel.vertices.forEach(scaleVector(getScaleAtLevel(recursionLevel))); // Compute the max distance x, y, or z origin values will be. // Same result as `Math.max(...cubeOrigins.map(o => o.x))`, but much faster. const maxComponent = getScaleAtLevel(recursionLevel) * (3 ** recursionLevel - 1); // Place cube geometry at each origin. cubeOrigins.forEach((origin, cubeIndex) => { // To compute occlusion (shading), find origin component with greatest // magnitude and normalize it relative to `maxComponent`. const occlusion = Math.max( Math.abs(origin.x), Math.abs(origin.y), Math.abs(origin.z) ) / maxComponent; // At lower iterations, occlusion looks better lightened up a bit. const occlusionLighter = recursionLevel > 2 ? occlusion : (occlusion + 0.8) / 1.8; // Clone, translate vertices to origin, and apply scale finalModel.vertices.push( ...cubeModel.vertices.map(v => ({ x: (v.x + origin.x) * scale, y: (v.y + origin.y) * scale, z: (v.z + origin.z) * scale })) ); // Clone polys, shift referenced vertex indexes, and compute color. finalModel.polys.push( ...cubeModel.polys.map(poly => ({ vIndexes: poly.vIndexes.map(add(cubeIndex * 8)) })) ); }); return finalModel; } // o: Vector3D - Position of cube's origin (center). // s: Vector3D - Determines size of menger sponge. function mengerSpongeSplit(o, s) { return [ // Top { x: o.x + s, y: o.y - s, z: o.z + s }, { x: o.x + s, y: o.y - s, z: o.z + 0 }, { x: o.x + s, y: o.y - s, z: o.z - s }, { x: o.x + 0, y: o.y - s, z: o.z + s }, { x: o.x + 0, y: o.y - s, z: o.z - s }, { x: o.x - s, y: o.y - s, z: o.z + s }, { x: o.x - s, y: o.y - s, z: o.z + 0 }, { x: o.x - s, y: o.y - s, z: o.z - s }, // Bottom { x: o.x + s, y: o.y + s, z: o.z + s }, { x: o.x + s, y: o.y + s, z: o.z + 0 }, { x: o.x + s, y: o.y + s, z: o.z - s }, { x: o.x + 0, y: o.y + s, z: o.z + s }, { x: o.x + 0, y: o.y + s, z: o.z - s }, { x: o.x - s, y: o.y + s, z: o.z + s }, { x: o.x - s, y: o.y + s, z: o.z + 0 }, { x: o.x - s, y: o.y + s, z: o.z - s }, // Middle { x: o.x + s, y: o.y + 0, z: o.z + s }, { x: o.x + s, y: o.y + 0, z: o.z - s }, { x: o.x - s, y: o.y + 0, z: o.z + s }, { x: o.x - s, y: o.y + 0, z: o.z - s } ]; } // Helper to optimize models by merging duplicate vertices within a threshold, // and removing all polys that share the same vertices. // Directly mutates the model. function optimizeModel(model, threshold=0.0001) { const { vertices, polys } = model; const compareVertices = (v1, v2) => ( Math.abs(v1.x - v2.x) < threshold && Math.abs(v1.y - v2.y) < threshold && Math.abs(v1.z - v2.z) < threshold ); const comparePolys = (p1, p2) => { const v1 = p1.vIndexes; const v2 = p2.vIndexes; return ( ( v1[0] === v2[0] || v1[0] === v2[1] || v1[0] === v2[2] || v1[0] === v2[3] ) && ( v1[1] === v2[0] || v1[1] === v2[1] || v1[1] === v2[2] || v1[1] === v2[3] ) && ( v1[2] === v2[0] || v1[2] === v2[1] || v1[2] === v2[2] || v1[2] === v2[3] ) && ( v1[3] === v2[0] || v1[3] === v2[1] || v1[3] === v2[2] || v1[3] === v2[3] ) ); }; vertices.forEach((v, i) => { v.originalIndexes = [i]; }); for (let i=vertices.length-1; i>=0; i--) { for (let ii=i-1; ii>=0; ii--) { const v1 = vertices[i]; const v2 = vertices[ii]; if (compareVertices(v1, v2)) { vertices.splice(i, 1); v2.originalIndexes.push(...v1.originalIndexes); break; } } } vertices.forEach((v, i) => { polys.forEach(p => { p.vIndexes.forEach((vi, ii, arr) => { const vo = v.originalIndexes; if (vo.includes(vi)) { arr[ii] = i; } }); }); }); polys.forEach(p => { const vi = p.vIndexes; p.sum = vi[0] + vi[1] + vi[2] + vi[3]; }); polys.sort((a, b) => b.sum - a.sum); // Assumptions: // 1. Each poly will either have no duplicates or 1 duplicate. // 2. If two polys are equal, they are both hidden (two cubes touching), // therefore both can be removed. for (let i=polys.length-1; i>=0; i--) { for (let ii=i-1; ii>=0; ii--) { const p1 = polys[i]; const p2 = polys[ii]; if (p1.sum !== p2.sum) break; if (comparePolys(p1, p2)) { polys.splice(i, 1); polys.splice(ii, 1); i--; break; } } } return model; } // Entity.js // ============================================================================ // ============================================================================ class Entity { constructor({ model, color, wireframe=false }) { const vertices = cloneVertices(model.vertices); const shadowVertices = cloneVertices(model.vertices); const colorHex = colorToHex(color); const darkColorHex = shadeColor(color, 0.4); const polys = model.polys.map(p => ({ vertices: p.vIndexes.map(vIndex => vertices[vIndex]), color: color, // custom rgb color object wireframe: wireframe, strokeWidth: wireframe ? 2 : 0, // Set to non-zero value to draw stroke strokeColor: colorHex, // must be a CSS color string strokeColorDark: darkColorHex, // must be a CSS color string depth: 0, middle: { x: 0, y: 0, z: 0 }, normalWorld: { x: 0, y: 0, z: 0 }, normalCamera: { x: 0, y: 0, z: 0 } })); const shadowPolys = model.polys.map(p => ({ vertices: p.vIndexes.map(vIndex => shadowVertices[vIndex]), wireframe: wireframe, normalWorld: { x: 0, y: 0, z: 0 } })); this.projected = {}; // Will store 2D projected data this.model = model; this.vertices = vertices; this.polys = polys; this.shadowVertices = shadowVertices; this.shadowPolys = shadowPolys; this.reset(); } // Better names: resetEntity, resetTransform, resetEntityTransform reset() { this.x = 0; this.y = 0; this.z = 0; this.xD = 0; this.yD = 0; this.zD = 0; this.rotateX = 0; this.rotateY = 0; this.rotateZ = 0; this.rotateXD = 0; this.rotateYD = 0; this.rotateZD = 0; this.scaleX = 1; this.scaleY = 1; this.scaleZ = 1; this.projected.x = 0; this.projected.y = 0; } transform() { transformVertices( this.model.vertices, this.vertices, this.x, this.y, this.z, this.rotateX, this.rotateY, this.rotateZ, this.scaleX, this.scaleY, this.scaleZ ); copyVerticesTo(this.vertices, this.shadowVertices); } // Projects origin point, stored as `projected` property. project() { projectVertexTo(this, this.projected); } } // getTarget.js // ============================================================================ // ============================================================================ // All active targets const targets = []; // Pool target instances by color, using a Map. // keys are color objects, and values are arrays of targets. // Also pool wireframe instances separately. const targetPool = new Map(allColors.map(c=>([c, []]))); const targetWireframePool = new Map(allColors.map(c=>([c, []]))); const getTarget = (() => { const slowmoSpawner = makeSpawner({ chance: 0.5, cooldownPerSpawn: 10000, maxSpawns: 1 }); let doubleStrong = false; const strongSpawner = makeSpawner({ chance: 0.3, cooldownPerSpawn: 12000, maxSpawns: 1 }); const spinnerSpawner = makeSpawner({ chance: 0.1, cooldownPerSpawn: 10000, maxSpawns: 1 }); // Cached array instances, no need to allocate every time. const axisOptions = [ ['x', 'y'], ['y', 'z'], ['z', 'x'] ]; function getTargetOfStyle(color, wireframe) { const pool = wireframe ? targetWireframePool : targetPool; let target = pool.get(color).pop(); if (!target) { target = new Entity({ model: optimizeModel(makeRecursiveCubeModel({ recursionLevel: 1, splitFn: mengerSpongeSplit, scale: targetRadius })), color: color, wireframe: wireframe }); // Init any properties that will be used. // These will not be automatically reset when recycled. target.color = color; target.wireframe = wireframe; // Some properties don't have their final value yet. // Initialize with any value of the right type. target.hit = false; target.maxHealth = 0; target.health = 0; } return target; } return function getTarget() { if (doubleStrong && state.game.score <= doubleStrongEnableScore) { doubleStrong = false; // Spawner is reset automatically when game resets. } else if (!doubleStrong && state.game.score > doubleStrongEnableScore) { doubleStrong = true; strongSpawner.mutate({ maxSpawns: 2 }); } // Target Parameters // -------------------------------- let color = pickOne([BLUE, GREEN, ORANGE]); let wireframe = false; let health = 1; let maxHealth = 3; const spinner = state.game.cubeCount >= spinnerThreshold && isInGame() && spinnerSpawner.shouldSpawn(); // Target Parameter Overrides // -------------------------------- if (state.game.cubeCount >= slowmoThreshold && slowmoSpawner.shouldSpawn()) { color = BLUE; wireframe = true; } else if (state.game.cubeCount >= strongThreshold && strongSpawner.shouldSpawn()) { color = PINK; health = 3; } // Target Creation // -------------------------------- const target = getTargetOfStyle(color, wireframe); target.hit = false; target.maxHealth = maxHealth; target.health = health; updateTargetHealth(target, 0); const spinSpeeds = [ Math.random() * 0.1 - 0.05, Math.random() * 0.1 - 0.05 ]; if (spinner) { // Ends up spinning a random axis spinSpeeds[0] = -0.25; spinSpeeds[1] = 0; target.rotateZ = random(0, TAU); } const axes = pickOne(axisOptions); spinSpeeds.forEach((spinSpeed, i) => { switch (axes[i]) { case 'x': target.rotateXD = spinSpeed; break; case 'y': target.rotateYD = spinSpeed; break; case 'z': target.rotateZD = spinSpeed; break; } }); return target; } })(); const updateTargetHealth = (target, healthDelta) => { target.health += healthDelta; // Only update stroke on non-wireframe targets. // Showing "glue" is a temporary attempt to display health. For now, there's // no reason to have wireframe targets with high health, so we're fine. if (!target.wireframe) { const strokeWidth = target.health - 1; const strokeColor = makeTargetGlueColor(target); for (let p of target.polys) { p.strokeWidth = strokeWidth; p.strokeColor = strokeColor; } } }; const returnTarget = target => { target.reset(); const pool = target.wireframe ? targetWireframePool : targetPool; pool.get(target.color).push(target); }; function resetAllTargets() { while(targets.length) { returnTarget(targets.pop()); } } // createBurst.js // ============================================================================ // ============================================================================ // Track all active fragments const frags = []; // Pool inactive fragments by color, using a Map. // keys are color objects, and values are arrays of fragments. // // Also pool wireframe instances separately. const fragPool = new Map(allColors.map(c=>([c, []]))); const fragWireframePool = new Map(allColors.map(c=>([c, []]))); const createBurst = (() => { // Precompute some private data to be reused for all bursts. const basePositions = mengerSpongeSplit({ x:0, y:0, z:0 }, fragRadius*2); const positions = cloneVertices(basePositions); const prevPositions = cloneVertices(basePositions); const velocities = cloneVertices(basePositions); const basePositionNormals = basePositions.map(normalize); const positionNormals = cloneVertices(basePositionNormals); const fragCount = basePositions.length; function getFragForTarget(target) { const pool = target.wireframe ? fragWireframePool : fragPool; let frag = pool.get(target.color).pop(); if (!frag) { frag = new Entity({ model: makeCubeModel({ scale: fragRadius }), color: target.color, wireframe: target.wireframe }); frag.color = target.color; frag.wireframe = target.wireframe; } return frag; } return (target, force=1) => { // Calculate fragment positions, and what would have been the previous positions // when still a part of the larger target. transformVertices( basePositions, positions, target.x, target.y, target.z, target.rotateX, target.rotateY, target.rotateZ, 1, 1, 1 ); transformVertices( basePositions, prevPositions, target.x - target.xD, target.y - target.yD, target.z - target.zD, target.rotateX - target.rotateXD, target.rotateY - target.rotateYD, target.rotateZ - target.rotateZD, 1, 1, 1 ); // Compute velocity of each fragment, based on previous positions. // Will write to `velocities` array. for (let i=0; i { frag.reset(); const pool = frag.wireframe ? fragWireframePool : fragPool; pool.get(frag.color).push(frag); }; // sparks.js // ============================================================================ // ============================================================================ const sparks = []; const sparkPool = []; function addSpark(x, y, xD, yD) { const spark = sparkPool.pop() || {}; spark.x = x + xD * 0.5; spark.y = y + yD * 0.5; spark.xD = xD; spark.yD = yD; spark.life = random(200, 300); spark.maxLife = spark.life; sparks.push(spark); return spark; } // Spherical spark burst function sparkBurst(x, y, count, maxSpeed) { const angleInc = TAU / count; for (let i=0; i { if (Math.random() < 0.4) { projectVertex(v); addSpark( v.x, v.y, random(-12, 12), random(-12, 12) ); } }); } function returnSpark(spark) { sparkPool.push(spark); } // hud.js // ============================================================================ // ============================================================================ const hudContainerNode = $('.hud'); function setHudVisibility(visible) { if (visible) { hudContainerNode.style.display = 'block'; } else { hudContainerNode.style.display = 'none'; } } /////////// // Score // /////////// const scoreNode = $('.score-lbl'); const cubeCountNode = $('.cube-count-lbl'); function renderScoreHud() { if (isCasualGame()) { scoreNode.style.display = 'none'; cubeCountNode.style.opacity = 1; } else { scoreNode.innerText = `SCORE: ${state.game.score}`; scoreNode.style.display = 'block'; cubeCountNode.style.opacity = 0.65 ; } cubeCountNode.innerText = `CUBES SMASHED: ${state.game.cubeCount}`; } renderScoreHud(); ////////////////// // Pause Button // ////////////////// handlePointerDown($('.pause-btn'), () => pauseGame()); //////////////////// // Slow-Mo Status // //////////////////// const slowmoNode = $('.slowmo'); const slowmoBarNode = $('.slowmo__bar'); function renderSlowmoStatus(percentRemaining) { slowmoNode.style.opacity = percentRemaining === 0 ? 0 : 1; slowmoBarNode.style.transform = `scaleX(${percentRemaining.toFixed(3)})`; } // menus.js // ============================================================================ // ============================================================================ // Top-level menu containers const menuContainerNode = $('.menus'); const menuMainNode = $('.menu--main'); const menuPauseNode = $('.menu--pause'); const menuScoreNode = $('.menu--score'); const finalScoreLblNode = $('.final-score-lbl'); const highScoreLblNode = $('.high-score-lbl'); function showMenu(node) { node.classList.add('active'); } function hideMenu(node) { node.classList.remove('active'); } function renderMenus() { hideMenu(menuMainNode); hideMenu(menuPauseNode); hideMenu(menuScoreNode); switch (state.menus.active) { case MENU_MAIN: showMenu(menuMainNode); break; case MENU_PAUSE: showMenu(menuPauseNode); break; case MENU_SCORE: finalScoreLblNode.textContent = formatNumber(state.game.score); if (isNewHighScore()) { highScoreLblNode.textContent = 'New High Score!'; } else { highScoreLblNode.textContent = `High Score: ${formatNumber(getHighScore())}`; } showMenu(menuScoreNode); break; } setHudVisibility(!isMenuVisible()); menuContainerNode.classList.toggle('has-active', isMenuVisible()); menuContainerNode.classList.toggle('interactive-mode', isMenuVisible() && pointerIsDown); } renderMenus(); //////////////////// // Button Actions // //////////////////// // Main Menu handleClick($('.play-normal-btn'), () => { setGameMode(GAME_MODE_RANKED); setActiveMenu(null); resetGame(); }); handleClick($('.play-casual-btn'), () => { setGameMode(GAME_MODE_CASUAL); setActiveMenu(null); resetGame(); }); // Pause Menu handleClick($('.resume-btn'), () => resumeGame()); handleClick($('.menu-btn--pause'), () => setActiveMenu(MENU_MAIN)); // Score Menu handleClick($('.play-again-btn'), () => { setActiveMenu(null); resetGame(); }); handleClick($('.menu-btn--score'), () => setActiveMenu(MENU_MAIN)); //////////////////// // Button Actions // //////////////////// // Main Menu handleClick($('.play-normal-btn'), () => { setGameMode(GAME_MODE_RANKED); setActiveMenu(null); resetGame(); }); handleClick($('.play-casual-btn'), () => { setGameMode(GAME_MODE_CASUAL); setActiveMenu(null); resetGame(); }); // Pause Menu handleClick($('.resume-btn'), () => resumeGame()); handleClick($('.menu-btn--pause'), () => setActiveMenu(MENU_MAIN)); // Score Menu handleClick($('.play-again-btn'), () => { setActiveMenu(null); resetGame(); }); handleClick($('.menu-btn--score'), () => setActiveMenu(MENU_MAIN)); // actions.js // ============================================================================ // ============================================================================ ////////////////// // MENU ACTIONS // ////////////////// function setActiveMenu(menu) { state.menus.active = menu; renderMenus(); } ///////////////// // HUD ACTIONS // ///////////////// function setScore(score) { state.game.score = score; renderScoreHud(); } function incrementScore(inc) { if (isInGame()) { state.game.score += inc; if (state.game.score < 0) { state.game.score = 0; } renderScoreHud(); } } function setCubeCount(count) { state.game.cubeCount = count; renderScoreHud(); } function incrementCubeCount(inc) { if (isInGame()) { state.game.cubeCount += inc; renderScoreHud(); } } ////////////////// // GAME ACTIONS // ////////////////// function setGameMode(mode) { state.game.mode = mode; } function resetGame() { resetAllTargets(); state.game.time = 0; resetAllCooldowns(); setScore(0); setCubeCount(0); spawnTime = getSpawnDelay(); } function pauseGame() { isInGame() && setActiveMenu(MENU_PAUSE); } function resumeGame() { isPaused() && setActiveMenu(null); } function endGame() { handleCanvasPointerUp(); if (isNewHighScore()) { setHighScore(state.game.score); } setActiveMenu(MENU_SCORE); } //////////////////////// // KEYBOARD SHORTCUTS // //////////////////////// window.addEventListener('keydown', event => { if (event.key === 'p') { isPaused() ? resumeGame() : pauseGame(); } }); // tick.js // ============================================================================ // ============================================================================ let spawnTime = 0; const maxSpawnX = 450; const pointerDelta = { x: 0, y: 0 }; const pointerDeltaScaled = { x: 0, y: 0 }; // Temp slowmo state. Should be relocated once this stabilizes. const slowmoDuration = 1500; let slowmoRemaining = 0; let spawnExtra = 0; const spawnExtraDelay = 300; let targetSpeed = 1; function tick(width, height, simTime, simSpeed, lag) { PERF_START('frame'); PERF_START('tick'); state.game.time += simTime; if (slowmoRemaining > 0) { slowmoRemaining -= simTime; if (slowmoRemaining < 0) { slowmoRemaining = 0; } targetSpeed = pointerIsDown ? 0.075 : 0.3; } else { const menuPointerDown = isMenuVisible() && pointerIsDown; targetSpeed = menuPointerDown ? 0.025 : 1; } renderSlowmoStatus(slowmoRemaining / slowmoDuration); gameSpeed += (targetSpeed - gameSpeed) / 22 * lag; gameSpeed = clamp(gameSpeed, 0, 1); const centerX = width / 2; const centerY = height / 2; const simAirDrag = 1 - (airDrag * simSpeed); const simAirDragSpark = 1 - (airDragSpark * simSpeed); // Pointer Tracking // ------------------- // Compute speed and x/y deltas. // There is also a "scaled" variant taking game speed into account. This serves two purposes: // - Lag won't create large spikes in speed/deltas // - In slow mo, speed is increased proportionately to match "reality". Without this boost, // it feels like your actions are dampened in slow mo. const forceMultiplier = 1 / (simSpeed * 0.75 + 0.25); pointerDelta.x = 0; pointerDelta.y = 0; pointerDeltaScaled.x = 0; pointerDeltaScaled.y = 0; const lastPointer = touchPoints[touchPoints.length - 1]; if (pointerIsDown && lastPointer && !lastPointer.touchBreak) { pointerDelta.x = (pointerScene.x - lastPointer.x); pointerDelta.y = (pointerScene.y - lastPointer.y); pointerDeltaScaled.x = pointerDelta.x * forceMultiplier; pointerDeltaScaled.y = pointerDelta.y * forceMultiplier; } const pointerSpeed = Math.hypot(pointerDelta.x, pointerDelta.y); const pointerSpeedScaled = pointerSpeed * forceMultiplier; // Track points for later calculations, including drawing trail. touchPoints.forEach(p => p.life -= simTime); if (pointerIsDown) { touchPoints.push({ x: pointerScene.x, y: pointerScene.y, life: touchPointLife }); } while (touchPoints[0] && touchPoints[0].life <= 0) { touchPoints.shift(); } // Entity Manipulation // -------------------- PERF_START('entities'); // Spawn targets spawnTime -= simTime; if (spawnTime <= 0) { if (spawnExtra > 0) { spawnExtra--; spawnTime = spawnExtraDelay; } else { spawnTime = getSpawnDelay(); } const target = getTarget(); const spawnRadius = Math.min(centerX * 0.8, maxSpawnX); target.x = (Math.random() * spawnRadius * 2 - spawnRadius); target.y = centerY + targetHitRadius * 2; target.z = (Math.random() * targetRadius*2 - targetRadius); target.xD = Math.random() * (target.x * -2 / 120); target.yD = -20; targets.push(target); } // Animate targets and remove when offscreen const leftBound = -centerX + targetRadius; const rightBound = centerX - targetRadius; const ceiling = -centerY - 120; const boundDamping = 0.4; targetLoop: for (let i = targets.length - 1; i >= 0; i--) { const target = targets[i]; target.x += target.xD * simSpeed; target.y += target.yD * simSpeed; if (target.y < ceiling) { target.y = ceiling; target.yD = 0; } if (target.x < leftBound) { target.x = leftBound; target.xD *= -boundDamping; } else if (target.x > rightBound) { target.x = rightBound; target.xD *= -boundDamping; } if (target.z < backboardZ) { target.z = backboardZ; target.zD *= -boundDamping; } target.yD += gravity * simSpeed; target.rotateX += target.rotateXD * simSpeed; target.rotateY += target.rotateYD * simSpeed; target.rotateZ += target.rotateZD * simSpeed; target.transform(); target.project(); // Remove if offscreen if (target.y > centerY + targetHitRadius * 2) { targets.splice(i, 1); returnTarget(target); if (isInGame()) { if (isCasualGame()) { incrementScore(-25); } else { endGame(); } } continue; } // If pointer is moving really fast, we want to hittest multiple points along the path. // We can't use scaled pointer speed to determine this, since we care about actual screen // distance covered. const hitTestCount = Math.ceil(pointerSpeed / targetRadius * 2); // Start loop at `1` and use `<=` check, so we skip 0% and end up at 100%. // This omits the previous point position, and includes the most recent. for (let ii=1; ii<=hitTestCount; ii++) { const percent = 1 - (ii / hitTestCount); const hitX = pointerScene.x - pointerDelta.x * percent; const hitY = pointerScene.y - pointerDelta.y * percent; const distance = Math.hypot( hitX - target.projected.x, hitY - target.projected.y ); if (distance <= targetHitRadius) { // Hit! (though we don't want to allow hits on multiple sequential frames) if (!target.hit) { target.hit = true; target.xD += pointerDeltaScaled.x * hitDampening; target.yD += pointerDeltaScaled.y * hitDampening; target.rotateXD += pointerDeltaScaled.y * 0.001; target.rotateYD += pointerDeltaScaled.x * 0.001; const sparkSpeed = 7 + pointerSpeedScaled * 0.125; if (pointerSpeedScaled > minPointerSpeed) { target.health--; incrementScore(10); if (target.health <= 0) { incrementCubeCount(1); createBurst(target, forceMultiplier); sparkBurst(hitX, hitY, 8, sparkSpeed); if (target.wireframe) { slowmoRemaining = slowmoDuration; spawnTime = 0; spawnExtra = 2; } targets.splice(i, 1); returnTarget(target); } else { sparkBurst(hitX, hitY, 8, sparkSpeed); glueShedSparks(target); updateTargetHealth(target, 0); } } else { incrementScore(5); sparkBurst(hitX, hitY, 3, sparkSpeed); } } // Break the current loop and continue the outer loop. // This skips to processing the next target. continue targetLoop; } } // This code will only run if target hasn't been "hit". target.hit = false; } // Animate fragments and remove when offscreen. const fragBackboardZ = backboardZ + fragRadius; // Allow fragments to move off-screen to sides for a while, since shadows are still visible. const fragLeftBound = -width; const fragRightBound = width; for (let i = frags.length - 1; i >= 0; i--) { const frag = frags[i]; frag.x += frag.xD * simSpeed; frag.y += frag.yD * simSpeed; frag.z += frag.zD * simSpeed; frag.xD *= simAirDrag; frag.yD *= simAirDrag; frag.zD *= simAirDrag; if (frag.y < ceiling) { frag.y = ceiling; frag.yD = 0; } if (frag.z < fragBackboardZ) { frag.z = fragBackboardZ; frag.zD *= -boundDamping; } frag.yD += gravity * simSpeed; frag.rotateX += frag.rotateXD * simSpeed; frag.rotateY += frag.rotateYD * simSpeed; frag.rotateZ += frag.rotateZD * simSpeed; frag.transform(); frag.project(); // Removal conditions if ( // Bottom of screen frag.projected.y > centerY + targetHitRadius || // Sides of screen frag.projected.x < fragLeftBound || frag.projected.x > fragRightBound || // Too close to camera frag.z > cameraFadeEndZ ) { frags.splice(i, 1); returnFrag(frag); continue; } } // 2D sparks for (let i = sparks.length - 1; i >= 0; i--) { const spark = sparks[i]; spark.life -= simTime; if (spark.life <= 0) { sparks.splice(i, 1); returnSpark(spark); continue; } spark.x += spark.xD * simSpeed; spark.y += spark.yD * simSpeed; spark.xD *= simAirDragSpark; spark.yD *= simAirDragSpark; spark.yD += gravity * simSpeed; } PERF_END('entities'); // 3D transforms // ------------------- PERF_START('3D'); // Aggregate all scene vertices/polys allVertices.length = 0; allPolys.length = 0; allShadowVertices.length = 0; allShadowPolys.length = 0; targets.forEach(entity => { allVertices.push(...entity.vertices); allPolys.push(...entity.polys); allShadowVertices.push(...entity.shadowVertices); allShadowPolys.push(...entity.shadowPolys); }); frags.forEach(entity => { allVertices.push(...entity.vertices); allPolys.push(...entity.polys); allShadowVertices.push(...entity.shadowVertices); allShadowPolys.push(...entity.shadowPolys); }); // Scene calculations/transformations allPolys.forEach(p => computePolyNormal(p, 'normalWorld')); allPolys.forEach(computePolyDepth); allPolys.sort((a, b) => b.depth - a.depth); // Perspective projection allVertices.forEach(projectVertex); allPolys.forEach(p => computePolyNormal(p, 'normalCamera')); PERF_END('3D'); PERF_START('shadows'); // Rotate shadow vertices to light source perspective transformVertices( allShadowVertices, allShadowVertices, 0, 0, 0, TAU/8, 0, 0, 1, 1, 1 ); allShadowPolys.forEach(p => computePolyNormal(p, 'normalWorld')); const shadowDistanceMult = Math.hypot(1, 1); const shadowVerticesLength = allShadowVertices.length; for (let i=0; i { if (p.wireframe) { ctx.lineWidth = 2; ctx.beginPath(); const { vertices } = p; const vCount = vertices.length; const firstV = vertices[0]; ctx.moveTo(firstV.x, firstV.y); for (let i=1; i { if (!p.wireframe && p.normalCamera.z < 0) return; if (p.strokeWidth !== 0) { ctx.lineWidth = p.normalCamera.z < 0 ? p.strokeWidth * 0.5 : p.strokeWidth; ctx.strokeStyle = p.normalCamera.z < 0 ? p.strokeColorDark : p.strokeColor; } const { vertices } = p; const lastV = vertices[vertices.length - 1]; const fadeOut = p.middle.z > cameraFadeStartZ; if (!p.wireframe) { const normalLight = p.normalWorld.y * 0.5 + p.normalWorld.z * -0.5; const lightness = normalLight > 0 ? 0.1 : ((normalLight ** 32 - normalLight) / 2) * 0.9 + 0.1; ctx.fillStyle = shadeColor(p.color, lightness); } // Fade out polys close to camera. `globalAlpha` must be reset later. if (fadeOut) { // If polygon gets really close to camera (outside `cameraFadeRange`) the alpha // can go negative, which has the appearance of alpha = 1. So, we'll clamp it at 0. ctx.globalAlpha = Math.max(0, 1 - (p.middle.z - cameraFadeStartZ) / cameraFadeRange); } ctx.beginPath(); ctx.moveTo(lastV.x, lastV.y); for (let v of vertices) { ctx.lineTo(v.x, v.y); } if (!p.wireframe) { ctx.fill(); } if (p.strokeWidth !== 0) { ctx.stroke(); } if (fadeOut) { ctx.globalAlpha = 1; } }); PERF_END('drawPolys'); PERF_START('draw2D'); // 2D Sparks // --------------- ctx.strokeStyle = sparkColor; ctx.lineWidth = sparkThickness; ctx.beginPath(); sparks.forEach(spark => { ctx.moveTo(spark.x, spark.y); // Shrink sparks to zero length as they die. // Speed up shrinking as life approaches 0 (root curve). // Note that sparks already get smaller over time as their speed slows // down from damping. So this is like a double scale down. To counter this // a bit and keep the sparks larger for longer, we'll also increase the scale // a bit after applying the root curve. const scale = (spark.life / spark.maxLife) ** 0.5 * 1.5; ctx.lineTo(spark.x - spark.xD*scale, spark.y - spark.yD*scale); }); ctx.stroke(); // Touch Strokes // --------------- ctx.strokeStyle = touchTrailColor; const touchPointCount = touchPoints.length; for (let i=1; i 68) { frameTime = 68; } const halfW = width / 2; const halfH = height / 2; // Convert pointer position from screen to scene coords. pointerScene.x = pointerScreen.x / viewScale - halfW; pointerScene.y = pointerScreen.y / viewScale - halfH; const lag = frameTime / 16.6667; const simTime = gameSpeed * frameTime; const simSpeed = gameSpeed * lag; tick(width, height, simTime, simSpeed, lag); // Auto clear canvas ctx.clearRect(0, 0, canvas.width, canvas.height); // Auto scale drawing for high res displays, and incorporate `viewScale`. // Also shift canvas so (0, 0) is the middle of the screen. // This just works with 3D perspective projection. const drawScale = dpr * viewScale; ctx.scale(drawScale, drawScale); ctx.translate(halfW, halfH); draw(ctx, width, height, viewScale); ctx.setTransform(1, 0, 0, 1, 0, 0); } const raf = () => requestAnimationFrame(frameHandler); // Start loop raf(); } // interaction.js // ============================================================================ // ============================================================================ // Interaction // ----------------------------- function handleCanvasPointerDown(x, y) { if (!pointerIsDown) { pointerIsDown = true; pointerScreen.x = x; pointerScreen.y = y; // On when menus are open, point down/up toggles an interactive mode. // We just need to rerender the menu system for it to respond. if (isMenuVisible()) renderMenus(); } } function handleCanvasPointerUp() { if (pointerIsDown) { pointerIsDown = false; touchPoints.push({ touchBreak: true, life: touchPointLife }); // On when menus are open, point down/up toggles an interactive mode. // We just need to rerender the menu system for it to respond. if (isMenuVisible()) renderMenus(); } } function handleCanvasPointerMove(x, y) { if (pointerIsDown) { pointerScreen.x = x; pointerScreen.y = y; } } // Use pointer events if available, otherwise fallback to touch events (for iOS). if ('PointerEvent' in window) { canvas.addEventListener('pointerdown', event => { event.isPrimary && handleCanvasPointerDown(event.clientX, event.clientY); }); canvas.addEventListener('pointerup', event => { event.isPrimary && handleCanvasPointerUp(); }); canvas.addEventListener('pointermove', event => { event.isPrimary && handleCanvasPointerMove(event.clientX, event.clientY); }); // We also need to know if the mouse leaves the page. For this game, it's best if that // cancels a swipe, so essentially acts as a "mouseup" event. document.body.addEventListener('mouseleave', handleCanvasPointerUp); } else { let activeTouchId = null; canvas.addEventListener('touchstart', event => { if (!pointerIsDown) { const touch = event.changedTouches[0]; activeTouchId = touch.identifier; handleCanvasPointerDown(touch.clientX, touch.clientY); } }); canvas.addEventListener('touchend', event => { for (let touch of event.changedTouches) { if (touch.identifier === activeTouchId) { handleCanvasPointerUp(); break; } } }); canvas.addEventListener('touchmove', event => { for (let touch of event.changedTouches) { if (touch.identifier === activeTouchId) { handleCanvasPointerMove(touch.clientX, touch.clientY); event.preventDefault(); break; } } }, { passive: false }); } // index.js // ============================================================================ // ============================================================================ setupCanvases();

Html code will be here

Made on
Tilda