Web + music: chiptune soundtrack, browser shell, one-command deploy
- tools/gen_music.py: pure-stdlib 8-bit synth -> music/{calm,eerie,tense,
jolly,spooky}.wav, five loop-clean mood tracks (the shop floor gets a
four-on-the-floor house groove, obviously). Engine plays them per room;
web build serves them next to the wasm.
- web/index.html: MORPQUEST page shell (EGA palette, README tab, no
account chrome).
- deploy.sh: builds the wasm via MRPGI/web/build.sh with this game baked
in and swaps it into forum-nginx:/usr/share/nginx/html/morpquest/,
right next door to /beyondmorp/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f421448919
commit
e391417cf9
41
deploy.sh
Executable file
41
deploy.sh
Executable file
@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =====================================================================
|
||||||
|
# MORPQUEST — Production Deploy (next door to Beyond Morp)
|
||||||
|
# =====================================================================
|
||||||
|
# Usage: bash deploy.sh [path-to-MRPGI]
|
||||||
|
#
|
||||||
|
# 1. Builds the wasm bundle with this game baked in (MRPGI/web/build.sh)
|
||||||
|
# 2. Rsyncs web/dist to VPS staging
|
||||||
|
# 3. Replaces /usr/share/nginx/html/morpquest/ inside forum-nginx
|
||||||
|
#
|
||||||
|
# Same VPS + container as beyondmorp's deploy.sh:
|
||||||
|
# ssh humanjing@100.71.119.27 (tailscale), container forum-nginx.
|
||||||
|
# The game then lives at <forum-domain>/morpquest/ beside /beyondmorp/.
|
||||||
|
# =====================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MRPGI="${1:-$HOME/Documents/MRPGI}"
|
||||||
|
GAME="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
VPS="humanjing@100.71.119.27"
|
||||||
|
CONTAINER="forum-nginx"
|
||||||
|
REMOTE_WEB_ROOT="/usr/share/nginx/html/morpquest"
|
||||||
|
STAGING="/tmp/morpquest_deploy"
|
||||||
|
|
||||||
|
# rustup's toolchain has the wasm target; homebrew's rust does not.
|
||||||
|
RUSTUP_BIN="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin"
|
||||||
|
[ -d "$RUSTUP_BIN" ] && export PATH="$RUSTUP_BIN:$PATH"
|
||||||
|
|
||||||
|
echo "==> building wasm bundle ($GAME baked in)"
|
||||||
|
sh "$MRPGI/web/build.sh" "$GAME"
|
||||||
|
|
||||||
|
echo "==> rsync to VPS staging"
|
||||||
|
ssh "$VPS" "rm -rf $STAGING && mkdir -p $STAGING"
|
||||||
|
rsync -az --delete "$MRPGI/web/dist/" "$VPS:$STAGING/"
|
||||||
|
|
||||||
|
echo "==> swap into $CONTAINER:$REMOTE_WEB_ROOT"
|
||||||
|
ssh "$VPS" "
|
||||||
|
docker exec $CONTAINER rm -rf $REMOTE_WEB_ROOT
|
||||||
|
docker cp $STAGING $CONTAINER:$REMOTE_WEB_ROOT
|
||||||
|
docker exec $CONTAINER ls $REMOTE_WEB_ROOT
|
||||||
|
"
|
||||||
|
echo "==> deployed. The queue has been waiting since sunrise."
|
||||||
BIN
music/calm.wav
Normal file
BIN
music/calm.wav
Normal file
Binary file not shown.
BIN
music/eerie.wav
Normal file
BIN
music/eerie.wav
Normal file
Binary file not shown.
BIN
music/jolly.wav
Normal file
BIN
music/jolly.wav
Normal file
Binary file not shown.
BIN
music/spooky.wav
Normal file
BIN
music/spooky.wav
Normal file
Binary file not shown.
BIN
music/tense.wav
Normal file
BIN
music/tense.wav
Normal file
Binary file not shown.
132
tools/gen_music.py
Normal file
132
tools/gen_music.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""8-bit chiptune generator -> music/{calm,eerie,tense,jolly,spooky}.wav
|
||||||
|
|
||||||
|
Pure stdlib (wave/math/struct), deterministic, loop-clean (whole bars, note
|
||||||
|
envelopes close before the bar line). The engine plays these per room mood at
|
||||||
|
low volume; anything missing falls back to the built-in synth.
|
||||||
|
"""
|
||||||
|
import math, os, struct, wave
|
||||||
|
|
||||||
|
SR = 22050
|
||||||
|
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'music')
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
def note(name):
|
||||||
|
"""'c4' -> Hz ('-' = rest -> 0)."""
|
||||||
|
if name in ('-', ''):
|
||||||
|
return 0.0
|
||||||
|
names = {'c': 0, 'cs': 1, 'd': 2, 'ds': 3, 'e': 4, 'f': 5, 'fs': 6,
|
||||||
|
'g': 7, 'gs': 8, 'a': 9, 'as': 10, 'b': 11}
|
||||||
|
pitch, octave = name[:-1], int(name[-1])
|
||||||
|
return 440.0 * 2 ** ((names[pitch] - 9) / 12 + octave - 4)
|
||||||
|
|
||||||
|
def square(freq, t, duty=0.5):
|
||||||
|
return 1.0 if (t * freq) % 1.0 < duty else -1.0
|
||||||
|
|
||||||
|
def tri(freq, t):
|
||||||
|
p = (t * freq) % 1.0
|
||||||
|
return 4 * p - 1 if p < 0.5 else 3 - 4 * p
|
||||||
|
|
||||||
|
_lfsr = 0xACE1
|
||||||
|
def _noise_step():
|
||||||
|
global _lfsr
|
||||||
|
bit = ((_lfsr >> 0) ^ (_lfsr >> 2) ^ (_lfsr >> 3) ^ (_lfsr >> 5)) & 1
|
||||||
|
_lfsr = (_lfsr >> 1) | (bit << 15)
|
||||||
|
return (_lfsr & 0xFF) / 127.5 - 1.0
|
||||||
|
|
||||||
|
def render(length_s, voices):
|
||||||
|
"""voices: list of (events, wave_fn, gain). events: (start_s, dur_s, freq[, duty])."""
|
||||||
|
n = int(length_s * SR)
|
||||||
|
buf = [0.0] * n
|
||||||
|
for events, fn, gain in voices:
|
||||||
|
for ev in events:
|
||||||
|
start, dur, freq = ev[0], ev[1], ev[2]
|
||||||
|
duty = ev[3] if len(ev) > 3 else 0.5
|
||||||
|
if freq == 0:
|
||||||
|
continue
|
||||||
|
i0, i1 = int(start * SR), min(int((start + dur) * SR), n)
|
||||||
|
body = i1 - i0
|
||||||
|
atk, rel = int(0.004 * SR), max(1, int(0.05 * SR))
|
||||||
|
nz = freq < 0 # negative freq = noise burst
|
||||||
|
for i in range(body):
|
||||||
|
t = i / SR
|
||||||
|
env = min(1.0, i / atk if atk else 1.0, (body - i) / rel)
|
||||||
|
if nz:
|
||||||
|
s = _noise_step()
|
||||||
|
elif fn is square:
|
||||||
|
s = square(freq, t, duty)
|
||||||
|
else:
|
||||||
|
s = fn(freq, t)
|
||||||
|
buf[i0 + i] += s * env * gain
|
||||||
|
peak = max(1e-6, max(abs(s) for s in buf))
|
||||||
|
scale = 0.82 / peak
|
||||||
|
return b''.join(struct.pack('<h', int(max(-1, min(1, s * scale)) * 32767)) for s in buf)
|
||||||
|
|
||||||
|
def write(name, pcm):
|
||||||
|
with wave.open(os.path.join(OUT, f'{name}.wav'), 'wb') as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(SR)
|
||||||
|
w.writeframes(pcm)
|
||||||
|
print(f'{name}.wav {len(pcm) // 2 / SR:.1f}s')
|
||||||
|
|
||||||
|
def seq(pattern, step, wave_fn, gain, dur_frac=0.9, duty=0.5, t0=0.0):
|
||||||
|
"""'c4 e4 - g4' -> events on a fixed step grid."""
|
||||||
|
ev = []
|
||||||
|
for i, tok in enumerate(pattern.split()):
|
||||||
|
f = -1.0 if tok == 'x' else note(tok)
|
||||||
|
if f:
|
||||||
|
ev.append((t0 + i * step, step * dur_frac, f, duty))
|
||||||
|
return (ev, wave_fn, gain)
|
||||||
|
|
||||||
|
# --- calm: the last calm place before you choose suffering on purpose -------
|
||||||
|
# 60 bpm, 4 bars. Cmaj7 / Fmaj7 arps over a soft triangle bass.
|
||||||
|
B = 1.0 # beat
|
||||||
|
calm = render(16.0, [
|
||||||
|
seq('c3 - - - f2 - - - c3 - - - f2 - g2 -', B, tri, 0.50, 0.95),
|
||||||
|
seq('e4 g4 b4 g4 a4 c5 e5 c5 e4 g4 b4 g4 a4 c5 d5 b4', B, square, 0.16, 0.55, 0.25),
|
||||||
|
seq('- - c5 - - - f5 - - - g5 - - - e5 -', B, tri, 0.12, 0.5),
|
||||||
|
])
|
||||||
|
write('calm', calm)
|
||||||
|
|
||||||
|
# --- eerie: the alley knows your name ----------------------------------------
|
||||||
|
# Slow tritone drift (A / Eb), long gaps, one distant ping per 4 bars.
|
||||||
|
eerie = render(16.0, [
|
||||||
|
([(0.0, 7.6, note('a2')), (8.0, 7.6, note('ds3'))], tri, 0.40),
|
||||||
|
([(0.0, 7.6, note('a2') * 1.006), (8.0, 7.6, note('ds3') * 1.007)], tri, 0.22),
|
||||||
|
seq('- - - a4 - - - - - - ds5 - - - - -', B, square, 0.07, 0.3, 0.18),
|
||||||
|
([(11.5, 0.12, -1.0)], square, 0.05),
|
||||||
|
])
|
||||||
|
write('eerie', eerie)
|
||||||
|
|
||||||
|
# --- tense: owner energy approaches ------------------------------------------
|
||||||
|
# 120 bpm staccato D-minor bass, ticking hat, no resolution on purpose.
|
||||||
|
S = 0.25 # 16th at 120
|
||||||
|
tense = render(12.0, [
|
||||||
|
seq(('d2 - d2 - f2 - d2 - d2 - d2 - as1 - c2 - ' * 3), S * 2, square, 0.40, 0.45, 0.5),
|
||||||
|
seq(('x - - - x - - - x - - - x - x - ' * 6), S, tri, 0.10, 0.15),
|
||||||
|
seq('- - - - - - - - d4 - - - - - - - - - - - - - - - a3 - - - - - - - '
|
||||||
|
'- - - - - - - - f4 - - - - - - - - - - - - - - - e4 - - - - - - -', S * 1.5 / 2, square, 0.09, 0.3, 0.25),
|
||||||
|
])
|
||||||
|
write('tense', tense)
|
||||||
|
|
||||||
|
# --- jolly: the shop floor is a house party at 33rpm -------------------------
|
||||||
|
# 118-ish bpm four-on-floor: noise kick+hat, octave square bass, major arp.
|
||||||
|
K = 0.508 # beat (≈118bpm), 8 bars of 4 = 16.26s
|
||||||
|
jolly = render(K * 32, [
|
||||||
|
seq('x - - - x - - - x - - - x - - - ' * 2, K / 1, tri, 0.55, 0.06), # kick thump
|
||||||
|
seq('- - x - - - x - - - x - - - x - ' * 2, K / 1, square, 0.08, 0.05), # offbeat hat
|
||||||
|
seq(('c2 c3 g2 c3 a1 a2 e2 a2 f2 f3 c3 f3 g2 g3 b2 g3 ' * 2), K / 2, square, 0.34, 0.5, 0.5),
|
||||||
|
seq('e4 g4 c5 g4 a4 c5 e5 c5 f4 a4 c5 a4 g4 b4 d5 g5 '
|
||||||
|
'e4 g4 c5 g4 a4 c5 e5 c5 f4 a4 c5 f5 g4 d5 b4 g4', K / 2, square, 0.13, 0.55, 0.25),
|
||||||
|
])
|
||||||
|
write('jolly', jolly)
|
||||||
|
|
||||||
|
# --- spooky: the back room keeps its own temperature -------------------------
|
||||||
|
spooky = render(16.0, [
|
||||||
|
([(0.0, 15.6, note('e2'))], tri, 0.42),
|
||||||
|
([(0.0, 15.6, note('e2') * 0.5)], tri, 0.30),
|
||||||
|
seq('- - - - - g4 - - - - - - fs4 - - -', B, square, 0.06, 0.5, 0.12),
|
||||||
|
([(3.0, 1.2, -1.0), (11.0, 1.6, -1.0)], square, 0.03),
|
||||||
|
])
|
||||||
|
write('spooky', spooky)
|
||||||
104
web/index.html
Normal file
104
web/index.html
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MORPQUEST: Record Store Day</title>
|
||||||
|
<meta name="description" content="A Sierra-style EGA adventure. Record Store Day, one car park, one white label, a wall of Beckies. Runs in your browser on the MRPGI engine.">
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; background: #000; overflow: hidden; }
|
||||||
|
canvas {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
#boot {
|
||||||
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; gap: 12px;
|
||||||
|
color: #55ff55; font-family: ui-monospace, Menlo, monospace; font-size: 14px;
|
||||||
|
pointer-events: none; transition: opacity .4s;
|
||||||
|
}
|
||||||
|
#boot b { font-size: 22px; letter-spacing: 3px; color: #ff55ff; }
|
||||||
|
#readme-tab {
|
||||||
|
position: fixed; bottom: 0; right: 24px; z-index: 10;
|
||||||
|
background: #ff55ff; color: #000; border: 0;
|
||||||
|
border-radius: 8px 8px 0 0; padding: 7px 14px 5px;
|
||||||
|
font: bold 13px ui-monospace, Menlo, monospace; letter-spacing: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#readme {
|
||||||
|
position: fixed; bottom: 34px; right: 12px; z-index: 10; display: none;
|
||||||
|
width: min(360px, calc(100vw - 24px)); max-height: 80vh; overflow-y: auto;
|
||||||
|
background: #0a0a12; color: #ccc; border: 1px solid #ff55ff; border-radius: 8px;
|
||||||
|
padding: 14px 16px; font: 13px/1.5 ui-monospace, Menlo, monospace;
|
||||||
|
}
|
||||||
|
#readme.open { display: block; }
|
||||||
|
#readme h1 { font-size: 15px; color: #ff55ff; margin: 0 0 6px; letter-spacing: 1px; }
|
||||||
|
#readme h2 { font-size: 13px; color: #55ffff; margin: 12px 0 2px; }
|
||||||
|
#readme p { margin: 4px 0; }
|
||||||
|
#readme kbd {
|
||||||
|
background: #16161f; border: 1px solid #444; border-radius: 4px;
|
||||||
|
padding: 0 5px; font: inherit; color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="boot"><b>MORPQUEST</b><span>queueing since sunrise…</span></div>
|
||||||
|
<canvas id="glcanvas" tabindex="1"></canvas>
|
||||||
|
<button id="readme-tab">README</button>
|
||||||
|
<div id="readme">
|
||||||
|
<h1>MORPQUEST: Record Store Day</h1>
|
||||||
|
<p>You're meant to be doing a quick cat-food run. Uncle's list is in the
|
||||||
|
glovebox. The record shop's front door is a wall of Taylor Swift superfans.
|
||||||
|
Morp is not cruel; Morp is accurate.</p>
|
||||||
|
<h2>Playing</h2>
|
||||||
|
<p><kbd>←↑↓→</kbd> or click to walk · type verbs +
|
||||||
|
<kbd>Enter</kbd> (<i>look, take paperclip, use seatbelt, talk becky…</i>)
|
||||||
|
· <kbd>1</kbd>–<kbd>6</kbd> pick a dialogue reply ·
|
||||||
|
<kbd>Esc</kbd> leaves a chat · <kbd>F1</kbd> CRT scanlines ·
|
||||||
|
<kbd>F2</kbd> sound.</p>
|
||||||
|
<h2>Advice from the walls</h2>
|
||||||
|
<p>Elastic. Eyepatch. Gratitude. Door.</p>
|
||||||
|
<p>DELAYS HAPPEN. DON'T PANIC. PANIC IS FOR TOURISTS.</p>
|
||||||
|
<p>There are 250 points, two deaths, and a true ending. Don't read signs
|
||||||
|
aloud unless you have all the words. Don't eat the kebab. (You'll eat the
|
||||||
|
kebab.)</p>
|
||||||
|
<h2>Made with</h2>
|
||||||
|
<p>The <a href="manual.html" target="_blank" style="color:#55ffff">MRPGI engine</a>
|
||||||
|
(a Rust reimagining of Sierra's AGI), backgrounds by nano banana squeezed
|
||||||
|
into 160×168 EGA, chiptunes by a 60-line Python synth. Adapted from
|
||||||
|
the <a href="../beyondmorp/" style="color:#55ffff">Beyond Morp</a> text
|
||||||
|
adventure next door. Monster Robot Party.</p>
|
||||||
|
</div>
|
||||||
|
<script src="mq_js_bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
// --- engine <-> page bridge (must register before the wasm loads) ------
|
||||||
|
miniquad_add_plugin({
|
||||||
|
register_plugin: (importObject) => {
|
||||||
|
importObject.env.mrpgi_world_saved = () => {};
|
||||||
|
importObject.env.mrpgi_open_manual = () => { window.open('manual.html', '_blank'); };
|
||||||
|
},
|
||||||
|
version: "1",
|
||||||
|
name: "mrpgi_bridge",
|
||||||
|
});
|
||||||
|
load("mrpgi.wasm");
|
||||||
|
const t = setInterval(() => {
|
||||||
|
if (typeof wasm_exports !== "undefined" && wasm_exports) {
|
||||||
|
document.getElementById("boot").style.opacity = "0";
|
||||||
|
clearInterval(t);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('glcanvas');
|
||||||
|
const tab = document.getElementById('readme-tab');
|
||||||
|
const panel = document.getElementById('readme');
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
panel.classList.toggle('open');
|
||||||
|
tab.textContent = panel.classList.contains('open') ? 'CLOSE' : 'README';
|
||||||
|
if (!panel.classList.contains('open')) canvas.focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user