upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.
Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).
Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
This commit is contained in:
parent
8d838dd8b1
commit
c332afd6ea
265 changed files with 80970 additions and 29167 deletions
241
src/games/minesweeper/index.html
Normal file
241
src/games/minesweeper/index.html
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Minesweeper</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { overflow: hidden; }
|
||||
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
|
||||
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
|
||||
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
|
||||
#hud { display: flex; gap: 18px; padding: 6px 10px; font-size: 13px; color: #ccc; flex-wrap: wrap; justify-content: center; }
|
||||
#hud b { color: #FFA500; }
|
||||
#board { display: grid; gap: 2px; margin: 4px auto; touch-action: manipulation; }
|
||||
.cell { display: flex; align-items: center; justify-content: center; background: #222; border: 1px solid #555; color: #eee; font-family: monospace; font-weight: bold; cursor: pointer; padding: 0; user-select: none; }
|
||||
.cell.open { background: #0d0d0d; border-color: #333; cursor: default; }
|
||||
.cell.boom { background: #3a1010; border-color: #f44; }
|
||||
.cell.flag { color: #FFA500; }
|
||||
.n1 { color: #55aaff; } .n2 { color: #6c6; } .n3 { color: #e66; } .n4 { color: #b7f; }
|
||||
.n5 { color: #fa0; } .n6 { color: #4dd; } .n7 { color: #f8f; } .n8 { color: #fff; }
|
||||
#msg { font-size: 15px; color: #FFA500; margin: 4px; text-align: center; min-height: 20px; }
|
||||
#actions { display: flex; gap: 8px; justify-content: center; margin: 4px; }
|
||||
#actions button { background: #222; border: 1px solid #555; color: #FFA500; padding: 6px 14px; cursor: pointer; font-family: monospace; font-size: 14px; }
|
||||
#flagBtn.on { background: #3a2a00; border-color: #FFA500; }
|
||||
#controls { color: #888; font-size: 12px; text-align: center; margin-bottom: 4px; }
|
||||
#scoreSubmit { text-align: center; margin: 6px; }
|
||||
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="topbar">
|
||||
<a href="/games" target="_top">← Back to Games</a>
|
||||
<span style="color:#FFA500;font-weight:bold">MINESWEEPER</span>
|
||||
</div>
|
||||
<div id="hud">
|
||||
<span>LEVEL <b id="levelEl">1</b></span>
|
||||
<span>MINES <b id="minesEl">0</b></span>
|
||||
<span>TIME <b id="timeEl">0</b></span>
|
||||
<span>SCORE <b id="scoreEl">0</b></span>
|
||||
<span>BEST <b id="bestEl">-</b></span>
|
||||
</div>
|
||||
<div id="board"></div>
|
||||
<div id="msg">Click any cell to start</div>
|
||||
<div id="actions">
|
||||
<button id="flagBtn">⚑ FLAG MODE: OFF</button>
|
||||
<button id="newBtn">NEW GAME</button>
|
||||
</div>
|
||||
<div id="controls">Click — reveal | Right click — flag | Clear the board to level up</div>
|
||||
<div id="scoreSubmit" style="display:none">
|
||||
<form method="POST" action="/games/submit-score" target="_top">
|
||||
<input type="hidden" name="game" value="minesweeper">
|
||||
<input type="hidden" id="scoreInput" name="score" value="0">
|
||||
<button type="submit">Submit Score to Hall of Fame</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
const boardEl = document.getElementById('board');
|
||||
let level, size, mines, grid, opened, flags, totalScore, gameState, mined, startTs, timerId;
|
||||
let flagMode = false;
|
||||
let best = parseInt(localStorage.getItem('minesweeper_best') || '-1');
|
||||
if (best >= 0) document.getElementById('bestEl').textContent = best;
|
||||
|
||||
function dims() {
|
||||
size = Math.min(7 + level, 16);
|
||||
const cells = size * size;
|
||||
mines = Math.min(Math.round(cells * (0.105 + level * 0.02)), Math.floor(cells * 0.28));
|
||||
}
|
||||
|
||||
function cellPx() {
|
||||
const w = (window.innerWidth - 24) / size;
|
||||
const h = (window.innerHeight - 210) / size;
|
||||
return Math.max(18, Math.min(34, Math.floor(Math.min(w, h))));
|
||||
}
|
||||
|
||||
function buildBoard() {
|
||||
dims();
|
||||
grid = Array.from({ length: size }, () => Array.from({ length: size }, () => ({ mine: false, open: false, flag: false, n: 0 })));
|
||||
opened = 0; flags = 0; mined = false;
|
||||
const px = cellPx();
|
||||
boardEl.style.gridTemplateColumns = `repeat(${size}, ${px}px)`;
|
||||
boardEl.innerHTML = '';
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'cell';
|
||||
b.style.width = px + 'px';
|
||||
b.style.height = px + 'px';
|
||||
b.style.fontSize = Math.floor(px * 0.55) + 'px';
|
||||
b.dataset.r = r; b.dataset.c = c;
|
||||
b.addEventListener('click', onTap);
|
||||
b.addEventListener('contextmenu', (e) => { e.preventDefault(); toggleFlag(r, c); });
|
||||
boardEl.appendChild(b);
|
||||
}
|
||||
}
|
||||
document.getElementById('levelEl').textContent = level;
|
||||
document.getElementById('minesEl').textContent = mines;
|
||||
document.getElementById('scoreEl').textContent = totalScore;
|
||||
}
|
||||
|
||||
function neighbors(r, c) {
|
||||
const out = [];
|
||||
for (let dr = -1; dr <= 1; dr++)
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
if (!dr && !dc) continue;
|
||||
const nr = r + dr, nc = c + dc;
|
||||
if (nr >= 0 && nr < size && nc >= 0 && nc < size) out.push([nr, nc]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function placeMines(sr, sc) {
|
||||
const safe = new Set([sr + ':' + sc]);
|
||||
neighbors(sr, sc).forEach(([r, c]) => safe.add(r + ':' + c));
|
||||
let left = mines;
|
||||
while (left > 0) {
|
||||
const r = Math.floor(Math.random() * size), c = Math.floor(Math.random() * size);
|
||||
if (safe.has(r + ':' + c) || grid[r][c].mine) continue;
|
||||
grid[r][c].mine = true; left--;
|
||||
}
|
||||
for (let r = 0; r < size; r++)
|
||||
for (let c = 0; c < size; c++)
|
||||
grid[r][c].n = neighbors(r, c).filter(([nr, nc]) => grid[nr][nc].mine).length;
|
||||
mined = true;
|
||||
startTs = Date.now();
|
||||
timerId = setInterval(() => {
|
||||
document.getElementById('timeEl').textContent = Math.floor((Date.now() - startTs) / 1000);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function cellBtn(r, c) {
|
||||
return boardEl.children[r * size + c];
|
||||
}
|
||||
|
||||
function toggleFlag(r, c) {
|
||||
if (gameState !== 'play') return;
|
||||
const cell = grid[r][c];
|
||||
if (cell.open) return;
|
||||
cell.flag = !cell.flag;
|
||||
flags += cell.flag ? 1 : -1;
|
||||
const b = cellBtn(r, c);
|
||||
b.textContent = cell.flag ? '⚑' : '';
|
||||
b.classList.toggle('flag', cell.flag);
|
||||
document.getElementById('minesEl').textContent = Math.max(0, mines - flags);
|
||||
}
|
||||
|
||||
function onTap(e) {
|
||||
if (gameState !== 'play') return;
|
||||
const r = +e.currentTarget.dataset.r, c = +e.currentTarget.dataset.c;
|
||||
if (flagMode) { toggleFlag(r, c); return; }
|
||||
reveal(r, c);
|
||||
}
|
||||
|
||||
function reveal(r, c) {
|
||||
const cell = grid[r][c];
|
||||
if (cell.open || cell.flag) return;
|
||||
if (!mined) placeMines(r, c);
|
||||
if (cell.mine) { boom(r, c); return; }
|
||||
const stack = [[r, c]];
|
||||
while (stack.length) {
|
||||
const [cr, cc] = stack.pop();
|
||||
const cur = grid[cr][cc];
|
||||
if (cur.open || cur.flag || cur.mine) continue;
|
||||
cur.open = true; opened++;
|
||||
totalScore += 10 * level;
|
||||
const b = cellBtn(cr, cc);
|
||||
b.classList.add('open');
|
||||
if (cur.n > 0) { b.textContent = cur.n; b.classList.add('n' + cur.n); }
|
||||
else { neighbors(cr, cc).forEach((p) => stack.push(p)); }
|
||||
}
|
||||
document.getElementById('scoreEl').textContent = totalScore;
|
||||
if (opened === size * size - mines) levelCleared();
|
||||
}
|
||||
|
||||
function levelCleared() {
|
||||
clearInterval(timerId);
|
||||
const secs = Math.floor((Date.now() - startTs) / 1000);
|
||||
const bonus = mines * 25 * level + Math.max(0, size * size - secs) * level;
|
||||
totalScore += bonus;
|
||||
document.getElementById('scoreEl').textContent = totalScore;
|
||||
gameState = 'cleared';
|
||||
document.getElementById('msg').textContent = `LEVEL ${level} CLEARED! +${bonus} bonus. Click to continue`;
|
||||
boardEl.addEventListener('click', nextLevel, { once: true });
|
||||
}
|
||||
|
||||
function nextLevel() {
|
||||
level++;
|
||||
gameState = 'play';
|
||||
document.getElementById('msg').textContent = '';
|
||||
document.getElementById('timeEl').textContent = '0';
|
||||
buildBoard();
|
||||
}
|
||||
|
||||
function boom(r, c) {
|
||||
clearInterval(timerId);
|
||||
gameState = 'over';
|
||||
for (let rr = 0; rr < size; rr++)
|
||||
for (let cc = 0; cc < size; cc++) {
|
||||
if (grid[rr][cc].mine) {
|
||||
const b = cellBtn(rr, cc);
|
||||
b.classList.add('open');
|
||||
b.textContent = '✹';
|
||||
b.style.color = '#f44';
|
||||
}
|
||||
}
|
||||
cellBtn(r, c).classList.add('boom');
|
||||
if (best < 0 || totalScore > best) {
|
||||
best = totalScore;
|
||||
localStorage.setItem('minesweeper_best', best);
|
||||
document.getElementById('bestEl').textContent = best;
|
||||
}
|
||||
document.getElementById('msg').textContent = `GAME OVER! Score: ${totalScore}`;
|
||||
document.getElementById('scoreInput').value = totalScore;
|
||||
document.getElementById('scoreSubmit').style.display = 'block';
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
clearInterval(timerId);
|
||||
level = 1; totalScore = 0; gameState = 'play';
|
||||
document.getElementById('msg').textContent = 'Click any cell to start';
|
||||
document.getElementById('timeEl').textContent = '0';
|
||||
document.getElementById('scoreSubmit').style.display = 'none';
|
||||
buildBoard();
|
||||
}
|
||||
|
||||
document.getElementById('flagBtn').addEventListener('click', () => {
|
||||
flagMode = !flagMode;
|
||||
const b = document.getElementById('flagBtn');
|
||||
b.textContent = '⚑ FLAG MODE: ' + (flagMode ? 'ON' : 'OFF');
|
||||
b.classList.toggle('on', flagMode);
|
||||
});
|
||||
document.getElementById('newBtn').addEventListener('click', startGame);
|
||||
window.addEventListener('resize', () => {
|
||||
if (gameState !== 'play' || mined) return;
|
||||
buildBoard();
|
||||
});
|
||||
|
||||
startGame();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
50
src/games/minesweeper/thumbnail.svg
Normal file
50
src/games/minesweeper/thumbnail.svg
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
|
||||
<rect width="400" height="220" fill="#000"/>
|
||||
<g stroke="#333" stroke-width="2">
|
||||
<rect x="60" y="30" width="40" height="40" fill="#222"/>
|
||||
<rect x="100" y="30" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="140" y="30" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="180" y="30" width="40" height="40" fill="#222"/>
|
||||
<rect x="220" y="30" width="40" height="40" fill="#222"/>
|
||||
<rect x="260" y="30" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="300" y="30" width="40" height="40" fill="#222"/>
|
||||
<rect x="60" y="70" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="100" y="70" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="140" y="70" width="40" height="40" fill="#222"/>
|
||||
<rect x="180" y="70" width="40" height="40" fill="#3a1010" stroke="#f44"/>
|
||||
<rect x="220" y="70" width="40" height="40" fill="#222"/>
|
||||
<rect x="260" y="70" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="300" y="70" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="60" y="110" width="40" height="40" fill="#222"/>
|
||||
<rect x="100" y="110" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="140" y="110" width="40" height="40" fill="#0d0d0d"/>
|
||||
<rect x="180" y="110" width="40" height="40" fill="#222"/>
|
||||
<rect x="220" y="110" width="40" height="40" fill="#222"/>
|
||||
<rect x="260" y="110" width="40" height="40" fill="#222"/>
|
||||
<rect x="300" y="110" width="40" height="40" fill="#0d0d0d"/>
|
||||
</g>
|
||||
<g font-family="monospace" font-weight="bold" font-size="26" text-anchor="middle">
|
||||
<text x="120" y="59" fill="#55aaff">1</text>
|
||||
<text x="160" y="59" fill="#6c6">2</text>
|
||||
<text x="280" y="59" fill="#55aaff">1</text>
|
||||
<text x="80" y="99" fill="#6c6">2</text>
|
||||
<text x="120" y="99" fill="#e66">3</text>
|
||||
<text x="280" y="99" fill="#6c6">2</text>
|
||||
<text x="320" y="99" fill="#55aaff">1</text>
|
||||
<text x="120" y="139" fill="#55aaff">1</text>
|
||||
<text x="160" y="139" fill="#55aaff">1</text>
|
||||
<text x="320" y="139" fill="#55aaff">1</text>
|
||||
</g>
|
||||
<circle cx="200" cy="90" r="11" fill="#f44"/>
|
||||
<g stroke="#f44" stroke-width="3">
|
||||
<line x1="200" y1="73" x2="200" y2="107"/>
|
||||
<line x1="183" y1="90" x2="217" y2="90"/>
|
||||
<line x1="188" y1="78" x2="212" y2="102"/>
|
||||
<line x1="212" y1="78" x2="188" y2="102"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="235" y1="122" x2="235" y2="146" stroke="#FFA500" stroke-width="3"/>
|
||||
<path d="M235 122 L255 129 L235 136 Z" fill="#FFA500"/>
|
||||
</g>
|
||||
<text x="200" y="190" font-family="monospace" font-weight="bold" font-size="28" fill="#FFA500" text-anchor="middle">MINESWEEPER</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
945
src/games/visualdj/index.html
Normal file
945
src/games/visualdj/index.html
Normal file
|
|
@ -0,0 +1,945 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Visual DJ</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--panel: #1a1a1a;
|
||||
--border: #333;
|
||||
--accent: #ff4ad6;
|
||||
--deckb: #4affd6;
|
||||
--mix: #ff9500;
|
||||
--fx: #4a9eff;
|
||||
--text: #ffffff;
|
||||
--muted: #888;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body { margin: 0; padding: 10px; font-family: Consolas, monospace; background: var(--bg); color: var(--text); display: flex; flex-direction: column; }
|
||||
.container { display: flex; gap: 10px; align-items: stretch; flex: 1; min-height: 0; }
|
||||
.stage-panel {
|
||||
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
|
||||
padding: 10px; flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.controls-panel {
|
||||
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
|
||||
padding: 10px; width: 460px; flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; overflow-x: hidden;
|
||||
}
|
||||
.decks { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
h3 { margin: 0 0 6px 0; color: var(--accent); font-size: 12px; letter-spacing: 2px; text-transform: uppercase; }
|
||||
.deck-section { border: 2px solid var(--accent); border-radius: 8px; padding: 8px; margin: 0; background: #1a0f17; }
|
||||
.deck-section.deck-b { border-color: var(--deckb); background: #0f1a17; }
|
||||
.deck-section.deck-b h3 { color: var(--deckb); }
|
||||
.deck-section.deck-b input[type="range"] { accent-color: var(--deckb); }
|
||||
.deck-section.deck-b .value-display { color: var(--deckb); }
|
||||
.deck-section.deck-b .program-btn { border-color: var(--deckb); color: var(--deckb); }
|
||||
.deck-section.deck-b .program-btn.active, .deck-section.deck-b .program-btn:hover { background: var(--deckb); color: #000; }
|
||||
.mix-section { border: 2px solid var(--mix); border-radius: 8px; padding: 8px; margin: 0; background: #1a0f00; }
|
||||
.mix-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
|
||||
.fx-section { border: 2px solid var(--fx); border-radius: 8px; padding: 8px; margin: 0; background: #0a0f1a; }
|
||||
.fx-section h3 { color: var(--fx); }
|
||||
.fx-section input[type="range"] { accent-color: var(--fx); }
|
||||
.fx-section .value-display { color: var(--fx); }
|
||||
.fx-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin-bottom: 6px; }
|
||||
.fx-btn { border-color: var(--fx); color: var(--fx); padding: 3px 1px; font-size: 9px; }
|
||||
.fx-btn:hover, .fx-btn.on { background: var(--fx); color: #000; }
|
||||
.fx-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
|
||||
.fx-text { display: flex; gap: 6px; align-items: center; }
|
||||
.fx-text input[type="text"] {
|
||||
flex: 1; min-width: 0; background: #000; color: var(--fx); border: 1px solid var(--fx); border-radius: 4px;
|
||||
padding: 2px 6px; font-family: inherit; font-size: 11px; height: 20px;
|
||||
}
|
||||
.scenes { display: flex; gap: 4px; align-items: center; flex: none; }
|
||||
.scenes .label-inline { font-size: 10px; color: #ccc; text-transform: uppercase; letter-spacing: 1px; margin-right: 2px; }
|
||||
.scene-btn { border-color: var(--fx); color: var(--fx); padding: 2px 7px; font-size: 10px; height: 20px; }
|
||||
.scene-btn:hover, .scene-btn.on { background: var(--fx); color: #000; }
|
||||
.scene-btn.armed { border-style: dashed; }
|
||||
.scene-btn.empty { opacity: 0.45; }
|
||||
.mix-section h3 { color: var(--mix); }
|
||||
.mix-section input[type="range"] { accent-color: var(--mix); }
|
||||
.mix-section .value-display { color: var(--mix); }
|
||||
.status { background:#000; color:var(--accent); padding:6px 10px; border-radius:6px; margin-bottom:6px; font-size:11px; flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mix-status { background:#1a0f00; color:var(--mix); border: 1px solid var(--mix); }
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:8px; flex-wrap: wrap; flex: none; }
|
||||
button {
|
||||
padding: 6px 10px; border: 1px solid var(--accent); background: var(--panel); color: var(--accent);
|
||||
border-radius: 6px; cursor: pointer; transition: all .2s; font-family: inherit; font-size: 12px;
|
||||
}
|
||||
button:hover { background: var(--accent); color: #000; }
|
||||
button.on { background: var(--accent); color: #000; }
|
||||
button.mix-btn { border-color: var(--mix); color: var(--mix); }
|
||||
button.mix-btn:hover, button.mix-btn.on { background: var(--mix); color: #000; }
|
||||
.stage { position: relative; background: #000; border-radius: 6px; overflow: hidden; flex: 1; min-height: 120px; }
|
||||
canvas { width: 100%; height: 100%; display: block; background: #000; cursor: crosshair; }
|
||||
.stage:fullscreen { border-radius: 0; }
|
||||
.stage:fullscreen canvas { width: 100vw; height: 100vh; object-fit: contain; }
|
||||
.help { background:#0a0a2a; color:#99aaff; padding:6px 10px; border-radius:6px; margin-top:8px; font-size:10px; line-height: 1.6; flex: none; }
|
||||
.help kbd { background:#000; border:1px solid #334; border-radius:3px; padding:0 4px; color:#fff; }
|
||||
.control-group { margin: 0; padding: 0; }
|
||||
label { display:block; margin: 0; font-size:10px; color:#ccc; text-transform:uppercase; letter-spacing:1px; line-height: 14px; }
|
||||
input[type="range"] { width:100%; margin: 0 0 2px 0; height: 14px; accent-color: var(--accent); }
|
||||
select {
|
||||
width: 100%; background: #000; color: var(--mix); border: 1px solid var(--mix); border-radius: 4px;
|
||||
padding: 1px 4px; font-family: inherit; font-size: 11px; margin: 0 0 4px 0; height: 20px;
|
||||
}
|
||||
.value-display { background:#000; color:var(--accent); padding:0 5px; border-radius:3px; font-size:10px; float:right; line-height: 14px; }
|
||||
.programs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-bottom: 6px; }
|
||||
.program-btn { padding: 3px 1px; font-size: 9px; letter-spacing: 0; }
|
||||
.program-btn.active { background: var(--accent); color: #000; }
|
||||
#topbar { width:100%; padding:6px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; margin-bottom:8px; flex: none; }
|
||||
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
|
||||
#topbar a:hover { text-decoration:underline; }
|
||||
#topbar span { color:#FFA500; font-weight:bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="topbar">
|
||||
<a href="/games" target="_top">← Back to Games</a>
|
||||
<span>VISUAL DJ</span>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="stage-panel">
|
||||
<div class="status" id="status">Status: Paused | FPS: -- | BPM: 120 | Time: 0.0 s</div>
|
||||
<div class="status mix-status" id="mixStatus">Deck A: Plasma | Deck B: Tunnel | Mix: 50% A/B | Blend: Screen | Mic: Off</div>
|
||||
<div class="toolbar">
|
||||
<button id="playBtn">▶ Play</button>
|
||||
<button id="flashBtn">✨ Flash</button>
|
||||
<button id="tapBtn" class="mix-btn">⏱ Tap Tempo</button>
|
||||
<button id="micBtn" class="mix-btn">🎤 Mic</button>
|
||||
<button id="snapBtn">📷 Snapshot</button>
|
||||
<button id="fullBtn">⛶ Fullscreen</button>
|
||||
</div>
|
||||
<div class="stage" id="stage">
|
||||
<canvas id="stageCanvas" width="680" height="540"></canvas>
|
||||
</div>
|
||||
<div class="help">
|
||||
<strong>Live controls:</strong><br />
|
||||
Drag on the stage to move the crossfader, click to flash.<br />
|
||||
<kbd>1</kbd>-<kbd>8</kbd> pattern on deck A · <kbd>Q</kbd>-<kbd>I</kbd> pattern on deck B · <kbd>Z</kbd>-<kbd>/</kbd> effects · <kbd>←</kbd> <kbd>→</kbd> crossfade · <kbd>Space</kbd> flash · <kbd>T</kbd> tap tempo · <kbd>P</kbd> play/pause · <kbd>F</kbd> fullscreen · <kbd>Shift</kbd>+<kbd>1</kbd>-<kbd>4</kbd> scenes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-panel">
|
||||
<div class="decks">
|
||||
<div class="deck-section" id="deckAPanel">
|
||||
<h3>Deck A</h3>
|
||||
<div class="programs" id="programsA"></div>
|
||||
<div class="control-group">
|
||||
<label>Speed <span class="value-display" id="speedADisplay">1.00</span></label>
|
||||
<input type="range" id="speedA" min="0" max="3" step="0.05" value="1" />
|
||||
<label>Intensity <span class="value-display" id="intensityADisplay">0.70</span></label>
|
||||
<input type="range" id="intensityA" min="0" max="1" step="0.05" value="0.7" />
|
||||
<label>Hue <span class="value-display" id="hueADisplay">320</span></label>
|
||||
<input type="range" id="hueA" min="0" max="360" step="1" value="320" />
|
||||
<label>Zoom <span class="value-display" id="zoomADisplay">1.00</span></label>
|
||||
<input type="range" id="zoomA" min="0.3" max="3" step="0.05" value="1" />
|
||||
<label>Rotation <span class="value-display" id="rotADisplay">0.00</span></label>
|
||||
<input type="range" id="rotA" min="-2" max="2" step="0.05" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="deck-section deck-b" id="deckBPanel">
|
||||
<h3>Deck B</h3>
|
||||
<div class="programs" id="programsB"></div>
|
||||
<div class="control-group">
|
||||
<label>Speed <span class="value-display" id="speedBDisplay">1.00</span></label>
|
||||
<input type="range" id="speedB" min="0" max="3" step="0.05" value="1" />
|
||||
<label>Intensity <span class="value-display" id="intensityBDisplay">0.70</span></label>
|
||||
<input type="range" id="intensityB" min="0" max="1" step="0.05" value="0.7" />
|
||||
<label>Hue <span class="value-display" id="hueBDisplay">170</span></label>
|
||||
<input type="range" id="hueB" min="0" max="360" step="1" value="170" />
|
||||
<label>Zoom <span class="value-display" id="zoomBDisplay">1.00</span></label>
|
||||
<input type="range" id="zoomB" min="0.3" max="3" step="0.05" value="1" />
|
||||
<label>Rotation <span class="value-display" id="rotBDisplay">0.00</span></label>
|
||||
<input type="range" id="rotB" min="-2" max="2" step="0.05" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mix-section">
|
||||
<h3>Mixer</h3>
|
||||
<div class="mix-grid">
|
||||
<label>Crossfader A ↔ B <span class="value-display" id="crossfadeDisplay">50%</span></label>
|
||||
<label>Blend mode</label>
|
||||
<input type="range" id="crossfade" min="0" max="1" step="0.01" value="0.5" />
|
||||
<select id="blend">
|
||||
<option value="screen" selected>Screen</option>
|
||||
<option value="lighter">Add</option>
|
||||
<option value="source-over">Normal</option>
|
||||
<option value="difference">Difference</option>
|
||||
<option value="multiply">Multiply</option>
|
||||
<option value="overlay">Overlay</option>
|
||||
</select>
|
||||
<label>Auto mix (s) <span class="value-display" id="automixDisplay">Off</span></label>
|
||||
<label>Trails <span class="value-display" id="trailsDisplay">0.30</span></label>
|
||||
<input type="range" id="automix" min="0" max="60" step="1" value="0" />
|
||||
<input type="range" id="trails" min="0" max="0.95" step="0.05" value="0.3" />
|
||||
<label>Brightness <span class="value-display" id="brightnessDisplay">1.00</span></label>
|
||||
<label>Strobe (per beat) <span class="value-display" id="strobeDisplay">0.00</span></label>
|
||||
<input type="range" id="brightness" min="0.2" max="2" step="0.05" value="1" />
|
||||
<input type="range" id="strobe" min="0" max="1" step="0.05" value="0" />
|
||||
<label>Beat pulse <span class="value-display" id="pulseDisplay">0.50</span></label>
|
||||
<label>Mic sensitivity <span class="value-display" id="micGainDisplay">2.0</span></label>
|
||||
<input type="range" id="pulse" min="0" max="1" step="0.05" value="0.5" />
|
||||
<input type="range" id="micGain" min="0.5" max="6" step="0.1" value="2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fx-section">
|
||||
<h3>Effects</h3>
|
||||
<div class="fx-grid" id="fxGrid"></div>
|
||||
<div class="fx-row">
|
||||
<label>FX amount <span class="value-display" id="fxAmountDisplay">0.50</span></label>
|
||||
<label>Text size <span class="value-display" id="textSizeDisplay">0.50</span></label>
|
||||
<input type="range" id="fxAmount" min="0" max="1" step="0.05" value="0.5" />
|
||||
<input type="range" id="textSize" min="0.1" max="1" step="0.05" value="0.5" />
|
||||
</div>
|
||||
<div class="fx-text">
|
||||
<input type="text" id="overlayText" maxlength="40" placeholder="Text on stage..." autocomplete="off" />
|
||||
<div class="scenes" id="scenes">
|
||||
<span class="label-inline">Scenes</span>
|
||||
<button class="scene-btn" id="sceneSave" title="Arm, then pick a slot to store the current setup">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PROGRAMS = [
|
||||
{ id: 'plasma', name: 'Plasma' },
|
||||
{ id: 'tunnel', name: 'Tunnel' },
|
||||
{ id: 'particles', name: 'Stars' },
|
||||
{ id: 'waves', name: 'Waves' },
|
||||
{ id: 'grid', name: 'Grid' },
|
||||
{ id: 'kaleido', name: 'Kaleido' },
|
||||
{ id: 'lissajous', name: 'Curves' },
|
||||
{ id: 'blocks', name: 'Blocks' }
|
||||
];
|
||||
const KEYS_A = ['1', '2', '3', '4', '5', '6', '7', '8'];
|
||||
const KEYS_B = ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i'];
|
||||
const EFFECTS = [
|
||||
{ id: 'mirror', name: 'Mirror' },
|
||||
{ id: 'quad', name: 'Quad' },
|
||||
{ id: 'pixelate', name: 'Pixels' },
|
||||
{ id: 'rgb', name: 'RGB split' },
|
||||
{ id: 'invert', name: 'Invert' },
|
||||
{ id: 'scanlines', name: 'Scanlines' },
|
||||
{ id: 'vignette', name: 'Vignette' },
|
||||
{ id: 'grain', name: 'Grain' },
|
||||
{ id: 'shake', name: 'Shake' },
|
||||
{ id: 'cycle', name: 'Color cycle' }
|
||||
];
|
||||
const KEYS_FX = ['z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/'];
|
||||
const SCENE_SLOTS = 4;
|
||||
|
||||
const hsl = (h, s, l, a) => 'hsla(' + ((h % 360) + 360) % 360 + ',' + s + '%,' + l + '%,' + (a === undefined ? 1 : a) + ')';
|
||||
|
||||
class Deck {
|
||||
constructor(name, program, hue) {
|
||||
this.name = name;
|
||||
this.program = program;
|
||||
this.speed = 1; this.intensity = 0.7; this.hue = hue; this.zoom = 1; this.rot = 0;
|
||||
this.time = 0; this.angle = 0;
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.low = document.createElement('canvas');
|
||||
this.low.width = 96; this.low.height = 72;
|
||||
this.lowCtx = this.low.getContext('2d');
|
||||
this.image = this.lowCtx.createImageData(this.low.width, this.low.height);
|
||||
this.particles = [];
|
||||
this.blocks = [];
|
||||
this.blockBeat = -1;
|
||||
}
|
||||
|
||||
resize(w, h) {
|
||||
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; }
|
||||
}
|
||||
|
||||
step(dt, beat) {
|
||||
this.time += dt * this.speed * (1 + beat * 0.5);
|
||||
this.angle += dt * this.rot;
|
||||
}
|
||||
|
||||
render(w, h, beat, beatIndex) {
|
||||
const ctx = this.ctx;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.save();
|
||||
ctx.translate(w / 2, h / 2);
|
||||
ctx.rotate(this.angle);
|
||||
ctx.scale(this.zoom, this.zoom);
|
||||
ctx.translate(-w / 2, -h / 2);
|
||||
const p = { w, h, t: this.time, beat, hue: this.hue, k: this.intensity, beatIndex };
|
||||
this['draw_' + this.program](ctx, p);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
draw_plasma(ctx, p) {
|
||||
const lw = this.low.width, lh = this.low.height, d = this.image.data;
|
||||
const t = p.t * 0.8;
|
||||
for (let y = 0; y < lh; y++) {
|
||||
for (let x = 0; x < lw; x++) {
|
||||
const nx = x / lw - 0.5, ny = y / lh - 0.5;
|
||||
const v = Math.sin(nx * 10 + t) + Math.sin(ny * 10 - t * 0.7) + Math.sin((nx + ny) * 10 + t * 0.5) + Math.sin(Math.hypot(nx, ny) * 20 - t * 1.3);
|
||||
const n = (v + 4) / 8;
|
||||
const hue = (p.hue + n * 120 * p.k + p.beat * 40) % 360;
|
||||
const rgb = this.hslToRgb(hue / 360, 0.9, 0.25 + 0.45 * n * (0.5 + p.k * 0.5));
|
||||
const i = (y * lw + x) * 4;
|
||||
d[i] = rgb[0]; d[i + 1] = rgb[1]; d[i + 2] = rgb[2]; d[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
this.lowCtx.putImageData(this.image, 0, 0);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(this.low, -p.w * 0.5, -p.h * 0.5, p.w * 2, p.h * 2);
|
||||
}
|
||||
|
||||
draw_tunnel(ctx, p) {
|
||||
const cx = p.w / 2, cy = p.h / 2;
|
||||
const maxR = Math.hypot(cx, cy) * 1.2;
|
||||
const rings = 14 + Math.round(p.k * 16);
|
||||
for (let i = rings; i >= 0; i--) {
|
||||
const f = ((i / rings) + (p.t * 0.25 % 1)) % 1;
|
||||
const r = Math.pow(f, 2.2) * maxR;
|
||||
ctx.beginPath();
|
||||
const sides = 5 + Math.round(p.k * 7);
|
||||
for (let s = 0; s <= sides; s++) {
|
||||
const a = (s / sides) * Math.PI * 2 + p.t * 0.3 + f * 2;
|
||||
const wob = 1 + Math.sin(a * 3 + p.t * 2) * 0.08 * p.k;
|
||||
const x = cx + Math.cos(a) * r * wob, y = cy + Math.sin(a) * r * wob;
|
||||
if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.strokeStyle = hsl(p.hue + f * 90, 100, 40 + f * 30, 0.35 + f * 0.65);
|
||||
ctx.lineWidth = 1 + f * (6 + p.beat * 10);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
draw_particles(ctx, p) {
|
||||
const want = 120 + Math.round(p.k * 380);
|
||||
while (this.particles.length < want) this.particles.push({ x: Math.random() * 2 - 1, y: Math.random() * 2 - 1, z: Math.random() });
|
||||
if (this.particles.length > want) this.particles.length = want;
|
||||
const cx = p.w / 2, cy = p.h / 2;
|
||||
const dz = 0.004 + p.beat * 0.02;
|
||||
for (const s of this.particles) {
|
||||
s.z -= dz * this.speed;
|
||||
if (s.z <= 0.02) { s.x = Math.random() * 2 - 1; s.y = Math.random() * 2 - 1; s.z = 1; }
|
||||
const px = cx + (s.x / s.z) * cx, py = cy + (s.y / s.z) * cy;
|
||||
const prev = s.z + dz * 3;
|
||||
const qx = cx + (s.x / prev) * cx, qy = cy + (s.y / prev) * cy;
|
||||
const b = 1 - s.z;
|
||||
ctx.strokeStyle = hsl(p.hue + b * 60, 100, 50 + b * 40, Math.min(1, b * 1.4));
|
||||
ctx.lineWidth = 1 + b * 3;
|
||||
ctx.beginPath(); ctx.moveTo(qx, qy); ctx.lineTo(px, py); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
draw_waves(ctx, p) {
|
||||
const lines = 8 + Math.round(p.k * 24);
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const f = i / lines;
|
||||
const yBase = p.h * (0.1 + f * 0.8);
|
||||
ctx.beginPath();
|
||||
for (let x = -20; x <= p.w + 20; x += 6) {
|
||||
const nx = x / p.w;
|
||||
const y = yBase + Math.sin(nx * (4 + f * 6) + p.t * (1 + f) + i) * (10 + p.k * 40 + p.beat * 40) * Math.sin(p.t * 0.3 + f * 3);
|
||||
if (x === -20) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = hsl(p.hue + f * 120, 100, 45 + f * 25, 0.5 + f * 0.5);
|
||||
ctx.lineWidth = 1 + f * 3;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
draw_grid(ctx, p) {
|
||||
const horizon = p.h * 0.45;
|
||||
const cx = p.w / 2;
|
||||
ctx.fillStyle = hsl(p.hue + 180, 80, 10 + p.beat * 15, 1);
|
||||
ctx.fillRect(0, 0, p.w, horizon);
|
||||
const sun = ctx.createRadialGradient(cx, horizon, 10, cx, horizon, p.w * 0.3);
|
||||
sun.addColorStop(0, hsl(p.hue, 100, 65, 0.9));
|
||||
sun.addColorStop(1, hsl(p.hue, 100, 50, 0));
|
||||
ctx.fillStyle = sun; ctx.fillRect(0, 0, p.w, horizon);
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(0, horizon, p.w, p.h - horizon);
|
||||
ctx.strokeStyle = hsl(p.hue, 100, 60, 0.9);
|
||||
ctx.lineWidth = 1.5;
|
||||
const cols = 8 + Math.round(p.k * 12);
|
||||
for (let i = -cols; i <= cols; i++) {
|
||||
const x = cx + (i / cols) * p.w * 0.7;
|
||||
ctx.beginPath(); ctx.moveTo(cx + (x - cx) * 0.05, horizon); ctx.lineTo(cx + (x - cx) * 3, p.h); ctx.stroke();
|
||||
}
|
||||
const rows = 12;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const f = ((r / rows) + (p.t * 0.4 % 1)) % 1;
|
||||
const y = horizon + Math.pow(f, 2) * (p.h - horizon);
|
||||
ctx.globalAlpha = 0.3 + f * 0.7;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(p.w, y); ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
draw_kaleido(ctx, p) {
|
||||
const cx = p.w / 2, cy = p.h / 2;
|
||||
const seg = 6 + Math.round(p.k * 6);
|
||||
const r = Math.min(cx, cy) * (0.9 + p.beat * 0.2);
|
||||
for (let s = 0; s < seg; s++) {
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate((s / seg) * Math.PI * 2);
|
||||
if (s % 2) ctx.scale(1, -1);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const a = p.t * (0.5 + i * 0.13) + i;
|
||||
const d = r * (0.2 + 0.7 * (0.5 + 0.5 * Math.sin(p.t * 0.7 + i)));
|
||||
const x = Math.cos(a) * d, y = Math.sin(a) * d * 0.5;
|
||||
ctx.fillStyle = hsl(p.hue + i * 30 + p.t * 20, 100, 55, 0.6);
|
||||
ctx.beginPath(); ctx.arc(x, y, 8 + i * 4 + p.beat * 12, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = hsl(p.hue + i * 30 + 180, 100, 70, 0.8);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(x, y); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
draw_lissajous(ctx, p) {
|
||||
const cx = p.w / 2, cy = p.h / 2;
|
||||
const ax = cx * 0.85, ay = cy * 0.85;
|
||||
const curves = 2 + Math.round(p.k * 4);
|
||||
for (let c = 0; c < curves; c++) {
|
||||
const fx = 2 + c, fy = 3 + c * 0.5 + Math.sin(p.t * 0.1) * 0.5;
|
||||
const ph = p.t * 0.5 + c;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= 400; i++) {
|
||||
const u = (i / 400) * Math.PI * 2;
|
||||
const x = cx + Math.sin(u * fx + ph) * ax * (1 - c * 0.08);
|
||||
const y = cy + Math.sin(u * fy) * ay * (1 - c * 0.08);
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = hsl(p.hue + c * 50 + p.t * 10, 100, 60, 0.85);
|
||||
ctx.lineWidth = 1.5 + c * 0.5 + p.beat * 4;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
draw_blocks(ctx, p) {
|
||||
const cols = 8, rows = 6;
|
||||
if (p.beatIndex !== this.blockBeat || !this.blocks.length) {
|
||||
this.blockBeat = p.beatIndex;
|
||||
this.blocks = [];
|
||||
const n = 4 + Math.round(p.k * 20);
|
||||
for (let i = 0; i < n; i++) this.blocks.push({ c: Math.floor(Math.random() * cols), r: Math.floor(Math.random() * rows), h: Math.random() * 90 });
|
||||
}
|
||||
const bw = p.w / cols, bh = p.h / rows;
|
||||
for (const b of this.blocks) {
|
||||
ctx.fillStyle = hsl(p.hue + b.h, 100, 40 + p.beat * 35, 0.5 + p.beat * 0.5);
|
||||
const inset = (1 - p.beat) * 6;
|
||||
ctx.fillRect(b.c * bw + inset, b.r * bh + inset, bw - inset * 2, bh - inset * 2);
|
||||
}
|
||||
ctx.strokeStyle = hsl(p.hue, 60, 30, 0.6);
|
||||
ctx.lineWidth = 1;
|
||||
for (let c = 1; c < cols; c++) { ctx.beginPath(); ctx.moveTo(c * bw, 0); ctx.lineTo(c * bw, p.h); ctx.stroke(); }
|
||||
for (let r = 1; r < rows; r++) { ctx.beginPath(); ctx.moveTo(0, r * bh); ctx.lineTo(p.w, r * bh); ctx.stroke(); }
|
||||
}
|
||||
|
||||
hslToRgb(h, s, l) {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const f = (t) => {
|
||||
if (t < 0) t += 1; if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
return [Math.round(f(h + 1 / 3) * 255), Math.round(f(h) * 255), Math.round(f(h - 1 / 3) * 255)];
|
||||
}
|
||||
}
|
||||
|
||||
class VisualDJ {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('stageCanvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.stage = document.getElementById('stage');
|
||||
this.deckA = new Deck('A', 'plasma', 320);
|
||||
this.deckB = new Deck('B', 'tunnel', 170);
|
||||
this.crossfade = 0.5; this.blend = 'screen'; this.automix = 0; this.automixDir = 1;
|
||||
this.trails = 0.3; this.brightness = 1; this.strobe = 0; this.pulse = 0.5; this.micGain = 2;
|
||||
this.running = false; this.time = 0; this.last = 0; this.fps = 0; this.frames = 0; this.fpsAt = 0;
|
||||
this.bpm = 120; this.beatAt = 0; this.beatIndex = 0; this.taps = [];
|
||||
this.flash = 0;
|
||||
this.mic = null; this.micLevel = 0;
|
||||
this.dragging = false; this.dragMoved = false;
|
||||
this.fx = new Set(); this.fxAmount = 0.5; this.textSize = 0.5; this.overlayText = '';
|
||||
this.fxCanvas = document.createElement('canvas'); this.fxCtx = this.fxCanvas.getContext('2d');
|
||||
this.chanCanvas = document.createElement('canvas'); this.chanCtx = this.chanCanvas.getContext('2d');
|
||||
this.pixCanvas = document.createElement('canvas'); this.pixCtx = this.pixCanvas.getContext('2d');
|
||||
this.grainCanvas = document.createElement('canvas'); this.grainCanvas.width = 160; this.grainCanvas.height = 120;
|
||||
this.grainCtx = this.grainCanvas.getContext('2d'); this.grainImage = this.grainCtx.createImageData(160, 120);
|
||||
this.scanPattern = null;
|
||||
this.scenes = new Array(SCENE_SLOTS).fill(null); this.sceneArmed = false;
|
||||
this.buildPrograms();
|
||||
this.buildEffects();
|
||||
this.buildScenes();
|
||||
this.bindControls();
|
||||
this.resize();
|
||||
this.updateControls();
|
||||
this.render(0);
|
||||
this.updateStatus();
|
||||
requestAnimationFrame(() => this.resize());
|
||||
}
|
||||
|
||||
buildPrograms() {
|
||||
const make = (deck, holder, keys) => {
|
||||
holder.innerHTML = '';
|
||||
PROGRAMS.forEach((prog, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'program-btn' + (deck.program === prog.id ? ' active' : '');
|
||||
b.textContent = prog.name;
|
||||
b.title = 'Key ' + keys[i].toUpperCase();
|
||||
b.addEventListener('click', () => this.setProgram(deck, prog.id));
|
||||
holder.appendChild(b);
|
||||
});
|
||||
};
|
||||
make(this.deckA, document.getElementById('programsA'), KEYS_A);
|
||||
make(this.deckB, document.getElementById('programsB'), KEYS_B);
|
||||
}
|
||||
|
||||
setProgram(deck, id) {
|
||||
deck.program = id;
|
||||
const holder = document.getElementById(deck === this.deckA ? 'programsA' : 'programsB');
|
||||
[...holder.children].forEach((b, i) => b.classList.toggle('active', PROGRAMS[i].id === id));
|
||||
this.updateStatus();
|
||||
if (!this.running) this.render(0);
|
||||
}
|
||||
|
||||
buildEffects() {
|
||||
const holder = document.getElementById('fxGrid');
|
||||
holder.innerHTML = '';
|
||||
EFFECTS.forEach((fx, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'fx-btn' + (this.fx.has(fx.id) ? ' on' : '');
|
||||
b.textContent = fx.name;
|
||||
b.title = 'Key ' + KEYS_FX[i].toUpperCase();
|
||||
b.addEventListener('click', () => this.toggleFx(fx.id));
|
||||
holder.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
toggleFx(id) {
|
||||
if (this.fx.has(id)) this.fx.delete(id); else this.fx.add(id);
|
||||
[...document.getElementById('fxGrid').children].forEach((b, i) => b.classList.toggle('on', this.fx.has(EFFECTS[i].id)));
|
||||
if (!this.running) this.render(0);
|
||||
}
|
||||
|
||||
buildScenes() {
|
||||
const holder = document.getElementById('scenes');
|
||||
[...holder.querySelectorAll('.scene-slot')].forEach(b => b.remove());
|
||||
for (let i = 0; i < SCENE_SLOTS; i++) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'scene-btn scene-slot' + (this.scenes[i] ? '' : ' empty');
|
||||
b.textContent = String(i + 1);
|
||||
b.title = 'Shift+' + (i + 1);
|
||||
b.addEventListener('click', () => this.scene(i));
|
||||
holder.appendChild(b);
|
||||
}
|
||||
document.getElementById('sceneSave').addEventListener('click', () => this.armScene(!this.sceneArmed));
|
||||
}
|
||||
|
||||
armScene(on) {
|
||||
this.sceneArmed = on;
|
||||
document.getElementById('sceneSave').classList.toggle('on', on);
|
||||
[...document.querySelectorAll('.scene-slot')].forEach(b => b.classList.toggle('armed', on));
|
||||
}
|
||||
|
||||
snapshotState() {
|
||||
const deck = (d) => ({ program: d.program, speed: d.speed, intensity: d.intensity, hue: d.hue, zoom: d.zoom, rot: d.rot });
|
||||
return {
|
||||
a: deck(this.deckA), b: deck(this.deckB),
|
||||
crossfade: this.crossfade, blend: this.blend, automix: this.automix, trails: this.trails, brightness: this.brightness,
|
||||
strobe: this.strobe, pulse: this.pulse, micGain: this.micGain,
|
||||
fx: [...this.fx], fxAmount: this.fxAmount, textSize: this.textSize, overlayText: this.overlayText
|
||||
};
|
||||
}
|
||||
|
||||
applyState(st) {
|
||||
for (const [deck, d] of [[this.deckA, st.a], [this.deckB, st.b]]) {
|
||||
Object.assign(deck, { speed: d.speed, intensity: d.intensity, hue: d.hue, zoom: d.zoom, rot: d.rot });
|
||||
this.setProgram(deck, d.program);
|
||||
}
|
||||
for (const k of ['crossfade', 'blend', 'automix', 'trails', 'brightness', 'strobe', 'pulse', 'micGain', 'fxAmount', 'textSize', 'overlayText']) this[k] = st[k];
|
||||
this.fx = new Set(st.fx);
|
||||
[...document.getElementById('fxGrid').children].forEach((b, i) => b.classList.toggle('on', this.fx.has(EFFECTS[i].id)));
|
||||
this.syncInputs();
|
||||
this.updateControls();
|
||||
if (!this.running) this.render(0);
|
||||
}
|
||||
|
||||
syncInputs() {
|
||||
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
||||
for (const [sfx, deck] of [['A', this.deckA], ['B', this.deckB]]) {
|
||||
set('speed' + sfx, deck.speed); set('intensity' + sfx, deck.intensity); set('hue' + sfx, deck.hue); set('zoom' + sfx, deck.zoom); set('rot' + sfx, deck.rot);
|
||||
}
|
||||
for (const k of ['crossfade', 'automix', 'trails', 'brightness', 'strobe', 'pulse', 'micGain', 'fxAmount', 'textSize', 'overlayText']) set(k, this[k]);
|
||||
set('blend', this.blend);
|
||||
}
|
||||
|
||||
scene(i) {
|
||||
const slots = [...document.querySelectorAll('.scene-slot')];
|
||||
if (this.sceneArmed) {
|
||||
this.scenes[i] = this.snapshotState();
|
||||
slots[i].classList.remove('empty');
|
||||
this.armScene(false);
|
||||
return;
|
||||
}
|
||||
if (!this.scenes[i]) return;
|
||||
this.applyState(this.scenes[i]);
|
||||
slots.forEach((b, j) => b.classList.toggle('on', j === i));
|
||||
}
|
||||
|
||||
bindControls() {
|
||||
const fxSliders = ['fxAmount', 'textSize'];
|
||||
for (const id of fxSliders) {
|
||||
document.getElementById(id).addEventListener('input', (e) => {
|
||||
this[id] = parseFloat(e.target.value);
|
||||
this.updateControls();
|
||||
if (!this.running) this.render(0);
|
||||
});
|
||||
}
|
||||
document.getElementById('overlayText').addEventListener('input', (e) => { this.overlayText = e.target.value; if (!this.running) this.render(0); });
|
||||
const deckSliders = [['speed', 'speed'], ['intensity', 'intensity'], ['hue', 'hue'], ['zoom', 'zoom'], ['rot', 'rot']];
|
||||
for (const [id, prop] of deckSliders) {
|
||||
for (const [suffix, deck] of [['A', this.deckA], ['B', this.deckB]]) {
|
||||
document.getElementById(id + suffix).addEventListener('input', (e) => {
|
||||
deck[prop] = parseFloat(e.target.value);
|
||||
this.updateControls();
|
||||
if (!this.running) this.render(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
const mix = ['crossfade', 'automix', 'trails', 'brightness', 'strobe', 'pulse', 'micGain'];
|
||||
for (const id of mix) {
|
||||
document.getElementById(id).addEventListener('input', (e) => {
|
||||
this[id] = parseFloat(e.target.value);
|
||||
this.updateControls();
|
||||
if (!this.running) this.render(0);
|
||||
});
|
||||
}
|
||||
document.getElementById('blend').addEventListener('change', (e) => { this.blend = e.target.value; this.updateStatus(); if (!this.running) this.render(0); });
|
||||
document.getElementById('playBtn').addEventListener('click', () => this.toggle());
|
||||
document.getElementById('flashBtn').addEventListener('click', () => this.doFlash());
|
||||
document.getElementById('tapBtn').addEventListener('click', () => this.tap());
|
||||
document.getElementById('micBtn').addEventListener('click', () => this.toggleMic());
|
||||
document.getElementById('snapBtn').addEventListener('click', () => this.snapshot());
|
||||
document.getElementById('fullBtn').addEventListener('click', () => this.fullscreen());
|
||||
|
||||
this.canvas.addEventListener('mousedown', (e) => { this.dragging = true; this.dragMoved = false; this.dragTo(e); });
|
||||
this.canvas.addEventListener('mousemove', (e) => { if (this.dragging) { this.dragMoved = true; this.dragTo(e); } });
|
||||
const up = () => { if (this.dragging && !this.dragMoved) this.doFlash(); this.dragging = false; };
|
||||
this.canvas.addEventListener('mouseup', up);
|
||||
this.canvas.addEventListener('mouseleave', () => { this.dragging = false; });
|
||||
this.canvas.addEventListener('touchstart', (e) => { this.dragging = true; this.dragMoved = false; this.dragTo(e.touches[0]); e.preventDefault(); }, { passive: false });
|
||||
this.canvas.addEventListener('touchmove', (e) => { if (this.dragging) { this.dragMoved = true; this.dragTo(e.touches[0]); } e.preventDefault(); }, { passive: false });
|
||||
this.canvas.addEventListener('touchend', up);
|
||||
|
||||
window.addEventListener('keydown', (e) => this.onKey(e));
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
document.addEventListener('fullscreenchange', () => this.resize());
|
||||
}
|
||||
|
||||
dragTo(e) {
|
||||
if (!this.dragMoved) return;
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
this.crossfade = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
|
||||
this.updateControls();
|
||||
if (!this.running) this.render(0);
|
||||
}
|
||||
|
||||
onKey(e) {
|
||||
if (e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName) && e.key !== 'Escape') return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (e.shiftKey && /^Digit[1-4]$/.test(e.code)) { e.preventDefault(); return this.scene(Number(e.code.slice(5)) - 1); }
|
||||
const ia = KEYS_A.indexOf(k), ib = KEYS_B.indexOf(k), ifx = KEYS_FX.indexOf(k);
|
||||
if (ia >= 0) return this.setProgram(this.deckA, PROGRAMS[ia].id);
|
||||
if (ib >= 0) return this.setProgram(this.deckB, PROGRAMS[ib].id);
|
||||
if (ifx >= 0) return this.toggleFx(EFFECTS[ifx].id);
|
||||
if (k === ' ') { e.preventDefault(); return this.doFlash(); }
|
||||
if (k === 'arrowleft') { e.preventDefault(); this.crossfade = Math.max(0, this.crossfade - 0.05); this.updateControls(); return; }
|
||||
if (k === 'arrowright') { e.preventDefault(); this.crossfade = Math.min(1, this.crossfade + 0.05); this.updateControls(); return; }
|
||||
if (k === 'p') return this.toggle();
|
||||
if (k === 'f') return this.fullscreen();
|
||||
if (k === 't') return this.tap();
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.stage.clientWidth || 680;
|
||||
const h = this.stage.clientHeight || Math.round(w * (540 / 680));
|
||||
if (w < 10 || h < 10) return;
|
||||
this.canvas.width = w; this.canvas.height = h;
|
||||
this.deckA.resize(w, h); this.deckB.resize(w, h);
|
||||
if (!this.running) this.render(0);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.running = !this.running;
|
||||
const btn = document.getElementById('playBtn');
|
||||
btn.innerHTML = this.running ? '⏸ Pause' : '▶ Play';
|
||||
btn.classList.toggle('on', this.running);
|
||||
if (this.running) { this.last = performance.now(); this.beatAt = this.last; requestAnimationFrame((t) => this.loop(t)); }
|
||||
this.updateStatus();
|
||||
}
|
||||
|
||||
doFlash() { this.flash = 1; if (!this.running) this.render(0); }
|
||||
|
||||
tap() {
|
||||
const now = performance.now();
|
||||
if (this.taps.length && now - this.taps[this.taps.length - 1] > 2500) this.taps = [];
|
||||
this.taps.push(now);
|
||||
if (this.taps.length > 8) this.taps.shift();
|
||||
if (this.taps.length >= 2) {
|
||||
const span = this.taps[this.taps.length - 1] - this.taps[0];
|
||||
this.bpm = Math.round(Math.max(40, Math.min(240, 60000 / (span / (this.taps.length - 1)))));
|
||||
}
|
||||
this.beatAt = now;
|
||||
this.beatIndex++;
|
||||
this.updateStatus();
|
||||
}
|
||||
|
||||
async toggleMic() {
|
||||
const btn = document.getElementById('micBtn');
|
||||
if (this.mic) {
|
||||
try { this.mic.stream.getTracks().forEach(t => t.stop()); this.mic.context.close(); } catch (e) {}
|
||||
this.mic = null; this.micLevel = 0;
|
||||
btn.classList.remove('on');
|
||||
this.updateStatus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
const context = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
source.connect(analyser);
|
||||
this.mic = { stream, context, analyser, data: new Uint8Array(analyser.frequencyBinCount), avg: 0 };
|
||||
btn.classList.add('on');
|
||||
} catch (e) {
|
||||
this.mic = null;
|
||||
btn.classList.remove('on');
|
||||
btn.textContent = '\u{1F3A4} Mic unavailable';
|
||||
setTimeout(() => { btn.innerHTML = '🎤 Mic'; }, 2000);
|
||||
}
|
||||
this.updateStatus();
|
||||
}
|
||||
|
||||
readMic(now) {
|
||||
if (!this.mic) { this.micLevel = 0; return; }
|
||||
this.mic.analyser.getByteTimeDomainData(this.mic.data);
|
||||
let e = 0;
|
||||
for (let i = 0; i < this.mic.data.length; i++) { const v = (this.mic.data[i] - 128) / 128; e += v * v; }
|
||||
const rms = Math.sqrt(e / this.mic.data.length);
|
||||
this.mic.avg = this.mic.avg * 0.95 + rms * 0.05;
|
||||
this.micLevel = Math.min(1, rms * this.micGain);
|
||||
if (rms > this.mic.avg * 1.6 && rms > 0.03 && now - this.beatAt > 60000 / this.bpm * 0.5) {
|
||||
this.beatAt = now; this.beatIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
a.download = 'visualdj-' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
|
||||
a.href = this.canvas.toDataURL('image/png');
|
||||
a.click();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
fullscreen() {
|
||||
if (document.fullscreenElement) { document.exitFullscreen(); return; }
|
||||
if (this.stage.requestFullscreen) this.stage.requestFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
beatValue(now) {
|
||||
const period = 60000 / this.bpm;
|
||||
while (now - this.beatAt >= period) { this.beatAt += period; this.beatIndex++; }
|
||||
const phase = (now - this.beatAt) / period;
|
||||
const envelope = Math.pow(1 - phase, 3);
|
||||
return Math.min(1, envelope * this.pulse + this.micLevel * 0.8);
|
||||
}
|
||||
|
||||
loop(now) {
|
||||
if (!this.running) return;
|
||||
const dt = Math.min(0.1, (now - this.last) / 1000);
|
||||
this.last = now;
|
||||
this.time += dt;
|
||||
this.frames++;
|
||||
if (now - this.fpsAt > 500) { this.fps = Math.round(this.frames * 1000 / (now - this.fpsAt)); this.frames = 0; this.fpsAt = now; this.updateStatus(); }
|
||||
this.readMic(now);
|
||||
if (this.automix > 0) {
|
||||
this.crossfade += (dt / this.automix) * this.automixDir;
|
||||
if (this.crossfade >= 1) { this.crossfade = 1; this.automixDir = -1; }
|
||||
if (this.crossfade <= 0) { this.crossfade = 0; this.automixDir = 1; }
|
||||
this.updateControls();
|
||||
}
|
||||
this.render(dt);
|
||||
requestAnimationFrame((t) => this.loop(t));
|
||||
}
|
||||
|
||||
render(dt) {
|
||||
const now = performance.now();
|
||||
const beat = this.running ? this.beatValue(now) : 0;
|
||||
const w = this.canvas.width, h = this.canvas.height;
|
||||
const ctx = this.ctx;
|
||||
this.deckA.step(dt, beat); this.deckB.step(dt, beat);
|
||||
this.deckA.resize(w, h); this.deckB.resize(w, h);
|
||||
this.deckA.render(w, h, beat, this.beatIndex);
|
||||
this.deckB.render(w, h, beat, this.beatIndex);
|
||||
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1 - this.trails;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const amt = this.fxAmount;
|
||||
let sx = 0, sy = 0;
|
||||
if (this.fx.has('shake') && this.running) { const m = beat * amt * 30; sx = (Math.random() - 0.5) * m; sy = (Math.random() - 0.5) * m; }
|
||||
if (this.fx.has('cycle')) ctx.filter = 'hue-rotate(' + Math.round((this.time * 40 * (0.5 + amt)) % 360) + 'deg)';
|
||||
ctx.globalAlpha = (1 - this.crossfade) * this.brightness;
|
||||
if (ctx.globalAlpha > 0.005) ctx.drawImage(this.deckA.canvas, sx, sy);
|
||||
ctx.globalAlpha = this.crossfade * this.brightness;
|
||||
ctx.globalCompositeOperation = this.blend;
|
||||
if (ctx.globalAlpha > 0.005) ctx.drawImage(this.deckB.canvas, -sx, -sy);
|
||||
ctx.filter = 'none';
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
this.applyEffects(w, h, beat);
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const strobeOn = this.strobe > 0 && beat > 1 - this.strobe * 0.6;
|
||||
const white = Math.max(this.flash, strobeOn ? 0.6 : 0);
|
||||
if (white > 0.01) {
|
||||
ctx.globalAlpha = Math.min(1, white);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
this.flash = Math.max(0, this.flash - dt * 4);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
snap(w, h) {
|
||||
if (this.fxCanvas.width !== w || this.fxCanvas.height !== h) { this.fxCanvas.width = w; this.fxCanvas.height = h; }
|
||||
this.fxCtx.globalCompositeOperation = 'source-over';
|
||||
this.fxCtx.clearRect(0, 0, w, h);
|
||||
this.fxCtx.drawImage(this.canvas, 0, 0);
|
||||
}
|
||||
|
||||
applyEffects(w, h, beat) {
|
||||
const ctx = this.ctx, amt = this.fxAmount;
|
||||
if (this.fx.has('pixelate')) {
|
||||
const size = 3 + amt * 25;
|
||||
const pw = Math.max(1, Math.round(w / size)), ph = Math.max(1, Math.round(h / size));
|
||||
if (this.pixCanvas.width !== pw || this.pixCanvas.height !== ph) { this.pixCanvas.width = pw; this.pixCanvas.height = ph; }
|
||||
this.pixCtx.imageSmoothingEnabled = true;
|
||||
this.pixCtx.drawImage(this.canvas, 0, 0, pw, ph);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(this.pixCanvas, 0, 0, pw, ph, 0, 0, w, h);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
}
|
||||
if (this.fx.has('mirror')) {
|
||||
this.snap(w, h);
|
||||
ctx.save(); ctx.translate(w, 0); ctx.scale(-1, 1);
|
||||
ctx.drawImage(this.fxCanvas, 0, 0, w / 2, h, 0, 0, w / 2, h);
|
||||
ctx.restore();
|
||||
}
|
||||
if (this.fx.has('quad')) {
|
||||
this.snap(w, h);
|
||||
ctx.save(); ctx.translate(0, h); ctx.scale(1, -1);
|
||||
ctx.drawImage(this.fxCanvas, 0, 0, w, h / 2, 0, 0, w, h / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
if (this.fx.has('rgb')) {
|
||||
this.snap(w, h);
|
||||
const off = (2 + amt * 18) * (0.6 + beat * 0.6);
|
||||
if (this.chanCanvas.width !== w || this.chanCanvas.height !== h) { this.chanCanvas.width = w; this.chanCanvas.height = h; }
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, w, h);
|
||||
for (const [color, dx] of [['#f00', -off], ['#0f0', 0], ['#00f', off]]) {
|
||||
const c = this.chanCtx;
|
||||
c.globalCompositeOperation = 'source-over'; c.clearRect(0, 0, w, h); c.drawImage(this.fxCanvas, 0, 0);
|
||||
c.globalCompositeOperation = 'multiply'; c.fillStyle = color; c.fillRect(0, 0, w, h);
|
||||
c.globalCompositeOperation = 'destination-in'; c.drawImage(this.fxCanvas, 0, 0);
|
||||
ctx.globalCompositeOperation = 'lighter'; ctx.drawImage(this.chanCanvas, dx, 0);
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
if (this.fx.has('invert')) {
|
||||
ctx.globalCompositeOperation = 'difference'; ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
if (this.fx.has('scanlines')) {
|
||||
if (!this.scanPattern) {
|
||||
const t = document.createElement('canvas'); t.width = 1; t.height = 3;
|
||||
const tc = t.getContext('2d'); tc.fillStyle = 'rgba(0,0,0,0.55)'; tc.fillRect(0, 2, 1, 1);
|
||||
this.scanPattern = ctx.createPattern(t, 'repeat');
|
||||
}
|
||||
ctx.globalAlpha = 0.4 + amt * 0.6; ctx.fillStyle = this.scanPattern; ctx.fillRect(0, 0, w, h); ctx.globalAlpha = 1;
|
||||
}
|
||||
if (this.fx.has('vignette')) {
|
||||
const g = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * (0.5 - amt * 0.3), w / 2, h / 2, Math.hypot(w, h) / 2);
|
||||
g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,' + (0.5 + amt * 0.5) + ')');
|
||||
ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
if (this.fx.has('grain')) {
|
||||
const d = this.grainImage.data;
|
||||
for (let i = 0; i < d.length; i += 4) { const v = Math.random() * 255; d[i] = d[i + 1] = d[i + 2] = v; d[i + 3] = 255; }
|
||||
this.grainCtx.putImageData(this.grainImage, 0, 0);
|
||||
ctx.globalCompositeOperation = 'overlay'; ctx.globalAlpha = 0.2 + amt * 0.6;
|
||||
ctx.imageSmoothingEnabled = false; ctx.drawImage(this.grainCanvas, 0, 0, w, h); ctx.imageSmoothingEnabled = true;
|
||||
ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
if (this.overlayText) {
|
||||
const size = Math.max(14, Math.round(Math.min(w, h) * (0.06 + this.textSize * 0.2) * (1 + beat * 0.12)));
|
||||
ctx.save();
|
||||
ctx.font = 'bold ' + size + 'px Consolas, monospace';
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = hsl(this.deckA.hue + this.time * 30, 100, 60, 1); ctx.shadowBlur = 20 + beat * 30;
|
||||
ctx.lineWidth = Math.max(2, size * 0.08); ctx.strokeStyle = 'rgba(0,0,0,0.85)';
|
||||
ctx.strokeText(this.overlayText, w / 2, h / 2);
|
||||
ctx.fillStyle = hsl(this.deckA.hue + this.time * 30, 100, 85, 1);
|
||||
ctx.fillText(this.overlayText, w / 2, h / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const set = (id, v, d = 2) => { const el = document.getElementById(id); if (el) el.textContent = typeof v === 'number' ? v.toFixed(d) : v; };
|
||||
for (const [s, deck] of [['A', this.deckA], ['B', this.deckB]]) {
|
||||
set('speed' + s + 'Display', deck.speed); set('intensity' + s + 'Display', deck.intensity);
|
||||
set('hue' + s + 'Display', deck.hue, 0); set('zoom' + s + 'Display', deck.zoom); set('rot' + s + 'Display', deck.rot);
|
||||
}
|
||||
set('crossfadeDisplay', Math.round(this.crossfade * 100) + '%');
|
||||
document.getElementById('crossfade').value = this.crossfade;
|
||||
set('automixDisplay', this.automix > 0 ? this.automix + ' s' : 'Off');
|
||||
set('trailsDisplay', this.trails); set('brightnessDisplay', this.brightness); set('strobeDisplay', this.strobe);
|
||||
set('pulseDisplay', this.pulse); set('micGainDisplay', this.micGain, 1);
|
||||
set('fxAmountDisplay', this.fxAmount); set('textSizeDisplay', this.textSize);
|
||||
this.updateStatus();
|
||||
}
|
||||
|
||||
updateStatus() {
|
||||
const name = (id) => (PROGRAMS.find(p => p.id === id) || {}).name || id;
|
||||
const blendName = document.getElementById('blend').selectedOptions[0].textContent;
|
||||
document.getElementById('status').textContent = 'Status: ' + (this.running ? 'Playing' : 'Paused') + ' | FPS: ' + (this.running ? this.fps : '--') + ' | BPM: ' + this.bpm + ' | Time: ' + this.time.toFixed(1) + ' s';
|
||||
document.getElementById('mixStatus').textContent = 'Deck A: ' + name(this.deckA.program) + ' | Deck B: ' + name(this.deckB.program) + ' | Mix: ' + Math.round(this.crossfade * 100) + '% A/B | Blend: ' + blendName + ' | Mic: ' + (this.mic ? 'On' : 'Off');
|
||||
}
|
||||
}
|
||||
|
||||
const dj = new VisualDJ();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
src/games/visualdj/thumbnail.svg
Normal file
22
src/games/visualdj/thumbnail.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
|
||||
<rect width="400" height="220" fill="#000"/>
|
||||
<rect x="10" y="10" width="380" height="200" rx="6" fill="#0a0a0a" stroke="#1a1a1a" stroke-width="1"/>
|
||||
|
||||
<circle cx="110" cy="100" r="62" fill="#1a0f17" stroke="#ff4ad6" stroke-width="2"/>
|
||||
<circle cx="110" cy="100" r="46" fill="none" stroke="#ff4ad6" stroke-width="1" opacity="0.5"/>
|
||||
<circle cx="110" cy="100" r="30" fill="none" stroke="#ff4ad6" stroke-width="1" opacity="0.35"/>
|
||||
<circle cx="110" cy="100" r="8" fill="#ff4ad6"/>
|
||||
<path d="M 60 100 Q 72 70 85 100 Q 98 130 110 100 Q 122 70 135 100 Q 148 130 160 100" stroke="#ff9500" stroke-width="2" fill="none" opacity="0.9"/>
|
||||
|
||||
<circle cx="290" cy="100" r="62" fill="#0f1a17" stroke="#4affd6" stroke-width="2"/>
|
||||
<polygon points="290,46 337,73 337,127 290,154 243,127 243,73" fill="none" stroke="#4affd6" stroke-width="1" opacity="0.5"/>
|
||||
<polygon points="290,66 319,83 319,117 290,134 261,117 261,83" fill="none" stroke="#4affd6" stroke-width="1" opacity="0.35"/>
|
||||
<circle cx="290" cy="100" r="8" fill="#4affd6"/>
|
||||
<path d="M 240 100 L 255 80 L 270 115 L 285 70 L 300 125 L 315 85 L 330 110 L 340 100" stroke="#ff9500" stroke-width="2" fill="none" opacity="0.9"/>
|
||||
|
||||
<rect x="150" y="176" width="100" height="6" rx="3" fill="#333"/>
|
||||
<rect x="150" y="176" width="55" height="6" rx="3" fill="#ff9500"/>
|
||||
<rect x="199" y="170" width="12" height="18" rx="2" fill="#fff"/>
|
||||
|
||||
<text x="200" y="213" font-family="'Courier New', monospace" font-size="13" fill="#ff9500" text-anchor="middle" font-weight="bold" letter-spacing="3">VISUALDJ</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
Loading…
Add table
Add a link
Reference in a new issue