Initial Commit

This commit is contained in:
2026-06-30 22:28:37 +03:00
commit a7d541a1cf
16 changed files with 833 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

115
dev_server.py Normal file
View File

@@ -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 = """
<script>
(() => {
const events = new EventSource("/__hotreload");
events.addEventListener("reload", () => window.location.reload());
})();
</script>
"""
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("</body>", f"{RELOAD_SNIPPET}</body>")
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()

40
index.html Normal file
View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>p5 World Y</title>
<link rel="stylesheet" href="./src/styles.css">
<script src="https://cdn.jsdelivr.net/npm/p5@1.9.4/lib/p5.min.js"></script>
<script type="module" src="./src/main.js"></script>
</head>
<body>
<main id="app" aria-label="p5 world viewport prototype">
<section class="stage">
<div class="canvas-panel viewport-panel">
<div class="panel-header">
<h1>Viewport</h1>
<span>800 x 600</span>
</div>
<div id="viewportCanvas"></div>
</div>
<aside class="canvas-panel world-panel">
<div class="panel-header">
<h2>World</h2>
<span>drag frame</span>
</div>
<div id="worldCanvas"></div>
</aside>
<section class="readout" aria-live="polite">
<h2>Visible Platforms</h2>
<div id="platform-readout"></div>
</section>
</section>
</main>
</body>
</html>

9
run.sh Executable file
View File

@@ -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"

15
src/lib/camera.js Normal file
View File

@@ -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;
}

14
src/lib/config.js Normal file
View File

@@ -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,
};

11
src/lib/math.js Normal file
View File

@@ -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);
}

48
src/lib/platforms.js Normal file
View File

@@ -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,
};
}

111
src/lib/player.js Normal file
View File

@@ -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();
}

24
src/lib/readout.js Normal file
View File

@@ -0,0 +1,24 @@
import { round } from "./math.js";
export function updatePlatformReadout(readoutElement, visiblePlatforms) {
if (!readoutElement) {
return;
}
if (visiblePlatforms.length === 0) {
readoutElement.innerHTML = "<p>No platforms in the viewport.</p>";
return;
}
readoutElement.innerHTML = visiblePlatforms
.map((platform) => {
return `
<article>
<strong>Platform ${platform.id}</strong>
<span>world (${round(platform.x)}, ${round(platform.y)})</span>
<span>local (${round(platform.local.x)}, ${round(platform.local.y)})</span>
</article>
`;
})
.join("");
}

80
src/lib/sketches.js Normal file
View File

@@ -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;
};
};
}

22
src/lib/state.js Normal file
View File

@@ -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,
};
}

125
src/lib/viewportRenderer.js Normal file
View File

@@ -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();
}

91
src/lib/worldRenderer.js Normal file
View File

@@ -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);
}

7
src/main.js Normal file
View File

@@ -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));

120
src/styles.css Normal file
View File

@@ -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;
}
}