<div class="wrapper">
<small class="touchscreen-message">
It seems that you are using a touchscreen.
<br />
This demo is not optimized for it, please try it on a computer.
</small>
<div class="intro">Move around using arrows and space.</div>
<div class="game">
<div class="player"></div>
<div class="trail"></div>
</div>
<pre class="status"></pre>
<!-- <a class="link" href="./index.html">Read the blog post ↗</a> -->
</div>
$light-blue: rgb(88, 170, 239);
$white: #eef3f5;
$gray: #555;
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue",
Helvetica, Inter, Arial, "Noto Sans", sans-serif;
line-height: 1.5;
overflow-x: hidden;
background: #18181b;
color: $white;
}
.link {
color: #6290ff;
border-bottom: 1px solid transparent;
transition: color 250ms, border 250ms;
margin-top: 50px;
display: block;
&:hover {
color: $white;
}
}
.wrapper {
padding: 30px;
}
.status {
display: flex;
margin-top: 20px;
gap: 10px;
}
.status div {
flex-basis: 110px;
}
.status span {
color: #a783f7;
}
.jump-status {
height: 15px;
max-width: 15px;
flex-shrink: 0;
border-radius: 50%;
margin-top: 5px;
display: inline-block;
}
.jump-status--ready {
background-color: lime;
vertical-align: baseline;
}
.jump-status--not-ready {
background-color: red;
}
.game {
height: 120px;
position: relative;
}
.player {
position: absolute;
width: 20px;
height: 30px;
background: #3171f6;
// move our blocky player to the coordinate's system origin
bottom: 0;
left: -10px;
}
.trail-point {
position: absolute;
width: 2px;
height: 2px;
bottom: 0;
left: -1px;
border-radius: 2px;
}
.intro {
color: #888;
position: absolute;
}
.touchscreen-message {
margin-bottom: 20px;
color: #f46d9d;
display: none;
}
@media (max-width: 400px) {
.touchscreen-message br {
display: none;
}
}
@media (hover: none) {
.touchscreen-message {
display: block;
}
}
View Compiled
// ----- Types
export type Vector = {
x: number;
y: number;
};
export type Trail = Array<{
color: string;
position: Vector;
}>;
// ----- Constants
const FRAME_DURATION: number = 1000 / 60;
// ----- Game state
// Game speed
const speed: number = 1;
// Movement
const accelerationGround: number = 1 * speed;
const decelerationGround: number = 2 * speed;
const maxSpeed: number = 8 * speed;
const decelerationAir: number = 0.5 * speed;
// Jumping
const initialJumpVelocity: number = 8 * speed;
const jumpDeceleration: number = 0.4 * speed;
const jumpFall: number = 1 * speed;
const jumpAllowThreshold: number = 30;
const velocity: Vector = {
x: 0,
y: 0
};
const position: Vector = {
x: 0,
y: 0
};
let isOnGround: boolean = true;
let isJumpReady: boolean = true;
const trailMaxLength: number = 200;
const trail: Trail = [];
let trailColor: string;
// Render
const statusElement = document.querySelector(".status") as HTMLPreElement;
const playerElement = document.querySelector(".player") as HTMLDivElement;
const trailElement = document.querySelector(".trail") as HTMLDivElement;
const jumpElement = document.querySelector(".jump") as HTMLDivElement;
const introElement = document.querySelector(".intro") as HTMLDivElement;
// Add some color to stringified objects
function replacer(key, value) {
return typeof value === "number" ? `<span>${value.toFixed(2)}</span>` : value;
}
function prettyStringify(object) {
return JSON.stringify(object, replacer, 2).replace(/"/g, "");
}
function render() {
// status
statusElement.innerHTML = `<div>position: ${prettyStringify(position)}</div>`;
statusElement.innerHTML += `<div>velocity: ${prettyStringify(velocity)}</div>`;
statusElement.innerHTML += isJumpReady
? '<div class="jump-status jump-status--ready" />'
: '<div class="jump-status jump-status--not-ready" />';
// player
playerElement.style.transform = `translate(${
position.x
}px, ${-position.y}px)`;
}
function renderTrail() {
let trailHTML: string = "";
for (let i = 0; i < trail.length; i++) {
const point = trail[i];
const { x, y } = point.position;
trailHTML += `<div
class="trail-point"
style="
background: ${point.color};
transform: translate(${x}px, ${-y}px);
"></div>`;
}
trailElement.innerHTML = trailHTML;
}
// ----- Keyboard input
const activeKeys: Record<string, boolean> = {};
const keys = {
SPACE: " ",
LEFT: "ArrowLeft",
RIGHT: "ArrowRight",
UP: "ArrowUp",
DOWN: "ArrowDown"
};
window.addEventListener("keydown", (e) => {
activeKeys[e.key] = true;
// Remove intro text
introElement.innerHTML = '';
// Prevent page scrolling on space press
if (e.key === ' ') {
e.preventDefault();
}
});
window.addEventListener("keyup", (e) => {
delete activeKeys[e.key];
});
// ----- Update
function updateHorizontalMovement(delta: number) {
const isLeftPressed = activeKeys[keys.LEFT];
const isRightPressed = activeKeys[keys.RIGHT];
const isExclusivelyLeft = isLeftPressed && !isRightPressed;
const isExclusivelyRight = isRightPressed && !isLeftPressed;
const isMovingRight = velocity.x > 0;
const isMovingLeft = velocity.x < 0;
const deceleration = isOnGround ? decelerationGround : decelerationAir;
if (isExclusivelyLeft) {
trailColor = "lime";
// Only left arrow is pressed
if (isMovingRight) {
// Slow down if player is already moving right
velocity.x -= deceleration * delta;
} else {
// If not, accelerate to the left
velocity.x -= accelerationGround * delta;
}
} else if (isExclusivelyRight) {
trailColor = "lime";
// Only right arrow is pressed
if (isMovingLeft) {
// Slow down if player is already moving left
velocity.x += deceleration * delta;
} else {
// If not, accelerate to the right
velocity.x += accelerationGround * delta;
}
} else {
trailColor = "red";
// Either both or no horizontal arrows are pressed
// Decelerate to the stop
if (isMovingRight) {
// Player is moving right, decelerate
velocity.x -= deceleration * delta;
// When velocity starts going in the opposite direction, stop the player
if (velocity.x < 0) {
velocity.x = 0;
}
} else if (isMovingLeft) {
// Player is moving left, decelerate
velocity.x += deceleration * delta;
// When velocity starts going in the opposite direction, stop the player
if (velocity.x > 0) {
velocity.x = 0;
}
}
}
// Cap at maximum speed
if (velocity.x > maxSpeed) {
trailColor = "silver";
velocity.x = maxSpeed;
} else if (velocity.x < -maxSpeed) {
trailColor = "silver";
velocity.x = -maxSpeed;
}
// Update player's position using new velocity value
position.x += velocity.x * delta;
}
function updateVerticalMovement(delta: number) {
const isJumpPressed: boolean = activeKeys[keys.SPACE];
isOnGround = position.y === 0;
if (isOnGround) {
if (isJumpPressed && isJumpReady) {
velocity.y = initialJumpVelocity;
trailColor = "blue";
isJumpReady = false;
}
} else {
if (isJumpPressed && velocity.y > 0) {
trailColor = "blue";
velocity.y -= jumpDeceleration * delta;
} else {
velocity.y -= jumpFall * delta;
trailColor = "purple";
}
}
// Update position
position.y += velocity.y * delta;
// Prevent player going into the ground
if (position.y < 0) {
position.y = 0;
velocity.y = 0;
}
if (!isJumpPressed && position.y < jumpAllowThreshold) {
// allow jumping again
isJumpReady = true;
}
}
function updateTrail() {
const last = trail[trail.length - 1];
const hasMoved =
position.x !== last?.position.x || position.y !== last?.position.y;
if (hasMoved) {
trail.push({
color: trailColor,
position: {
...position
}
});
if (trail.length > trailMaxLength) {
trail.shift();
}
// For performance, trail is only rendered when it is changed
renderTrail();
}
}
// ----- Game loop
let lastUpdate: number = performance.now();
function gameLoop() {
const now = performance.now();
const delta = (now - lastUpdate) / FRAME_DURATION;
// Update game state
updateHorizontalMovement(delta);
updateVerticalMovement(delta);
updateTrail();
// Render
render();
// Update time
lastUpdate = now;
// Next frame
requestAnimationFrame(gameLoop);
// setTimeout(gameLoop, 30);
}
gameLoop();
View Compiled
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.