← back to Games Agentabrams
games/rubiks-cube/AbramsCube.aa
400 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Abrams Cube — CSS 3D</title>
<style>
:root { --s: 60px; }
* { box-sizing: border-box; user-select: none; -webkit-user-select: none; }
html, body { height: 100%; }
body {
margin: 0; display: flex; flex-direction: column; align-items: center;
background: radial-gradient(circle at 50% 30%, #2b303c, #12141a 75%);
font-family: system-ui, -apple-system, sans-serif; color: #e8e9ef; overflow: hidden;
}
h1 { font-size: 16px; font-weight: 600; letter-spacing: 3px; text-transform: uppercase; margin: 14px 0 2px; opacity: .85; }
.hint { font-size: 12px; opacity: .5; margin: 0 0 2px; text-align: center; padding: 0 12px; }
#hud {
display: flex; gap: 18px; font-size: 13px; opacity: .85; margin: 4px 0 0;
font-variant-numeric: tabular-nums; letter-spacing: .5px;
}
#hud b { font-weight: 700; color: #fff; }
#viewport {
flex: 1; width: 100%; display: flex; align-items: center; justify-content: center;
perspective: 1000px; cursor: grab; touch-action: none; position: relative;
}
#viewport.dragging { cursor: grabbing; }
#banner {
position: absolute; left: 50%; top: 14%; transform: translateX(-50%);
background: linear-gradient(180deg, #2f8f5b, #1f6f44); border: 1px solid #3fae72;
border-radius: 12px; padding: 12px 22px; font-size: 16px; font-weight: 700;
letter-spacing: .5px; box-shadow: 0 8px 30px rgba(0,0,0,.45);
opacity: 0; transition: opacity .35s; pointer-events: none; white-space: nowrap;
}
#banner.show { opacity: 1; }
#cube { position: relative; width: 0; height: 0; transform-style: preserve-3d; }
.cubie {
position: absolute; width: var(--s); height: var(--s);
left: calc(var(--s) / -2); top: calc(var(--s) / -2);
transform-style: preserve-3d;
}
.face {
position: absolute; inset: 0; border-radius: 14%;
border: 3px solid #0b0d11;
box-shadow: inset 0 0 6px rgba(0,0,0,.35);
}
.f-front { transform: translateZ(calc(var(--s) / 2)); }
.f-back { transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
.f-right { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.f-left { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.f-top { transform: rotateX(90deg) translateZ(calc(var(--s) / 2)); }
.f-bottom { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }
#controls { padding: 8px 12px 16px; display: flex; flex-direction: column; gap: 7px; align-items: center; }
.row { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
button {
font: 600 14px/1 system-ui, sans-serif; color: #e8e9ef;
background: linear-gradient(180deg, #3a4152, #2a3040);
border: 1px solid #4a5268; border-radius: 9px;
min-width: 44px; padding: 10px 8px; cursor: pointer;
transition: transform .08s, background .15s;
}
button:hover { background: linear-gradient(180deg, #4a5268, #333a4c); }
button:active { transform: scale(.93); }
button.slice { opacity: .8; }
.big { min-width: 96px; letter-spacing: 1px; }
#scramble { background: linear-gradient(180deg, #7c5cff, #5b3fe0); border-color: #8f78ff; }
#scramble:hover { background: linear-gradient(180deg, #8d70ff, #6a4ef0); }
#reset { background: linear-gradient(180deg, #2f8f5b, #1f6f44); border-color: #3fae72; }
#reset:hover { background: linear-gradient(180deg, #37a066, #27804f); }
#undo { background: linear-gradient(180deg, #b3823a, #8f6425); border-color: #cf9c50; }
#undo:hover { background: linear-gradient(180deg, #c4914a, #a0722e); }
</style>
</head>
<body>
<h1>Abrams Cube</h1>
<p class="hint">Drag to orbit · scroll to zoom · keys U/D/L/R/F/B/M/E/S turn faces (Shift = counter-clockwise) · ⌘Z / Ctrl+Z undo</p>
<div id="hud"><span>Moves <b id="movesHud">0</b></span><span>Time <b id="timeHud">0:00</b></span></div>
<div id="viewport">
<div id="cube"></div>
<div id="banner"></div>
</div>
<div id="controls">
<div class="row" id="movesRow"></div>
<div class="row" id="primesRow"></div>
<div class="row">
<button id="scramble" class="big">Scramble</button>
<button id="undo" class="big">Undo</button>
<button id="reset" class="big">Reset</button>
</div>
</div>
<script>
(function () {
var viewport = document.getElementById('viewport');
var cubeEl = document.getElementById('cube');
var banner = document.getElementById('banner');
var movesHud = document.getElementById('movesHud');
var timeHud = document.getElementById('timeHud');
var S = 60;
function sizeCube() {
S = Math.max(34, Math.min(72, Math.floor(Math.min(innerWidth, innerHeight - 230) / 5.2)));
document.documentElement.style.setProperty('--s', S + 'px');
}
sizeCube();
var DARK = '#171a20';
var I3 = [1,0,0, 0,1,0, 0,0,1];
function mul(a, b) { // 3x3 row-major
var r = new Array(9);
for (var i = 0; i < 3; i++) for (var j = 0; j < 3; j++)
r[i*3+j] = a[i*3]*b[j] + a[i*3+1]*b[3+j] + a[i*3+2]*b[6+j];
return r;
}
function applyM(m, v) {
return [ m[0]*v[0]+m[1]*v[1]+m[2]*v[2],
m[3]*v[0]+m[4]*v[1]+m[5]*v[2],
m[6]*v[0]+m[7]*v[1]+m[8]*v[2] ];
}
function roundM(m) { return m.map(function (v) { return Math.round(v); }); }
function axisRot(axis, deg) {
var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
if (axis === 'x') return [1,0,0, 0,c,-s, 0,s,c];
if (axis === 'y') return [c,0,s, 0,1,0, -s,0,c];
return [c,-s,0, s,c,0, 0,0,1];
}
var cubies = [];
function makeFace(cls, color) {
var f = document.createElement('div');
f.className = 'face ' + cls;
f.style.background = color === DARK ? DARK
: 'linear-gradient(145deg, ' + color + ', ' + color + ' 60%, rgba(0,0,0,.18))';
if (color !== DARK) f.style.backgroundColor = color;
return f;
}
function build() {
cubeEl.innerHTML = '';
cubies = [];
for (var x = -1; x <= 1; x++) for (var y = -1; y <= 1; y++) for (var z = -1; z <= 1; z++) {
if (!x && !y && !z) continue;
var el = document.createElement('div');
el.className = 'cubie';
el.appendChild(makeFace('f-top', y === -1 ? '#f4f6f5' : DARK)); // white up
el.appendChild(makeFace('f-bottom', y === 1 ? '#ffd60a' : DARK)); // yellow down
el.appendChild(makeFace('f-right', x === 1 ? '#e5484d' : DARK)); // red right
el.appendChild(makeFace('f-left', x === -1 ? '#ff9f1c' : DARK)); // orange left
el.appendChild(makeFace('f-front', z === 1 ? '#30c04f' : DARK)); // green front
el.appendChild(makeFace('f-back', z === -1 ? '#3b82f6' : DARK)); // blue back
cubeEl.appendChild(el);
var c = { x: x, y: y, z: z, ix: x, iy: y, iz: z, m: I3.slice(), el: el };
cubies.push(c);
render(c, null);
}
}
function render(c, R) {
var m = R ? mul(R, c.m) : c.m;
var p = [c.x * S, c.y * S, c.z * S];
if (R) p = applyM(R, p);
c.el.style.transform = 'matrix3d(' +
m[0] + ',' + m[3] + ',' + m[6] + ',0,' +
m[1] + ',' + m[4] + ',' + m[7] + ',0,' +
m[2] + ',' + m[5] + ',' + m[8] + ',0,' +
p[0] + ',' + p[1] + ',' + p[2] + ',1)';
}
// Face turns: clockwise viewed from outside = +90deg about the outward normal.
// Slices follow standard notation: M follows L, E follows D, S follows F.
var MOVES = {
U: { axis: 'y', layer: -1, dir: -1 },
D: { axis: 'y', layer: 1, dir: 1 },
R: { axis: 'x', layer: 1, dir: 1 },
L: { axis: 'x', layer: -1, dir: -1 },
F: { axis: 'z', layer: 1, dir: 1 },
B: { axis: 'z', layer: -1, dir: -1 },
M: { axis: 'x', layer: 0, dir: -1 },
E: { axis: 'y', layer: 0, dir: 1 },
S: { axis: 'z', layer: 0, dir: 1 }
};
var OUTER = ['U','D','L','R','F','B'];
// ---- state ----
var queue = [], animating = false, gen = 0;
var history = [], moves = 0, scrambled = false;
var timerStart = null, elapsed = 0;
function coordOf(c, axis) { return axis === 'x' ? c.x : axis === 'y' ? c.y : c.z; }
function enqueue(name, prime, dur, src) {
var mv = MOVES[name];
if (!mv) return;
src = src || 'user';
if (src === 'user') {
hideBanner();
if (scrambled && timerStart === null) timerStart = performance.now();
}
queue.push({ name: name, prime: !!prime, src: src,
axis: mv.axis, layer: mv.layer, dir: mv.dir * (prime ? -1 : 1),
dur: dur || 260 });
if (!animating) next();
}
function next() {
var item = queue.shift();
if (!item) { animating = false; return; }
animating = true;
var myGen = gen;
var affected = cubies.filter(function (c) { return coordOf(c, item.axis) === item.layer; });
var t0 = performance.now();
function frame(now) {
if (myGen !== gen) return;
var t = Math.min(1, (now - t0) / item.dur);
var e = t < .5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2;
var R = axisRot(item.axis, 90 * item.dir * e);
affected.forEach(function (c) { render(c, R); });
if (t < 1) { requestAnimationFrame(frame); return; }
var Rq = roundM(axisRot(item.axis, 90 * item.dir));
affected.forEach(function (c) {
var p = applyM(Rq, [c.x, c.y, c.z]).map(Math.round);
c.x = p[0]; c.y = p[1]; c.z = p[2];
c.m = roundM(mul(Rq, c.m));
render(c, null);
});
commit(item);
next();
}
requestAnimationFrame(frame);
}
function commit(item) {
if (item.src === 'user') {
history.push({ name: item.name, prime: item.prime });
moves++;
} else if (item.src === 'undo') {
history.pop();
moves = Math.max(0, moves - 1);
}
updateHud();
if (scrambled && item.src !== 'scramble' && isSolved()) celebrate();
}
// Solved when every sticker's current world direction equals its home direction.
// (Sufficient for any state reachable by legal turns; catches slice moves too.)
function isSolved() {
for (var i = 0; i < cubies.length; i++) {
var c = cubies[i], dirs = [];
if (c.ix) dirs.push([c.ix, 0, 0]);
if (c.iy) dirs.push([0, c.iy, 0]);
if (c.iz) dirs.push([0, 0, c.iz]);
for (var j = 0; j < dirs.length; j++) {
var d = dirs[j], w = applyM(c.m, d);
if (Math.round(w[0]) !== d[0] || Math.round(w[1]) !== d[1] || Math.round(w[2]) !== d[2]) return false;
}
}
return true;
}
function fmtTime(ms) {
var s = Math.floor(ms / 1000);
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
}
function updateHud() { movesHud.textContent = moves; }
setInterval(function () {
if (timerStart !== null) elapsed = performance.now() - timerStart;
timeHud.textContent = fmtTime(elapsed);
}, 200);
function celebrate() {
scrambled = false;
if (timerStart !== null) { elapsed = performance.now() - timerStart; timerStart = null; }
banner.textContent = '🎉 Solved in ' + moves + ' move' + (moves === 1 ? '' : 's') +
(elapsed > 0 ? ' · ' + fmtTime(elapsed) : '') + '!';
banner.classList.add('show');
clearTimeout(celebrate.t);
celebrate.t = setTimeout(hideBanner, 6000);
}
function hideBanner() { banner.classList.remove('show'); }
function scramble() {
hideBanner();
scrambled = true;
history.length = 0; moves = 0;
timerStart = null; elapsed = 0;
updateHud();
var last = '';
for (var i = 0; i < 22; i++) {
var n;
do { n = OUTER[Math.floor(Math.random() * OUTER.length)]; } while (n === last);
last = n;
enqueue(n, Math.random() < .5, 110, 'scramble');
}
}
function reset() {
gen++; queue.length = 0; animating = false;
history.length = 0; moves = 0;
scrambled = false; timerStart = null; elapsed = 0;
hideBanner(); updateHud();
cubies.forEach(function (c) {
c.x = c.ix; c.y = c.iy; c.z = c.iz; c.m = I3.slice();
render(c, null);
});
}
function undo() {
if (animating || queue.length || !history.length) return;
var last = history[history.length - 1];
enqueue(last.name, !last.prime, 200, 'undo');
}
// ---- controls ----
var movesRow = document.getElementById('movesRow');
var primesRow = document.getElementById('primesRow');
['U','D','L','R','F','B','M','E','S'].forEach(function (n) {
var slice = 'MES'.indexOf(n) !== -1;
var b = document.createElement('button');
b.textContent = n;
if (slice) b.className = 'slice';
b.title = 'Turn ' + n + ' clockwise';
b.onclick = function () { enqueue(n, false); };
movesRow.appendChild(b);
var bp = document.createElement('button');
bp.textContent = n + '′';
if (slice) bp.className = 'slice';
bp.title = 'Turn ' + n + ' counter-clockwise';
bp.onclick = function () { enqueue(n, true); };
primesRow.appendChild(bp);
});
document.getElementById('scramble').onclick = scramble;
document.getElementById('reset').onclick = reset;
document.getElementById('undo').onclick = undo;
document.addEventListener('keydown', function (ev) {
if (ev.metaKey || ev.ctrlKey) {
if (ev.key.toLowerCase() === 'z') { ev.preventDefault(); undo(); }
return;
}
if (ev.repeat) return;
var k = ev.key.toUpperCase();
if (MOVES[k]) { ev.preventDefault(); enqueue(k, ev.shiftKey); }
else if (ev.key === '+' || ev.key === '=') setZoom(zoom * 1.12);
else if (ev.key === '-' || ev.key === '_') setZoom(zoom / 1.12);
});
// ---- orbit + zoom ----
var rx = -28, ry = -38, zoom = 1, dragging = false, px = 0, py = 0;
function applyOrbit() {
cubeEl.style.transform = 'scale3d(' + zoom + ',' + zoom + ',' + zoom + ') ' +
'rotateX(' + rx + 'deg) rotateY(' + ry + 'deg)';
}
function setZoom(z) { zoom = Math.max(.5, Math.min(1.9, z)); applyOrbit(); }
applyOrbit();
viewport.addEventListener('pointerdown', function (ev) {
dragging = true; px = ev.clientX; py = ev.clientY;
viewport.classList.add('dragging');
viewport.setPointerCapture(ev.pointerId);
});
viewport.addEventListener('pointermove', function (ev) {
if (!dragging) return;
ry += (ev.clientX - px) * 0.4;
rx -= (ev.clientY - py) * 0.4;
rx = Math.max(-90, Math.min(90, rx));
px = ev.clientX; py = ev.clientY;
applyOrbit();
});
function endDrag() { dragging = false; viewport.classList.remove('dragging'); }
viewport.addEventListener('pointerup', endDrag);
viewport.addEventListener('pointercancel', endDrag);
viewport.addEventListener('wheel', function (ev) {
ev.preventDefault();
setZoom(zoom * Math.exp(-ev.deltaY * 0.0012));
}, { passive: false });
var resizeT;
addEventListener('resize', function () {
clearTimeout(resizeT);
resizeT = setTimeout(function () {
sizeCube();
cubies.forEach(function (c) { render(c, null); });
}, 120);
});
build();
// Test/debug hook (harmless in normal use)
window.__cube = {
isSolved: isSolved,
isBusy: function () { return animating || queue.length > 0; },
getMoves: function () { return moves; },
getHistoryLength: function () { return history.length; },
getOrbit: function () { return { rx: rx, ry: ry, zoom: zoom }; },
enqueue: function (n, p) { enqueue(n, p, 40); },
scramble: scramble, reset: reset, undo: undo
};
})();
</script>
</body>
</html>