summaryrefslogtreecommitdiff
path: root/static/src/_posts/2021-04-11-ripple-v2.md
diff options
context:
space:
mode:
Diffstat (limited to 'static/src/_posts/2021-04-11-ripple-v2.md')
-rw-r--r--static/src/_posts/2021-04-11-ripple-v2.md436
1 files changed, 436 insertions, 0 deletions
diff --git a/static/src/_posts/2021-04-11-ripple-v2.md b/static/src/_posts/2021-04-11-ripple-v2.md
new file mode 100644
index 0000000..cbde032
--- /dev/null
+++ b/static/src/_posts/2021-04-11-ripple-v2.md
@@ -0,0 +1,436 @@
+---
+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="reset()">(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 reset() {
+ game = new Game(canvas, document.getElementById("score"));
+}
+
+function nextFrame() {
+ if (game.shouldReset()) reset();
+
+ 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.