Top Digital Marketing Agency in Dhrangadhra | Website Development & Designing – Netbyzz
<!-- CODE = #A057 --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>NETBYZZ A057</title> <link rel="stylesheet" href="style.css"> </head> <body> <!-- Game canvas --> <canvas id="c"></canvas> <!-- Gameplay HUD --> <div class="hud"> <div class="hud__score"> <div class="score-lbl"></div> <div class="cube-count-lbl"></div> </div> <div class="pause-btn"><div></div></div> <div class="slowmo"> <div class="slowmo__bar"></div> </div> </div> <!-- Menu System --> <div class="menus"> <div class="menu menu--main"> <h1>MENJA</h1> <button type="button" class="play-normal-btn">PLAY GAME</button> <button type="button" class="play-casual-btn">CASUAL MODE</button> </div> <div class="menu menu--pause"> <h1>Paused</h1> <button type="button" class="resume-btn">RESUME GAME</button> <button type="button" class="menu-btn--pause">MAIN MENU</button> </div> <div class="menu menu--score"> <h1>Game Over</h1> <h2>Your Score:</h2> <div class="final-score-lbl"></div> <div class="high-score-lbl"></div> <button type="button" class="play-again-btn">PLAY AGAIN</button> <button type="button" class="menu-btn--score">MAIN MENU</button> </div> </div> <script src="script.js"></script> </body> </html>
body { margin: 0; background-color: #000; background-image: radial-gradient(ellipse at top, #335476 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%); 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__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 < len; i++) { const v1 = arr1[i]; const v2 = arr2[i]; v2.x = v1.x; v2.y = v1.y; v2.z = v1.z; } } // Compute triangle midpoint. // Mutates `middle` property of given `poly`. function computeTriMiddle(poly) { const v = poly.vertices; poly.middle.x = (v[0].x + v[1].x + v[2].x) / 3; poly.middle.y = (v[0].y + v[1].y + v[2].y) / 3; poly.middle.z = (v[0].z + v[1].z + v[2].z) / 3; } // Compute quad midpoint. // Mutates `middle` property of given `poly`. function computeQuadMiddle(poly) { const v = poly.vertices; poly.middle.x = (v[0].x + v[1].x + v[2].x + v[3].x) / 4; poly.middle.y = (v[0].y + v[1].y + v[2].y + v[3].y) / 4; poly.middle.z = (v[0].z + v[1].z + v[2].z + v[3].z) / 4; } function computePolyMiddle(poly) { if (poly.vertices.length === 3) { computeTriMiddle(poly); } else { computeQuadMiddle(poly); } } // Compute distance from any polygon (tri or quad) midpoint to camera. // Sets `depth` property of given `poly`. // Also triggers midpoint calculation, which mutates `middle` property of `poly`. function computePolyDepth(poly) { computePolyMiddle(poly); const dX = poly.middle.x; const dY = poly.middle.y; const dZ = poly.middle.z - cameraDistance; poly.depth = Math.hypot(dX, dY, dZ); } // Compute normal of any polygon. Uses normalized vector cross product. // Mutates `normalName` property of given `poly`. function computePolyNormal(poly, normalName) { // Store quick refs to vertices const v1 = poly.vertices[0]; const v2 = poly.vertices[1]; const v3 = poly.vertices[2]; // Calculate difference of vertices, following winding order. const ax = v1.x - v2.x; const ay = v1.y - v2.y; const az = v1.z - v2.z; const bx = v1.x - v3.x; const by = v1.y - v3.y; const bz = v1.z - v3.z; // Cross product const nx = ay * bz - az * by; const ny = az * bx - ax * bz; const nz = ax * by - ay * bx; // Compute magnitude of normal and normalize const mag = Math.hypot(nx, ny, nz); const polyNormal = poly[normalName]; polyNormal.x = nx / mag; polyNormal.y = ny / mag; polyNormal.z = nz / mag; } // Apply translation/rotation/scale to all given vertices. // If `vertices` and `target` are the same array, the vertices will be mutated in place. // If `vertices` and `target` are different arrays, `vertices` will not be touched, instead the // transformed values from `vertices` will be written to `target` array. function transformVertices( vertices, target, tX, tY, tZ, rX, rY, rZ, sX, sY, sZ ) { // Matrix multiplcation constants only need calculated once for all vertices. const sinX = Math.sin(rX); const cosX = Math.cos(rX); const sinY = Math.sin(rY); const cosY = Math.cos(rY); const sinZ = Math.sin(rZ); const cosZ = Math.cos(rZ); // Using forEach() like map(), but with a (recycled) target array. vertices.forEach((v, 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 < fragCount; i++) { const position = positions[i]; const prevPosition = prevPositions[i]; const velocity = velocities[i]; velocity.x = position.x - prevPosition.x; velocity.y = position.y - prevPosition.y; velocity.z = position.z - prevPosition.z; } // Apply target rotation to normals transformVertices( basePositionNormals, positionNormals, 0, 0, 0, target.rotateX, target.rotateY, target.rotateZ, 1, 1, 1 ); for (let i = 0; i < fragCount; i++) { const position = positions[i]; const velocity = velocities[i]; const normal = positionNormals[i]; const frag = getFragForTarget(target); frag.x = position.x; frag.y = position.y; frag.z = position.z; frag.rotateX = target.rotateX; frag.rotateY = target.rotateY; frag.rotateZ = target.rotateZ; const burstSpeed = 2 * force; const randSpeed = 2 * force; const rotateScale = 0.015; frag.xD = velocity.x + normal.x * burstSpeed + Math.random() * randSpeed; frag.yD = velocity.y + normal.y * burstSpeed + Math.random() * randSpeed; frag.zD = velocity.z + normal.z * burstSpeed + Math.random() * randSpeed; frag.rotateXD = frag.xD * rotateScale; frag.rotateYD = frag.yD * rotateScale; frag.rotateZD = frag.zD * rotateScale; frags.push(frag); } }; })(); const returnFrag = (frag) => { 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 < count; i++) { const angle = i * angleInc + angleInc * Math.random(); const speed = (1 - Math.random() ** 3) * maxSpeed; addSpark(x, y, Math.sin(angle) * speed, Math.cos(angle) * speed); } } // Make a target "leak" sparks from all vertices. // This is used to create the effect of target glue "shedding". let glueShedVertices; function glueShedSparks(target) { if (!glueShedVertices) { glueShedVertices = cloneVertices(target.vertices); } else { copyVerticesTo(target.vertices, glueShedVertices); } glueShedVertices.forEach((v) => { 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 < shadowVerticesLength; i++) { const distance = allVertices[i].z - backboardZ; allShadowVertices[i].z -= shadowDistanceMult * distance; } transformVertices( allShadowVertices, allShadowVertices, 0, 0, 0, -TAU / 8, 0, 0, 1, 1, 1 ); allShadowVertices.forEach(projectVertex); PERF_END("shadows"); PERF_END("tick"); } // draw.js // ============================================================================ // ============================================================================ function draw(ctx, width, height, viewScale) { PERF_START("draw"); const halfW = width / 2; const halfH = height / 2; // 3D Polys // --------------- ctx.lineJoin = "bevel"; PERF_START("drawShadows"); ctx.fillStyle = shadowColor; ctx.strokeStyle = shadowColor; allShadowPolys.forEach((p) => { 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 < vCount; i++) { const v = vertices[i]; ctx.lineTo(v.x, v.y); } ctx.closePath(); ctx.stroke(); } else { ctx.beginPath(); const { vertices } = p; const vCount = vertices.length; const firstV = vertices[0]; ctx.moveTo(firstV.x, firstV.y); for (let i = 1; i < vCount; i++) { const v = vertices[i]; ctx.lineTo(v.x, v.y); } ctx.closePath(); ctx.fill(); } }); PERF_END("drawShadows"); PERF_START("drawPolys"); allPolys.forEach((p) => { 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"); ctx.strokeStyle = sparkColor; ctx.lineWidth = sparkThickness; ctx.beginPath(); sparks.forEach((spark) => { ctx.moveTo(spark.x, spark.y); 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 < touchPointCount; i++) { const current = touchPoints[i]; const prev = touchPoints[i - 1]; if (current.touchBreak || prev.touchBreak) { continue; } const scale = current.life / touchPointLife; ctx.lineWidth = scale * touchTrailThickness; ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(current.x, current.y); ctx.stroke(); } PERF_END("draw2D"); PERF_END("draw"); PERF_END("frame"); // Display performance updates. PERF_UPDATE(); } function setupCanvases() { const ctx = canvas.getContext("2d"); // devicePixelRatio alias const dpr = window.devicePixelRatio || 1; // View will be scaled so objects appear sized similarly on all screen sizes. let viewScale; // Dimensions (taking viewScale into account!) let width, height; function handleResize() { const w = window.innerWidth; const h = window.innerHeight; viewScale = h / 1000; width = w / viewScale; height = h / viewScale; canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.width = w + "px"; canvas.style.height = h + "px"; } handleResize(); window.addEventListener("resize", handleResize); let lastTimestamp = 0; function frameHandler(timestamp) { let frameTime = timestamp - lastTimestamp; lastTimestamp = timestamp; raf(); if (isPaused()) return; if (frameTime < 0) { frameTime = 17; } else if (frameTime > 68) { frameTime = 68; } const halfW = width / 2; const halfH = height / 2; 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); ctx.clearRect(0, 0, canvas.width, canvas.height); 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(); } 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 } ); } setupCanvases();