Initial Commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
115
dev_server.py
Normal file
115
dev_server.py
Normal 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
40
index.html
Normal 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
9
run.sh
Executable 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
15
src/lib/camera.js
Normal 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
14
src/lib/config.js
Normal 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
11
src/lib/math.js
Normal 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
48
src/lib/platforms.js
Normal 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
111
src/lib/player.js
Normal 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
24
src/lib/readout.js
Normal 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
80
src/lib/sketches.js
Normal 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
22
src/lib/state.js
Normal 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
125
src/lib/viewportRenderer.js
Normal 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
91
src/lib/worldRenderer.js
Normal 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
7
src/main.js
Normal 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
120
src/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user