- index.html: WIMVEE GLYTCH match-the-glitch prototype (playable)
- docs/FKTRY_LORE.md: world bible / MODELBEAST asset codex
- fktry/: vite+three+TS scaffold, contracts, main loop, seed data
- fktry/MASTERPLAN.md + CONTRACTS.md + lanes/LANE-{SIM,RENDER,UI,SCREEN,DATA}.md:
round protocol and Round 1 orders for parallel Opus 4.8 executors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
330 lines
12 KiB
HTML
330 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>WIMVEE GLYTCH</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
:root{
|
|
--bg:#050208; --fg:#d8ffd8; --dim:#4a6b4a; --hot:#ff3fd4; --cy:#3fffe0;
|
|
}
|
|
*{box-sizing:border-box; margin:0; padding:0}
|
|
body{
|
|
background:var(--bg); color:var(--fg);
|
|
font-family:"Courier New", ui-monospace, monospace;
|
|
display:flex; flex-direction:column; align-items:center;
|
|
min-height:100vh; padding:16px; gap:10px;
|
|
text-transform:uppercase; letter-spacing:.08em;
|
|
}
|
|
h1{font-size:18px; color:var(--hot); text-shadow:2px 0 var(--cy), -2px 0 #ff2222; }
|
|
#screens{display:flex; gap:14px; flex-wrap:wrap; justify-content:center}
|
|
.panel{display:flex; flex-direction:column; gap:4px; align-items:center}
|
|
.panel .label{font-size:11px; color:var(--dim)}
|
|
canvas.screen{
|
|
width:380px; height:285px; image-rendering:pixelated;
|
|
border:1px solid #222; background:#000;
|
|
}
|
|
#hud{display:flex; gap:24px; align-items:center; font-size:13px}
|
|
#sync{font-size:22px; min-width:140px; text-align:center}
|
|
#sync.hot{color:var(--cy)}
|
|
#params{list-style:none; font-size:12px; width:772px; max-width:95vw;
|
|
display:grid; grid-template-columns:repeat(4,1fr); gap:3px 14px}
|
|
#params li{display:flex; gap:6px; align-items:center; color:var(--dim); padding:2px 4px}
|
|
#params li.sel{color:var(--fg); background:#131020; outline:1px solid #2a2440}
|
|
#params li .bar{flex:1; height:8px; background:#181425; position:relative; overflow:hidden}
|
|
#params li .bar i{position:absolute; left:0; top:0; bottom:0; background:var(--hot)}
|
|
#params li.sel .bar i{background:var(--cy)}
|
|
#help{font-size:10px; color:var(--dim); max-width:772px; text-align:center; line-height:1.7}
|
|
#round{color:var(--hot)}
|
|
#msg{font-size:14px; color:var(--cy); height:18px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>WIMVEE GLYTCH</h1>
|
|
<div id="hud">
|
|
<span>ROUND <span id="round">1</span></span>
|
|
<span id="sync">SYNC 00.0%</span>
|
|
<span id="msg"></span>
|
|
</div>
|
|
<div id="screens">
|
|
<div class="panel"><div class="label">TARGET SIGNAL</div><canvas id="cvT" class="screen" width="512" height="384"></canvas></div>
|
|
<div class="panel"><div class="label">YOUR SIGNAL</div><canvas id="cvP" class="screen" width="512" height="384"></canvas></div>
|
|
</div>
|
|
<ul id="params"></ul>
|
|
<div id="help">
|
|
←/→ or L1/R1: select property · ↑/↓ or d-pad: adjust · left stick Y: fine adjust ·
|
|
hold shift / R2: slow · N or START: new round · match the target signal to ≥96% sync
|
|
</div>
|
|
|
|
<script>
|
|
"use strict";
|
|
|
|
// ---- glitch parameter definitions -------------------------------------
|
|
const PARAMS = [
|
|
{ key:"mosh", name:"MOSH" }, // macroblock displacement
|
|
{ key:"melt", name:"MELT" }, // vertical pixel drip
|
|
{ key:"chroma", name:"CHROMA" }, // RGB channel split
|
|
{ key:"tear", name:"TEAR" }, // scanline horizontal tears
|
|
{ key:"roll", name:"V-HOLD" }, // vertical hold roll
|
|
{ key:"burn", name:"BURN" }, // film burn blotches
|
|
{ key:"noise", name:"STATIC" }, // analog snow
|
|
{ key:"freeze", name:"FREEZE" }, // time quantize + dropout
|
|
];
|
|
const N = PARAMS.length;
|
|
|
|
let target = new Float32Array(N);
|
|
let player = new Float32Array(N);
|
|
let sel = 0, round = 1, winTimer = 0, locked = false;
|
|
|
|
// ---- base "broadcast" texture: SMPTE bars + camcorder overlay ---------
|
|
function makeBaseTexture(){
|
|
const c = document.createElement("canvas");
|
|
c.width = 512; c.height = 384;
|
|
const g = c.getContext("2d");
|
|
const bars = ["#c0c0c0","#c0c000","#00c0c0","#00c000","#c000c0","#c00000","#0000c0"];
|
|
const bw = c.width / bars.length;
|
|
bars.forEach((col,i)=>{ g.fillStyle=col; g.fillRect(i*bw,0,bw+1,c.height*0.62); });
|
|
// castellations strip
|
|
const casts = ["#0000c0","#131313","#c000c0","#131313","#00c0c0","#131313","#c0c0c0"];
|
|
casts.forEach((col,i)=>{ g.fillStyle=col; g.fillRect(i*bw,c.height*0.62,bw+1,c.height*0.10); });
|
|
// pluge / ramp bottom
|
|
g.fillStyle="#101010"; g.fillRect(0,c.height*0.72,c.width,c.height*0.28);
|
|
const ramp = g.createLinearGradient(0,0,c.width,0);
|
|
ramp.addColorStop(0,"#000"); ramp.addColorStop(1,"#fff");
|
|
g.fillStyle=ramp; g.fillRect(c.width*0.55,c.height*0.80,c.width*0.4,20);
|
|
// camcorder overlay text
|
|
g.fillStyle="#eee"; g.font="bold 26px 'Courier New', monospace";
|
|
g.fillText("PLAY ▶", 24, c.height*0.86);
|
|
g.fillText("SP 0:12:34", 24, c.height*0.945);
|
|
g.font="bold 20px 'Courier New', monospace";
|
|
g.fillStyle="#f4f4f4";
|
|
g.fillText("WIMVEE GLYTCH", c.width*0.55, c.height*0.945);
|
|
g.fillStyle="#ff4444"; g.beginPath();
|
|
g.arc(c.width-30, c.height*0.83, 8, 0, Math.PI*2); g.fill();
|
|
return c;
|
|
}
|
|
|
|
// ---- webgl -------------------------------------------------------------
|
|
const VS = `
|
|
attribute vec2 p;
|
|
varying vec2 vUv;
|
|
void main(){ vUv = p*0.5+0.5; vUv.y = 1.0-vUv.y; gl_Position = vec4(p,0.,1.); }`;
|
|
|
|
const FS = `
|
|
precision mediump float;
|
|
varying vec2 vUv;
|
|
uniform sampler2D tex;
|
|
uniform float t;
|
|
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
|
|
|
|
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
|
|
float vnoise(vec2 p){
|
|
vec2 i=floor(p), f=fract(p); f=f*f*(3.-2.*f);
|
|
return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),
|
|
mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);
|
|
}
|
|
float fbm(vec2 p){ return 0.5*vnoise(p)+0.25*vnoise(p*2.3)+0.125*vnoise(p*5.1); }
|
|
|
|
void main(){
|
|
// freeze quantizes the timebase everything else animates on
|
|
float tt = mix(t, floor(t*2.5)/2.5, clamp(u_freeze*1.4,0.,1.));
|
|
vec2 uv = vUv;
|
|
|
|
// v-hold roll
|
|
uv.y = fract(uv.y + u_roll * tt * 0.35);
|
|
|
|
// scanline tears: bands shove sideways
|
|
float band = floor(uv.y * 36.0);
|
|
float tr = hash(vec2(band, floor(tt*7.0)));
|
|
if(tr > 1.0 - u_tear*0.85){
|
|
uv.x += (hash(vec2(band, floor(tt*7.0)+9.0)) - 0.5) * u_tear * 0.5;
|
|
}
|
|
|
|
// datamosh: macroblocks pick up stale offsets
|
|
vec2 blk = floor(uv * vec2(16.0, 12.0));
|
|
float mb = hash(blk + floor(tt*3.0));
|
|
if(mb > 1.0 - u_mosh*0.9){
|
|
vec2 off = vec2(hash(blk+1.7), hash(blk+4.2)) - 0.5;
|
|
uv += off * u_mosh * 0.35;
|
|
}
|
|
|
|
// melt: columns drip downward
|
|
float drip = pow(vnoise(vec2(uv.x*30.0, floor(tt*1.5))), 3.0);
|
|
uv.y -= u_melt * drip * uv.y * 0.9;
|
|
|
|
uv = fract(uv);
|
|
|
|
// chroma split
|
|
float ca = u_chroma * 0.03;
|
|
vec3 col;
|
|
col.r = texture2D(tex, uv + vec2( ca, 0.)).r;
|
|
col.g = texture2D(tex, uv).g;
|
|
col.b = texture2D(tex, uv - vec2( ca, ca*0.6)).b;
|
|
|
|
// static
|
|
float sn = hash(uv*vec2(431.0,917.0) + fract(tt)*13.0);
|
|
col = mix(col, vec3(sn), u_noise * 0.7);
|
|
|
|
// freeze dropout: grey flash lines
|
|
float dropo = step(0.985 - u_freeze*0.12, hash(vec2(floor(uv.y*90.0), floor(tt*5.0))));
|
|
col = mix(col, vec3(0.45), dropo * u_freeze);
|
|
|
|
// film burn: fbm blotch eats the frame, hot rim then black
|
|
float b = fbm(uv*3.0 + vec2(0., -tt*0.4));
|
|
float edge = smoothstep(1.0 - u_burn*1.1, 1.0 - u_burn*1.1 + 0.12, b);
|
|
vec3 rim = vec3(1.0, 0.55, 0.15);
|
|
col = mix(col, rim, edge * (1.0 - smoothstep(0.05, 0.35, edge)) * 2.0 * u_burn);
|
|
col = mix(col, vec3(0.02), smoothstep(0.3, 1.0, edge) * step(0.01, u_burn));
|
|
|
|
// faint scanlines for vibe (constant, not scored)
|
|
col *= 0.92 + 0.08 * sin(vUv.y * 384.0 * 3.1415);
|
|
|
|
gl_FragColor = vec4(col, 1.0);
|
|
}`;
|
|
|
|
function makeGL(canvas){
|
|
const gl = canvas.getContext("webgl", {antialias:false});
|
|
const compile = (type, src)=>{
|
|
const s = gl.createShader(type);
|
|
gl.shaderSource(s, src); gl.compileShader(s);
|
|
if(!gl.getShaderParameter(s, gl.COMPILE_STATUS))
|
|
throw new Error(gl.getShaderInfoLog(s));
|
|
return s;
|
|
};
|
|
const prog = gl.createProgram();
|
|
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
|
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
|
|
gl.linkProgram(prog);
|
|
if(!gl.getProgramParameter(prog, gl.LINK_STATUS))
|
|
throw new Error(gl.getProgramInfoLog(prog));
|
|
gl.useProgram(prog);
|
|
|
|
const buf = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
|
|
const loc = gl.getAttribLocation(prog, "p");
|
|
gl.enableVertexAttribArray(loc);
|
|
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
|
|
|
const texture = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, makeBaseTexture());
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
|
|
const uni = { t: gl.getUniformLocation(prog, "t") };
|
|
for(const p of PARAMS) uni[p.key] = gl.getUniformLocation(prog, "u_"+p.key);
|
|
return { gl, uni };
|
|
}
|
|
|
|
const T = makeGL(document.getElementById("cvT"));
|
|
const P = makeGL(document.getElementById("cvP"));
|
|
|
|
function draw(ctx, vals, time){
|
|
const { gl, uni } = ctx;
|
|
gl.uniform1f(uni.t, time);
|
|
PARAMS.forEach((p,i)=> gl.uniform1f(uni[p.key], vals[i]));
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
}
|
|
|
|
// ---- rounds & scoring ---------------------------------------------------
|
|
function newRound(){
|
|
target.fill(0); player.fill(0);
|
|
const active = Math.min(2 + Math.floor((round-1)/1.5), N);
|
|
const idx = [...Array(N).keys()].sort(()=>Math.random()-0.5).slice(0, active);
|
|
for(const i of idx) target[i] = 0.15 + Math.random()*0.8;
|
|
winTimer = 0; locked = false;
|
|
document.getElementById("round").textContent = round;
|
|
document.getElementById("msg").textContent = "";
|
|
}
|
|
|
|
function syncScore(){
|
|
let d = 0;
|
|
for(let i=0;i<N;i++) d += Math.abs(target[i]-player[i]);
|
|
return Math.max(0, 1 - d/ (N*0.45)) * 100;
|
|
}
|
|
|
|
// ---- ui -----------------------------------------------------------------
|
|
const list = document.getElementById("params");
|
|
PARAMS.forEach((p,i)=>{
|
|
const li = document.createElement("li");
|
|
li.innerHTML = `<span style="width:64px">${p.name}</span><span class="bar"><i></i></span>`;
|
|
list.appendChild(li);
|
|
});
|
|
function renderUI(score){
|
|
[...list.children].forEach((li,i)=>{
|
|
li.classList.toggle("sel", i===sel);
|
|
li.querySelector("i").style.width = (player[i]*100).toFixed(1)+"%";
|
|
});
|
|
const el = document.getElementById("sync");
|
|
el.textContent = "SYNC " + score.toFixed(1).padStart(4,"0") + "%";
|
|
el.classList.toggle("hot", score >= 96);
|
|
}
|
|
|
|
// ---- input ----------------------------------------------------------------
|
|
const keys = {};
|
|
addEventListener("keydown", e=>{
|
|
keys[e.key] = true;
|
|
const step = e.shiftKey ? 0.01 : 0.05;
|
|
if(e.key==="ArrowLeft"){ sel = (sel+N-1)%N; e.preventDefault(); }
|
|
if(e.key==="ArrowRight"){ sel = (sel+1)%N; e.preventDefault(); }
|
|
if(e.key==="ArrowUp" && !locked){ player[sel] = Math.min(1, player[sel] + step); e.preventDefault(); }
|
|
if(e.key==="ArrowDown" && !locked){ player[sel] = Math.max(0, player[sel] - step); e.preventDefault(); }
|
|
if(e.key==="n"||e.key==="N"){ round=1; newRound(); }
|
|
});
|
|
addEventListener("keyup", e=> keys[e.key]=false);
|
|
|
|
let prevPad = {};
|
|
function pollInput(dt){
|
|
if(locked) return;
|
|
|
|
const pad = navigator.getGamepads && navigator.getGamepads()[0];
|
|
if(pad){
|
|
const pressed = i => pad.buttons[i] && pad.buttons[i].pressed;
|
|
const was = i => prevPad[i];
|
|
if(pressed(4) && !was(4)) sel = (sel+N-1)%N; // L1
|
|
if(pressed(5) && !was(5)) sel = (sel+1)%N; // R1
|
|
if(pressed(9) && !was(9)){ round=1; newRound(); } // start
|
|
const slow = pad.buttons[7] && pad.buttons[7].value > 0.3 ? 0.25 : 1.0; // R2
|
|
if(pressed(12)) player[sel] = Math.min(1, player[sel] + 0.6*dt*slow); // dpad up
|
|
if(pressed(13)) player[sel] = Math.max(0, player[sel] - 0.6*dt*slow); // dpad down
|
|
if(pressed(14) && !was(14)) sel = (sel+N-1)%N;
|
|
if(pressed(15) && !was(15)) sel = (sel+1)%N;
|
|
const ly = pad.axes[1] || 0;
|
|
if(Math.abs(ly) > 0.15) player[sel] = Math.min(1, Math.max(0, player[sel] - ly*0.4*dt));
|
|
prevPad = {}; pad.buttons.forEach((b,i)=> prevPad[i]=b.pressed);
|
|
}
|
|
}
|
|
|
|
// ---- main loop --------------------------------------------------------------
|
|
let last = performance.now();
|
|
function frame(now){
|
|
const dt = Math.min(0.05, (now-last)/1000); last = now;
|
|
const t = now/1000;
|
|
pollInput(dt);
|
|
|
|
const score = syncScore();
|
|
if(!locked){
|
|
if(score >= 96){
|
|
winTimer += dt;
|
|
document.getElementById("msg").textContent = "LOCKING " + (2-winTimer).toFixed(1);
|
|
if(winTimer >= 2){
|
|
locked = true;
|
|
document.getElementById("msg").textContent = "SIGNAL LOCKED";
|
|
setTimeout(()=>{ round++; newRound(); }, 1200);
|
|
}
|
|
} else { winTimer = 0; if(!locked) document.getElementById("msg").textContent = ""; }
|
|
}
|
|
|
|
draw(T, target, t);
|
|
draw(P, player, t);
|
|
renderUI(score);
|
|
requestAnimationFrame(frame);
|
|
}
|
|
newRound();
|
|
requestAnimationFrame(frame);
|
|
</script>
|
|
</body>
|
|
</html>
|