Tier 2: the Trainspotter's Notebook — real records from discogs_full
assets/extract_tunes.py pulls 523 canon tunes (repress-count ranked, club pool
12"-only) into public/tunes.json across shop/fair/club genre pools; top ~8% of
each chunk flagged grail ("CERTIFIED CLASSIC", gold flash, 2000 pts). Level
clear reveals a real record (artist — title, label, year), the announcer speaks
it, and first IDs land in a persistent localStorage notebook (+1000). Title
screen shows notebook progress. Also: overlay/sub text got backgrounds for
readability, and prompt() is guarded for webviews that lack it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d903dd57ca
commit
a1f4588deb
92
assets/extract_tunes.py
Normal file
92
assets/extract_tunes.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract the trainspotting tune pools from local discogs_full -> public/tunes.json.
|
||||||
|
|
||||||
|
Canon = most-pressed masters (repress count across 10.4M releases = mass-owned classic).
|
||||||
|
The club pool additionally requires the main release to be a 12" — keeps it club wax,
|
||||||
|
not Madonna albums tagged House. Grail flag = top ~8% of each chunk (certified classics,
|
||||||
|
gold flash in-game). Null label renders as "White Label", which is exactly right.
|
||||||
|
|
||||||
|
Read-only against discogs_full (canonical). release.master_id is indexed; each chunk
|
||||||
|
query returns in ~1s.
|
||||||
|
|
||||||
|
python3 assets/extract_tunes.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PSQL = "/opt/homebrew/opt/postgresql@16/bin/psql"
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "public" / "tunes.json"
|
||||||
|
|
||||||
|
# pool -> chunks of (tag, is_genre, year_lo, year_hi, n, twelve_inch_only)
|
||||||
|
POOLS = {
|
||||||
|
"shop": [ # RECORD SHOP: disco / soul / funk
|
||||||
|
("Disco", False, 1968, 1989, 70, False),
|
||||||
|
("Soul", False, 1962, 1985, 70, False),
|
||||||
|
("Funk", False, 1968, 1985, 70, False),
|
||||||
|
],
|
||||||
|
"fair": [ # RECORD FAIR: hip hop / jazz-funk
|
||||||
|
("Hip Hop", True, 1979, 2005, 100, False),
|
||||||
|
("Jazz-Funk", False, 1968, 1995, 70, False),
|
||||||
|
],
|
||||||
|
"club": [ # CLUB NIGHT: house / techno / jungle, 12"s only
|
||||||
|
("House", False, 1985, 2005, 70, True),
|
||||||
|
("Techno", False, 1985, 2005, 70, True),
|
||||||
|
("Jungle", False, 1990, 2000, 50, True),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
GRAIL_FRACTION = 0.08 # top of each ranked chunk = certified classic
|
||||||
|
|
||||||
|
|
||||||
|
def q(sql):
|
||||||
|
r = subprocess.run([PSQL, "-d", "discogs_full", "--csv", "-t", "-c", sql],
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
import csv, io
|
||||||
|
return list(csv.reader(io.StringIO(r.stdout)))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(tag, is_genre, ylo, yhi, limit, twelve):
|
||||||
|
join = ("JOIN master_genre mg ON mg.master_id=m.id AND mg.genre=" if is_genre
|
||||||
|
else "JOIN master_style ms ON ms.master_id=m.id AND ms.style=")
|
||||||
|
twelve_sql = ("""AND EXISTS (SELECT 1 FROM release_format rf
|
||||||
|
WHERE rf.release_id=m.main_release AND rf.descriptions LIKE '%12"%')"""
|
||||||
|
if twelve else "")
|
||||||
|
rows = q(f"""
|
||||||
|
SELECT m.title, m.year, COUNT(r.id) AS presses,
|
||||||
|
(SELECT ma.artist_name FROM master_artist ma WHERE ma.master_id=m.id
|
||||||
|
ORDER BY ma.position NULLS LAST LIMIT 1),
|
||||||
|
(SELECT rl.label_name FROM release_label rl WHERE rl.release_id=m.main_release LIMIT 1)
|
||||||
|
FROM master m {join}'{tag}'
|
||||||
|
JOIN release r ON r.master_id=m.id
|
||||||
|
WHERE m.year BETWEEN {ylo} AND {yhi} {twelve_sql}
|
||||||
|
GROUP BY m.id ORDER BY presses DESC LIMIT {limit}""")
|
||||||
|
grails = max(1, int(len(rows) * GRAIL_FRACTION))
|
||||||
|
out = []
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
if len(r) < 5 or not r[0] or not r[3]:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"a": r[3], "t": r[0],
|
||||||
|
"l": r[4] or "White Label",
|
||||||
|
"y": int(r[1]) if r[1] else None,
|
||||||
|
**({"g": 1} if i < grails else {}),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
pools = {}
|
||||||
|
for pool, chunks in POOLS.items():
|
||||||
|
seen = set()
|
||||||
|
tunes = []
|
||||||
|
for chunk in chunks:
|
||||||
|
for tune in fetch(*chunk):
|
||||||
|
key = (tune["a"].lower(), tune["t"].lower())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
tunes.append(tune)
|
||||||
|
pools[pool] = tunes
|
||||||
|
print(f"{pool}: {len(tunes)} tunes ({sum(1 for t in tunes if t.get('g'))} grails)")
|
||||||
|
|
||||||
|
OUT.write_text(json.dumps(pools, ensure_ascii=False, separators=(",", ":")))
|
||||||
|
print(f"wrote {OUT} ({OUT.stat().st_size // 1024}KB, {sum(len(v) for v in pools.values())} total)")
|
||||||
1
public/tunes.json
Normal file
1
public/tunes.json
Normal file
File diff suppressed because one or more lines are too long
@ -45,10 +45,13 @@ const ENEMY_TYPES = {
|
|||||||
|
|
||||||
// Level themes cycle with level index; mix = weighted generator spawn table.
|
// Level themes cycle with level index; mix = weighted generator spawn table.
|
||||||
const THEMES = [
|
const THEMES = [
|
||||||
{ name: 'RECORD SHOP', floor: 'floor', wall: 'wall', mix: [['poser', 0.7], ['nerd', 0.3]] },
|
{ name: 'RECORD SHOP', pool: 'shop', floor: 'floor', wall: 'wall', mix: [['poser', 0.7], ['nerd', 0.3]] },
|
||||||
{ name: 'RECORD FAIR', floor: 'floor_carpet', wall: 'wall_shelf', mix: [['poser', 0.5], ['nerd', 0.25], ['pirate', 0.25]] },
|
{ name: 'RECORD FAIR', pool: 'fair', floor: 'floor_carpet', wall: 'wall_shelf', mix: [['poser', 0.5], ['nerd', 0.25], ['pirate', 0.25]] },
|
||||||
{ name: 'CLUB NIGHT', floor: 'floor_checker', wall: 'wall_poster', mix: [['poser', 0.4], ['nerd', 0.2], ['pirate', 0.2], ['ghoul', 0.2]] },
|
{ name: 'CLUB NIGHT', pool: 'club', floor: 'floor_checker', wall: 'wall_poster', mix: [['poser', 0.4], ['nerd', 0.2], ['pirate', 0.2], ['ghoul', 0.2]] },
|
||||||
];
|
];
|
||||||
|
const ID_SCORE = 100; // re-identifying a tune you already know
|
||||||
|
const FIRST_ID_SCORE = 1000; // new notebook entry
|
||||||
|
const GRAIL_ID_SCORE = 2000; // first ID of a certified classic
|
||||||
|
|
||||||
export default class GameScene extends Phaser.Scene {
|
export default class GameScene extends Phaser.Scene {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -59,6 +62,7 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
// BASE_URL makes assets resolve under the deploy subpath (partly.party/vinylgauntlet/)
|
// BASE_URL makes assets resolve under the deploy subpath (partly.party/vinylgauntlet/)
|
||||||
const base = import.meta.env.BASE_URL;
|
const base = import.meta.env.BASE_URL;
|
||||||
for (const key of TEXTURE_KEYS) this.load.image(key, `${base}sprites/${key}.png`);
|
for (const key of TEXTURE_KEYS) this.load.image(key, `${base}sprites/${key}.png`);
|
||||||
|
this.load.json('tunes', `${base}tunes.json`); // real records from discogs_full
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
@ -115,6 +119,8 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
fontSize: '40px',
|
fontSize: '40px',
|
||||||
color: '#ff00ff',
|
color: '#ff00ff',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
backgroundColor: '#000000c0',
|
||||||
|
padding: { x: 16, y: 8 },
|
||||||
})
|
})
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setScrollFactor(0)
|
.setScrollFactor(0)
|
||||||
@ -125,6 +131,8 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
backgroundColor: '#000000c0',
|
||||||
|
padding: { x: 14, y: 8 },
|
||||||
})
|
})
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setScrollFactor(0)
|
.setScrollFactor(0)
|
||||||
@ -631,6 +639,43 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
else if (this.vibe < 500) this.say(LINES.lowVibe);
|
else if (this.vibe < 500) this.say(LINES.lowVibe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the Trainspotter's Notebook (localStorage) ---
|
||||||
|
|
||||||
|
tuneKey(tune) {
|
||||||
|
return `${tune.a}|${tune.t}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNotebook() {
|
||||||
|
try {
|
||||||
|
return new Set(JSON.parse(localStorage.getItem('vg_notebook')) || []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notebookTotals() {
|
||||||
|
const pools = this.cache.json.get('tunes');
|
||||||
|
if (!pools) return null;
|
||||||
|
const total = Object.values(pools).reduce((n, p) => n + p.length, 0);
|
||||||
|
return { spotted: this.loadNotebook().size, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a tune from this theme's pool, preferring ones not yet in the notebook.
|
||||||
|
spotTune() {
|
||||||
|
const pools = this.cache.json.get('tunes');
|
||||||
|
const pool = pools && pools[this.theme.pool];
|
||||||
|
if (!pool || !pool.length) return null;
|
||||||
|
const notebook = this.loadNotebook();
|
||||||
|
const fresh = pool.filter((t) => !notebook.has(this.tuneKey(t)));
|
||||||
|
const pick = Phaser.Utils.Array.GetRandom(fresh.length ? fresh : pool);
|
||||||
|
const first = !notebook.has(this.tuneKey(pick));
|
||||||
|
if (first) {
|
||||||
|
notebook.add(this.tuneKey(pick));
|
||||||
|
localStorage.setItem('vg_notebook', JSON.stringify([...notebook]));
|
||||||
|
}
|
||||||
|
return { ...pick, first };
|
||||||
|
}
|
||||||
|
|
||||||
levelClear() {
|
levelClear() {
|
||||||
if (this.state !== 'PLAYING') return;
|
if (this.state !== 'PLAYING') return;
|
||||||
this.state = 'LEVEL_CLEAR';
|
this.state = 'LEVEL_CLEAR';
|
||||||
@ -639,10 +684,25 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
this.barBg.setVisible(false);
|
this.barBg.setVisible(false);
|
||||||
this.barFill.setVisible(false);
|
this.barFill.setVisible(false);
|
||||||
const next = (this.levelIndex + 1) % LEVELS.length;
|
const next = (this.levelIndex + 1) % LEVELS.length;
|
||||||
this.showOverlay('TUNE IDENTIFIED');
|
const tune = this.spotTune();
|
||||||
this.sub.setText(`next: ${THEMES[next % THEMES.length].name}`).setVisible(true);
|
if (tune) {
|
||||||
this.say(LINES.levelClear);
|
this.score += tune.first ? (tune.g ? GRAIL_ID_SCORE : FIRST_ID_SCORE) : ID_SCORE;
|
||||||
this.time.delayedCall(1500, () => {
|
if (tune.g) this.cameras.main.flash(600, 255, 215, 0); // gold flash for a classic
|
||||||
|
this.showOverlay(tune.g ? 'CERTIFIED CLASSIC' : 'TUNE IDENTIFIED');
|
||||||
|
const nb = this.notebookTotals();
|
||||||
|
this.sub
|
||||||
|
.setText(
|
||||||
|
`${tune.a} — ${tune.t}\n(${tune.l}, ${tune.y || '????'})\n\n` +
|
||||||
|
`${tune.first ? `+${tune.g ? GRAIL_ID_SCORE : FIRST_ID_SCORE} NEW ENTRY` : 'already in the notebook'}` +
|
||||||
|
(nb ? ` notebook ${nb.spotted}/${nb.total}` : ''),
|
||||||
|
)
|
||||||
|
.setVisible(true);
|
||||||
|
announce('tune', `Tune identified. ${tune.a}. ${tune.t}.`, this.time.now);
|
||||||
|
} else {
|
||||||
|
this.showOverlay('TUNE IDENTIFIED');
|
||||||
|
this.say(LINES.levelClear);
|
||||||
|
}
|
||||||
|
this.time.delayedCall(2800, () => {
|
||||||
this.physics.resume();
|
this.physics.resume();
|
||||||
if (this.levelIndex + 1 >= LEVELS.length) this.densityBase += 0.5; // loop back harder
|
if (this.levelIndex + 1 >= LEVELS.length) this.densityBase += 0.5; // loop back harder
|
||||||
this.hideOverlay();
|
this.hideOverlay();
|
||||||
@ -666,7 +726,12 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
const scores = this.loadScores();
|
const scores = this.loadScores();
|
||||||
if (scores.length >= 5 && this.score <= scores[scores.length - 1].score) return;
|
if (scores.length >= 5 && this.score <= scores[scores.length - 1].score) return;
|
||||||
// ponytail: window.prompt beats building a text-entry widget; revisit if it grates
|
// ponytail: window.prompt beats building a text-entry widget; revisit if it grates
|
||||||
const raw = window.prompt(`High score: ${this.score}! Your initials:`, 'AAA');
|
let raw = 'YOU';
|
||||||
|
try {
|
||||||
|
raw = window.prompt(`High score: ${this.score}! Your initials:`, 'AAA');
|
||||||
|
} catch {
|
||||||
|
/* some webviews lack prompt() — default initials */
|
||||||
|
}
|
||||||
const initials = (raw || 'AAA').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 3) || 'AAA';
|
const initials = (raw || 'AAA').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 3) || 'AAA';
|
||||||
scores.push({ initials, score: this.score });
|
scores.push({ initials, score: this.score });
|
||||||
scores.sort((a, b) => b.score - a.score);
|
scores.sort((a, b) => b.score - a.score);
|
||||||
@ -691,8 +756,12 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
const hs = this.loadScores()
|
const hs = this.loadScores()
|
||||||
.map((s, i) => `${i + 1}. ${s.initials} ${s.score}`)
|
.map((s, i) => `${i + 1}. ${s.initials} ${s.score}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
const nb = this.notebookTotals();
|
||||||
|
const nbLine = nb && nb.spotted ? `\n\nNOTEBOOK ${nb.spotted}/${nb.total} tunes spotted` : '';
|
||||||
this.showOverlay('VINYL GAUNTLET');
|
this.showOverlay('VINYL GAUNTLET');
|
||||||
this.sub.setText(hs ? `HIGH SCORES\n${hs}` : 'dig deep. name every tune.').setVisible(true);
|
this.sub
|
||||||
|
.setText((hs ? `HIGH SCORES\n${hs}` : 'dig deep. name every tune.') + nbLine)
|
||||||
|
.setVisible(true);
|
||||||
this.classButtons.forEach((b) => b.setVisible(true));
|
this.classButtons.forEach((b) => b.setVisible(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user