machine-unlimited-mobile/js/machine.js

570 lines
19 KiB
JavaScript

// Constants ///////////////////////////////////////////////////////////////////
const kDimensions = {
wheelSize: 96,
leverWidth: 98,
sausageHeight: 48,
};
const kState = {
waiting: "waiting",
readyToGrate: "readyToGrate",
readyToLoad: "readyToLoad",
};
const kAction = {
completeLoading: Symbol("completeLoading"),
startPullingLever: Symbol("startPullingLever"),
stopPullingLever: Symbol("stopPullingLever"),
pullLever: Symbol("pullLever"),
startTurningWheel: Symbol("startTurningWheel"),
stopTurningWheel: Symbol("stopTurningWheel"),
turnWheel: Symbol("turnWheel"),
setIsWaitingForFrame: Symbol("setIsWaitingForFrame"),
createSausage: Symbol("createSausage"),
completeGrating: Symbol("completeGrating"),
};
const kRotationDirection = {
clockwise: Symbol("clockwise"),
counterClockwise: Symbol("counterClockwise"),
};
const kPullDirection = {
right: Symbol("right"),
left: Symbol("left"),
};
// Configuration ///////////////////////////////////////////////////////////////
const configuration = {
wheelStrength: 1,
maxLoad: 100,
leverTravel: 80.72,
graterTravel: 69,
chainSpeed: 0.5,
grateStrength: 0.01,
defaultUrl: "/wiki/Machine",
};
// App State ///////////////////////////////////////////////////////////////////
/**
* The main app state
*/
let appState = null;
/**
* Functions to handle side effects: Creation or removal of HTML elements,
* actions that occur as a result of a change in state.
*/
const middleware = [
/**
* Makes text unselectable while we move the lever or wheel.
*/
function disableSelect(_, action) {
if (
action === kAction.startTurningWheel ||
action === kAction.startPullingLever
) {
document.querySelector(".machine")?.classList.add("no-select");
}
},
/**
* Makes text selectable when we're done moving the lever or wheel.
*/
function enableSelect(_, action) {
if (
action === kAction.stopTurningWheel ||
action === kAction.stopPullingLever
) {
document.querySelector(".machine")?.classList.remove("no-select");
}
},
/**
* Cleans up the old iFrames when we load the new one, and triggers a complete.
*/
function completeLoading(state, action) {
if (
action === kAction.turnWheel &&
state.currentState === kState.readyToLoad &&
parseFloat(state.load) >= configuration.maxLoad
) {
const iframes = $iframeContainer.querySelectorAll("iframe");
for (const iframe of iframes) {
if (iframe !== appState.currentIframe) {
$iframeContainer.removeChild(iframe);
}
}
send(kAction.completeLoading);
}
},
/**
* Sets the individual load state to the current iframe.
*/
function updateIframe(state, action) {
if (
action === kAction.turnWheel &&
state.currentState === kState.readyToLoad &&
parseFloat(state.load) <= configuration.maxLoad
) {
state.currentIframe?.style.setProperty(
"--loadProgress",
`${100 - state.load}vh`,
);
}
},
/**
* Cleans up the sausage and loads the iframe once the grating is complete.
*/
function completeGrating(state, action) {
if (
action === kAction.pullLever &&
state.currentState === kState.readyToGrate &&
parseFloat(state.grateLevel) >= kDimensions.sausageHeight
) {
removeSausages();
const $iframe = loadIframe(state.currentUrl, 0);
send(kAction.completeGrating, { iframe: $iframe });
}
},
/**
* Creates the sausage element if we are at the right state.
*/
function createSausageIfWaiting(state, action, data) {
if (
action === kAction.createSausage &&
state.currentState === kState.waiting
) {
createSausage(data.href, data.label);
}
},
];
/**
* We store the state as CSS variables and data attributes in an element
* declared as the $stateContainer. UI updates as a result of this. We
* try to avoid manipulating the elements with JS, except for creating and
* removing elements.
*/
const $stateContainer = document.querySelector(".machine");
const updateState = function updateState(state) {
for (const [key, value] of Object.entries(state)) {
$stateContainer.style.setProperty(`--${key}`, value);
$stateContainer.dataset[key] = value;
}
appState = state;
};
/**
* We modify a copy of the state and return it.
*/
const reduce = function reduce(stateReference, action, data) {
let state = Object.assign({}, stateReference);
switch (action) {
case kAction.turnWheel:
state.wheelRotation = `${data.angle}rad`;
if (state.currentState === kState.readyToLoad) {
const sign = data.direction === kRotationDirection.clockwise ? 1 : -1;
const load = Math.max(
Math.min(
state.load - sign * data.magnitude * configuration.wheelStrength,
configuration.maxLoad,
),
0,
);
state.load = load;
if (load < configuration.maxLoad && load > 0) {
const chainOffset =
parseFloat(state.chainOffset) +
sign * data.magnitude * configuration.chainSpeed;
state.chainOffset = `${chainOffset}%`;
}
}
break;
case kAction.pullLever:
const diff = Math.abs(state.leverTravel - data.magnitude);
state.leverTravel = data.magnitude;
const rotation = (data.magnitude * configuration.leverTravel) / 100;
state.leverRotation = `${rotation}deg`;
const gratingTravel = (data.magnitude * configuration.graterTravel) / 100;
state.graterTravel = `${gratingTravel}px`;
if (state.currentState === kState.readyToGrate) {
const grateLevel = Math.min(
parseFloat(state.grateLevel) + diff * configuration.grateStrength,
kDimensions.sausageHeight,
);
state.grateLevel = `${grateLevel}px`;
}
break;
case kAction.createSausage:
state.currentUrl = data.href;
state.grateLevel = "0%";
state.currentState = kState.readyToGrate;
break;
case kAction.startTurningWheel:
state.isTurningWheel = true;
break;
case kAction.stopTurningWheel:
state.isTurningWheel = false;
break;
case kAction.startPullingLever:
state.isPullingLever = true;
break;
case kAction.stopPullingLever:
state.isPullingLever = false;
break;
case kAction.completeLoading:
state.currentState = kState.waiting;
state.currentIframe = null;
state.load = 0;
break;
case kAction.completeGrating:
state.currentState = kState.readyToLoad;
state.currentIframe = data.iframe;
state.grateLevel = "0%";
state.load = 0;
break;
}
return state;
};
/**
* The main mechanism for view changes to trigger updates.
* It receives an action and its payload.
* It queues the middleware to run after this.
*/
const send = function send(action, data) {
const state = Object.assign({}, appState);
for (const m of middleware) {
setTimeout(() => m(state, action, data), 0);
}
const newState = reduce(state, action, data);
updateState(newState);
};
// Initializzation /////////////////////////////////////////////////////////////
/**
* Initializes state and events.
*/
const initialize = function initialize() {
const state = {
currentState: kState.waiting,
isPullingLever: false,
isTurningWheel: false,
leverTravel: 0,
grateLevel: 0,
leverRotation: "0deg",
graterTravel: "0rem",
wheelRotation: "0deg",
chainOffset: "0%",
load: 0,
};
updateState(state);
// Reset interaction with widgets if we lift a mouse button.
document.addEventListener("mouseup", () => {
send(kAction.stopTurningWheel);
send(kAction.stopPullingLever);
});
// The lever handle initiates interactions with the lever.
document
.querySelector(".lever .handle")
?.addEventListener("mousedown", () => {
send(kAction.startPullingLever);
});
// The wheel handle initiates interactions with the wheel.
document
.querySelector(".wheel .handle")
?.addEventListener("mousedown", () => {
send(kAction.startTurningWheel);
});
// Rotation is calculated relatively to the whole wheel.
// And travel based no the full arc of the lever
const $wheel = document.querySelector(".wheel");
const $lever = document.querySelector(".lever");
document.addEventListener("mousemove", (event) => {
if (appState.isTurningWheel && !appState.waitingForFrame) {
requestAnimationFrame(() => {
const elementPosition = $wheel.getBoundingClientRect();
const x = event.clientX - elementPosition.left;
const y = event.clientY - elementPosition.top;
const angle = angleFromCenter(
x,
y,
kDimensions.wheelSize,
kDimensions.wheelSize,
);
const oldAngle = parseFloat(appState.wheelRotation);
const { direction, magnitude } = rotationDirection(oldAngle, angle);
send(kAction.turnWheel, { angle, direction, magnitude });
send(kAction.setIsWaitingForFrame, { state: false });
});
send(kAction.setIsWaitingForFrame, { state: true });
}
if (appState.isPullingLever && !appState.waitingForFrame) {
requestAnimationFrame(() => {
const elementPosition = $lever.getBoundingClientRect();
const x = event.clientX - elementPosition.left;
const magnitude = Math.max(
Math.min((x * 100) / kDimensions.leverWidth, 100),
0,
);
const direction =
magnitude >= appState.leverTravel
? kPullDirection.right
: kPullDirection.left;
send(kAction.pullLever, { magnitude, direction });
send(kAction.setIsWaitingForFrame, { state: false });
});
send(kAction.setIsWaitingForFrame, { state: true });
}
});
// The URL Grater is the drop area.
const $grater = document.querySelector(".grater-bed");
$grater.addEventListener("dragenter", (event) => {
event.preventDefault();
if (appState.currentState === kState.waiting) {
$grater.classList.add("drag-over");
}
});
$grater.addEventListener("dragover", (event) => event.preventDefault());
$grater.addEventListener("dragleave", (event) => {
if (!$grater.contains(event.relatedTarget)) {
$grater.classList.remove("drag-over");
}
});
$grater.addEventListener("drop", (event) => {
event.preventDefault();
if (appState.currentState !== kState.waiting) {
return;
}
const href = event.dataTransfer.getData("text/uri-list");
const label = event.dataTransfer.getData("text/plain");
$grater.classList.remove("drag-over");
send(kAction.createSausage, { href, label });
});
const params = new URLSearchParams(window.location.search);
const urlFromQuery = params.get("url");
const url = urlFromQuery ?? configuration.defaultUrl;
loadIframe(url, 100);
};
// Utility Functions ///////////////////////////////////////////////////////////
/**
* Calculates the angle of a point (x,y) relative to the center of a square
* with dimensions (w, h). The result is in radians.
*/
const angleFromCenter = function angleFromCenter(x, y, w, h) {
const centerX = w / 2;
const centerY = h / 2;
const dx = x - centerX;
const dy = y - centerY;
return Math.atan2(dy, dx);
};
/**
* Given two angles, calculates the direction of the rotation.
*/
const rotationDirection = function rotationDirection(angle1, angle2) {
let diff = angle2 - angle1;
if (diff > Math.PI) diff -= 2 * Math.PI;
if (diff < -Math.PI) diff += 2 * Math.PI;
const magnitude = Math.abs(diff);
const direction =
diff > 0
? kRotationDirection.counterClockwise
: kRotationDirection.clockwise;
return {
direction,
magnitude,
};
};
/**
* Loads an iframe and appends it into the body.
*/
const $iframeContainer = document.querySelector(".web-content");
const loadIframe = function loadIframe(url, loadProgress) {
const $iframe = document.createElement("iframe");
$iframe.src = `/proxy?url=${encodeURIComponent(url)}`;
$iframe.style.setProperty("--loadProgress", `${100 - loadProgress}vh`);
$iframeContainer.appendChild($iframe);
$iframe.addEventListener("load", () => {
const iframeDocument = $iframe.contentDocument;
iframeDocument.querySelectorAll("a").forEach(($link) => {
$link.addEventListener("click", (event) => event.preventDefault());
$link.draggable = true;
$link.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/uri-list", $link.href);
event.dataTransfer.setData("text/plain", $link.textContent.trim());
const ghost = $link.cloneNode(true);
ghost.style.background = "url('/images/Sausage.svg')";
ghost.style.backgroundRepeat = "no-repeat";
ghost.style.cursor = "grabbing";
ghost.style.display = "block";
ghost.style.height = "3rem";
ghost.style.width = "8.125rem";
ghost.style.color = "#fff";
ghost.style.textAlign = "center";
ghost.style.fontSize = "0.625rem";
ghost.style.fontFamily = "sans-serif";
ghost.style.textTransform = "uppercase";
ghost.style.paddingTop = "1rem";
document.body.appendChild(ghost);
event.dataTransfer.setDragImage(ghost, 10, 10);
});
});
});
return $iframe;
};
/**
* Creates a sausage link for grating.
*/
const createSausage = function createSausage(href, label) {
const $sausage = document.createElement("article");
$sausage.classList.add("sausage");
$sausage.dataset.href = href;
$sausage.innerText = label;
const $graterBed = document.querySelector(".grater-bed");
$graterBed?.appendChild($sausage);
};
/**
* Removes a completely grated sausage link.
*/
const removeSausages = function removeSausages() {
const $graterBed = document.querySelector(".grater-bed");
const $existingSausages = $graterBed.querySelectorAll(".sausage");
for (const $existingSausage of $existingSausages) {
$graterBed?.removeChild($existingSausage);
}
};
initialize();
// MOBILE CHEESE SYSTEM
let mobileState = { href: null, label: null, isDragging: false };
window.addEventListener('message', (e) => {
if (e.data.type === 'linkTapped') {
mobileState.href = e.data.href;
mobileState.label = e.data.label;
const cheese = document.createElement('div');
cheese.textContent = '🧀';
cheese.style.position = 'fixed';
cheese.style.fontSize = '3rem';
cheese.style.zIndex = '10000';
cheese.style.left = '50%';
cheese.style.top = '50%';
cheese.style.transform = 'translate(-50%, -50%)';
document.body.appendChild(cheese);
cheese.addEventListener('touchstart', () => { mobileState.isDragging = true; });
let lastCheese = cheese;
document.addEventListener('touchmove', (e2) => {
if (!mobileState.isDragging) return;
const t = e2.touches[0];
lastCheese.style.left = t.clientX + 'px';
lastCheese.style.top = t.clientY + 'px';
}, { passive: false });
document.addEventListener('touchend', (e2) => {
if (!mobileState.isDragging) return;
mobileState.isDragging = false;
lastCheese.remove();
if (mobileState.href) {
const sausage = document.createElement('button');
sausage.textContent = mobileState.label;
sausage.style.cssText = `
position: fixed;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
padding: 14px 28px;
background: linear-gradient(180deg, #ff5555 0%, #dd0000 50%, #bb0000 100%);
color: white;
border: 4px solid #ffdd00;
border-radius: 50px;
font-size: 16px;
font-weight: bold;
z-index: 99999;
cursor: pointer;
box-shadow: 0 6px 20px rgba(0,0,0,0.4), inset 0 2px 8px rgba(255,255,255,0.3);
max-width: 85%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
sausage.addEventListener('click', async () => {
const grater = document.querySelector('.grater-bed');
const machine = document.querySelector('[class*="machine"]') || document.querySelector('body > div');
const levers = document.querySelectorAll('.lever');
const cheeses = document.querySelectorAll('div[style*="font-size: 3rem"]');
if (grater) grater.style.animation = 'grind 0.12s ease-in-out 6';
if (machine && machine !== document.body) machine.style.animation = 'shake 0.1s ease-in-out 6';
// Animate levers - pull/push them
levers.forEach((lever, index) => {
lever.style.animation = 'pump 0.15s ease-in-out 6';
// Also transform them to actually move
const handle = lever.querySelector('.handle');
if (handle) {
handle.style.animation = index === 0 ? 'pullPush 0.15s ease-in-out 6' : 'rotateWheel 0.15s ease-in-out 6';
}
});
cheeses.forEach(cheese => { cheese.style.animation = 'bounceGrate 0.2s ease-in-out 6'; });
await new Promise(r => setTimeout(r, 750));
if (grater) grater.style.animation = '';
if (machine) machine.style.animation = '';
levers.forEach(lever => { lever.style.animation = ''; });
cheeses.forEach(cheese => { cheese.style.animation = ''; });
const url = `${window.location.origin}/proxy?url=${encodeURIComponent(mobileState.href)}`;
document.querySelector('.web-content').innerHTML = `<iframe src="${url}" style="width:100%;height:100%;border:none;"></iframe>`;
sausage.remove();
});
if (!document.getElementById('grind-animations')) {
const style = document.createElement('style');
style.id = 'grind-animations';
style.innerHTML = `
@keyframes grind { 0% { transform: rotateZ(-3deg); } 25% { transform: rotateZ(3deg); } 50% { transform: rotateZ(-3deg); } 75% { transform: rotateZ(3deg); } 100% { transform: rotateZ(-3deg); } }
@keyframes bounceGrate { 0% { transform: translateY(0) scale(1); } 50% { transform: translateY(-5px) scale(1.05); } 100% { transform: translateY(0) scale(1); } }
@keyframes shake { 0% { transform: translateX(0); } 25% { transform: translateX(-2px); } 50% { transform: translateX(2px); } 75% { transform: translateX(-2px); } 100% { transform: translateX(0); } }
@keyframes pump { 0% { transform: rotateZ(0deg); } 25% { transform: rotateZ(-15deg); } 50% { transform: rotateZ(0deg); } 75% { transform: rotateZ(-15deg); } 100% { transform: rotateZ(0deg); } }
@keyframes pullPush { 0% { transform: translateY(0); } 25% { transform: translateY(20px); } 50% { transform: translateY(0); } 75% { transform: translateY(20px); } 100% { transform: translateY(0); } }
@keyframes rotateWheel { 0% { transform: rotate(0deg); } 25% { transform: rotate(90deg); } 50% { transform: rotate(0deg); } 75% { transform: rotate(90deg); } 100% { transform: rotate(0deg); } }
`;
document.head.appendChild(style);
}
document.body.appendChild(sausage);
}
}, { passive: false });
}
});