commit a7d541a1cf0860ba1df4ac16055412ea771bdff8 Author: Daniel Legt Date: Tue Jun 30 22:28:37 2026 +0300 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/dev_server.py b/dev_server.py new file mode 100644 index 0000000..e7c66c2 --- /dev/null +++ b/dev_server.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from io import BytesIO +from pathlib import Path +import argparse +import errno +import os +import time + + +ROOT = Path(__file__).resolve().parent +WATCH_EXTENSIONS = {".html", ".css", ".js"} +RELOAD_SNIPPET = """ + +""" + + +class HotReloadHandler(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def do_GET(self): + if self.path == "/__hotreload": + self.handle_hotreload() + return + + super().do_GET() + + def translate_path(self, path): + clean_path = path.split("?", 1)[0].split("#", 1)[0].lstrip("/") + return str(ROOT / clean_path) + + def send_head(self): + path = Path(self.translate_path(self.path)) + if path.is_dir(): + path = path / "index.html" + + if path.suffix == ".html" and path.exists(): + html = path.read_text(encoding="utf-8") + html = html.replace("", f"{RELOAD_SNIPPET}") + body = html.encode("utf-8") + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + return BytesIO(body) + + return super().send_head() + + def handle_hotreload(self): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-store") + self.send_header("Connection", "keep-alive") + self.end_headers() + + last_seen = latest_mtime() + while True: + time.sleep(0.5) + current = latest_mtime() + if current > last_seen: + last_seen = current + try: + self.wfile.write(b"event: reload\ndata: changed\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + break + + +def latest_mtime(): + latest = 0.0 + for directory, names, files in os.walk(ROOT): + if ".git" in names: + names.remove(".git") + + for filename in files: + path = Path(directory) / filename + if path.suffix in WATCH_EXTENSIONS: + latest = max(latest, path.stat().st_mtime) + + return latest + + +def main(): + parser = argparse.ArgumentParser(description="Run a tiny p5 dev server with hot reload.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + + server, port = create_server(args.host, args.port) + print(f"Serving http://{args.host}:{port}", flush=True) + print("Watching .html, .css, and .js files for changes", flush=True) + server.serve_forever() + + +def create_server(host, preferred_port): + for port in range(preferred_port, preferred_port + 20): + try: + return ThreadingHTTPServer((host, port), HotReloadHandler), port + except OSError as error: + if error.errno != errno.EADDRINUSE: + raise + + raise OSError(f"No available port found from {preferred_port} to {preferred_port + 19}") + + +if __name__ == "__main__": + main() diff --git a/index.html b/index.html new file mode 100644 index 0000000..562a55a --- /dev/null +++ b/index.html @@ -0,0 +1,40 @@ + + + + + + + p5 World Y + + + + + + +
+
+
+
+

Viewport

+ 800 x 600 +
+
+
+ + + +
+

Visible Platforms

+
+
+
+
+ + + diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..a6ccb42 --- /dev/null +++ b/run.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +HOST="${HOST:-0.0.0.0}" +PORT="${PORT:-8003}" + +python3 dev_server.py --host "$HOST" --port "$PORT" diff --git a/src/lib/camera.js b/src/lib/camera.js new file mode 100644 index 0000000..06956d0 --- /dev/null +++ b/src/lib/camera.js @@ -0,0 +1,15 @@ +import { VIEWPORT, WORLD } from "./config.js"; +import { clamp } from "./math.js"; + +export function followPlayerUp(camera, player) { + const targetY = player.position.y - VIEWPORT.height * 0.42; + + if (targetY < camera.y) { + camera.y = clamp(targetY, 0, WORLD.height - VIEWPORT.height); + } +} + +export function resetCameraToWorldBottom(camera) { + camera.x = 0; + camera.y = WORLD.height - VIEWPORT.height; +} diff --git a/src/lib/config.js b/src/lib/config.js new file mode 100644 index 0000000..4a41049 --- /dev/null +++ b/src/lib/config.js @@ -0,0 +1,14 @@ +export const VIEWPORT = { + width: 800, + height: 600, +}; + +export const WORLD = { + width: 800, + height: 2400, +}; + +export const WORLD_CANVAS = { + width: 260, + height: 600, +}; diff --git a/src/lib/math.js b/src/lib/math.js new file mode 100644 index 0000000..d570e3d --- /dev/null +++ b/src/lib/math.js @@ -0,0 +1,11 @@ +export function isInside(x, y, rect) { + return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; +} + +export function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +export function round(value) { + return Math.round(value); +} diff --git a/src/lib/platforms.js b/src/lib/platforms.js new file mode 100644 index 0000000..c3b473e --- /dev/null +++ b/src/lib/platforms.js @@ -0,0 +1,48 @@ +import { WORLD, VIEWPORT } from "./config.js"; + +export function createPlatforms() { + const result = []; + const count = 16; + const spacing = 145; + + for (let index = 0; index < count; index += 1) { + const width = index === 0 ? 220 : 150 + (index % 4) * 34; + const x = index === 0 ? (WORLD.width - width) / 2 : 40 + ((index * 173) % (WORLD.width - width - 80)); + const y = WORLD.height - 70 - index * spacing; + + result.push({ + id: index + 1, + x, + y, + width, + height: 22, + }); + } + + return result; +} + +export function getVisiblePlatforms(platforms, camera) { + return platforms + .filter((platform) => isPlatformVisible(platform, camera)) + .map((platform) => ({ + ...platform, + local: worldToViewport(platform, camera), + })); +} + +export function isPlatformVisible(platform, camera) { + return ( + platform.x + platform.width >= camera.x && + platform.x <= camera.x + VIEWPORT.width && + platform.y + platform.height >= camera.y && + platform.y <= camera.y + VIEWPORT.height + ); +} + +export function worldToViewport(point, camera) { + return { + x: point.x - camera.x, + y: point.y - camera.y, + }; +} diff --git a/src/lib/player.js b/src/lib/player.js new file mode 100644 index 0000000..1d5acd1 --- /dev/null +++ b/src/lib/player.js @@ -0,0 +1,111 @@ +import { WORLD } from "./config.js"; + +const GRAVITY = 0.32; +const JUMP_VELOCITY = -10.5; +const HORIZONTAL_ACCELERATION = 0.65; +const HORIZONTAL_FRICTION = 0.86; +const MAX_HORIZONTAL_SPEED = 7; + +export function createPlayer({ x = 0, y = 0, radius = 18 } = {}) { + return { + position: { x, y }, + previousPosition: { x, y }, + velocity: { x: 0, y: 0 }, + radius, + wasReset: false, + }; +} + +export function updatePlayer(player, keys, platforms) { + player.wasReset = false; + player.previousPosition.x = player.position.x; + player.previousPosition.y = player.position.y; + + const xDirection = Number(keys.has("arrowright") || keys.has("d")) - Number(keys.has("arrowleft") || keys.has("a")); + + player.velocity.x += xDirection * HORIZONTAL_ACCELERATION; + player.velocity.x *= HORIZONTAL_FRICTION; + player.velocity.x = Math.max(-MAX_HORIZONTAL_SPEED, Math.min(MAX_HORIZONTAL_SPEED, player.velocity.x)); + player.velocity.y += GRAVITY; + + player.position.x += player.velocity.x; + player.position.y += player.velocity.y; + + wrapPlayerHorizontally(player); + bounceOnPlatforms(player, platforms); + resetIfPlayerFalls(player, platforms); +} + +export function placePlayerOnPlatform(player, platform) { + player.position.x = platform.x + platform.width / 2; + player.position.y = platform.y - player.radius; + player.previousPosition.x = player.position.x; + player.previousPosition.y = player.position.y; + player.velocity.x = 0; + player.velocity.y = JUMP_VELOCITY; +} + +function wrapPlayerHorizontally(player) { + if (player.position.x < -player.radius) { + player.position.x = WORLD.width + player.radius; + } + + if (player.position.x > WORLD.width + player.radius) { + player.position.x = -player.radius; + } +} + +function bounceOnPlatforms(player, platforms) { + if (player.velocity.y <= 0) { + return; + } + + const previousBottom = player.previousPosition.y + player.radius; + const currentBottom = player.position.y + player.radius; + + for (const platform of platforms) { + const isCrossingTop = previousBottom <= platform.y && currentBottom >= platform.y; + const isOverPlatform = player.position.x + player.radius >= platform.x && player.position.x - player.radius <= platform.x + platform.width; + + if (isCrossingTop && isOverPlatform) { + player.position.y = platform.y - player.radius; + player.velocity.y = JUMP_VELOCITY; + return; + } + } +} + +function resetIfPlayerFalls(player, platforms) { + if (player.position.y - player.radius <= WORLD.height) { + return; + } + + placePlayerOnPlatform(player, platforms[0]); + player.wasReset = true; +} + +export function drawPlayer(p, player, camera) { + const localX = player.position.x - camera.x; + const localY = player.position.y - camera.y; + + p.push(); + p.noStroke(); + p.fill(52, 152, 219); + p.circle(localX, localY, player.radius * 2); + + p.fill(255); + p.circle(localX + player.radius * 0.35, localY - player.radius * 0.25, player.radius * 0.35); + p.pop(); +} + +export function drawWorldPlayer(p, player, area) { + p.push(); + p.noStroke(); + p.fill(96, 165, 250); + p.circle( + area.x + player.position.x * area.scale, + area.y + player.position.y * area.scale, + Math.max(5, player.radius * 2 * area.scale), + ); + p.pop(); +} diff --git a/src/lib/readout.js b/src/lib/readout.js new file mode 100644 index 0000000..bf7f1b2 --- /dev/null +++ b/src/lib/readout.js @@ -0,0 +1,24 @@ +import { round } from "./math.js"; + +export function updatePlatformReadout(readoutElement, visiblePlatforms) { + if (!readoutElement) { + return; + } + + if (visiblePlatforms.length === 0) { + readoutElement.innerHTML = "

No platforms in the viewport.

"; + return; + } + + readoutElement.innerHTML = visiblePlatforms + .map((platform) => { + return ` +
+ Platform ${platform.id} + world (${round(platform.x)}, ${round(platform.y)}) + local (${round(platform.local.x)}, ${round(platform.local.y)}) +
+ `; + }) + .join(""); +} diff --git a/src/lib/sketches.js b/src/lib/sketches.js new file mode 100644 index 0000000..e777d3a --- /dev/null +++ b/src/lib/sketches.js @@ -0,0 +1,80 @@ +import { VIEWPORT, WORLD_CANVAS } from "./config.js"; +import { followPlayerUp, resetCameraToWorldBottom } from "./camera.js"; +import { isInside } from "./math.js"; +import { updatePlayer } from "./player.js"; +import { drawViewport } from "./viewportRenderer.js"; +import { drawWorldCanvas, getWorldDrawArea, getWorldViewportFrame, setCameraFromWorldCanvasY } from "./worldRenderer.js"; + +export function createViewportSketch(state) { + return (p) => { + p.setup = () => { + const canvas = p.createCanvas(VIEWPORT.width, VIEWPORT.height); + canvas.parent("viewportCanvas"); + p.textFont("monospace"); + + state.readoutElement = document.querySelector("#platform-readout"); + }; + + p.draw = () => { + updatePlayer(state.player, state.pressedKeys, state.platforms); + if (state.player.wasReset) { + resetCameraToWorldBottom(state.camera); + } + followPlayerUp(state.camera, state.player); + drawViewport(p, state); + }; + + p.keyPressed = () => { + state.pressedKeys.add(p.key.toLowerCase()); + return false; + }; + + p.keyReleased = () => { + state.pressedKeys.delete(p.key.toLowerCase()); + return false; + }; + }; +} + +export function createWorldSketch(state) { + return (p) => { + let isDraggingViewport = false; + let dragOffsetY = 0; + + p.setup = () => { + const canvas = p.createCanvas(WORLD_CANVAS.width, WORLD_CANVAS.height); + canvas.parent("worldCanvas"); + p.textFont("monospace"); + }; + + p.draw = () => { + drawWorldCanvas(p, state); + }; + + p.mousePressed = () => { + const frame = getWorldViewportFrame(state.camera); + + if (isInside(p.mouseX, p.mouseY, frame)) { + isDraggingViewport = true; + dragOffsetY = p.mouseY - frame.y; + return; + } + + if (isInside(p.mouseX, p.mouseY, getWorldDrawArea())) { + setCameraFromWorldCanvasY(state.camera, p.mouseY - frame.height / 2); + isDraggingViewport = true; + dragOffsetY = frame.height / 2; + } + }; + + p.mouseDragged = () => { + if (isDraggingViewport) { + setCameraFromWorldCanvasY(state.camera, p.mouseY - dragOffsetY); + } + }; + + p.mouseReleased = () => { + isDraggingViewport = false; + }; + }; +} diff --git a/src/lib/state.js b/src/lib/state.js new file mode 100644 index 0000000..632731d --- /dev/null +++ b/src/lib/state.js @@ -0,0 +1,22 @@ +import { WORLD, VIEWPORT } from "./config.js"; +import { createPlatforms } from "./platforms.js"; +import { createPlayer, placePlayerOnPlatform } from "./player.js"; + +export function createGameState() { + const platforms = createPlatforms(); + const player = createPlayer({ + radius: 18, + }); + placePlayerOnPlatform(player, platforms[0]); + + return { + camera: { + x: 0, + y: WORLD.height - VIEWPORT.height, + }, + platforms, + player, + pressedKeys: new Set(), + readoutElement: null, + }; +} diff --git a/src/lib/viewportRenderer.js b/src/lib/viewportRenderer.js new file mode 100644 index 0000000..6d14d00 --- /dev/null +++ b/src/lib/viewportRenderer.js @@ -0,0 +1,125 @@ +import { VIEWPORT, WORLD } from "./config.js"; +import { drawPlayer } from "./player.js"; +import { getVisiblePlatforms, isPlatformVisible, worldToViewport } from "./platforms.js"; +import { updatePlatformReadout } from "./readout.js"; +import { clamp, round } from "./math.js"; + +export function drawViewport(p, state) { + const visiblePlatforms = getVisiblePlatforms(state.platforms, state.camera); + + p.background(15, 18, 25); + drawViewportGrid(p, state.camera); + drawWorldBounds(p, state.camera); + + for (const platform of state.platforms) { + const local = worldToViewport(platform, state.camera); + + if (!isPlatformVisible(platform, state.camera)) { + continue; + } + + drawPlatform(p, platform, local); + drawPlatformCoordinates(p, platform, local); + } + + drawPlayer(p, state.player, state.camera); + drawViewportHud(p, state.camera, visiblePlatforms.length); + updatePlatformReadout(state.readoutElement, visiblePlatforms); +} + +function drawViewportGrid(p, camera) { + p.push(); + p.stroke(31, 37, 50); + p.strokeWeight(1); + + for (let x = -camera.x % 50; x < VIEWPORT.width; x += 50) { + p.line(x, 0, x, VIEWPORT.height); + } + + for (let y = -camera.y % 50; y < VIEWPORT.height; y += 50) { + p.line(0, y, VIEWPORT.width, y); + } + + p.pop(); +} + +function drawWorldBounds(p, camera) { + const topY = -camera.y; + const bottomY = WORLD.height - camera.y; + + p.push(); + p.stroke(99, 116, 139); + p.strokeWeight(2); + p.noFill(); + p.rect(-camera.x, topY, WORLD.width, WORLD.height); + + p.noStroke(); + p.fill(148, 163, 184); + p.textSize(12); + if (topY >= 0 && topY < VIEWPORT.height) { + p.text("world y = 0", 16, topY + 20); + } + if (bottomY > 0 && bottomY <= VIEWPORT.height) { + p.text(`world y = ${WORLD.height}`, 16, bottomY - 12); + } + p.pop(); +} + +function drawPlatform(p, platform, local) { + p.push(); + p.noStroke(); + p.fill(46, 213, 115); + p.rect(local.x, local.y, platform.width, platform.height, 4); + + p.fill(159, 255, 194); + p.rect(local.x, local.y, platform.width, 4, 4); + p.pop(); +} + +function drawPlatformCoordinates(p, platform, local) { + const labelX = clamp(local.x + platform.width + 12, 12, VIEWPORT.width - 288); + const labelY = clamp(local.y - 11, 12, VIEWPORT.height - 58); + const labelWidth = 276; + const labelHeight = 46; + + p.push(); + p.noStroke(); + p.fill(9, 13, 23, 230); + p.rect(labelX, labelY, labelWidth, labelHeight, 6); + + p.stroke(94, 234, 212); + p.strokeWeight(1); + p.line(local.x + platform.width, local.y + platform.height / 2, labelX, labelY + labelHeight / 2); + + p.noStroke(); + p.fill(226, 232, 240); + p.textSize(12); + p.text(`platform ${platform.id}`, labelX + 10, labelY + 15); + + p.fill(125, 211, 252); + p.text(`world: (${round(platform.x)}, ${round(platform.y)})`, labelX + 10, labelY + 30); + + p.fill(190, 242, 100); + p.text(`local: (${round(local.x)}, ${round(local.y)})`, labelX + 148, labelY + 30); + p.pop(); +} + +function drawViewportHud(p, camera, visibleCount) { + p.push(); + p.noStroke(); + p.fill(9, 13, 23, 238); + p.rect(14, 14, 562, 104, 8); + + p.fill(248, 250, 252); + p.textSize(15); + p.text("world -> viewport transform", 28, 38); + + p.fill(125, 211, 252); + p.textSize(13); + p.text("localX = worldX - cameraX", 28, 62); + p.text("localY = worldY - cameraY", 28, 82); + + p.fill(203, 213, 225); + p.text(`camera: (${round(camera.x)}, ${round(camera.y)}) visible platforms: ${visibleCount}`, 28, 104); + p.pop(); +} diff --git a/src/lib/worldRenderer.js b/src/lib/worldRenderer.js new file mode 100644 index 0000000..e41cdd2 --- /dev/null +++ b/src/lib/worldRenderer.js @@ -0,0 +1,91 @@ +import { VIEWPORT, WORLD, WORLD_CANVAS } from "./config.js"; +import { drawWorldPlayer } from "./player.js"; +import { isPlatformVisible } from "./platforms.js"; +import { clamp, round } from "./math.js"; + +export function drawWorldCanvas(p, state) { + const area = getWorldDrawArea(); + const frame = getWorldViewportFrame(state.camera); + + p.background(11, 15, 23); + + p.push(); + p.noStroke(); + p.fill(15, 23, 42); + p.rect(area.x, area.y, area.width, area.height, 8); + + p.stroke(30, 41, 59); + p.strokeWeight(1); + for (let y = area.y; y <= area.y + area.height; y += area.scale * 200) { + p.line(area.x, y, area.x + area.width, y); + } + + for (const platform of state.platforms) { + p.noStroke(); + p.fill(isPlatformVisible(platform, state.camera) ? p.color(134, 239, 172) : p.color(74, 222, 128, 130)); + p.rect( + area.x + platform.x * area.scale, + area.y + platform.y * area.scale, + platform.width * area.scale, + Math.max(3, platform.height * area.scale), + 2, + ); + } + + drawWorldPlayer(p, state.player, area); + + p.noFill(); + p.stroke(94, 234, 212); + p.strokeWeight(3); + p.rect(frame.x, frame.y, frame.width, frame.height, 4); + + p.noStroke(); + p.fill(94, 234, 212); + p.rect(frame.x + frame.width - 36, frame.y + 8, 24, 4, 2); + p.rect(frame.x + frame.width - 36, frame.y + 16, 24, 4, 2); + p.rect(frame.x + frame.width - 36, frame.y + 24, 24, 4, 2); + + p.fill(226, 232, 240); + p.textSize(12); + p.text("y 0", area.x + area.width + 8, area.y + 10); + p.text(`y ${WORLD.height}`, area.x + area.width + 8, area.y + area.height - 4); + + p.fill(148, 163, 184); + p.text(`camera y ${round(state.camera.y)}`, 16, WORLD_CANVAS.height - 18); + p.pop(); +} + +export function getWorldDrawArea() { + const scale = Math.min( + (WORLD_CANVAS.width - 60) / WORLD.width, + (WORLD_CANVAS.height - 24) / WORLD.height, + ); + const width = WORLD.width * scale; + const height = WORLD.height * scale; + + return { + x: (WORLD_CANVAS.width - width) / 2 - 10, + y: 12, + width, + height, + scale, + }; +} + +export function getWorldViewportFrame(camera) { + const area = getWorldDrawArea(); + + return { + x: area.x + camera.x * area.scale, + y: area.y + camera.y * area.scale, + width: VIEWPORT.width * area.scale, + height: VIEWPORT.height * area.scale, + }; +} + +export function setCameraFromWorldCanvasY(camera, y) { + const area = getWorldDrawArea(); + const worldY = (y - area.y) / area.scale; + + camera.y = clamp(worldY, 0, WORLD.height - VIEWPORT.height); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..3bae5ed --- /dev/null +++ b/src/main.js @@ -0,0 +1,7 @@ +import { createGameState } from "./lib/state.js"; +import { createViewportSketch, createWorldSketch } from "./lib/sketches.js"; + +const state = createGameState(); + +new p5(createViewportSketch(state)); +new p5(createWorldSketch(state)); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..9f60a84 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,120 @@ +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #0b0f17; + color: #e5e7eb; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +#app { + min-height: 100%; + padding: 24px; +} + +.stage { + display: grid; + grid-template-columns: 800px 260px minmax(220px, 300px); + gap: 18px; + align-items: start; +} + +.canvas-panel, +.readout { + border: 1px solid #263142; + border-radius: 8px; + background: #111827; + box-shadow: 0 18px 50px rgb(0 0 0 / 24%); +} + +.panel-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + height: 44px; + padding: 0 14px; + border-bottom: 1px solid #263142; +} + +h1, +h2 { + margin: 0; + font-size: 14px; + font-weight: 700; + letter-spacing: 0; +} + +.panel-header span { + color: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +#viewportCanvas, +#worldCanvas { + line-height: 0; +} + +canvas { + display: block; +} + +.readout { + min-height: 644px; + padding: 14px; +} + +.readout h2 { + margin-bottom: 12px; +} + +#platform-readout { + display: grid; + gap: 10px; +} + +#platform-readout article { + display: grid; + gap: 4px; + padding: 10px; + border: 1px solid #263142; + border-radius: 6px; + background: #0f172a; +} + +#platform-readout strong { + color: #f8fafc; + font-size: 13px; +} + +#platform-readout span, +#platform-readout p { + margin: 0; + color: #cbd5e1; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +@media (max-width: 1440px) { + body { + overflow: auto; + } + + .stage { + grid-template-columns: 800px 260px; + } + + .readout { + grid-column: 1 / -1; + min-height: 0; + } +}