|
// 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'); |
|
|
|
// 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<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(); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
// canvas.js |
|
// ============================================================================ |
|
// ============================================================================ |
|
|
|
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'; |
|
} |
|
|
|
// Set initial size |
|
handleResize(); |
|
// resize fullscreen canvas |
|
window.addEventListener('resize', handleResize); |
|
|
|
|
|
// Run game loop |
|
let lastTimestamp = 0; |
|
function frameHandler(timestamp) { |
|
let frameTime = timestamp - lastTimestamp; |
|
lastTimestamp = timestamp; |
|
|
|
// always queue another frame |
|
raf(); |
|
|
|
// If game is paused, we'll still track frameTime (above) but all other |
|
// game logic and drawing can be avoided. |
|
if (isPaused()) return; |
|
|
|
// make sure negative time isn't reported (first frame can be whacky) |
|
if (frameTime < 0) { |
|
frameTime = 17; |
|
} |
|
// - cap minimum framerate to 15fps[~68ms] (assuming 60fps[~17ms] as 'normal') |
|
else if (frameTime > 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(); |