ripple v2
This commit is contained in:
parent
72378552c3
commit
9556472df5
@ -4,6 +4,7 @@ title: >-
|
|||||||
description: >-
|
description: >-
|
||||||
Hop Till You Drop!
|
Hop Till You Drop!
|
||||||
tags: tech
|
tags: tech
|
||||||
|
series: ripple
|
||||||
---
|
---
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
434
src/_posts/2021-04-11-ripple-v2.md
Normal file
434
src/_posts/2021-04-11-ripple-v2.md
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
---
|
||||||
|
title: >-
|
||||||
|
Ripple V2: A Better Game
|
||||||
|
description: >-
|
||||||
|
The sequel no one was waiting for!
|
||||||
|
tags: tech
|
||||||
|
series: ripple
|
||||||
|
---
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<b>Movement:</b> Arrow keys or WASD<br/>
|
||||||
|
<b>Jump:</b> Space<br/>
|
||||||
|
<b>Goal:</b> Jump as many times as possible without touching a ripple!<br/>
|
||||||
|
<br/>
|
||||||
|
<b>Press Jump To Begin!</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
_Who can make the muddy water clear?<br/>
|
||||||
|
Let it be still, and it will gradually become clear._
|
||||||
|
|
||||||
|
<canvas id="canvas"
|
||||||
|
style="border:1px dashed #AAA"
|
||||||
|
tabindex=0>
|
||||||
|
Your browser doesn't support canvas. At this point in the world that's actually
|
||||||
|
pretty cool, well done!
|
||||||
|
</canvas>
|
||||||
|
<button onclick="resetGame()">(R)eset</button>
|
||||||
|
<span style="font-size: 2rem; margin-left: 1rem;">Score:
|
||||||
|
<span style="font-weight: bold" id="score">0</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
const palette = [
|
||||||
|
"#264653",
|
||||||
|
"#2A9D8F",
|
||||||
|
"#E9C46A",
|
||||||
|
"#F4A261",
|
||||||
|
"#E76F51",
|
||||||
|
];
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
|
||||||
|
function hypotenuse(w, h) {
|
||||||
|
return Math.sqrt(Math.pow(w, 2) + Math.pow(h, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas = document.getElementById("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const whitelistedKeys = {
|
||||||
|
"ArrowUp": {},
|
||||||
|
"KeyW": {map: "ArrowUp"},
|
||||||
|
"ArrowLeft": {},
|
||||||
|
"KeyA": {map: "ArrowLeft"},
|
||||||
|
"ArrowRight": {},
|
||||||
|
"KeyD": {map: "ArrowRight"},
|
||||||
|
"ArrowDown": {},
|
||||||
|
"KeyS": {map: "ArrowDown"},
|
||||||
|
"Space": {},
|
||||||
|
"KeyR": {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let keyboard = {};
|
||||||
|
|
||||||
|
canvas.addEventListener('keydown', (event) => {
|
||||||
|
let keyInfo = whitelistedKeys[event.code];
|
||||||
|
if (!keyInfo) return;
|
||||||
|
|
||||||
|
let code = event.code;
|
||||||
|
if (keyInfo.map) code = keyInfo.map;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
keyboard[code] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('keyup', (event) => {
|
||||||
|
let keyInfo = whitelistedKeys[event.code];
|
||||||
|
if (!keyInfo) return;
|
||||||
|
|
||||||
|
let code = event.code;
|
||||||
|
if (keyInfo.map) code = keyInfo.map;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
delete keyboard[code];
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const C = 700; // scales the overall speed of the radius
|
||||||
|
const T = 500; // on which tick the radius change becomes linear
|
||||||
|
|
||||||
|
/*
|
||||||
|
f(x) = sqrt(C*x) when x < T
|
||||||
|
(C/(2*sqrt(CT)))(x-T) + sqrt(CT) when x >= T
|
||||||
|
|
||||||
|
radius(x) = f(x) + playerRadius;
|
||||||
|
*/
|
||||||
|
|
||||||
|
const F1 = (x) => Math.sqrt(C*x);
|
||||||
|
const F2C1 = C / (2 * Math.sqrt(C*T));
|
||||||
|
const F2C2 = Math.sqrt(C * T);
|
||||||
|
const F2 = (x) => (F2C1 * (x - T)) + F2C2;
|
||||||
|
const F = (x) => {
|
||||||
|
if (x < T) return F1(x);
|
||||||
|
return F2(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
class Ripple {
|
||||||
|
|
||||||
|
constructor(id, currTick, x, y, bounces, color) {
|
||||||
|
this.id = id;
|
||||||
|
this.tick = currTick;
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.thickness = Math.pow(bounces+1, 1.25);
|
||||||
|
this.color = color;
|
||||||
|
this.winner = false;
|
||||||
|
|
||||||
|
this.maxRadius = hypotenuse(x, y);
|
||||||
|
this.maxRadius = Math.max(this.maxRadius, hypotenuse(width-x, y));
|
||||||
|
this.maxRadius = Math.max(this.maxRadius, hypotenuse(x, height-y));
|
||||||
|
this.maxRadius = Math.max(this.maxRadius, hypotenuse(width-x, height-y));
|
||||||
|
}
|
||||||
|
|
||||||
|
radius(currTick) {
|
||||||
|
const x = currTick - this.tick;
|
||||||
|
return F(x) + playerRadius;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(ctx, currTick) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, this.radius(currTick), 0, Math.PI * 2, false);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.lineWidth = this.thickness;
|
||||||
|
ctx.strokeStyle = this.winner ? "#FF0000" : this.color;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
canGC(currTick) {
|
||||||
|
return this.radius(currTick) > this.maxRadius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerRadius = 10;
|
||||||
|
const playerMoveAccel = 0.5;
|
||||||
|
const playerMoveDecel = 0.7;
|
||||||
|
const playerMaxMoveSpeed = 4;
|
||||||
|
const playerJumpSpeed = 0.08;
|
||||||
|
const playerMaxHeight = 1;
|
||||||
|
const playerGravity = 0.01;
|
||||||
|
|
||||||
|
class Player{
|
||||||
|
|
||||||
|
constructor(x, y, color) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.z = 0;
|
||||||
|
this.xVelocity = 0;
|
||||||
|
this.yVelocity = 0;
|
||||||
|
this.zVelocity = 0;
|
||||||
|
this.color = color;
|
||||||
|
this.falling = false;
|
||||||
|
this.lastJumpHeight = 0;
|
||||||
|
this.loser = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
act() {
|
||||||
|
if (keyboard["ArrowUp"]) {
|
||||||
|
this.yVelocity = Math.max(-playerMaxMoveSpeed, this.yVelocity - playerMoveAccel);
|
||||||
|
} else if (keyboard["ArrowDown"]) {
|
||||||
|
this.yVelocity = Math.min(playerMaxMoveSpeed, this.yVelocity + playerMoveAccel);
|
||||||
|
} else if (this.yVelocity > 0) {
|
||||||
|
this.yVelocity = Math.max(0, this.yVelocity - playerMoveDecel);
|
||||||
|
} else if (this.yVelocity < 0) {
|
||||||
|
this.yVelocity = Math.min(0, this.yVelocity + playerMoveDecel);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.y += this.yVelocity;
|
||||||
|
this.y = Math.max(0+playerRadius, this.y);
|
||||||
|
this.y = Math.min(height-playerRadius, this.y);
|
||||||
|
|
||||||
|
if (keyboard["ArrowLeft"]) {
|
||||||
|
this.xVelocity = Math.max(-playerMaxMoveSpeed, this.xVelocity - playerMoveAccel);
|
||||||
|
} else if (keyboard["ArrowRight"]) {
|
||||||
|
this.xVelocity = Math.min(playerMaxMoveSpeed, this.xVelocity + playerMoveAccel);
|
||||||
|
} else if (this.xVelocity > 0) {
|
||||||
|
this.xVelocity = Math.max(0, this.xVelocity - playerMoveDecel);
|
||||||
|
} else if (this.xVelocity < 0) {
|
||||||
|
this.xVelocity = Math.min(0, this.xVelocity + playerMoveDecel);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.x += this.xVelocity;
|
||||||
|
this.x = Math.max(0+playerRadius, this.x);
|
||||||
|
this.x = Math.min(width-playerRadius, this.x);
|
||||||
|
|
||||||
|
let jumpHeld = keyboard["Space"];
|
||||||
|
|
||||||
|
if (jumpHeld && !this.falling && this.z < playerMaxHeight) {
|
||||||
|
this.lastJumpHeight = 0;
|
||||||
|
this.zVelocity = playerJumpSpeed;
|
||||||
|
} else {
|
||||||
|
this.zVelocity = Math.max(-playerJumpSpeed, this.zVelocity - playerGravity);
|
||||||
|
this.falling = this.z > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevZ = this.z;
|
||||||
|
this.z = Math.max(0, this.z + this.zVelocity);
|
||||||
|
this.lastJumpHeight = Math.max(this.z, this.lastJumpHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(ctx) {
|
||||||
|
let y = this.y - (this.z * 40);
|
||||||
|
let radius = playerRadius * (this.z+1)
|
||||||
|
|
||||||
|
// draw main
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, y, radius, 0, Math.PI * 2, false);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.lineWidth = 0;
|
||||||
|
ctx.fillStyle = this.color;
|
||||||
|
ctx.fill();
|
||||||
|
if (this.loser) {
|
||||||
|
ctx.strokeStyle = '#FF0000';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw shadow, if in the air
|
||||||
|
if (this.z > 0) {
|
||||||
|
let radius = Math.max(0, playerRadius * (1.2 - this.z));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, radius, 0, Math.PI * 2, false);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.lineWidth = 0;
|
||||||
|
ctx.fillStyle = this.color+"33";
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Game {
|
||||||
|
|
||||||
|
constructor(canvas, scoreEl) {
|
||||||
|
this.currTick = 0;
|
||||||
|
this.player = new Player(width/2, height/2, palette[0]);
|
||||||
|
this.state = 'play';
|
||||||
|
this.score = 0;
|
||||||
|
this.scoreEl = scoreEl;
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext("2d");
|
||||||
|
this.ripples = [];
|
||||||
|
this.nextRippleID = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldReset() {
|
||||||
|
return keyboard['KeyR'];
|
||||||
|
}
|
||||||
|
|
||||||
|
newRippleID() {
|
||||||
|
let id = this.nextRippleID;
|
||||||
|
this.nextRippleID++;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRipple initializes and stores a new ripple at the given coordinates, as
|
||||||
|
// well as all sub-ripples which make up the initial ripple's reflections.
|
||||||
|
newRipple(x, y, bounces, color) {
|
||||||
|
color = color ? color : palette[Math.floor(Math.random() * palette.length)];
|
||||||
|
|
||||||
|
let ripplePos = [];
|
||||||
|
let nextRipples = [];
|
||||||
|
|
||||||
|
let addRipple = (x, y) => {
|
||||||
|
for (let i in ripplePos) {
|
||||||
|
if (ripplePos[i][0] == x && ripplePos[i][1] == y) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ripple = new Ripple(this.newRippleID(), this.currTick, x, y, bounces, color);
|
||||||
|
nextRipples.push(ripple);
|
||||||
|
ripplePos.push([x, y]);
|
||||||
|
this.ripples.push(ripple);
|
||||||
|
};
|
||||||
|
|
||||||
|
// add initial ripple, after this we deal with the sub-ripples.
|
||||||
|
addRipple(x, y);
|
||||||
|
|
||||||
|
while (bounces > 0) {
|
||||||
|
bounces--;
|
||||||
|
let prevRipples = nextRipples;
|
||||||
|
nextRipples = [];
|
||||||
|
|
||||||
|
for (let i in prevRipples) {
|
||||||
|
let prevX = prevRipples[i].x;
|
||||||
|
let prevY = prevRipples[i].y;
|
||||||
|
addRipple(prevX, -prevY);
|
||||||
|
addRipple(-prevX, prevY);
|
||||||
|
addRipple((2*this.canvas.width)-prevX, prevY);
|
||||||
|
addRipple(prevX, (2*this.canvas.height)-prevY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// playerRipplesState returns a mapping of rippleID -> boolean, where each
|
||||||
|
// boolean indicates the ripple's relation to the player at the moment. true
|
||||||
|
// indicates the player is outside the ripple, false indicates the player is
|
||||||
|
// within the ripple.
|
||||||
|
playerRipplesState() {
|
||||||
|
let state = {};
|
||||||
|
for (let i in this.ripples) {
|
||||||
|
let ripple = this.ripples[i];
|
||||||
|
let rippleRadius = ripple.radius(this.currTick);
|
||||||
|
let hs = Math.pow(ripple.x-this.player.x, 2) + Math.pow(ripple.y-this.player.y, 2);
|
||||||
|
state[ripple.id] = hs > Math.pow(rippleRadius + playerRadius, 2);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerHasJumpedOverRipple(prev, curr) {
|
||||||
|
for (const rippleID in prev) {
|
||||||
|
if (!curr.hasOwnProperty(rippleID)) continue;
|
||||||
|
if (curr[rippleID] != prev[rippleID]) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (this.state != 'play') return;
|
||||||
|
|
||||||
|
let playerPrevZ = this.player.z;
|
||||||
|
this.player.act();
|
||||||
|
|
||||||
|
if (playerPrevZ == 0 && this.player.z > 0) {
|
||||||
|
// player has jumped
|
||||||
|
this.prevPlayerRipplesState = this.playerRipplesState();
|
||||||
|
|
||||||
|
} else if (playerPrevZ > 0 && this.player.z == 0) {
|
||||||
|
|
||||||
|
// player has landed, don't produce a ripple unless there are no
|
||||||
|
// existing ripples or the player jumped over an existing one.
|
||||||
|
if (
|
||||||
|
this.ripples.length == 0 ||
|
||||||
|
this.playerHasJumpedOverRipple(
|
||||||
|
this.prevPlayerRipplesState,
|
||||||
|
this.playerRipplesState()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
let bounces = Math.floor((this.player.lastJumpHeight*1.8)+1);
|
||||||
|
console.log("spawning ripple with bounces:", bounces);
|
||||||
|
this.newRipple(this.player.x, this.player.y, bounces);
|
||||||
|
this.score += bounces;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.player.z == 0) {
|
||||||
|
for (let i in this.ripples) {
|
||||||
|
let ripple = this.ripples[i];
|
||||||
|
let rippleRadius = ripple.radius(this.currTick);
|
||||||
|
if (rippleRadius < playerRadius * 1.5) continue;
|
||||||
|
let hs = Math.pow(ripple.x-this.player.x, 2) + Math.pow(ripple.y-this.player.y, 2);
|
||||||
|
if (hs > Math.pow(rippleRadius + playerRadius, 2)) {
|
||||||
|
continue;
|
||||||
|
} else if (hs <= Math.pow(rippleRadius - playerRadius, 2)) {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
console.log("game over", ripple);
|
||||||
|
ripple.winner = true;
|
||||||
|
this.player.loser = true;
|
||||||
|
this.state = 'gameOver';
|
||||||
|
// deliberately don't break here, in case multiple ripples hit
|
||||||
|
// the player on the same frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ripples = this.ripples.filter(ripple => !ripple.canGC(this.currTick));
|
||||||
|
|
||||||
|
this.currTick++;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ripples.forEach(ripple => ripple.draw(this.ctx, this.currTick));
|
||||||
|
this.player.draw(this.ctx)
|
||||||
|
this.scoreEl.innerHTML = this.score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const requestAnimationFrame =
|
||||||
|
window.requestAnimationFrame ||
|
||||||
|
window.mozRequestAnimationFrame ||
|
||||||
|
window.webkitRequestAnimationFrame ||
|
||||||
|
window.msRequestAnimationFrame;
|
||||||
|
|
||||||
|
let game = new Game(canvas, document.getElementById("score"));
|
||||||
|
|
||||||
|
function nextFrame() {
|
||||||
|
if (game.shouldReset()) {
|
||||||
|
game = new Game(canvas, document.getElementById("score"));
|
||||||
|
}
|
||||||
|
|
||||||
|
game.update()
|
||||||
|
game.draw()
|
||||||
|
requestAnimationFrame(nextFrame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(nextFrame);
|
||||||
|
|
||||||
|
canvas.focus();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
There's been two major changes to the mechanics of the game since the previous
|
||||||
|
version:
|
||||||
|
|
||||||
|
* A new ripple is created _only_ if there are no ripples on the field already,
|
||||||
|
or if the player has jumped over an existing ripple.
|
||||||
|
|
||||||
|
* The score is increased only if a ripple is created, and is increased by the
|
||||||
|
number of bounces off the wall that ripple will have. Put another way, the
|
||||||
|
score is increased based on how high you jump.
|
||||||
|
|
||||||
|
Other small changes include:
|
||||||
|
|
||||||
|
* Ripple growth rate has been modified. It's now harder for a player to run into
|
||||||
|
the ripple they just created.
|
||||||
|
|
||||||
|
* Ripple thickness indicates how many bounces are left in the ripple. This was
|
||||||
|
the case previously, but it's been made more obvious.
|
||||||
|
|
||||||
|
* Small performance improvements.
|
Loading…
Reference in New Issue
Block a user