Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion plugins/highway_3d/screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,9 @@
let _drawRecentByString = null;
/** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */
let _drawChordTemplates = null;
/** Teaching marks sd/ch overlay pref (§6.2.2), mirrored from the 2D
* highway's `teachingMarksVisible` bundle flag. fg renders regardless. */
let _drawTeachingMarks = false;
let _laneTargetColor = null;
let _renderScale = 1;
let lyricsCanvas = null, lyricsCtx = null;
Expand Down Expand Up @@ -3017,6 +3020,7 @@
let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads)
let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry
let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo;
let pTeachMarkLbl; // teaching marks fg/sd label sprites (§6.2.2)
let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo
let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown
let pSusRibbon = null, pSusRibbonOl = null;
Expand Down Expand Up @@ -6095,6 +6099,14 @@
_nfl.material.depthTest = false;
return _nfl;
});
// Teaching marks fg/sd labels (§6.2.2). One pool, two get()s per note
// (finger + degree); the texture is swapped per draw via material.map.
pTeachMarkLbl = pool(lblG, () => {
const _tml = new T.Sprite(txtMat('0', '#7fd1ff', false, 'teachMark').clone());
_tml.material.fog = false;
_tml.material.depthTest = false;
return _tml;
});
pConnectorLine = pool(noteG, () => new T.Line(
new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]),
new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }),
Expand Down Expand Up @@ -6153,6 +6165,7 @@
pSusRailBloom.warm(_WARM_CHORD);
pTechPlane.warm(_WARM_CHORD);
pNoteFretLabel.warm(_WARM_NOTE);
pTeachMarkLbl.warm(_WARM_NOTE);
pChordFrameFill.warm(_WARM_CHORD);
pChordBox.warm(_WARM_CHORD);
pChordLbl.warm(_WARM_CHORD);
Expand Down Expand Up @@ -8353,6 +8366,7 @@
if (pMuteXLines) pMuteXLines.reset();
if (pFHXLines) pFHXLines.reset();
pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset();
pTeachMarkLbl.reset();
pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset();
// Clear per-frame queues in-place (avoid reallocating the array object).
_ndLabels.length = 0;
Expand Down Expand Up @@ -8816,6 +8830,7 @@

_drawNextByString = nextNoteByString;
_drawChordTemplates = bundle.chordTemplates ?? null;
_drawTeachingMarks = !!bundle.teachingMarksVisible;

// ── Recent-past event per string (for _nextAnyT deadline) ─────
// Once a note/chord passes `now` it leaves _drawNextByString,
Expand Down Expand Up @@ -9830,6 +9845,12 @@
// apply the wrong contour). Reset explicitly.
_scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined;
_scrChordNote.bt = cn.bt || 0;
// Same stale-scratch hazard for the teaching marks
// (§6.2.2): fg/sd are omit-when-default on the wire,
// so a chord note without them must reset to -1 or it
// inherits the previous note's finger/degree label.
_scrChordNote.fg = Number.isInteger(cn.fg) ? cn.fg : -1;
_scrChordNote.sd = Number.isInteger(cn.sd) ? cn.sd : -1;
drawNote(
_scrChordNote,
now,
Expand Down Expand Up @@ -11316,6 +11337,22 @@
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1;
}

// Teaching marks (§6.2.2) — display only, never grading. Pure label
// helpers, mirroring static/highway.js so the two highways agree;
// node-tested via tests/js/highway_teaching_marks.test.js.
function teachingFingerLabel(fg) {
// fret-hand finger: '' when unset/out of range; 0 -> 'T' (thumb),
// 1..4 -> '1'..'4'.
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
return fg === 0 ? 'T' : String(fg);
}
function teachingDegreeLabel(sd) {
// scale degree: chromatic 0..11 above the active key tonic; '' when
// unset/out of range.
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
return String(sd);
}

function bnvSampleAt(bnv, t) {
// Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is
// seconds from the note onset) at elapsed time t. Clamps to the
Expand Down Expand Up @@ -12260,6 +12297,32 @@
fretLabel.scale.set(flS, flS, 1);
fretLabel.material.opacity = alpha;
}

// Teaching marks (§6.2.2) — display only, never grading. The
// fret-hand finger (fg) renders by default to the right of the
// fret label; the scale degree (sd) is opt-in (mirrors the 2D
// `teachingMarksVisible` toggle) and renders to the left.
if (alpha > 0 && n.f > 0) {
const _tmS = 5.0 * K * _textSizeMul * fretLabelScaleForFret(n.f);
const _drawTeachMark = (text, colorHex, dx, cacheKey) => {
if (!text) return;
const spr = pTeachMarkLbl.get();
const m = txtMat(text, colorHex, false, cacheKey);
if (spr.material.map !== m.map) {
spr.material.map = m.map;
spr.material.needsUpdate = true;
}
spr.position.set(x + dx, labelY, noteZ);
spr.renderOrder = renderOrderForLayerAtZ(noteZ,
_isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL');
spr.scale.set(_tmS, _tmS, 1);
spr.material.opacity = alpha;
};
_drawTeachMark(teachingFingerLabel(n.fg), '#7fd1ff', NW * 0.95, 'teachFg');
if (_drawTeachingMarks) {
_drawTeachMark(teachingDegreeLabel(n.sd), '#ffcc66', -NW * 0.95, 'teachSd');
}
}
}
}

Expand Down Expand Up @@ -12973,7 +13036,7 @@
_renderScale = 1;
mBeatM = mBeatQ = null;
pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null;
pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = null;
pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = pTeachMarkLbl = null;
if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; }
if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; }
if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; }
Expand Down
121 changes: 120 additions & 1 deletion static/highway.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ function createHighway() {
// have any.
let _phrasesHaveHandShapes = false;
let showLyrics = localStorage.getItem('showLyrics') !== 'false';
// Teaching marks (§6.2.2): the fret-hand finger numeral (fg) renders by
// default (small, on the gem), but the scale-degree (sd) + strum-group (ch)
// overlays are opt-in so the default highway stays uncluttered. Display only
// — never used for grading.
let _showTeachingMarks = localStorage.getItem('showTeachingMarks') === 'true';
let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H)
// slopsmith#254 — per-note judgment overlay. A plugin (note_detect)
// registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null
Expand Down Expand Up @@ -485,6 +490,37 @@ function createHighway() {
return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v }));
}

/** Teaching mark (§6.2.2): fret-hand-finger label for a note's `fg`.
* '' when unset/out of range; 0 → 'T' (thumb), 1..4 → '1'..'4'. Pure. */
function teachingFingerLabel(fg) {
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
return fg === 0 ? 'T' : String(fg);
}

/** Teaching mark (§6.2.2): scale-degree label for a note's `sd` (chromatic
* 0..11 above the active key tonic). '' when unset/out of range. Pure. */
function teachingDegreeLabel(sd) {
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
return String(sd);
}

/** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`.
* Returns the groups (in first-seen order) for each ch value >= 0 that has
* at least two members — a lone note is not a strum gesture. Pure; drives
* the strum-bracket overlay and is node-tested. */
function strumGroupBuckets(items) {
if (!Array.isArray(items)) return [];
const order = [];
const byKey = new Map();
for (const it of items) {
const ch = it && Number.isInteger(it.ch) ? it.ch : -1;
if (ch < 0) continue;
if (!byKey.has(ch)) { byKey.set(ch, []); order.push(ch); }
byKey.get(ch).push(it);
}
return order.map(k => byKey.get(k)).filter(g => g.length >= 2);
}

/** Call while lefty mirror transform is active; keeps glyphs readable. */
function fillTextReadable(text, x, y) {
// ctx may be null when the 2D context was never acquired
Expand Down Expand Up @@ -737,6 +773,9 @@ function createHighway() {
lefty: _lefty,
renderScale: _effectiveRenderScale(),
lyricsVisible: showLyrics,
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
// (e.g. the 3D highway) can mirror the 2D opt-in toggle.
teachingMarksVisible: _showTeachingMarks,

// 2D-style helpers (renderers that don't need these can ignore).
// `fillTextUnmirrored` is deliberately NOT exposed here —
Expand Down Expand Up @@ -1687,6 +1726,29 @@ function createHighway() {

if (sz < 14) return; // Skip small technique labels

// Teaching marks (§6.2.2) — display only, never grading. The fret-hand
// finger (fg) renders by default as a small numeral hugging the gem's
// right edge (T = thumb, 1..4); the scale degree (sd) is opt-in and sits
// on the left edge so the two never collide with the centred fret number.
const fgLabel = teachingFingerLabel(opts?.fg);
if (fgLabel) {
ctx.fillStyle = '#7fd1ff';
ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
fillTextReadable(fgLabel, x + half + 2, y + half * 0.5);
}
if (_showTeachingMarks) {
const sdLabel = teachingDegreeLabel(opts?.sd);
if (sdLabel) {
ctx.fillStyle = '#ffcc66';
ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
fillTextReadable(sdLabel, x - half - 2, y + half * 0.5);
}
}

// Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to
// the target fret; unpitched (slu) draws a dashed diagonal with no
// arrowhead (no definite target pitch). The two are mutually exclusive
Expand Down Expand Up @@ -1879,11 +1941,56 @@ function createHighway() {

const x = fretX(n.f, p.scale, W);
drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, _noteStateProvider ? _noteState(n, n.t) : null);
drawnNotes.push({ t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale });
drawnNotes.push({
t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale,
ch: Number.isInteger(n.ch) ? n.ch : -1,
pkd: Number.isInteger(n.pkd) ? n.pkd : -1,
});
}

// Draw unison bend connectors
drawUnisonBends(W, H, drawnNotes);
// Strum-group brackets (teaching mark ch, §6.2.2) — opt-in overlay.
// Scoped to standalone notes (the stream drawNotes renders); chord-note
// strum groups aren't bracketed (the editor authors ch over single-note
// selections, and chord notes already read as one simultaneous gesture).
if (_showTeachingMarks) drawStrumGroups(W, H, drawnNotes);
}

function drawStrumGroups(W, H, drawnNotes) {
// Teaching mark (§6.2.2): notes sharing a `ch` key >= 0 are one
// strum/rake gesture. Connect each group's gems with a bracket and a
// single arrowhead whose direction comes from `pkd` (0 = down-strum,
// 1 = up-strum). Display only — never grading.
for (const group of strumGroupBuckets(drawnNotes)) {
const pts = group.slice().sort((a, b) => a.y - b.y || a.x - b.x);
const scale = pts[0].scale;
const sz = Math.max(12, 80 * scale * (H / 900));
if (sz < 14) continue;
const pkd = (group.find(p => p.pkd === 0 || p.pkd === 1) || {}).pkd;

ctx.save();
ctx.strokeStyle = '#c89bff';
ctx.lineWidth = Math.max(2, sz / 12);
ctx.lineJoin = 'round';
ctx.beginPath();
pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
ctx.stroke();
// Arrowhead at the gesture start: down-strum (pkd 0) points toward
// the last gem, up-strum (pkd 1) toward the first.
if (pkd === 0 || pkd === 1) {
const head = pkd === 1 ? pts[0] : pts[pts.length - 1];
const from = pkd === 1 ? pts[1] : pts[pts.length - 2];
const dy = Math.sign(head.y - from.y) || 1;
const a = sz * 0.18;
ctx.beginPath();
ctx.moveTo(head.x - a, head.y - dy * a);
ctx.lineTo(head.x, head.y);
ctx.lineTo(head.x + a, head.y - dy * a);
ctx.stroke();
}
ctx.restore();
}
}

function drawUnisonBends(W, H, drawnNotes) {
Expand Down Expand Up @@ -3675,6 +3782,18 @@ function createHighway() {
},
setOnLyricsChange(fn) { _onLyricsChange = fn; },

// Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg
// numeral is unaffected (always on). Persisted to localStorage.
getTeachingMarksVisible() { return _showTeachingMarks; },
toggleTeachingMarks() {
_showTeachingMarks = !_showTeachingMarks;
localStorage.setItem('showTeachingMarks', String(_showTeachingMarks));
},
setTeachingMarksVisible(v) {
_showTeachingMarks = !!v;
localStorage.setItem('showTeachingMarks', String(_showTeachingMarks));
},

reconnect(filename, arrangement) {
// Close old WS but keep audio + animation running
if (ws) { ws.close(); ws = null; }
Expand Down
Loading
Loading