HTML preprocessors can make writing HTML more powerful or convenient. For instance, Markdown is designed to be easier to write and read for text documents and you could write a loop in Pug.
In CodePen, whatever you write in the HTML editor is what goes within the <body>
tags in a basic HTML5 template. So you don't have access to higher-up elements like the <html>
tag. If you want to add classes there that can affect the whole document, this is the place to do it.
In CodePen, whatever you write in the HTML editor is what goes within the <body>
tags in a basic HTML5 template. If you need things in the <head>
of the document, put that code here.
The resource you are linking to is using the 'http' protocol, which may not work when the browser is using https.
CSS preprocessors help make authoring CSS easier. All of them offer things like variables and mixins to provide convenient abstractions.
It's a common practice to apply CSS to a page that styles elements such that they are consistent across all browsers. We offer two of the most popular choices: normalize.css and a reset. Or, choose Neither and nothing will be applied.
To get the best cross-browser support, it is a common practice to apply vendor prefixes to CSS properties and values that require them to work. For instance -webkit-
or -moz-
.
We offer two popular choices: Autoprefixer (which processes your CSS server-side) and -prefix-free (which applies prefixes via a script, client-side).
Any URLs added here will be added as <link>
s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.
You can apply CSS to your Pen from any stylesheet on the web. Just put a URL to it here and we'll apply it, in the order you have them, before the CSS in the Pen itself.
You can also link to another Pen here (use the .css
URL Extension) and we'll pull the CSS from that Pen and include it. If it's using a matching preprocessor, use the appropriate URL Extension and we'll combine the code before preprocessing, so you can use the linked Pen as a true dependency.
JavaScript preprocessors can help make authoring JavaScript easier and more convenient.
Babel includes JSX processing.
Any URL's added here will be added as <script>
s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.
You can apply a script from anywhere on the web to your Pen. Just put a URL to it here and we'll add it, in the order you have them, before the JavaScript in the Pen itself.
If the script you link to has the file extension of a preprocessor, we'll attempt to process it before applying.
You can also link to another Pen here, and we'll pull the JavaScript from that Pen and include it. If it's using a matching preprocessor, we'll combine the code before preprocessing, so you can use the linked Pen as a true dependency.
Search for and use JavaScript packages from npm here. By selecting a package, an import
statement will be added to the top of the JavaScript editor for this package.
Using packages here is powered by esm.sh, which makes packages from npm not only available on a CDN, but prepares them for native JavaScript ESM usage.
All packages are different, so refer to their docs for how they work.
If you're using React / ReactDOM, make sure to turn on Babel for the JSX processing.
If active, Pens will autosave every 30 seconds after being saved once.
If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.
If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.
Visit your global Editor Settings.
<!-- 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 class="credits">An 8kB game by <a href="https://cmiller.tech">Caleb Miller</a></div>
</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>
body {
margin: 0;
background-color: #000;
background-image: radial-gradient(ellipse at top, #335476 0.0%, #31506e 11.1%, #304b67 22.2%, #2f4760 33.3%, #2d4359 44.4%, #2c3f51 55.6%, #2a3a4a 66.7%, #293643 77.8%, #28323d 88.9%, #262e36 100.0%);
height: 100vh;
overflow: hidden;
font-family: monospace;
font-weight: bold;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.75);
}
#c {
display: block;
touch-action: none;
transform: translateZ(0);
}
/*/////////////////////
// HUD //
/////////////////////*/
.hud__score,
.pause-btn {
position: fixed;
font-size: calc(14px + 2vw + 1vh);
}
.hud__score {
top: 0.65em;
left: 0.65em;
pointer-events: none;
user-select: none;
}
.cube-count-lbl {
font-size: 0.46em;
}
.pause-btn {
position: fixed;
top: 0;
right: 0;
padding: 0.8em 0.65em;
}
.pause-btn > div {
position: relative;
width: 0.8em;
height: 0.8em;
opacity: 0.75;
}
.pause-btn > div::before,
.pause-btn > div::after {
content: '';
display: block;
width: 34%;
height: 100%;
position: absolute;
background-color: #fff;
}
.pause-btn > div::after {
right: 0;
}
.slowmo {
position: fixed;
bottom: 0;
width: 100%;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s;
will-change: opacity;
}
.slowmo::before {
content: 'SLOW-MO';
display: block;
font-size: calc(8px + 1vw + 0.5vh);
margin-left: 0.5em;
margin-bottom: 8px;
}
.slowmo::after {
content: '';
display: block;
position: fixed;
bottom: 0;
width: 100%;
height: 1.5vh;
background-color: rgba(0, 0, 0, 0.25);
z-index: -1;
}
.slowmo__bar {
height: 1.5vh;
background-color: rgba(255, 255, 255, 0.75);
transform-origin: 0 0;
}
/*/////////////////////
// MENUS //
/////////////////////*/
.menus::before {
content: '';
pointer-events: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: #000;
opacity: 0;
transition: opacity 0.2s;
transition-timing-function: ease-in;
}
.menus.has-active::before {
opacity: 0.08;
transition-duration: 0.4s;
transition-timing-function: ease-out;
}
.menus.interactive-mode::before {
opacity: 0.02;
}
/* Menu containers */
.menu {
pointer-events: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
text-align: center;
color: rgba(255, 255, 255, 0.9);
opacity: 0;
visibility: hidden;
transform: translateY(30px);
transition-property: opacity, visibility, transform;
transition-duration: 0.2s;
transition-timing-function: ease-in;
}
.menu.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition-duration: 0.4s;
transition-timing-function: ease-out;
}
.menus.interactive-mode .menu.active {
opacity: 0.6;
}
.menus:not(.interactive-mode) .menu.active > * {
pointer-events: auto;
}
/* Common menu elements */
h1 {
font-size: 4rem;
line-height: 0.95;
text-align: center;
font-weight: bold;
margin: 0 0.65em 1em;
}
h2 {
font-size: 1.2rem;
line-height: 1;
text-align: center;
font-weight: bold;
margin: -1em 0.65em 1em;
}
.final-score-lbl {
font-size: 5rem;
margin: -0.2em 0 0;
}
.high-score-lbl {
font-size: 1.2rem;
margin: 0 0 2.5em;
}
button {
display: block;
position: relative;
width: 200px;
padding: 12px 20px;
background: transparent;
border: none;
outline: none;
user-select: none;
font-family: monospace;
font-weight: bold;
font-size: 1.4rem;
color: #fff;
opacity: 0.75;
transition: opacity 0.3s;
}
button::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(255, 255, 255, 0.15);
transform: scale(0, 0);
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
}
/* No `:focus` styles because this is a mouse/touch game! */
button:active {
opacity: 1;
}
button:active::before {
transform: scale(1, 1);
opacity: 1;
}
.credits {
position: fixed;
width: 100%;
left: 0;
bottom: 20px;
}
a {
color: white;
}
/* Only enable hover state on large screens */
@media (min-width: 1025px) {
button:hover {
opacity: 1;
}
button:hover::before {
transform: scale(1, 1);
opacity: 1;
}
}
// globalConfig.js
// ============================================================================
// ============================================================================
// Provides global variables used by the entire program.
// Most of this should be configuration.
// Timing multiplier for entire game engine.
let gameSpeed = 1;
// Colors
const BLUE = { r: 0x67, g: 0xd7, b: 0xf0 };
const GREEN = { r: 0xa6, g: 0xe0, b: 0x2c };
const PINK = { r: 0xfa, g: 0x24, b: 0x73 };
const ORANGE = { r: 0xfe, g: 0x95, b: 0x22 };
const allColors = [BLUE, GREEN, PINK, ORANGE];
// Gameplay
const getSpawnDelay = () => {
const spawnDelayMax = 1400;
const spawnDelayMin = 550;
const spawnDelay = spawnDelayMax - state.game.cubeCount * 3.1;
return Math.max(spawnDelay, spawnDelayMin);
}
const doubleStrongEnableScore = 2000;
// Number of cubes that must be smashed before activating a feature.
const slowmoThreshold = 10;
const strongThreshold = 25;
const spinnerThreshold = 25;
// Interaction state
let pointerIsDown = false;
// The last known position of the primary pointer in screen coordinates.`
let pointerScreen = { x: 0, y: 0 };
// Same as `pointerScreen`, but converted to scene coordinates in rAF.
let pointerScene = { x: 0, y: 0 };
// Minimum speed of pointer before "hits" are counted.
const minPointerSpeed = 60;
// The hit speed affects the direction the target post-hit. This number dampens that force.
const hitDampening = 0.1;
// Backboard receives shadows and is the farthest negative Z position of entities.
const backboardZ = -400;
const shadowColor = '#262e36';
// How much air drag is applied to standard objects
const airDrag = 0.022;
const gravity = 0.3;
// Spark config
const sparkColor = 'rgba(170,221,255,.9)';
const sparkThickness = 2.2;
const airDragSpark = 0.1;
// Track pointer positions to show trail
const touchTrailColor = 'rgba(170,221,255,.62)';
const touchTrailThickness = 7;
const touchPointLife = 120;
const touchPoints = [];
// Size of in-game targets. This affects rendered size and hit area.
const targetRadius = 40;
const targetHitRadius = 50;
const makeTargetGlueColor = target => {
// const alpha = (target.health - 1) / (target.maxHealth - 1);
// return `rgba(170,221,255,${alpha.toFixed(3)})`;
return 'rgb(170,221,255)';
};
// Size of target fragments
const fragRadius = targetRadius / 3;
// Game canvas element needed in setup.js and interaction.js
const canvas = document.querySelector('#c');
// 3D camera config
// Affects perspective
const cameraDistance = 900;
// Does not affect perspective
const sceneScale = 1;
// Objects that get too close to the camera will be faded out to transparent over this range.
// const cameraFadeStartZ = 0.8*cameraDistance - 6*targetRadius;
const cameraFadeStartZ = 0.45*cameraDistance;
const cameraFadeEndZ = 0.65*cameraDistance;
const cameraFadeRange = cameraFadeEndZ - cameraFadeStartZ;
// Globals used to accumlate all vertices/polygons in each frame
const allVertices = [];
const allPolys = [];
const allShadowVertices = [];
const allShadowPolys = [];
// state.js
// ============================================================================
// ============================================================================
///////////
// Enums //
///////////
// Game Modes
const GAME_MODE_RANKED = Symbol('GAME_MODE_RANKED');
const GAME_MODE_CASUAL = Symbol('GAME_MODE_CASUAL');
// Available Menus
const MENU_MAIN = Symbol('MENU_MAIN');
const MENU_PAUSE = Symbol('MENU_PAUSE');
const MENU_SCORE = Symbol('MENU_SCORE');
//////////////////
// Global State //
//////////////////
const state = {
game: {
mode: GAME_MODE_RANKED,
// Run time of current game.
time: 0,
// Player score.
score: 0,
// Total number of cubes smashed in game.
cubeCount: 0
},
menus: {
// Set to `null` to hide all menus
active: MENU_MAIN
}
};
////////////////////////////
// Global State Selectors //
////////////////////////////
const isInGame = () => !state.menus.active;
const isMenuVisible = () => !!state.menus.active;
const isCasualGame = () => state.game.mode === GAME_MODE_CASUAL;
const isPaused = () => state.menus.active === MENU_PAUSE;
///////////////////
// Local Storage //
///////////////////
const highScoreKey = '__menja__highScore';
const getHighScore = () => {
const raw = localStorage.getItem(highScoreKey);
return raw ? parseInt(raw, 10) : 0;
};
let _lastHighscore = getHighScore();
const setHighScore = score => {
_lastHighscore = getHighScore();
localStorage.setItem(highScoreKey, String(score));
};
const isNewHighScore = () => state.game.score > _lastHighscore;
// utils.js
// ============================================================================
// ============================================================================
const invariant = (condition, message) => {
if (!condition) throw new Error(message);
};
/////////
// DOM //
/////////
const $ = selector => document.querySelector(selector);
const handleClick = (element, handler) => element.addEventListener('click', handler);
const handlePointerDown = (element, handler) => {
element.addEventListener('touchstart', handler);
element.addEventListener('mousedown', handler);
};
////////////////////////
// Formatting Helpers //
////////////////////////
// Converts a number into a formatted string with thousand separators.
const formatNumber = num => num.toLocaleString();
////////////////////
// Math Constants //
////////////////////
const PI = Math.PI;
const TAU = Math.PI * 2;
const ETA = Math.PI * 0.5;
//////////////////
// Math Helpers //
//////////////////
// Clamps a number between min and max values (inclusive)
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
// Linearly interpolate between numbers a and b by a specific amount.
// mix >= 0 && mix <= 1
const lerp = (a, b, mix) => (b - a) * mix + a;
////////////////////
// Random Helpers //
////////////////////
// Generates a random number between min (inclusive) and max (exclusive)
const random = (min, max) => Math.random() * (max - min) + min;
// Generates a random integer between and possibly including min and max values
const randomInt = (min, max) => ((Math.random() * (max - min + 1)) | 0) + min;
// Returns a random element from an array
const pickOne = arr => arr[Math.random() * arr.length | 0];
///////////////////
// Color Helpers //
///////////////////
// Converts an { r, g, b } color object to a 6-digit hex code.
const colorToHex = color => {
return '#' +
(color.r | 0).toString(16).padStart(2, '0') +
(color.g | 0).toString(16).padStart(2, '0') +
(color.b | 0).toString(16).padStart(2, '0');
};
// Operates on an { r, g, b } color object.
// Returns string hex code.
// `lightness` must range from 0 to 1. 0 is pure black, 1 is pure white.
const shadeColor = (color, lightness) => {
let other, mix;
if (lightness < 0.5) {
other = 0;
mix = 1 - (lightness * 2);
} else {
other = 255;
mix = lightness * 2 - 1;
}
return '#' +
(lerp(color.r, other, mix) | 0).toString(16).padStart(2, '0') +
(lerp(color.g, other, mix) | 0).toString(16).padStart(2, '0') +
(lerp(color.b, other, mix) | 0).toString(16).padStart(2, '0');
};
////////////////////
// Timing Helpers //
////////////////////
const _allCooldowns = [];
const makeCooldown = (rechargeTime, units=1) => {
let timeRemaining = 0;
let lastTime = 0;
const initialOptions = { rechargeTime, units };
const updateTime = () => {
const now = state.game.time;
// Reset time remaining if time goes backwards.
if (now < lastTime) {
timeRemaining = 0;
} else {
// update...
timeRemaining -= now-lastTime;
if (timeRemaining < 0) timeRemaining = 0;
}
lastTime = now;
};
const canUse = () => {
updateTime();
return timeRemaining <= (rechargeTime * (units-1));
};
const cooldown = {
canUse,
useIfAble() {
const usable = canUse();
if (usable) timeRemaining += rechargeTime;
return usable;
},
mutate(options) {
if (options.rechargeTime) {
// Apply recharge time delta so change takes effect immediately.
timeRemaining -= rechargeTime-options.rechargeTime;
if (timeRemaining < 0) timeRemaining = 0;
rechargeTime = options.rechargeTime;
}
if (options.units) units = options.units;
},
reset() {
timeRemaining = 0;
lastTime = 0;
this.mutate(initialOptions);
}
};
_allCooldowns.push(cooldown);
return cooldown;
};
const resetAllCooldowns = () => _allCooldowns.forEach(cooldown => cooldown.reset());
const makeSpawner = ({ chance, cooldownPerSpawn, maxSpawns }) => {
const cooldown = makeCooldown(cooldownPerSpawn, maxSpawns);
return {
shouldSpawn() {
return Math.random() <= chance && cooldown.useIfAble();
},
mutate(options) {
if (options.chance) chance = options.chance;
cooldown.mutate({
rechargeTime: options.cooldownPerSpawn,
units: options.maxSpawns
});
}
};
};
////////////////////
// Vector Helpers //
////////////////////
const normalize = v => {
const mag = Math.hypot(v.x, v.y, v.z);
return {
x: v.x / mag,
y: v.y / mag,
z: v.z / mag
};
}
// Curried math helpers
const add = a => b => a + b;
// Curried vector helpers
const scaleVector = scale => vector => {
vector.x *= scale;
vector.y *= scale;
vector.z *= scale;
};
////////////////
// 3D Helpers //
////////////////
// Clone array and all vertices.
function cloneVertices(vertices) {
return vertices.map(v => ({ x: v.x, y: v.y, z: v.z }));
}
// Copy vertex data from one array into another.
// Arrays must be the same length.
function copyVerticesTo(arr1, arr2) {
const len = arr1.length;
for (let i=0; i<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();
Also see: Tab Triggers