A game with a dynamic soundtrack. Life is the Pursuit of PI.
A Pen by HARUN PEHLİVAN on CodePen.
<section> | |
<div class="progress">0</div> | |
<div class="canvas"> | |
<canvas class="overlay"></canvas> | |
<a class="author" href="https://twitter.com/jake_albaugh" target="blank">@jake_albaugh</a> | |
<a class="restart" href="#">Restart</a> | |
</div> | |
<div class="abilities"></div> | |
</section> | |
<div class="intro"> | |
<p>Pursuit of PI</p> | |
<p> | |
You have three sides.<br> | |
Acquire more and achieve PI. | |
</p> | |
<p> | |
Avoid horizontal edges<br> | |
<span>↓</span> Increase your Abilities <span>↓</span><br> | |
Click anywhere (everywhere) to Play | |
</p> | |
<p> | |
<small> | |
Play on a computer<br> | |
<em>Life is the Pursuit of PI.</em><br> | |
<em> | |
Sound is a part of Life.<br> | |
There will be Sound. | |
</em> | |
</small> | |
</div> | |
<a class="fullscreen" href="#">Fullscreen</a> |
A game with a dynamic soundtrack. Life is the Pursuit of PI.
A Pen by HARUN PEHLİVAN on CodePen.
console.clear(); | |
const FF = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; | |
const PI = Math.PI, PI2 = PI * 2; | |
document.documentElement.addEventListener('mousedown', () => { | |
if (Tone.context.state !== 'running') Tone.context.resume(); | |
}); | |
const WIREFRAMES = true, | |
PIXEL = 40, | |
PIXELS_H = 20, | |
PIXELS_GROUND_H = 1, | |
PIXELS_AIR_H = PIXELS_H - PIXELS_GROUND_H, | |
VIEW_W = 1800, | |
VIEW_H = PIXEL * PIXELS_H, | |
GROUND_H = PIXEL * PIXELS_GROUND_H, | |
AIR_H = PIXEL * PIXELS_AIR_H, | |
BLOCK_GROUND_Y = VIEW_H - GROUND_H - PIXEL * 0.5, | |
STEP = 1; | |
const // module aliases | |
Bodies = Matter.Bodies, | |
Body = Matter.Body, | |
Composite = Matter.Composite, | |
Constraint = Matter.Constraint, | |
Engine = Matter.Engine, | |
Events = Matter.Events, | |
Mouse = Matter.Mouse, | |
MouseConstraint = Matter.MouseConstraint, | |
Render = Matter.Render, | |
Vector = Matter.Vector, | |
World = Matter.World; | |
const BODY_OPTS = { | |
ceiling: { | |
render: { fillStyle: '#000' }, label: 'ceiling', isStatic: true, | |
friction: 0.0, restitution: 0.0, density: 1.0 | |
}, | |
ground: { | |
render: { fillStyle: 'rgba(0,0,0,0.5)' }, label: 'ground', isStatic: true, | |
friction: 1.0, restitution: 0.0, density: 1.0 | |
}, | |
jumper: { | |
render: { fillStyle: '#FFF' }, label: 'jumper', angle: PI * 0.5, | |
friction: 0.1, restitution: 0.0, density: 0.01 | |
}, | |
obstacle: { | |
render: { fillStyle: 'rgba(255,255,255,1)' }, label: 'obstacle', isStatic: true, | |
friction: 0.0, restitution: 0.0, density: 1.0 | |
}, | |
token: { | |
render: { fillStyle: '#FFF' }, label: 'token', angle: PI * 0.25, isStatic: true, | |
friction: 0.0, restitution: 0.0, density: 0.0, isSensor: true | |
} | |
} | |
class Abilities { | |
constructor(progress) { | |
this.progress = progress; | |
this.$element = document.querySelector('.abilities'); | |
this.report(); | |
} | |
report() { | |
this.$element.innerHTML = ''; | |
if (this.progress.stage !== this.stage) { | |
this.stage = this.progress.stage; | |
this.animTop = true; | |
} else { | |
this.animTop = false; | |
} | |
let abilities = this.abilities.slice(0, this.progress.stage); | |
abilities.reverse().forEach((ability, i) => { | |
if (this.animTop && i === 0) { | |
this.$element.innerHTML += `<p class="anim">${ability}</p>` | |
} else { | |
this.$element.innerHTML += `<p>${ability}</p>` | |
} | |
}); | |
} | |
get stages() { | |
return { | |
jump: 1, | |
spin: 2, | |
tilt: 3, | |
down: 4, | |
reverse: 5 | |
} | |
} | |
get abilities() { | |
// ↓↑←→ | |
return [ | |
'Jump by holding and releasing <span>↑</span> on the ground', | |
'Spin by holding <span>←</span> or <span>→</span> in the air', | |
'Tilt by holding <span>←</span> or <span>→</span> on the ground', | |
'Force down by pressing <span>↓</span> in the air', | |
'The sands of time are <em>confused</em>', | |
]; | |
} | |
} | |
class Background { | |
constructor({ directions }) { | |
this.direction = directions[Math.floor(Math.random() * directions.length)]; | |
let maxLength = 8 * PIXEL, | |
minLength = 2 * PIXEL, | |
range = maxLength - minLength; | |
this.l = Math.floor(Math.random() * maxLength) + minLength; | |
this.x = this.direction === 1 ? VIEW_W : -this.l; | |
this.y = Math.random() * AIR_H; | |
this.moveStep = ((this.l / (maxLength + minLength))) * PIXEL * this.direction; | |
} | |
move(ratio) { | |
this.x -= (this.moveStep * 0.25 + ratio * this.moveStep * 0.75); | |
} | |
offScreen() { | |
if (this.direction === 1) | |
return this.x < -this.l; | |
else | |
return this.x > VIEW_W + this.l; | |
} | |
} | |
class Canvas { | |
constructor({ context, overlay }) { | |
this.context = context; | |
this.overlay = overlay.getContext('2d'); | |
} | |
animationTick(game) { | |
let { | |
backgrounds, ground, jumper, obstacles, | |
power, progress, progressing, tokens | |
} = game; | |
let centered = jumper.adjustedCentered; | |
let lightness = centered * 90; | |
let fill = `hsl(0, 0%, ${lightness}%)`; | |
let stroke = `hsl(0, 0%, ${centered * 70 + 30}%)`; | |
document.body.style.backgroundColor = fill; | |
document.body.style.color = stroke; | |
this.overlay.clearRect(0, 0, VIEW_W, VIEW_H); | |
this.context.fillStyle = fill; | |
this.overlay.strokeStyle = stroke; | |
this.context.fillRect(0, 0, VIEW_W, VIEW_H); | |
this.overlay.strokeRect(0, 0, VIEW_W, VIEW_H); | |
this.animateBackgrounds(backgrounds, stroke); | |
if (!progressing) this.animatePower(power, jumper, progress, stroke); | |
this.animateJumperStartPosition(jumper); | |
// rendering bodies | |
this.renderBody(ground, stroke, fill); | |
obstacles.forEach(o => this.renderBody(o.body, stroke, fill)); | |
tokens.forEach(t => this.renderBody(t.body, 'white', fill)); | |
this.renderBody(jumper.body, 'white', fill); | |
// this.renderJumperCrosshair(jumper.body, 'white'); | |
} | |
renderJumperCrosshair(body, stroke) { | |
let x = body.position.x, | |
y = body.position.y, | |
a = body.angle, | |
r = PIXEL * 0.125; | |
this.overlay.strokeStyle = stroke; | |
this.overlay.translate(x, y); | |
this.overlay.rotate(a); | |
this.overlay.beginPath(); | |
this.overlay.moveTo(-r, 0); | |
this.overlay.lineTo(r, 0); | |
this.overlay.stroke(); | |
this.overlay.rotate(-a); | |
this.overlay.translate(-x, -y); | |
} | |
animateJumperStartPosition(jumper) { | |
this.overlay.strokeStyle = jumper.body.render.fillStyle; | |
this.overlay.lineCap = 'round'; | |
this.overlay.lineWidth = 2; | |
this.overlay.beginPath(); | |
this.overlay.moveTo(jumper.startX, AIR_H + GROUND_H * 0.25); | |
this.overlay.lineTo(jumper.startX, AIR_H + GROUND_H * 0.75); | |
this.overlay.stroke(); | |
} | |
animateBackgrounds(backgrounds, stroke) { | |
this.context.lineWidth = 2; | |
this.context.strokeStyle = stroke; | |
backgrounds.forEach(item => { | |
this.context.beginPath(); | |
this.context.moveTo(item.x, item.y); | |
this.context.lineTo(item.x + item.l, item.y); | |
this.context.stroke(); | |
}); | |
} | |
animatePower(power, jumper, progress, stroke) { | |
let maxTilt = progress.maxTilt; | |
let maxDistance = PIXEL * PIXELS_AIR_H - PIXEL * 2.3; | |
this.overlay.lineWidth = 2; | |
this.overlay.lineCap = 'round'; | |
// initial x, y | |
let startX = jumper.body.position.x, | |
startY = jumper.body.position.y; | |
if (maxTilt !== 0 && false) { | |
// the full area | |
let angleMin = PI * -0.5 + (-PI * maxTilt), | |
angleMax = PI * -0.5 + (PI * maxTilt), | |
curveX = startX, | |
curveY = startY - maxDistance - PIXEL * 1.5; | |
this.tiltAngleMin = this.calculatePathAtAngle(startX, startY, maxDistance, angleMin); | |
this.tiltAngleMax = this.calculatePathAtAngle(startX, startY, maxDistance, angleMax); | |
this.overlay.fillStyle = 'rgba(255, 255, 255, 0.04)'; | |
this.overlay.beginPath(); | |
this.overlay.moveTo(this.tiltAngleMin.from.x, this.tiltAngleMin.from.y); | |
this.overlay.lineTo(this.tiltAngleMin.to.x, this.tiltAngleMin.to.y); | |
this.overlay.quadraticCurveTo( | |
curveX, curveY, this.tiltAngleMax.to.x, this.tiltAngleMax.to.y | |
); | |
this.overlay.lineTo(this.tiltAngleMax.from.x, this.tiltAngleMax.from.y); | |
this.overlay.fill(); | |
} | |
// the angle of tilt | |
let angle = PI * -0.5 + (jumper.tilt * PI * maxTilt); | |
// coordinates for starting power just off the duder | |
let maxPath = this.calculatePathAtAngle(startX, startY, maxDistance, angle); | |
// coordinates for showing power | |
let currPath = this.calculatePathAtAngle(startX, startY, (power.meter * maxDistance), angle); | |
this.overlay.strokeStyle = stroke; | |
this.overlay.beginPath(); | |
this.overlay.moveTo(maxPath.from.x, maxPath.from.y); | |
this.overlay.lineTo(maxPath.to.x, maxPath.to.y); | |
this.overlay.stroke(); | |
this.overlay.strokeStyle = '#fff'; | |
this.overlay.beginPath(); | |
this.overlay.moveTo(currPath.from.x, currPath.from.y); | |
this.overlay.lineTo(currPath.to.x, currPath.to.y); | |
this.overlay.stroke(); | |
} | |
calculatePathAtAngle(startX, startY, maxDistance, angle) { | |
let angleCos = Math.cos(angle); | |
let angleSin = Math.sin(angle); | |
let fromX = startX + PIXEL * angleCos, | |
fromY = startY + PIXEL * angleSin; | |
let toX = fromX + maxDistance * angleCos, | |
toY = fromY + maxDistance * angleSin; | |
return { | |
from: { x: fromX, y: fromY }, | |
to: { x: toX, y: toY } | |
} | |
} | |
renderBody(body, stroke, fill) { | |
this.context.strokeStyle = stroke || body.render.fillStyle; | |
this.context.fillStyle = fill || body.render.fillStyle; | |
this.context.lineWidth = 2; | |
this.context.beginPath(); | |
body.vertices.forEach(({ x, y }, j) => { | |
let method = (j === 0) ? 'moveTo' : 'lineTo'; | |
this.context[method](x, y); | |
}); | |
this.context.closePath(); | |
this.context.fill(); | |
this.context.stroke(); | |
} | |
} | |
class Game { | |
constructor({ world, canvas, score, reset }) { | |
let $final = document.body.querySelector('.final-score'); | |
if ($final) $final.remove(); | |
this.reset = reset; // function to reset the game | |
this.playing = true; | |
this.world = world; | |
this.canvas = canvas; | |
this.progress = new Progress(); | |
this.abilities = new Abilities(this.progress); | |
this.progress.abilities = this.abilities; | |
this.power = new Power(this.progress); | |
this.score = score; | |
this.score.bind(this.progress, this); | |
this.progressing = false; | |
this.backgrounds = []; | |
this.obstacles = []; | |
this.tokens = []; | |
this.tokenIds = []; | |
this.buildCeiling(); | |
this.buildGround(); | |
this.jumper = new Jumper(this.progress); | |
this.addBodiesToWorld(); | |
} | |
// Adding bodies to the matter js world | |
addBodiesToWorld() { | |
World.add(this.world, this.ceiling); | |
World.add(this.world, this.ground); | |
World.add(this.world, this.jumper.body); | |
} | |
// Building an instance of Background and adding it to backgrounds | |
buildBackground() { | |
this.backgrounds.push(new Background({ directions: this.progress.directions(this.abilities) })); | |
} | |
// The ceiling body resting just above the top of the window | |
buildCeiling() { | |
this.ceiling = Bodies.rectangle( | |
VIEW_W * 0.5, -GROUND_H - PIXEL, VIEW_W, GROUND_H, BODY_OPTS.ceiling | |
); | |
} | |
// The ground body dropping beneath the bottom of the window | |
buildGround() { | |
let relH = GROUND_H * 2, | |
relW = VIEW_W * 1.5, | |
relY = VIEW_H - relH * 0.5 + (relH - GROUND_H); | |
this.ground = Bodies.rectangle( | |
VIEW_W * 0.5, relY, relW, relH, BODY_OPTS.ground | |
); | |
} | |
// Building an instance of Obstacle and adding it to obstacles | |
buildObstacle() { | |
let obstacle = new Obstacle({ stage: this.progress.stage, directions: this.progress.directions(this.abilities) }); | |
this.obstacles.push(obstacle); | |
World.add(this.world, obstacle.body); | |
} | |
// Building an instance of Token and adding it to tokens | |
buildToken() { | |
let token = new Token({ sides: this.jumper.sides + 1, directions: this.progress.directions(this.abilities) }); | |
this.tokens.push(token); | |
this.tokenIds.push(token.body.id); | |
World.add(this.world, token.body); | |
} | |
// Determining if the game is over | |
checkGameOver() { | |
if (this.jumper.offScreen()) { | |
this.handleGameEnd(); | |
} | |
} | |
// Firing the Jumper, setting "progressing" state | |
fire() { | |
if (!this.started) { | |
this.score.start(); | |
this.started = true; | |
} | |
if (this.power.force !== 0) { | |
this.jumper.fire(this.power.force); | |
this.progressing = true; | |
} | |
} | |
// Handling all collision pairs | |
handleCollisionStart({ pairs }) { | |
pairs.forEach((collision, i) => { | |
let { bodyA, bodyB } = collision; | |
let speed = collision.collision.axisBody.speed; | |
let coll = bodyA.label + bodyB.label; | |
if (coll === 'groundjumper' || coll === 'jumperground') | |
this.handleJumperLanded(); | |
if (coll === 'obstaclejumper' || coll === 'jumperobstacle') | |
this.handleJumperObstacle(); | |
if (coll === 'tokenjumper' || coll === 'jumpertoken') | |
this.handleTokenHit(bodyA.label === 'jumper' ? bodyB : bodyA); | |
}); | |
} | |
// Handling keydown Jumper "charging" state | |
handleJumperCharging() { | |
if (this.progressing) return; | |
this.charging = true; | |
} | |
// Handling keydown Jumper downward force command | |
handleJumperDown() { | |
if (this.progress.stage >= this.abilities.stages.down) | |
this.jumper.down(); | |
} | |
// Handling keyup Jumper upward force command | |
handleJumperFire() { | |
this.fire(); | |
this.progress.startJump(); | |
this.power.reset(); | |
this.charging = false; | |
} | |
// Jumper has first touched the ground after Jumping | |
handleJumperLanded() { | |
this.progressing = false; | |
this.progress.endJump(); | |
} | |
// Jumper has touched an obstacle | |
handleJumperObstacle() {} | |
// Command to spin is being fired | |
handleJumperSpin(direction) { | |
if (this.progress.stage >= this.abilities.stages.spin) | |
this.jumper.adjustSpin(direction); | |
} | |
// Command to tilt is being fired | |
handleJumperTilt(direction) { | |
if (this.progress.stage >= this.abilities.stages.tilt) | |
this.jumper.adjustTilt(direction); | |
} | |
// The Game is over | |
handleGameEnd() { | |
if (this.playing) { | |
this.score.kill(); | |
this.notifyFinalScore(); | |
this.playing = false; | |
} | |
} | |
// The level is being increased | |
handleLevelTick() { | |
this.abilities.report(); | |
} | |
// Animation frame | |
handleTickAfter() { | |
this.jumper.tick(); | |
this.canvas.animationTick(this); | |
if (!this.progressing) this.jumper.updateStartX(); | |
this.checkGameOver(); | |
this.tickItems(); | |
if (!this.playing) return; | |
if (this.charging) this.tickPower(); | |
if (this.progressing) this.tickProgress(); | |
} | |
// A Token has been hit by the Jumper | |
handleTokenHit(token) { | |
this.jumper.increaseSides(); | |
Composite.remove(this.world, token); | |
let idx = this.tokenIds.indexOf(token.id); | |
this.tokenIds.splice(idx, 1); | |
this.tokens.splice(idx, 1); | |
this.tokens.forEach(token => { | |
token.increaseSides(); | |
}); | |
} | |
notifyFinalScore() { | |
let $el = document.createElement('div'); | |
$el.className = 'final-score'; | |
$el.innerHTML = this.progress.finalReport(); | |
let $p = document.createElement('p'); | |
let $a = document.createElement('a'); | |
$a.innerHTML = 'Restart'; | |
$a.addEventListener('click', () => { this.restart() }); | |
$p.appendChild($a); | |
$el.appendChild($p); | |
$a.setAttribute('href', '#'); | |
document.body.appendChild($el); | |
} | |
// Move our items forward (in progressing state) | |
progressItems() { | |
this.progressGroup(this.obstacles); | |
this.progressGroup(this.tokens, this.tokenIds); | |
this.progressGroup(this.backgrounds); | |
} | |
// Take a group of items and progress it, removing items that are now offscreen | |
progressGroup(group, ids) { | |
let removes = []; | |
let centeredAmount = (1 - this.jumper.adjustedCentered); | |
let amount = (centeredAmount * 0.5) * this.jumper.proximityFromGround + (centeredAmount * 0.5); | |
group.forEach((item, i) => { | |
item.move(amount); | |
if (item.offScreen()) { | |
removes.push(i); | |
if (item.body) Composite.remove(this.world, item.body); | |
} | |
}); | |
for (let i = removes.length - 1; i >= 0; i--) { | |
if (ids) ids.splice(removes[i], 1); | |
group.splice(removes[i], 1); | |
} | |
} | |
// Restarting the game | |
restart() { | |
this.score.kill(); | |
this.reset(); | |
} | |
// Each anim tick actions for Items | |
tickItems() { | |
this.tokens.forEach((token, i) => { token.spin(); }); | |
} | |
// Ticking the power (in charging state) | |
tickPower() { | |
if (this.charging) this.power.tick(); | |
} | |
// Ticking the progress (in progressing state) | |
tickProgress() { | |
this.score.updateBPM(1 - this.jumper.adjustedCentered); | |
let levelTick = this.progress.tick(this.jumper); | |
this.score.updateFilter(this.jumper.proximityFromGround); | |
if (levelTick) this.handleLevelTick(); | |
if (this.progress.doBackground()) this.buildBackground(); | |
if (this.progress.doStep()) this.progressItems(); | |
if (this.progress.doObstacle()) this.buildObstacle(); | |
if (this.progress.doToken()) this.buildToken(); | |
} | |
} | |
class Jumper { | |
constructor(progress) { | |
this.progress = progress; | |
this.sides = 3; | |
this.name = 'Trigon'; | |
this.spinSpeed = 0.005; | |
this.tilt = 0; | |
this.centered = 0; | |
this.maxForce = 0.4; | |
this.buildBody(); | |
} | |
adjustSpin(direction) { | |
this.spin = this.body.angularVelocity + this.spinSpeed * direction; | |
Body.setAngularVelocity(this.body, this.spin); | |
} | |
adjustTilt(direction) { | |
let value = this.tilt + direction * this.progress.maxTilt; | |
this.tilt = Math.min(Math.max(value, -1), 1); | |
} | |
buildBody() { | |
let x = VIEW_W * 0.5, | |
y = BLOCK_GROUND_Y - PIXEL * 0.125; | |
this.startX = x; | |
this.body = Bodies.polygon(x, y, this.sides, PIXEL * 0.5, BODY_OPTS.jumper); | |
} | |
down() { | |
Body.applyForce(this.body, this.body.position, { x: 0, y: Math.min(this.maxForce, 1) }); | |
} | |
fire(force) { | |
let x = this.tilt * 0.05, | |
y = force * this.maxForce; | |
this.updateStartX(); | |
Body.applyForce(this.body, this.body.position, { x, y }); | |
} | |
increaseSides() { | |
this.sides += 1; | |
this.sides = Math.min(100, this.sides); | |
this.name = polygonName(this.sides); | |
let body = Bodies.polygon(0, 0, this.sides, PIXEL * 0.5, {}); | |
let areaBefore = this.body.area; | |
this.body.friction += 0.05; | |
// this.body.restitution += 0.025; | |
Body.setVertices(this.body, body.vertices); | |
// account for extra force for more area | |
this.maxForce *= this.body.area / areaBefore; | |
} | |
offScreen() { | |
return this.body.position.x < 0 || this.body.position.x > VIEW_W; | |
} | |
tick() { | |
this.centered = Math.abs(this.body.position.x - VIEW_W * 0.5) / (VIEW_W * 0.5); | |
this.adjustedCentered = Math.pow(this.centered, 2); | |
} | |
updateStartX() { | |
this.startX = this.body.position.x; | |
} | |
get proximityFromGround() { | |
return 1 - ((this.body.position.y - PIXEL * 0.5) / AIR_H); | |
} | |
} | |
class Obstacle { | |
constructor({ stage, directions }) { | |
this.direction = directions[Math.floor(Math.random() * directions.length)]; | |
this.stage = stage; | |
this.moveStep = PIXEL * (Math.random() * 0.1 + 0.025) * this.direction; | |
this.buildBody(); | |
} | |
buildBody() { | |
let allOptions = this.stageOptions; | |
let options = allOptions[(this.stage - 1) % allOptions.length]; | |
let idx = Math.floor(Math.random() * options.length); | |
let option = options[idx]; | |
if (option.s) { | |
let a = this.randomAngle(), | |
s = option.s, | |
r = option.r * PIXEL, | |
d = r * 2, | |
y = (Math.random() * (AIR_H - d) - d * 0.5), | |
x = this.direction === 1 ? VIEW_W + d * 0.5 : d * -0.5; | |
let opts = BODY_OPTS.obstacle; | |
opts.angle = a; | |
this.body = Bodies.polygon(x, y, s, r, opts); | |
} else { | |
let h = option.h * PIXEL, | |
w = option.w * PIXEL, | |
y = (Math.random() * (AIR_H - h) - h * 0.5), | |
x = this.direction === 1 ? VIEW_W + w * 0.5 : w * -0.5; | |
this.body = Bodies.rectangle(x, y, w, h, BODY_OPTS.obstacle); | |
} | |
} | |
move(ratio) { | |
let x = this.body.position.x - (this.moveStep * 0.25 + ratio * this.moveStep * 0.75); | |
Body.setPosition(this.body, { x, y: this.body.position.y }); | |
} | |
offScreen() { | |
if (this.direction === 1) | |
return this.body.position.x < -PIXEL; | |
else | |
return this.body.position.x > VIEW_W + PIXEL; | |
} | |
randomAngle() { | |
return [PI * 0.5, 0, PI * -0.5][Math.floor(Math.random() * 3)]; | |
} | |
get stageOptions() { | |
let rectSm = this.shapeRect(1), | |
circSm = this.shapePoly(1, 16), | |
beamSm = this.shapeBeam(2), | |
platSm = this.shapePlat(2), | |
trigSm = this.shapePoly(2, 3), | |
rectMd = this.shapeRect(2), | |
circMd = this.shapePoly(2, 16), | |
beamMd = this.shapeBeam(3), | |
platMd = this.shapePlat(3), | |
trigMd = this.shapePoly(3, 3); | |
return [ | |
[rectSm, circSm, beamSm, platSm], | |
[rectSm, circSm, trigSm, beamMd, platMd], | |
[rectMd, circMd, trigMd, beamMd, platMd], | |
]; | |
} | |
shapeRect(size) { return { w: size, h: size }; } | |
shapeBeam(size) { return { w: 1, h: size }; } | |
shapePlat(size) { return { w: size, h: 1 }; } | |
shapePoly(di, s) { return { s, r: di * 0.5 }; } | |
} | |
class Power { | |
constructor() { | |
this.meter = 0; | |
this.step = 0.025; | |
this.direction = 1; | |
} | |
get force() { | |
return this.meter * -1; | |
} | |
reset() { | |
this.meter = 0; | |
} | |
tick() { | |
this.meter += this.step * this.direction; | |
if (this.meter < 0) { | |
this.direction = 1; | |
this.meter = 0; | |
} else if (this.meter > 1) { | |
this.direction = -1; | |
this.meter = 1; | |
} | |
} | |
} | |
class Progress { | |
constructor() { | |
this.$element = document.querySelector('.progress'); | |
this.best = 0; | |
this.jump = 0; | |
this.jumpStart = 0; | |
this.jumperName = 'Trigon'; | |
this.jumperSides = 3; | |
this.stage = 1; | |
this.level = 1; | |
this.levelsPerStage = 4; | |
this.levelStep = 750; | |
this.position = 0; | |
this.update(); | |
this.report(); | |
} | |
tick(jumper) { | |
this.jumperName = jumper.name; | |
this.jumperSides = jumper.sides; | |
this.position++; | |
this.jump = this.position - this.jumpStart; | |
let levelTick = false; | |
if (Math.floor(this.position / this.levelStep + 1) !== this.level) { | |
levelTick = true; | |
this.stepForward(); | |
} | |
this.report(); | |
return levelTick; | |
} | |
startJump() { | |
this.jumpStart = this.position; | |
} | |
endJump() { | |
this.best = Math.max(this.best, this.jump); | |
this.report(); | |
} | |
report() { | |
let extra = `[${this.jumperSides}]`; | |
if (this.jumperName === 'Circle') extra = ''; | |
this.$element.innerHTML = ` | |
<span> | |
L${this.level} | |
${this.units(this.position)} | |
</span> | |
<span>${this.jumperName} ${extra}</span> | |
<span> | |
J ${this.units(this.jump)} | |
B ${this.units(this.best)} | |
</span> | |
`; | |
} | |
finalReport() { | |
if (this.jumperName === 'Circle') { | |
return ` | |
<p>You Achieved</p> | |
<p>Circularity</p> | |
<p> | |
Total ${this.units(this.position)}<br> | |
Best Jump ${this.units(this.best)} | |
</p> | |
` | |
} | |
return ` | |
<p>Your Pursuit is Over</p> | |
<p>${this.jumperName}</p> | |
<p> | |
Total ${this.units(this.position)}<br> | |
Best Jump ${this.units(this.best)} | |
</p> | |
`; | |
} | |
stepForward() { | |
this.level++; | |
this.stage = Math.floor((this.level - 1) / this.levelsPerStage) + 1; | |
this.update(); | |
} | |
doStep() { | |
return this.position % STEP === 0; | |
} | |
doObstacle() { | |
return this.position % (STEP * this.obstacle) === 0; | |
} | |
doBackground() { | |
return Math.random() < 0.05; | |
} | |
doToken() { | |
return this.position % (STEP * this.token) === 0; | |
} | |
update() { | |
let max = this.optsObstacle.length; | |
let idx = (this.stage - 1) % max; | |
if (this.stage <= 7) { | |
this.obstacle = this.optsObstacle[idx]; | |
this.token = this.optsToken[idx]; | |
this.maxTilt = this.optsMaxTilt[idx]; | |
} | |
} | |
directions(abilities) { | |
if (this.stage < abilities.stages.reverse) return [1]; | |
return [1, -1]; | |
} | |
get optsMaxTilt() { | |
return [0, 0, 0.025, 0.025, 0.05, 0.05, 0.1]; | |
} | |
get optsObstacle() { | |
return [128, 128, 96, 96, 64, 64, 32]; | |
} | |
get optsToken() { | |
return [256, 256, 192, 192, 128, 128, 64]; | |
} | |
units(int) { | |
return int.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + 'u'; | |
} | |
} | |
class Score { | |
constructor() { | |
this.exists = false; | |
} | |
bind(progress, game) { | |
this.position = 0; | |
this.rate = '32n'; | |
this.chordChange = 8 * 4; // 8 32nd notes = 1 quarter note | |
this.arpeggiatorChange = 4; | |
this.keyIdx = 0; | |
this.chordIdx = 0; | |
this.arpeggiatorIdx = 0; | |
this.progress = progress; | |
this.game = game; | |
this.on = { bass: false, arpeggiator: false }; | |
if (!this.exists) this.initialize(); | |
this.exists = true; | |
} | |
buildChannels() { | |
this.channels = {}; | |
this.channels.main = new Tone.Gain(1); | |
this.channels.bass = new Tone.Gain(0.9); | |
this.channels.bassL = new Tone.Panner(-1); | |
this.channels.bassR = new Tone.Panner(1); | |
this.channels.arpeggiator = new Tone.Gain(0.45); | |
} | |
buildEffects() { | |
this.effects = {}; | |
this.effects.filter = new Tone.AutoFilter(); | |
this.effects.filter.frequency.value = 4; | |
this.effects.filter.type = 'square'; | |
this.effects.filter.depth.value = 0.3; | |
this.effects.filter.baseFrequency = 50; | |
this.effects.filter.octaves = 2.6; | |
this.effects.filter.filter.type = 'lowpass'; | |
this.effects.filter.filter.rolloff = -24; | |
this.effects.filter.filter.Q.value = 1; | |
this.effects.filter.start(); | |
} | |
buildSynths() { | |
this.synths = {}; | |
this.synths.bassL = new Tone.AMSynth(); | |
this.synths.bassL.set({ | |
envelope: { attack: 0.125, release: 0.125 }, | |
oscillator: { type: 'square4' }, | |
portamento: FF ? 0 : 0.25 | |
}); | |
this.synths.bassL.setNote('C1'); | |
this.synths.bassR = new Tone.AMSynth(); | |
this.synths.bassR.set({ | |
envelope: { attack: 0.125, release: 0.125 }, | |
oscillator: { type: 'square4' }, | |
portamento: FF ? 0 : 0.25 | |
}); | |
this.synths.bassR.setNote('C1'); | |
this.synths.arpeggiator = new Tone.AMSynth(); | |
this.synths.arpeggiator.set({ | |
envelope: { attack: 0.5, release: 0.125 }, | |
oscillator: { type: 'sawtooth' }, | |
portamento: 0.05 | |
}); | |
} | |
changeChord() { | |
this.updateArpeggiatorNotes(); | |
if (this.on.bass) { | |
this.synths.bassL.setNote(this.chord[0] + '1', this.time); | |
this.synths.bassR.setNote(this.chord[2] + '1', this.time); | |
} else { | |
this.on.bass = true; | |
this.synths.bassL.triggerAttack(this.chord[0] + '1', this.time); | |
this.synths.bassR.triggerAttack(this.chord[2] + '1', this.time); | |
} | |
this.chordIdx++; | |
} | |
connectModules() { | |
this.channels.main.toMaster(); | |
this.effects.filter.connect(this.channels.main); | |
this.channels.bassL.connect(this.channels.bass); | |
this.channels.bassR.connect(this.channels.bass); | |
this.channels.bass.connect(this.effects.filter); | |
this.channels.arpeggiator.connect(this.effects.filter); | |
this.synths.bassL.connect(this.channels.bassL); | |
this.synths.bassR.connect(this.channels.bassR); | |
this.synths.arpeggiator.connect(this.channels.arpeggiator); | |
} | |
initialize() { | |
this.buildChannels(); | |
this.buildEffects(); | |
this.buildSynths(); | |
this.connectModules(); | |
// repeated event every 8th note | |
Tone.Transport.scheduleRepeat((time) => { | |
this.time = time; | |
this.tick(); | |
}, this.rate); | |
} | |
start() { | |
Tone.Transport.start(); | |
} | |
stop() { | |
Tone.Transport.stop(); | |
} | |
tick() { | |
if (this.position % 16 === 0) this.effects.filter.sync(); | |
if (this.game.progressing) | |
if (this.position % this.chordChange === 0) this.changeChord(); | |
if (this.position % this.arpeggiatorChange === 0) | |
this.triggerArpeggiator(); | |
this.position++; | |
} | |
triggerArpeggiator() { | |
let note = this.arpeggiatorNotes[this.arpeggiatorIdx % 9]; | |
if (this.on.arpeggiator) { | |
this.synths.arpeggiator.setNote(note, this.time); | |
} else { | |
this.on.arpeggiator = true; | |
this.synths.arpeggiator.triggerAttack(note, this.time); | |
} | |
this.arpeggiatorIdx++; | |
} | |
updateBPM(ratio) { | |
let bpm = ratio * 40 + 80; | |
Tone.Transport.bpm.value = bpm; | |
} | |
updateFilter(ratio) { | |
let frequency = ratio * 1250 + 50; | |
this.effects.filter.depth.value = 0.3 * (1-ratio); | |
this.effects.filter.baseFrequency = frequency; | |
} | |
updateArpeggiatorNotes() { | |
this.arpeggiatorIdx = 0; | |
this.arpeggiatorNotes = []; | |
for (let i = 0; i < 3; i++) { | |
this.arpeggiatorNotes.push(this.chord[i] + '3'); | |
this.arpeggiatorNotes.push(this.chord[i] + '4'); | |
this.arpeggiatorNotes.push(this.chord[i] + '5'); | |
} | |
this.arpeggiatorNotes = shuffleArray(this.arpeggiatorNotes); | |
} | |
get key() { | |
return this.keys[(this.progress.stage - 1) % this.keys.length]; | |
} | |
get keys() { | |
return [ | |
[ | |
// G Locrian | |
['G', 'Bb', 'Db'], // Gdim | |
['A#', 'C#', 'F'], // A#min | |
['F', 'Ab', 'C'], // Fmin | |
['C', 'Eb', 'G'], // Cmin | |
['C#', 'F', 'G#'], // C#maj | |
['A#', 'C#', 'F'], // A#maj | |
['D#', 'G', 'A#'], // D#maj | |
['G#', 'C', 'D#'] // G#maj | |
], | |
// D# Major | |
[ | |
['D#', 'G', 'Bb'], // D#maj | |
['G', 'Bb', 'D'], // Gmin | |
['C', 'Eb', 'G'], // Cmin | |
['G', 'C', 'Eb'], // Cmin/G | |
['C#', 'F', 'G#'], // Dbmaj | |
['A#', 'D', 'F'], // Bbmaj | |
['C', 'Eb', 'G'], // Cmin | |
['F', 'Ab', 'C'], // Fmin | |
], | |
// C Minor (Aeolian) | |
[ | |
['C', 'Eb', 'G'], // Cmin | |
['Eb', 'B', 'Gb'], // D#maj | |
['Ab', 'C', 'Eb'], // G#maj | |
['F', 'Ab', 'C'], // Fmin | |
['G', 'Bb', 'D'], // Gmin | |
['Eb', 'B', 'Gb'], // D#maj | |
['Ab', 'C', 'Eb'], // G#maj | |
['D', 'F', 'Ab'], // Ddim | |
] | |
]; | |
} | |
get chord() { | |
return this.key[this.chordIdx % this.key.length]; | |
} | |
// stopping all sound, resetting bpm, resetting chords | |
kill() { | |
this.stop(); | |
this.synths.bassL.triggerRelease(this.time); | |
this.synths.bassR.triggerRelease(this.time); | |
this.synths.arpeggiator.triggerRelease(this.time); | |
} | |
} | |
class Token { | |
constructor({ sides, directions }) { | |
this.sides = sides; | |
this.direction = directions[Math.floor(Math.random() * directions.length)]; | |
this.moveStep = PIXEL * (Math.random() * 0.1 + 0.025) * this.direction; | |
this.buildBody(); | |
} | |
buildBody() { | |
let r = PIXEL * 0.4, | |
y = Math.random() * (AIR_H - r), | |
x = this.direction === 1 ? VIEW_W + r : -r; | |
this.body = Bodies.polygon(x, y, this.sides, r, BODY_OPTS.token); | |
} | |
spin() { | |
Body.setAngle(this.body, this.body.angle + 0.05); | |
} | |
move(ratio) { | |
let x = this.body.position.x - (this.moveStep * 0.25 + ratio * this.moveStep * 0.75); | |
Body.setPosition(this.body, { x, y: this.body.position.y }); | |
} | |
increaseSides() { | |
this.sides++; | |
let body = Bodies.polygon(0, 0, this.sides, PIXEL * 0.4, {}); | |
let areaBefore = this.body.area; | |
Body.setVertices(this.body, body.vertices); | |
} | |
offScreen() { | |
if (this.direction === 1) | |
return this.body.position.x < -PIXEL; | |
else | |
return this.body.position.x > VIEW_W + PIXEL; | |
} | |
} | |
newGame(); | |
let focused = false; | |
setInterval(checkPageFocus, 200); | |
function checkPageFocus() { | |
let isFocused = document.hasFocus(); | |
if (isFocused && !focused) { | |
document.body.classList.add('focused'); | |
focused = true; | |
} else if (!isFocused && focused) { | |
document.body.classList.remove('focused'); | |
focused = false; | |
} | |
} | |
let fullscreen = new Fullscreen(); | |
let $fullscreen = document.querySelector('.fullscreen'); | |
if (fullscreen.supported) { | |
$fullscreen.addEventListener('click', () => { fullscreen.toggle(); }); | |
} else { | |
$fullscreen.remove(); | |
} | |
function newGame() { | |
let overlay = document.querySelector('canvas.overlay'); | |
overlay.width = VIEW_W; | |
overlay.height = VIEW_H; | |
document.body.querySelector('.restart').addEventListener('click', () => { | |
game.restart(); | |
}); | |
let fresh = false; | |
if (window.engine) { | |
Engine.clear(window.engine); | |
World.clear(engine.world); | |
delete window.game; | |
} else { | |
fresh = true; | |
// create a world and engine | |
window.world = World.create({ gravity: { x: 0, y: 0.5 } }); | |
window.engine = Engine.create({ world, timing: { timeScale: 1 } }); | |
window.score = new Score(); | |
// create a renderer | |
window.element = document.querySelector('div.canvas'); | |
window.render = Render.create({ | |
element, engine, | |
options: { | |
wireframes: WIREFRAMES, | |
width: VIEW_W, | |
height: VIEW_H, | |
background: 'transparent' | |
} | |
}); | |
} | |
let canvas = new Canvas({ context: render.context, overlay }); | |
window.game = new Game({ world, canvas, score, reset: newGame }); | |
let arrowR = 39, arrowL = 37, | |
arrowD = 40, arrowU = 38; | |
document.body.addEventListener('keydown', ({ keyCode }) => { | |
if (keyCode === arrowU && !game.charging) game.handleJumperCharging(); | |
if (game.progressing) { | |
if (keyCode === arrowR) game.handleJumperSpin(1); | |
if (keyCode === arrowL) game.handleJumperSpin(-1); | |
} else { | |
if (keyCode === arrowR) game.handleJumperTilt(1); | |
if (keyCode === arrowL) game.handleJumperTilt(-1); | |
} | |
}); | |
document.body.addEventListener('keyup', ({ keyCode }) => { | |
if (keyCode === arrowU && game.charging) game.handleJumperFire(); | |
if (game.progressing) { | |
if (keyCode === arrowD) game.handleJumperDown(); | |
} | |
}); | |
if (fresh) { | |
Events.on(render, 'afterRender', () => game.handleTickAfter()); | |
Events.on(engine, 'collisionStart', (e) => { | |
game.handleCollisionStart({ pairs: e.pairs }); | |
}); | |
// run the engine | |
Engine.run(engine); | |
// run the renderer | |
Render.run(render); | |
} | |
} | |
// shuffling an array | |
function shuffleArray(array) { | |
var currentIndex = array.length, temporaryValue, randomIndex; | |
// While there remain elements to shuffle... | |
while (0 !== currentIndex) { | |
// Pick a remaining element... | |
randomIndex = Math.floor(Math.random() * currentIndex); | |
currentIndex -= 1; | |
// And swap it with the current element. | |
temporaryValue = array[currentIndex]; | |
array[currentIndex] = array[randomIndex]; | |
array[randomIndex] = temporaryValue; | |
} | |
return array; | |
} | |
// polygonal name for x sides from 1 through 100 | |
function polygonName(sides) { | |
return [ | |
null, | |
'Monogon', 'Digon', 'Trigon', 'Tetragon', 'Pentagon', | |
'Hexagon', 'Heptagon', 'Octagon', 'Enneagon', 'Decagon', | |
'Hendecagon', 'Dodecagon', 'Trisdecagon', 'Tetradecagon', 'Pentadecagon', | |
'Hexadecagon', 'Heptadecagon', 'Octadecagon', 'Enneadecagon', 'Icosagon', | |
'Icosikaihenagon', 'Icosikaidigon', 'Icosikaitrigon', 'Icosikaitetragon', | |
'Icosikaipentagon', | |
'Icosikaihexagon', 'Icosikaiheptagon', 'Icosikaioctagon', 'Icosikaienneagon', | |
'Triacontagon', | |
'Triacontakaihenagon', 'Triacontakaidigon', 'Triacontakaitrigon', | |
'Triacontakaitetragon', 'Triacontakaipentagon', | |
'Triacontakaihexagon', 'Triacontakaiheptagon', 'Triacontakaioctagon', | |
'Triacontakaienneagon', 'Tetracontagon', | |
'Tetracontakaihenagon', 'Tetracontakaidigon', 'Tetracontakaitrigon', | |
'Tetracontakaitetragon', 'Tetracontakaipentagon', | |
'Tetracontakaihexagon', 'Tetracontakaiheptagon', 'Tetracontakaioctagon', | |
'Tetracontakaienneagon', 'Pentacontagon', | |
'Pentacontakaihenagon', 'Pentacontakaidigon', 'Pentacontakaitrigon', | |
'Pentacontakaitetragon', 'Pentacontakaipentagon', | |
'Pentacontakaihexagon', 'Pentacontakaiheptagon', 'Pentacontakaioctagon', | |
'Pentacontakaienneagon', 'Hexacontagon', | |
'Hexacontakaihenagon', 'Hexacontakaidigon', 'Hexacontakaitrigon', | |
'Hexacontakaitetragon', 'Hexacontakaipentagon', | |
'Hexacontakaihexagon', 'Hexacontakaiheptagon', 'Hexacontakaioctagon', | |
'Hexacontakaienneagon', 'Heptacontagon', | |
'Heptacontakaihenagon', 'Heptacontakaidigon', 'Heptacontakaitrigon', | |
'Heptacontakaitetragon', 'Heptacontakaipentagon', | |
'Heptacontakaihexagon', 'Heptacontakaiheptagon', 'Heptacontakaioctagon', | |
'Heptacontakaienneagon', 'Octacontagon', | |
'Octacontakaihenagon', 'Octacontakaidigon', 'Octacontakaitrigon', | |
'Octacontakaitetragon', 'Octacontakaipentagon', | |
'Octacontakaihexagon', 'Octacontakaiheptagon', 'Octacontakaioctagon', | |
'Octacontakaienneagon', 'Enneacontagon', | |
'Enneacontakaihenagon', 'Enneacontakaidigon', 'Enneacontakaitrigon', | |
'Enneacontakaitetragon', 'Enneacontakaipentagon', | |
'Enneacontakaihexagon', 'Enneacontakaiheptagon', 'Enneacontakaioctagon', | |
'Enneacontakaienneagon', 'Circle' | |
][sides]; | |
} |
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.12.0/matter.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/0.12.0/Tone.min.js"></script> | |
<script src="https://codepen.io/jakealbaugh/pen/d9901ff910bd5f4a8b91e629b5d99d28.js"></script> |
@import url('https://fonts.googleapis.com/css?family=Share+Tech+Mono'); | |
$font-family: 'Share Tech Mono', 'Andale Mono', monospace; | |
html, body { height: 100%; } | |
body { | |
background-color: #000; | |
-webkit-font-smoothing: antialiased; | |
-moz-osx-font-smoothing: grayscale; | |
font-family: $font-family; | |
cursor: crosshair; | |
overflow: hidden; | |
} | |
section { | |
position: absolute; | |
width: 1200px; | |
max-width: calc(100% - 1rem); | |
max-height: calc(100% - 1rem); | |
top: 50%; left: 50%; | |
transform: translate3d(-50%, -50%, 0); | |
display: flex; | |
flex-direction: column; | |
div.progress, | |
div.abilities { | |
text-align: center; | |
padding: 0.5rem 0; | |
.anim { | |
animation: animIn 250ms ease-in forwards; | |
} | |
} | |
@keyframes animIn { | |
from { transform: scale(1.1); } | |
to { transform: scale(1.0); } | |
} | |
div.progress { | |
color: white; | |
display: flex; | |
justify-content: space-between; | |
align-content: space-between; | |
span { | |
text-align: center; | |
flex-basis: 33.33%; | |
&:first-child { text-align: left; } | |
&:last-child { text-align: right; } | |
} | |
} | |
div.abilities { | |
p:first-child { color: white; } | |
p { margin: 0; } | |
em { font-style: italic; } | |
line-height: 1.4rem; | |
height: 1.4rem; | |
} | |
div.canvas { | |
flex-basis: 100%; | |
width: 100%; | |
box-sizing: border-box; | |
position: relative; | |
.author, | |
.restart { | |
position: absolute; | |
top: calc(100% + 0.5rem); | |
} | |
.restart { | |
right: 0; | |
} | |
.author { | |
left: 0; | |
} | |
canvas { | |
display: block; | |
width: 100%; | |
height: auto; | |
&.overlay { | |
pointer-events: none; | |
position: absolute; | |
top: 0; | |
left: 0; | |
z-index: 9; | |
} | |
} | |
} | |
} | |
p span { | |
font-family: Helvetica, sans-serif; | |
font-weight: bold; | |
} | |
body div.intro { display: block; } | |
body.focused div.intro { display: none; } | |
div.intro, | |
div.final-score { | |
color: white; | |
background: black; | |
text-align: center; | |
position: absolute; | |
top: 50%; left: 50%; | |
width: 95%; | |
max-width: 450px; | |
padding: 1rem; | |
box-sizing: border-box; | |
transform: translate(-50%, -50%); | |
border: 1px solid; | |
p { margin: 0;} | |
p + p { margin-top: 0.8rem; } | |
} | |
div.intro { | |
p:first-child { font-size: 2rem; } | |
} | |
div.final-score { | |
p:nth-child(2) { font-size: 2rem; } | |
} | |
a { | |
z-index: 99; | |
color: inherit; | |
text-decoration: none; | |
border-bottom: 1px solid transparent; | |
&:hover { | |
color: white; | |
border-bottom-color: white; | |
} | |
} | |
.abilities, | |
.fullscreen, | |
.restart { | |
user-select: none; | |
} | |
.fullscreen { | |
position: absolute; | |
top: 1rem; | |
left: 50%; | |
transform: translateX(-50%); | |
} |