function unreachable() { return new Error("unreachable"); } if (typeof VERSION !== "undefined") { document.getElementById("versionSpan").innerHTML = '' + VERSION.tag + ''; } var canvas = document.getElementById("canvas"); // tile codes var SPACE = 0; var WALL = 1; var SPIKE = 2; var FRUIT_v0 = 3; // legacy var EXIT = 4; var PORTAL = 5; var validTileCodes = [SPACE, WALL, SPIKE, EXIT, PORTAL]; // object types var SNAKE = "s"; var BLOCK = "b"; var FRUIT = "f"; var tileSize = 30; var level; var unmoveStuff = {undoStack:[], redoStack:[], spanId:"movesSpan", undoButtonId:"unmoveButton", redoButtonId:"removeButton"}; var uneditStuff = {undoStack:[], redoStack:[], spanId:"editsSpan", undoButtonId:"uneditButton", redoButtonId:"reeditButton"}; var paradoxes = []; function loadLevel(newLevel) { level = newLevel; currentSerializedLevel = compressSerialization(stringifyLevel(newLevel)); activateAnySnakePlease(); unmoveStuff.undoStack = []; unmoveStuff.redoStack = []; undoStuffChanged(unmoveStuff); uneditStuff.undoStack = []; uneditStuff.redoStack = []; undoStuffChanged(uneditStuff); blockSupportRenderCache = {}; render(); } var magicNumber_v0 = "3tFRIoTU"; var magicNumber = "HyRr4JK1"; var exampleLevel = magicNumber_v0 + "&" + "17&31" + "?" + "0000000000000000000000000000000" + "0000000000000000000000000000000" + "0000000000000000000000000000000" + "0000000000000000000000000000000" + "0000000000000000000000000000000" + "0000000000000000000000000000000" + "0000000000000000000040000000000" + "0000000000000110000000000000000" + "0000000000000111100000000000000" + "0000000000000011000000000000000" + "0000000000000010000010000000000" + "0000000000000010100011000000000" + "0000001111111000110000000110000" + "0000011111111111111111111110000" + "0000011111111101111111111100000" + "0000001111111100111111111100000" + "0000001111111000111111111100000" + "/" + "s0 ?351&350&349/" + "f0 ?328/" + "f1 ?366/"; var testLevel_v0 = "3tFRIoTU&5&5?0005*00300024005*001000/b0?7&6&15&23/s3?18/s0?1&0&5/s1?2/s4?10/s2?17/b2?9/b3?14/b4?19/b1?4&20/b5?24/"; var testLevel_v0_converted = "HyRr4JK1&5&5?0005*4024005*001000/b0?7&6&15&23/s3?18/s0?1&0&5/s1?2/s4?10/s2?17/b2?9/b3?14/b4?19/b1?4&20/b5?24/f0?8/"; function parseLevel(string) { // magic number var cursor = 0; skipWhitespace(); var versionTag = string.substr(cursor, magicNumber.length); switch (versionTag) { case magicNumber_v0: case magicNumber: break; default: throw new Error("not a snakefall level"); } cursor += magicNumber.length; consumeKeyword("&"); var level = { height: -1, width: -1, map: [], objects: [], }; // height, width level.height = readInt(); consumeKeyword("&"); level.width = readInt(); // map var mapData = readRun(); mapData = decompressSerialization(mapData); if (level.height * level.width !== mapData.length) throw parserError("height, width, and map.length do not jive"); var upconvertedObjects = []; var fruitCount = 0; for (var i = 0; i < mapData.length; i++) { var tileCode = mapData[i].charCodeAt(0) - "0".charCodeAt(0); if (tileCode === FRUIT_v0 && versionTag === magicNumber_v0) { // fruit used to be a tile code. now it's an object. upconvertedObjects.push({ type: FRUIT, id: fruitCount++, dead: false, // unused locations: [i], }); tileCode = SPACE; } if (validTileCodes.indexOf(tileCode) === -1) throw parserError("invalid tilecode: " + JSON.stringify(mapData[i])); level.map.push(tileCode); } // objects skipWhitespace(); while (cursor < string.length) { var object = { type: "?", id: -1, dead: false, locations: [], }; // type object.type = string[cursor]; var locationsLimit; if (object.type === SNAKE) locationsLimit = -1; else if (object.type === BLOCK) locationsLimit = -1; else if (object.type === FRUIT) locationsLimit = 1; else throw parserError("expected object type code"); cursor += 1; // id object.id = readInt(); // locations var locationsData = readRun(); var locationStrings = locationsData.split("&"); if (locationStrings.length === 0) throw parserError("locations must be non-empty"); if (locationsLimit !== -1 && locationStrings.length > locationsLimit) throw parserError("too many locations"); locationStrings.forEach(function(locationString) { var location = parseInt(locationString); if (!(0 <= location && location < level.map.length)) throw parserError("location out of bounds: " + JSON.stringify(locationString)); object.locations.push(location); }); level.objects.push(object); skipWhitespace(); } for (var i = 0; i < upconvertedObjects.length; i++) { level.objects.push(upconvertedObjects[i]); } return level; function skipWhitespace() { while (" \n\t\r".indexOf(string[cursor]) !== -1) { cursor += 1; } } function consumeKeyword(keyword) { skipWhitespace(); if (string.indexOf(keyword, cursor) !== cursor) throw parserError("expected " + JSON.stringify(keyword)); cursor += 1; } function readInt() { skipWhitespace(); for (var i = cursor; i < string.length; i++) { if ("0123456789".indexOf(string[i]) === -1) break; } var substring = string.substring(cursor, i); if (substring.length === 0) throw parserError("expected int"); cursor = i; return parseInt(substring, 10); } function readRun() { consumeKeyword("?"); var endIndex = string.indexOf("/", cursor); var substring = string.substring(cursor, endIndex); cursor = endIndex + 1; return substring; } function parserError(message) { return new Error("parse error at position " + cursor + ": " + message); } } function stringifyLevel(level) { var output = magicNumber + "&"; output += level.height + "&" + level.width + "\n"; output += "?\n"; for (var r = 0; r < level.height; r++) { output += " " + level.map.slice(r * level.width, (r + 1) * level.width).join("") + "\n"; } output += "/\n"; output += serializeObjects(level.objects); // sanity check var shouldBeTheSame = parseLevel(output); if (!deepEquals(level, shouldBeTheSame)) throw asdf; // serialization/deserialization is broken return output; } function serializeObjects(objects) { var output = ""; for (var i = 0; i < objects.length; i++) { var object = objects[i]; output += object.type + object.id + " "; output += "?" + object.locations.join("&") + "/\n"; } return output; } function serializeObjectState(object) { if (object == null) return [0,[]]; return [object.dead, copyArray(object.locations)]; } var base66 = "----0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; function compressSerialization(string) { string = string.replace(/\s+/g, ""); // run-length encode several 0's in a row, etc. // 2000000000000003 -> 2*A03 ("A" is 14 in base66 defined above) var result = ""; var runStart = 0; for (var i = 1; i < string.length + 1; i++) { var runLength = i - runStart; if (string[i] === string[runStart] && runLength < base66.length - 1) continue; // end of run if (runLength >= 4) { // compress result += "*" + base66[runLength] + string[runStart]; } else { // literal result += string.substring(runStart, i); } runStart = i; } return result; } function decompressSerialization(string) { string = string.replace(/\s+/g, ""); var result = ""; for (var i = 0; i < string.length; i++) { if (string[i] === "*") { i += 1; var runLength = base66.indexOf(string[i]); i += 1; var char = string[i]; for (var j = 0; j < runLength; j++) { result += char; } } else { result += string[i]; } } return result; } var replayMagicNumber = "nmGTi8PB"; function stringifyReplay() { var output = replayMagicNumber + "&"; // only specify the snake id in an input if it's different from the previous. // the first snake index is 0 to optimize for the single-snake case. var currentSnakeId = 0; for (var i = 0; i < unmoveStuff.undoStack.length; i++) { var firstChange = unmoveStuff.undoStack[i][0]; if (firstChange[0] !== "i") throw unreachable(); var snakeId = firstChange[1]; var dr = firstChange[2]; var dc = firstChange[3]; var directionCode; if (dr ===-1 && dc === 0) directionCode = "u"; else if (dr === 0 && dc ===-1) directionCode = "l"; else if (dr === 1 && dc === 0) directionCode = "d"; else if (dr === 0 && dc === 1) directionCode = "r"; else throw unreachable(); if (snakeId !== currentSnakeId) { output += snakeId; // int to string currentSnakeId = snakeId; } output += directionCode; } return output; } function parseAndLoadReplay(string) { string = decompressSerialization(string); var expectedPrefix = replayMagicNumber + "&"; if (string.substring(0, expectedPrefix.length) !== expectedPrefix) throw new Error("unrecognized replay string"); var cursor = expectedPrefix.length; // the starting snakeid is 0, which may not exist, but we only validate it when doing a move. activeSnakeId = 0; while (cursor < string.length) { var snakeIdStr = ""; var c = string.charAt(cursor); cursor += 1; while ('0' <= c && c <= '9') { snakeIdStr += c; if (cursor >= string.length) throw new Error("replay string has unexpected end of input"); c = string.charAt(cursor); cursor += 1; } if (snakeIdStr.length > 0) { activeSnakeId = parseInt(snakeIdStr); // don't just validate when switching snakes, but on every move. } // doing a move. if (!getSnakes().some(function(snake) { return snake.id === activeSnakeId; })) { throw new Error("invalid snake id: " + activeSnakeId); } switch (c) { case 'l': move( 0, -1); break; case 'u': move(-1, 0); break; case 'r': move( 0, 1); break; case 'd': move( 1, 0); break; default: throw new Error("replay string has invalid direction: " + c); } } // now that the replay was executed successfully, undo it all so that it's available in the redo buffer. reset(unmoveStuff); document.getElementById("removeButton").classList.add("click-me"); } var currentSerializedLevel; function saveLevel() { if (isDead()) return alert("Can't save while you're dead!"); var serializedLevel = compressSerialization(stringifyLevel(level)); currentSerializedLevel = serializedLevel; var hash = "#level=" + serializedLevel; expectHash = hash; location.hash = hash; // This marks a starting point for solving the level. unmoveStuff.undoStack = []; unmoveStuff.redoStack = []; editorHasBeenTouched = false; undoStuffChanged(unmoveStuff); } function saveReplay() { if (dirtyState === EDITOR_DIRTY) return alert("Can't save a replay with unsaved editor changes."); // preserve the level in the url bar. var hash = "#level=" + currentSerializedLevel; if (dirtyState === REPLAY_DIRTY) { // there is a replay to save hash += "#replay=" + compressSerialization(stringifyReplay()); } expectHash = hash; location.hash = hash; } function deepEquals(a, b) { if (a == null) return b == null; if (typeof a === "string" || typeof a === "number" || typeof a === "boolean") return a === b; if (Array.isArray(a)) { if (!Array.isArray(b)) return false; if (a.length !== b.length) return false; for (var i = 0; i < a.length; i++) { if (!deepEquals(a[i], b[i])) return false; } return true; } // must be objects var aKeys = Object.keys(a); var bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; aKeys.sort(); bKeys.sort(); if (!deepEquals(aKeys, bKeys)) return false; for (var i = 0; i < aKeys.length; i++) { if (!deepEquals(a[aKeys[i]], b[bKeys[i]])) return false; } return true; } function getLocation(level, r, c) { if (!isInBounds(level, r, c)) throw unreachable(); return r * level.width + c; } function getRowcol(level, location) { if (location < 0 || location >= level.width * level.height) throw unreachable(); var r = Math.floor(location / level.width); var c = location % level.width; return {r:r, c:c}; } function isInBounds(level, r, c) { if (c < 0 || c >= level.width) return false;; if (r < 0 || r >= level.height) return false;; return true; } function offsetLocation(location, dr, dc) { var rowcol = getRowcol(level, location); return getLocation(level, rowcol.r + dr, rowcol.c + dc); } var SHIFT = 1; var CTRL = 2; var ALT = 4; document.addEventListener("keydown", function(event) { var modifierMask = ( (event.shiftKey ? SHIFT : 0) | (event.ctrlKey ? CTRL : 0) | (event.altKey ? ALT : 0) ); switch (event.keyCode) { case 37: // left if (modifierMask === 0) { move(0, -1); break; } return; case 38: // up if (modifierMask === 0) { move(-1, 0); break; } return; case 39: // right if (modifierMask === 0) { move(0, 1); break; } return; case 40: // down if (modifierMask === 0) { move(1, 0); break; } return; case 8: // backspace if (modifierMask === 0) { undo(unmoveStuff); break; } if (modifierMask === SHIFT) { redo(unmoveStuff); break; } return; case "Q".charCodeAt(0): if (modifierMask === 0) { undo(unmoveStuff); break; } if (modifierMask === SHIFT) { redo(unmoveStuff); break; } return; case "Z".charCodeAt(0): if (modifierMask === 0) { undo(unmoveStuff); break; } if (modifierMask === SHIFT) { redo(unmoveStuff); break; } if (persistentState.showEditor && modifierMask === CTRL) { undo(uneditStuff); break; } if (persistentState.showEditor && modifierMask === CTRL|SHIFT) { redo(uneditStuff); break; } return; case "Y".charCodeAt(0): if (modifierMask === 0) { redo(unmoveStuff); break; } if (persistentState.showEditor && modifierMask === CTRL) { redo(uneditStuff); break; } return; case "R".charCodeAt(0): if (persistentState.showEditor && modifierMask === SHIFT) { setPaintBrushTileCode("select"); break; } if (modifierMask === 0) { reset(unmoveStuff); break; } if (modifierMask === SHIFT) { unreset(unmoveStuff); break; } return; case 220: // backslash if (modifierMask === 0) { toggleShowEditor(); break; } return; case "A".charCodeAt(0): if (!persistentState.showEditor && modifierMask === 0) { move(0, -1); break; } if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(PORTAL); break; } if ( persistentState.showEditor && modifierMask === CTRL) { selectAll(); break; } return; case "E".charCodeAt(0): if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(SPACE); break; } return; case 46: // delete if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(SPACE); break; } return; case "W".charCodeAt(0): if (!persistentState.showEditor && modifierMask === 0) { move(-1, 0); break; } if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(WALL); break; } return; case "S".charCodeAt(0): if (!persistentState.showEditor && modifierMask === 0) { move(1, 0); break; } if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(SPIKE); break; } if ( persistentState.showEditor && modifierMask === SHIFT) { setPaintBrushTileCode("resize"); break; } if ( persistentState.showEditor && modifierMask === CTRL) { saveLevel(); break; } if (!persistentState.showEditor && modifierMask === CTRL) { saveReplay(); break; } if (modifierMask === (CTRL|SHIFT)) { saveReplay(); break; } return; case "X".charCodeAt(0): if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(EXIT); break; } if ( persistentState.showEditor && modifierMask === CTRL) { cutSelection(); break; } return; case "F".charCodeAt(0): if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(FRUIT); break; } return; case "D".charCodeAt(0): if (!persistentState.showEditor && modifierMask === 0) { move(0, 1); break; } if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(SNAKE); break; } return; case "B".charCodeAt(0): if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(BLOCK); break; } return; case "G".charCodeAt(0): if (modifierMask === 0) { toggleGrid(); break; } if ( persistentState.showEditor && modifierMask === SHIFT) { toggleGravity(); break; } return; case "C".charCodeAt(0): if ( persistentState.showEditor && modifierMask === SHIFT) { toggleCollision(); break; } if ( persistentState.showEditor && modifierMask === CTRL) { copySelection(); break; } return; case "V".charCodeAt(0): if ( persistentState.showEditor && modifierMask === CTRL) { setPaintBrushTileCode("paste"); break; } return; case 32: // spacebar case 9: // tab if (modifierMask === 0) { switchSnakes( 1); break; } if (modifierMask === SHIFT) { switchSnakes(-1); break; } return; case "1".charCodeAt(0): case "2".charCodeAt(0): case "3".charCodeAt(0): case "4".charCodeAt(0): var index = event.keyCode - "1".charCodeAt(0); var delta; if (modifierMask === 0) { delta = 1; } else if (modifierMask === SHIFT) { delta = -1; } else return; if (isAlive()) { (function() { var snakes = findSnakesOfColor(index); if (snakes.length === 0) return; for (var i = 0; i < snakes.length; i++) { if (snakes[i].id === activeSnakeId) { activeSnakeId = snakes[(i + delta + snakes.length) % snakes.length].id; return; } } activeSnakeId = snakes[0].id; })(); } break; case 27: // escape if ( persistentState.showEditor && modifierMask === 0) { setPaintBrushTileCode(null); break; } return; default: return; } event.preventDefault(); render(); }); document.getElementById("switchSnakesButton").addEventListener("click", function() { switchSnakes(1); render(); }); function switchSnakes(delta) { if (!isAlive()) return; var snakes = getSnakes(); snakes.sort(compareId); for (var i = 0; i < snakes.length; i++) { if (snakes[i].id === activeSnakeId) { activeSnakeId = snakes[(i + delta + snakes.length) % snakes.length].id; return; } } activeSnakeId = snakes[0].id; } document.getElementById("showGridButton").addEventListener("click", function() { toggleGrid(); }); document.getElementById("saveProgressButton").addEventListener("click", function() { saveReplay(); }); document.getElementById("restartButton").addEventListener("click", function() { reset(unmoveStuff); render(); }); document.getElementById("unmoveButton").addEventListener("click", function() { undo(unmoveStuff); render(); }); document.getElementById("removeButton").addEventListener("click", function() { redo(unmoveStuff); render(); }); document.getElementById("showHideEditor").addEventListener("click", function() { toggleShowEditor(); }); function toggleShowEditor() { persistentState.showEditor = !persistentState.showEditor; savePersistentState(); showEditorChanged(); } function toggleGrid() { persistentState.showGrid = !persistentState.showGrid; savePersistentState(); render(); } ["serializationTextarea", "shareLinkTextbox"].forEach(function(id) { document.getElementById(id).addEventListener("keydown", function(event) { // let things work normally event.stopPropagation(); }); }); document.getElementById("submitSerializationButton").addEventListener("click", function() { var string = document.getElementById("serializationTextarea").value; try { var newLevel = parseLevel(string); } catch (e) { alert(e); return; } loadLevel(newLevel); }); document.getElementById("shareLinkTextbox").addEventListener("focus", function() { setTimeout(function() { document.getElementById("shareLinkTextbox").select(); }, 0); }); var paintBrushTileCode = null; var paintBrushSnakeColorIndex = 0; var paintBrushBlockId = 0; var paintBrushObject = null; var selectionStart = null; var selectionEnd = null; var resizeDragAnchorRowcol = null; var clipboardData = null; var clipboardOffsetRowcol = null; var paintButtonIdAndTileCodes = [ ["resizeButton", "resize"], ["selectButton", "select"], ["pasteButton", "paste"], ["paintSpaceButton", SPACE], ["paintWallButton", WALL], ["paintSpikeButton", SPIKE], ["paintExitButton", EXIT], ["paintFruitButton", FRUIT], ["paintPortalButton", PORTAL], ["paintSnakeButton", SNAKE], ["paintBlockButton", BLOCK], ]; paintButtonIdAndTileCodes.forEach(function(pair) { var id = pair[0]; var tileCode = pair[1]; document.getElementById(id).addEventListener("click", function() { setPaintBrushTileCode(tileCode); }); }); document.getElementById("uneditButton").addEventListener("click", function() { undo(uneditStuff); render(); }); document.getElementById("reeditButton").addEventListener("click", function() { redo(uneditStuff); render(); }); document.getElementById("saveLevelButton").addEventListener("click", function() { saveLevel(); }); document.getElementById("copyButton").addEventListener("click", function() { copySelection(); }); document.getElementById("cutButton").addEventListener("click", function() { cutSelection(); }); document.getElementById("cheatGravityButton").addEventListener("click", function() { toggleGravity(); }); document.getElementById("cheatCollisionButton").addEventListener("click", function() { toggleCollision(); }); function toggleGravity() { isGravityEnabled = !isGravityEnabled; isCollisionEnabled = true; refreshCheatButtonText(); } function toggleCollision() { isCollisionEnabled = !isCollisionEnabled; isGravityEnabled = false; refreshCheatButtonText(); } function refreshCheatButtonText() { document.getElementById("cheatGravityButton").textContent = isGravityEnabled ? "Gravity: ON" : "Gravity: OFF"; document.getElementById("cheatGravityButton").style.background = isGravityEnabled ? "" : "#f88"; document.getElementById("cheatCollisionButton").textContent = isCollisionEnabled ? "Collision: ON" : "Collision: OFF"; document.getElementById("cheatCollisionButton").style.background = isCollisionEnabled ? "" : "#f88"; } // be careful with location vs rowcol, because this variable is used when resizing var lastDraggingRowcol = null; var hoverLocation = null; var draggingChangeLog = null; canvas.addEventListener("mousedown", function(event) { if (event.altKey) return; if (event.button !== 0) return; event.preventDefault(); var location = getLocationFromEvent(event); if (persistentState.showEditor && paintBrushTileCode != null) { // editor tool lastDraggingRowcol = getRowcol(level, location); if (paintBrushTileCode === "select") selectionStart = location; if (paintBrushTileCode === "resize") resizeDragAnchorRowcol = lastDraggingRowcol; draggingChangeLog = []; paintAtLocation(location, draggingChangeLog); } else { // playtime var object = findObjectAtLocation(location); if (object == null) return; if (object.type !== SNAKE) return; // active snake activeSnakeId = object.id; render(); } }); canvas.addEventListener("dblclick", function(event) { if (event.altKey) return; if (event.button !== 0) return; event.preventDefault(); if (persistentState.showEditor && paintBrushTileCode === "select") { // double click with select tool var location = getLocationFromEvent(event); var object = findObjectAtLocation(location); if (object == null) return; stopDragging(); if (object.type === SNAKE) { // edit snakes of this color paintBrushTileCode = SNAKE; paintBrushSnakeColorIndex = object.id % snakeColors.length; } else if (object.type === BLOCK) { // edit this particular block paintBrushTileCode = BLOCK; paintBrushBlockId = object.id; } else if (object.type === FRUIT) { // edit fruits, i guess paintBrushTileCode = FRUIT; } else throw unreachable(); paintBrushTileCodeChanged(); } }); document.addEventListener("mouseup", function(event) { stopDragging(); }); function stopDragging() { if (lastDraggingRowcol != null) { // release the draggin' lastDraggingRowcol = null; paintBrushObject = null; resizeDragAnchorRowcol = null; pushUndo(uneditStuff, draggingChangeLog); draggingChangeLog = null; } } canvas.addEventListener("mousemove", function(event) { if (!persistentState.showEditor) return; var location = getLocationFromEvent(event); var mouseRowcol = getRowcol(level, location); if (lastDraggingRowcol != null) { // Dragging Force - Through the Fruit and Flames var lastDraggingLocation = getLocation(level, lastDraggingRowcol.r, lastDraggingRowcol.c); // we need to get rowcols for everything before we start dragging, because dragging might resize the world. var path = getNaiveOrthogonalPath(lastDraggingLocation, location).map(function(location) { return getRowcol(level, location); }); path.forEach(function(rowcol) { // convert to location at the last minute in case each of these steps is changing the coordinate system. paintAtLocation(getLocation(level, rowcol.r, rowcol.c), draggingChangeLog); }); lastDraggingRowcol = mouseRowcol; hoverLocation = null; } else { // hovering if (hoverLocation !== location) { hoverLocation = location; render(); } } }); canvas.addEventListener("mouseout", function() { if (hoverLocation !== location) { // turn off the hover when the mouse leaves hoverLocation = null; render(); } }); function getLocationFromEvent(event) { var r = Math.floor(eventToMouseY(event, canvas) / tileSize); var c = Math.floor(eventToMouseX(event, canvas) / tileSize); // since the canvas is centered, the bounding client rect can be half-pixel aligned, // resulting in slightly out-of-bounds mouse events. r = clamp(r, 0, level.height); c = clamp(c, 0, level.width); return getLocation(level, r, c); } function eventToMouseX(event, canvas) { return event.clientX - canvas.getBoundingClientRect().left; } function eventToMouseY(event, canvas) { return event.clientY - canvas.getBoundingClientRect().top; } function selectAll() { selectionStart = 0; selectionEnd = level.map.length - 1; setPaintBrushTileCode("select"); } function setPaintBrushTileCode(tileCode) { if (tileCode === "paste") { // make sure we have something to paste if (clipboardData == null) return; } if (paintBrushTileCode === "select" && tileCode !== "select" && selectionStart != null && selectionEnd != null) { // usually this means to fill in the selection if (tileCode == null) { // cancel selection selectionStart = null; selectionEnd = null; return; } if (typeof tileCode === "number" && tileCode !== PORTAL) { // fill in the selection fillSelection(tileCode); selectionStart = null; selectionEnd = null; return; } // ok, just select something else then. selectionStart = null; selectionEnd = null; } if (tileCode === SNAKE) { if (paintBrushTileCode === SNAKE) { // next snake color paintBrushSnakeColorIndex = (paintBrushSnakeColorIndex + 1) % snakeColors.length; } } else if (tileCode === BLOCK) { var blocks = getBlocks(); if (paintBrushTileCode === BLOCK && blocks.length > 0) { // cycle through block ids blocks.sort(compareId); if (paintBrushBlockId != null) { (function() { for (var i = 0; i < blocks.length; i++) { if (blocks[i].id === paintBrushBlockId) { i += 1; if (i < blocks.length) { // next block id paintBrushBlockId = blocks[i].id; } else { // new block id paintBrushBlockId = null; } return; } } throw unreachable() })(); } else { // first one paintBrushBlockId = blocks[0].id; } } else { // new block id paintBrushBlockId = null; } } else if (tileCode == null) { // escape if (paintBrushTileCode === BLOCK && paintBrushBlockId != null) { // stop editing this block, but keep the block brush selected tileCode = BLOCK; paintBrushBlockId = null; } } paintBrushTileCode = tileCode; paintBrushTileCodeChanged(); } function paintBrushTileCodeChanged() { paintButtonIdAndTileCodes.forEach(function(pair) { var id = pair[0]; var tileCode = pair[1]; var backgroundStyle = ""; if (tileCode === paintBrushTileCode) { if (tileCode === SNAKE) { // show the color of the active snake in the color of the button backgroundStyle = snakeColors[paintBrushSnakeColorIndex]; } else { backgroundStyle = "#ff0"; } } document.getElementById(id).style.background = backgroundStyle; }); var isSelectionMode = paintBrushTileCode === "select"; ["cutButton", "copyButton"].forEach(function (id) { document.getElementById(id).disabled = !isSelectionMode; }); document.getElementById("pasteButton").disabled = clipboardData == null; render(); } function cutSelection() { copySelection(); fillSelection(SPACE); render(); } function copySelection() { var selectedLocations = getSelectedLocations(); if (selectedLocations.length === 0) return; var selectedObjects = []; selectedLocations.forEach(function(location) { var object = findObjectAtLocation(location); if (object != null) addIfNotPresent(selectedObjects, object); }); setClipboardData({ level: JSON.parse(JSON.stringify(level)), selectedLocations: selectedLocations, selectedObjects: JSON.parse(JSON.stringify(selectedObjects)), }); } function setClipboardData(data) { // find the center var minR = Infinity; var maxR = -Infinity; var minC = Infinity; var maxC = -Infinity; data.selectedLocations.forEach(function(location) { var rowcol = getRowcol(data.level, location); if (rowcol.r < minR) minR = rowcol.r; if (rowcol.r > maxR) maxR = rowcol.r; if (rowcol.c < minC) minC = rowcol.c; if (rowcol.c > maxC) maxC = rowcol.c; }); var offsetR = Math.floor((minR + maxR) / 2); var offsetC = Math.floor((minC + maxC) / 2); clipboardData = data; clipboardOffsetRowcol = {r:offsetR, c:offsetC}; paintBrushTileCodeChanged(); } function fillSelection(tileCode) { var changeLog = []; var locations = getSelectedLocations(); locations.forEach(function(location) { if (level.map[location] !== tileCode) { changeLog.push(["m", location, level.map[location], tileCode]); level.map[location] = tileCode; } removeAnyObjectAtLocation(location, changeLog); }); pushUndo(uneditStuff, changeLog); } function getSelectedLocations() { if (selectionStart == null || selectionEnd == null) return []; var rowcol1 = getRowcol(level, selectionStart); var rowcol2 = getRowcol(level, selectionEnd); var r1 = rowcol1.r; var c1 = rowcol1.c; var r2 = rowcol2.r; var c2 = rowcol2.c; if (r2 < r1) { var tmp = r1; r1 = r2; r2 = tmp; } if (c2 < c1) { var tmp = c1; c1 = c2; c2 = tmp; } var objects = []; var locations = []; for (var r = r1; r <= r2; r++) { for (var c = c1; c <= c2; c++) { var location = getLocation(level, r, c); locations.push(location); var object = findObjectAtLocation(location); if (object != null) addIfNotPresent(objects, object); } } // select the rest of any partially-selected objects objects.forEach(function(object) { object.locations.forEach(function(location) { addIfNotPresent(locations, location); }); }); return locations; } function setHeight(newHeight, changeLog) { if (newHeight < level.height) { // crop for (var r = newHeight; r < level.height; r++) { for (var c = 0; c < level.width; c++) { var location = getLocation(level, r, c); removeAnyObjectAtLocation(location, changeLog); // also delete non-space tiles paintTileAtLocation(location, SPACE, changeLog); } } level.map.splice(newHeight * level.width); } else { // expand for (var r = level.height; r < newHeight; r++) { for (var c = 0; c < level.width; c++) { level.map.push(SPACE); } } } changeLog.push(["h", level.height, newHeight]); level.height = newHeight; } function setWidth(newWidth, changeLog) { if (newWidth < level.width) { // crop for (var r = level.height - 1; r >= 0; r--) { for (var c = level.width - 1; c >= newWidth; c--) { var location = getLocation(level, r, c); removeAnyObjectAtLocation(location, changeLog); paintTileAtLocation(location, SPACE, changeLog); level.map.splice(location, 1); } } } else { // expand for (var r = level.height - 1; r >= 0; r--) { var insertionPoint = level.width * (r + 1); for (var c = level.width; c < newWidth; c++) { // boy is this inefficient. ... YOLO! level.map.splice(insertionPoint, 0, SPACE); } } } var transformLocation = makeScaleCoordinatesFunction(level.width, newWidth); level.objects.forEach(function(object) { object.locations = object.locations.map(transformLocation); }); changeLog.push(["w", level.width, newWidth]); level.width = newWidth; } function newSnake(color, location) { var snakes = findSnakesOfColor(color); snakes.sort(compareId); for (var i = 0; i < snakes.length; i++) { if (snakes[i].id !== i * snakeColors.length + color) break; } return { type: SNAKE, id: i * snakeColors.length + color, dead: false, locations: [location], }; } function newBlock(location) { var blocks = getBlocks(); blocks.sort(compareId); for (var i = 0; i < blocks.length; i++) { if (blocks[i].id !== i) break; } return { type: BLOCK, id: i, dead: false, // unused locations: [location], }; } function newFruit(location) { var fruits = getObjectsOfType(FRUIT); fruits.sort(compareId); for (var i = 0; i < fruits.length; i++) { if (fruits[i].id !== i) break; } return { type: FRUIT, id: i, dead: false, // unused locations: [location], }; } function paintAtLocation(location, changeLog) { if (typeof paintBrushTileCode === "number") { removeAnyObjectAtLocation(location, changeLog); paintTileAtLocation(location, paintBrushTileCode, changeLog); } else if (paintBrushTileCode === "resize") { var toRowcol = getRowcol(level, location); var dr = toRowcol.r - resizeDragAnchorRowcol.r; var dc = toRowcol.c - resizeDragAnchorRowcol.c; resizeDragAnchorRowcol = toRowcol; if (dr !== 0) setHeight(level.height + dr, changeLog); if (dc !== 0) setWidth(level.width + dc, changeLog); } else if (paintBrushTileCode === "select") { selectionEnd = location; } else if (paintBrushTileCode === "paste") { var hoverRowcol = getRowcol(level, location); var pastedData = previewPaste(hoverRowcol.r, hoverRowcol.c); pastedData.selectedLocations.forEach(function(location) { var tileCode = pastedData.level.map[location]; removeAnyObjectAtLocation(location, changeLog); paintTileAtLocation(location, tileCode, changeLog); }); pastedData.selectedObjects.forEach(function(object) { // refresh the ids so there are no collisions. if (object.type === SNAKE) { object.id = newSnake(object.id % snakeColors.length).id; } else if (object.type === BLOCK) { object.id = newBlock().id; } else if (object.type === FRUIT) { object.id = newFruit().id; } else throw unreachable(); level.objects.push(object); changeLog.push([object.type, object.id, [0,[]], serializeObjectState(object)]); }); } else if (paintBrushTileCode === SNAKE) { var oldSnakeSerialization = serializeObjectState(paintBrushObject); if (paintBrushObject != null) { // keep dragging if (paintBrushObject.locations[0] === location) return; // we just did that // watch out for self-intersection var selfIntersectionIndex = paintBrushObject.locations.indexOf(location); if (selfIntersectionIndex !== -1) { // truncate from here back paintBrushObject.locations.splice(selfIntersectionIndex); } } // make sure there's space behind us paintTileAtLocation(location, SPACE, changeLog); removeAnyObjectAtLocation(location, changeLog); if (paintBrushObject == null) { var thereWereNoSnakes = countSnakes() === 0; paintBrushObject = newSnake(paintBrushSnakeColorIndex, location); level.objects.push(paintBrushObject); if (thereWereNoSnakes) activateAnySnakePlease(); } else { // extend le snake paintBrushObject.locations.unshift(location); } changeLog.push([paintBrushObject.type, paintBrushObject.id, oldSnakeSerialization, serializeObjectState(paintBrushObject)]); } else if (paintBrushTileCode === BLOCK) { var objectHere = findObjectAtLocation(location); if (paintBrushBlockId == null && objectHere != null && objectHere.type === BLOCK) { // just start editing this block paintBrushBlockId = objectHere.id; } else { // make a change // make sure there's space behind us paintTileAtLocation(location, SPACE, changeLog); var thisBlock = null; if (paintBrushBlockId != null) { thisBlock = findBlockById(paintBrushBlockId); } var oldBlockSerialization = serializeObjectState(thisBlock); if (thisBlock == null) { // create new block removeAnyObjectAtLocation(location, changeLog); thisBlock = newBlock(location); level.objects.push(thisBlock); paintBrushBlockId = thisBlock.id; } else { var existingIndex = thisBlock.locations.indexOf(location); if (existingIndex !== -1) { // reclicking part of this object means to delete just part of it. if (thisBlock.locations.length === 1) { // goodbye removeObject(thisBlock, changeLog); paintBrushBlockId = null; } else { thisBlock.locations.splice(existingIndex, 1); } } else { // add a tile to the block removeAnyObjectAtLocation(location, changeLog); thisBlock.locations.push(location); } } changeLog.push([thisBlock.type, thisBlock.id, oldBlockSerialization, serializeObjectState(thisBlock)]); delete blockSupportRenderCache[thisBlock.id]; } } else if (paintBrushTileCode === FRUIT) { paintTileAtLocation(location, SPACE, changeLog); removeAnyObjectAtLocation(location, changeLog); var object = newFruit(location) level.objects.push(object); changeLog.push([object.type, object.id, serializeObjectState(null), serializeObjectState(object)]); } else throw unreachable(); render(); } function paintTileAtLocation(location, tileCode, changeLog) { if (level.map[location] === tileCode) return; changeLog.push(["m", location, level.map[location], tileCode]); level.map[location] = tileCode; } function pushUndo(undoStuff, changeLog) { // changeLog = [ // ["i", 0, -1, 0, animationQueue, freshlyRemovedAnimatedObjects], // // player input for snake 0, dr:-1, dc:0. has no effect on state. // // "i" is always the first change in normal player movement. // // if a changeLog does not start with "i", then it is an editor action. // // animationQueue and freshlyRemovedAnimatedObjects // // are used for animating re-move. // ["m", 21, 0, 1], // map at location 23 changed from 0 to 1 // ["s", 0, [false, [1,2]], [false, [2,3]]], // snake id 0 moved from alive at [1, 2] to alive at [2, 3] // ["s", 1, [false, [11,12]], [true, [12,13]]], // snake id 1 moved from alive at [11, 12] to dead at [12, 13] // ["b", 1, [false, [20,30]], [false, []]], // block id 1 was deleted from location [20, 30] // ["f", 0, [false, [40]], [false, []]], // fruit id 0 was deleted from location [40] // ["h", 25, 10], // height changed from 25 to 10. all cropped tiles are guaranteed to be SPACE. // ["w", 8, 10], // width changed from 8 to 10. a change in the coordinate system. // ["m", 23, 2, 0], // map at location 23 changed from 2 to 0 in the new coordinate system. // 10, // the last change is always a declaration of the final width of the map. // ]; reduceChangeLog(changeLog); if (changeLog.length === 0) return; changeLog.push(level.width); undoStuff.undoStack.push(changeLog); undoStuff.redoStack = []; paradoxes = []; if (undoStuff === uneditStuff) editorHasBeenTouched = true; undoStuffChanged(undoStuff); } function reduceChangeLog(changeLog) { for (var i = 0; i < changeLog.length - 1; i++) { var change = changeLog[i]; if (change[0] === "i") { continue; // don't reduce player input } else if (change[0] === "h") { for (var j = i + 1; j < changeLog.length; j++) { var otherChange = changeLog[j]; if (otherChange[0] === "h") { // combine change[2] = otherChange[2]; changeLog.splice(j, 1); j--; continue; } else if (otherChange[0] === "w") { continue; // no interaction between height and width } else break; // no more reduction possible } if (change[1] === change[2]) { // no change changeLog.splice(i, 1); i--; } } else if (change[0] === "w") { for (var j = i + 1; j < changeLog.length; j++) { var otherChange = changeLog[j]; if (otherChange[0] === "w") { // combine change[2] = otherChange[2]; changeLog.splice(j, 1); j--; continue; } else if (otherChange[0] === "h") { continue; // no interaction between height and width } else break; // no more reduction possible } if (change[1] === change[2]) { // no change changeLog.splice(i, 1); i--; } } else if (change[0] === "m") { for (var j = i + 1; j < changeLog.length; j++) { var otherChange = changeLog[j]; if (otherChange[0] === "m" && otherChange[1] === change[1]) { // combine change[3] = otherChange[3]; changeLog.splice(j, 1); j--; } else if (otherChange[0] === "w" || otherChange[0] === "h") { break; // can't reduce accros resizes } } if (change[2] === change[3]) { // no change changeLog.splice(i, 1); i--; } } else if (change[0] === SNAKE || change[0] === BLOCK || change[0] === FRUIT) { for (var j = i + 1; j < changeLog.length; j++) { var otherChange = changeLog[j]; if (otherChange[0] === change[0] && otherChange[1] === change[1]) { // combine change[3] = otherChange[3]; changeLog.splice(j, 1); j--; } else if (otherChange[0] === "w" || otherChange[0] === "h") { break; // can't reduce accros resizes } } if (deepEquals(change[2], change[3])) { // no change changeLog.splice(i, 1); i--; } } else throw unreachable(); } } function undo(undoStuff) { if (undoStuff.undoStack.length === 0) return; // already at the beginning animationQueue = []; animationQueueCursor = 0; paradoxes = []; undoOneFrame(undoStuff); undoStuffChanged(undoStuff); } function reset(undoStuff) { animationQueue = []; animationQueueCursor = 0; paradoxes = []; while (undoStuff.undoStack.length > 0) { undoOneFrame(undoStuff); } undoStuffChanged(undoStuff); } function undoOneFrame(undoStuff) { var doThis = undoStuff.undoStack.pop(); var redoChangeLog = []; undoChanges(doThis, redoChangeLog); if (redoChangeLog.length > 0) { redoChangeLog.push(level.width); undoStuff.redoStack.push(redoChangeLog); } if (undoStuff === uneditStuff) editorHasBeenTouched = true; } function redo(undoStuff) { if (undoStuff.redoStack.length === 0) return; // already at the beginning animationQueue = []; animationQueueCursor = 0; paradoxes = []; redoOneFrame(undoStuff); undoStuffChanged(undoStuff); } function unreset(undoStuff) { animationQueue = []; animationQueueCursor = 0; paradoxes = []; while (undoStuff.redoStack.length > 0) { redoOneFrame(undoStuff); } undoStuffChanged(undoStuff); // don't animate the last frame animationQueue = []; animationQueueCursor = 0; freshlyRemovedAnimatedObjects = []; } function redoOneFrame(undoStuff) { var doThis = undoStuff.redoStack.pop(); var undoChangeLog = []; undoChanges(doThis, undoChangeLog); if (undoChangeLog.length > 0) { undoChangeLog.push(level.width); undoStuff.undoStack.push(undoChangeLog); } if (undoStuff === uneditStuff) editorHasBeenTouched = true; } function undoChanges(changes, changeLog) { var widthContext = changes.pop(); var transformLocation = widthContext === level.width ? identityFunction : makeScaleCoordinatesFunction(widthContext, level.width); for (var i = changes.length - 1; i >= 0; i--) { var paradoxDescription = undoChange(changes[i]); if (paradoxDescription != null) paradoxes.push(paradoxDescription); } var lastChange = changes[changes.length - 1]; if (lastChange[0] === "i") { // replay animation animationQueue = lastChange[4]; animationQueueCursor = 0; freshlyRemovedAnimatedObjects = lastChange[5]; animationStart = new Date().getTime(); } function undoChange(change) { // note: everything here is going backwards: to -> from if (change[0] === "i") { // no state change, but preserve the intention. changeLog.push(change); return null; } else if (change[0] === "h") { // change height var fromHeight = change[1]; var toHeight = change[2]; if (level.height !== toHeight) return "Impossible"; setHeight(fromHeight, changeLog); } else if (change[0] === "w") { // change width var fromWidth = change[1]; var toWidth = change[2]; if (level.width !== toWidth) return "Impossible"; setWidth(fromWidth, changeLog); } else if (change[0] === "m") { // change map tile var location = transformLocation(change[1]); var fromTileCode = change[2]; var toTileCode = change[3]; if (location >= level.map.length) return "Can't turn " + describe(toTileCode) + " into " + describe(fromTileCode) + " out of bounds"; if (level.map[location] !== toTileCode) return "Can't turn " + describe(toTileCode) + " into " + describe(fromTileCode) + " because there's " + describe(level.map[location]) + " there now"; paintTileAtLocation(location, fromTileCode, changeLog); } else if (change[0] === SNAKE || change[0] === BLOCK || change[0] === FRUIT) { // change object var type = change[0]; var id = change[1]; var fromDead = change[2][0]; var toDead = change[3][0]; var fromLocations = change[2][1].map(transformLocation); var toLocations = change[3][1].map(transformLocation); if (fromLocations.filter(function(location) { return location >= level.map.length; }).length > 0) { return "Can't move " + describe(type, id) + " out of bounds"; } var object = findObjectOfTypeAndId(type, id); if (toLocations.length !== 0) { // should exist at this location if (object == null) return "Can't move " + describe(type, id) + " because it doesn't exit"; if (!deepEquals(object.locations, toLocations)) return "Can't move " + describe(object) + " because it's in the wrong place"; if (object.dead !== toDead) return "Can't move " + describe(object) + " because it's alive/dead state doesn't match"; // doit if (fromLocations.length !== 0) { var oldState = serializeObjectState(object); object.locations = fromLocations; object.dead = fromDead; changeLog.push([object.type, object.id, oldState, serializeObjectState(object)]); } else { removeObject(object, changeLog); } } else { // shouldn't exist if (object != null) return "Can't create " + describe(type, id) + " because it already exists"; // doit object = { type: type, id: id, dead: fromDead, locations: fromLocations, }; level.objects.push(object); changeLog.push([object.type, object.id, [0,[]], serializeObjectState(object)]); } } else throw unreachable(); } } function describe(arg1, arg2) { // describe(0) -> "Space" // describe(SNAKE, 0) -> "Snake 0 (Red)" // describe(object) -> "Snake 0 (Red)" // describe(BLOCK, 1) -> "Block 1" // describe(FRUIT) -> "Fruit" if (typeof arg1 === "number") { switch (arg1) { case SPACE: return "Space"; case WALL: return "a Wall"; case SPIKE: return "Spikes"; case EXIT: return "an Exit"; case PORTAL: return "a Portal"; default: throw unreachable(); } } if (arg1 === SNAKE) { var color = (function() { switch (snakeColors[arg2 % snakeColors.length]) { case "#f00": return " (Red)"; case "#0f0": return " (Green)"; case "#00f": return " (Blue)"; case "#ff0": return " (Yellow)"; default: throw unreachable(); } })(); return "Snake " + arg2 + color; } if (arg1 === BLOCK) { return "Block " + arg2; } if (arg1 === FRUIT) { return "Fruit"; } if (typeof arg1 === "object") return describe(arg1.type, arg1.id); throw unreachable(); } function undoStuffChanged(undoStuff) { var movesText = undoStuff.undoStack.length + "+" + undoStuff.redoStack.length; document.getElementById(undoStuff.spanId).textContent = movesText; document.getElementById(undoStuff.undoButtonId).disabled = undoStuff.undoStack.length === 0; document.getElementById(undoStuff.redoButtonId).disabled = undoStuff.redoStack.length === 0; // render paradox display var uniqueParadoxes = []; var paradoxCounts = []; paradoxes.forEach(function(paradoxDescription) { var index = uniqueParadoxes.indexOf(paradoxDescription); if (index !== -1) { paradoxCounts[index] += 1; } else { uniqueParadoxes.push(paradoxDescription); paradoxCounts.push(1); } }); var paradoxDivContent = ""; uniqueParadoxes.forEach(function(paradox, i) { if (i > 0) paradoxDivContent += "
\n"; if (paradoxCounts[i] > 1) paradoxDivContent += "(" + paradoxCounts[i] + "x) "; paradoxDivContent += "Time Travel Paradox! " + uniqueParadoxes[i]; }); document.getElementById("paradoxDiv").innerHTML = paradoxDivContent; updateDirtyState(); if (unmoveStuff.redoStack.length === 0) { document.getElementById("removeButton").classList.remove("click-me"); } } var CLEAN_NO_TIMELINES = 0; var CLEAN_WITH_REDO = 1; var REPLAY_DIRTY = 2; var EDITOR_DIRTY = 3; var dirtyState = CLEAN_NO_TIMELINES; var editorHasBeenTouched = false; function updateDirtyState() { if (haveCheatcodesBeenUsed() || editorHasBeenTouched) { dirtyState = EDITOR_DIRTY; } else if (unmoveStuff.undoStack.length > 0) { dirtyState = REPLAY_DIRTY; } else if (unmoveStuff.redoStack.length > 0) { dirtyState = CLEAN_WITH_REDO; } else { dirtyState = CLEAN_NO_TIMELINES; } var saveLevelButton = document.getElementById("saveLevelButton"); // the save button clears your timelines saveLevelButton.disabled = dirtyState === CLEAN_NO_TIMELINES; if (dirtyState >= EDITOR_DIRTY) { // you should save saveLevelButton.classList.add("click-me"); saveLevelButton.textContent = "*" + "Save Level"; } else { saveLevelButton.classList.remove("click-me"); saveLevelButton.textContent = "Save Level"; } var saveProgressButton = document.getElementById("saveProgressButton"); // you can't save a replay if your level is dirty if (dirtyState === CLEAN_WITH_REDO) { saveProgressButton.textContent = "Forget Progress"; } else { saveProgressButton.textContent = "Save Progress"; } saveProgressButton.disabled = dirtyState >= EDITOR_DIRTY || dirtyState === CLEAN_NO_TIMELINES; } function haveCheatcodesBeenUsed() { return !unmoveStuff.undoStack.every(function(changeLog) { // normal movement always starts with "i". return changeLog[0][0] === "i"; }); } var persistentState = { showEditor: false, showGrid: false, }; function savePersistentState() { localStorage.snakefall = JSON.stringify(persistentState); } function loadPersistentState() { try { persistentState = JSON.parse(localStorage.snakefall); } catch (e) { } persistentState.showEditor = !!persistentState.showEditor; persistentState.showGrid = !!persistentState.showGrid; showEditorChanged(); } var isGravityEnabled = true; function isGravity() { return isGravityEnabled || !persistentState.showEditor; } var isCollisionEnabled = true; function isCollision() { return isCollisionEnabled || !persistentState.showEditor; } function isAnyCheatcodeEnabled() { return persistentState.showEditor && ( !isGravityEnabled || !isCollisionEnabled ); } function showEditorChanged() { document.getElementById("showHideEditor").textContent = (persistentState.showEditor ? "Hide" : "Show") + " Editor Stuff"; ["editorDiv", "editorPane"].forEach(function(id) { document.getElementById(id).style.display = persistentState.showEditor ? "block" : "none"; }); document.getElementById("wasdSpan").textContent = persistentState.showEditor ? "" : "/WASD"; render(); } function move(dr, dc) { if (!isAlive()) return; animationQueue = []; animationQueueCursor = 0; freshlyRemovedAnimatedObjects = []; animationStart = new Date().getTime(); var activeSnake = findActiveSnake(); var headRowcol = getRowcol(level, activeSnake.locations[0]); var newRowcol = {r:headRowcol.r + dr, c:headRowcol.c + dc}; if (!isInBounds(level, newRowcol.r, newRowcol.c)) return; var newLocation = getLocation(level, newRowcol.r, newRowcol.c); var changeLog = []; // The changeLog for a player movement starts with the input // when playing normally. if (!isAnyCheatcodeEnabled()) { changeLog.push(["i", activeSnake.id, dr, dc, animationQueue, freshlyRemovedAnimatedObjects]); } var ate = false; var pushedObjects = []; if (isCollision()) { var newTile = level.map[newLocation]; if (!isTileCodeAir(newTile)) return; // can't go through that tile var otherObject = findObjectAtLocation(newLocation); if (otherObject != null) { if (otherObject === activeSnake) return; // can't push yourself if (otherObject.type === FRUIT) { // eat removeObject(otherObject, changeLog); ate = true; } else { // push objects if (!checkMovement(activeSnake, otherObject, dr, dc, pushedObjects)) return false; } } } // slither forward var activeSnakeOldState = serializeObjectState(activeSnake); var size1 = activeSnake.locations.length === 1; var slitherAnimations = [ 70, [ // size-1 snakes really do more of a move than a slither size1 ? MOVE_SNAKE : SLITHER_HEAD, activeSnake.id, dr, dc, ] ]; activeSnake.locations.unshift(newLocation); if (!ate) { // drag your tail forward var oldRowcol = getRowcol(level, activeSnake.locations[activeSnake.locations.length - 1]); var newRowcol = getRowcol(level, activeSnake.locations[activeSnake.locations.length - 2]); if (!size1) { slitherAnimations.push([ SLITHER_TAIL, activeSnake.id, newRowcol.r - oldRowcol.r, newRowcol.c - oldRowcol.c, ]); } activeSnake.locations.pop(); } changeLog.push([activeSnake.type, activeSnake.id, activeSnakeOldState, serializeObjectState(activeSnake)]); // did you just push your face into a portal? var portalLocations = getActivePortalLocations(); var portalActivationLocations = []; if (portalLocations.indexOf(newLocation) !== -1) { portalActivationLocations.push(newLocation); } // push everything, too moveObjects(pushedObjects, dr, dc, portalLocations, portalActivationLocations, changeLog, slitherAnimations); animationQueue.push(slitherAnimations); // gravity loop var stateToAnimationIndex = {}; if (isGravity()) for (var fallHeight = 1;; fallHeight++) { var serializedState = serializeObjects(level.objects); var infiniteLoopStartIndex = stateToAnimationIndex[serializedState]; if (infiniteLoopStartIndex != null) { // infinite loop animationQueue.push([0, [INFINITE_LOOP, animationQueue.length - infiniteLoopStartIndex]]); break; } else { stateToAnimationIndex[serializedState] = animationQueue.length; } // do portals separate from falling logic if (portalActivationLocations.length === 1) { var portalAnimations = [500]; if (activatePortal(portalLocations, portalActivationLocations[0], portalAnimations, changeLog)) { animationQueue.push(portalAnimations); } portalActivationLocations = []; } // now do falling logic var didAnything = false; var fallingAnimations = [ 70 / Math.sqrt(fallHeight), ]; var exitAnimationQueue = []; // check for exit if (!isUneatenFruit()) { var snakes = getSnakes(); for (var i = 0; i < snakes.length; i++) { var snake = snakes[i]; if (level.map[snake.locations[0]] === EXIT) { // (one of) you made it! removeAnimatedObject(snake, changeLog); exitAnimationQueue.push([ 200, [EXIT_SNAKE, snake.id, 0, 0], ]); didAnything = true; } } } // fall var dyingObjects = []; var fallingObjects = level.objects.filter(function(object) { if (object.type === FRUIT) return; // can't fall var theseDyingObjects = []; if (!checkMovement(null, object, 1, 0, [], theseDyingObjects)) return false; // this object can fall. maybe more will fall with it too. we'll check those separately. theseDyingObjects.forEach(function(object) { addIfNotPresent(dyingObjects, object); }); return true; }); if (dyingObjects.length > 0) { var anySnakesDied = false; dyingObjects.forEach(function(object) { if (object.type === SNAKE) { // look what you've done var oldState = serializeObjectState(object); object.dead = true; changeLog.push([object.type, object.id, oldState, serializeObjectState(object)]); anySnakesDied = true; } else if (object.type === BLOCK) { // a box fell off the world removeAnimatedObject(object, changeLog); removeFromArray(fallingObjects, object); exitAnimationQueue.push([ 200, [ DIE_BLOCK, object.id, 0, 0 ], ]); didAnything = true; } else throw unreachable(); }); if (anySnakesDied) break; } if (fallingObjects.length > 0) { moveObjects(fallingObjects, 1, 0, portalLocations, portalActivationLocations, changeLog, fallingAnimations); didAnything = true; } if (!didAnything) break; Array.prototype.push.apply(animationQueue, exitAnimationQueue); if (fallingAnimations.length > 1) animationQueue.push(fallingAnimations); } pushUndo(unmoveStuff, changeLog); render(); } function checkMovement(pusher, pushedObject, dr, dc, pushedObjects, dyingObjects) { // pusher can be null (for gravity) pushedObjects.push(pushedObject); // find forward locations var forwardLocations = []; for (var i = 0; i < pushedObjects.length; i++) { pushedObject = pushedObjects[i]; for (var j = 0; j < pushedObject.locations.length; j++) { var rowcol = getRowcol(level, pushedObject.locations[j]); var forwardRowcol = {r:rowcol.r + dr, c:rowcol.c + dc}; if (!isInBounds(level, forwardRowcol.r, forwardRowcol.c)) { if (dyingObjects == null) { // can't push things out of bounds return false; } else { // this thing is going to fall out of bounds addIfNotPresent(dyingObjects, pushedObject); addIfNotPresent(pushedObjects, pushedObject); continue; } } var forwardLocation = getLocation(level, forwardRowcol.r, forwardRowcol.c); var yetAnotherObject = findObjectAtLocation(forwardLocation); if (yetAnotherObject != null) { if (yetAnotherObject.type === FRUIT) { // not pushable return false; } if (yetAnotherObject === pusher) { // indirect pushing ourselves. // special check for when we're indirectly pushing the tip of our own tail. if (forwardLocation === pusher.locations[pusher.locations.length -1]) { // for some reason this is ok. continue; } return false; } addIfNotPresent(pushedObjects, yetAnotherObject); } else { addIfNotPresent(forwardLocations, forwardLocation); } } } // check forward locations for (var i = 0; i < forwardLocations.length; i++) { var forwardLocation = forwardLocations[i]; // many of these locations can be inside objects, // but that means the tile must be air, // and we already know pushing that object. var tileCode = level.map[forwardLocation]; if (!isTileCodeAir(tileCode)) { if (dyingObjects != null) { if (tileCode === SPIKE) { // uh... which object was this again? var deadObject = findObjectAtLocation(offsetLocation(forwardLocation, -dr, -dc)); if (deadObject.type === SNAKE) { // ouch! addIfNotPresent(dyingObjects, deadObject); continue; } } } // can't push into something solid return false; } } // the push is go return true; } function activateAnySnakePlease() { var snakes = getSnakes(); if (snakes.length === 0) return; // nope.avi activeSnakeId = snakes[0].id; } function moveObjects(objects, dr, dc, portalLocations, portalActivationLocations, changeLog, animations) { objects.forEach(function(object) { var oldState = serializeObjectState(object); var oldPortals = getSetIntersection(portalLocations, object.locations); for (var i = 0; i < object.locations.length; i++) { object.locations[i] = offsetLocation(object.locations[i], dr, dc); } changeLog.push([object.type, object.id, oldState, serializeObjectState(object)]); animations.push([ "m" + object.type, // MOVE_SNAKE | MOVE_BLOCK object.id, dr, dc, ]); var newPortals = getSetIntersection(portalLocations, object.locations); var activatingPortals = newPortals.filter(function(portalLocation) { return oldPortals.indexOf(portalLocation) === -1; }); if (activatingPortals.length === 1) { // exactly one new portal we're touching. activate it portalActivationLocations.push(activatingPortals[0]); } }); } function activatePortal(portalLocations, portalLocation, animations, changeLog) { var otherPortalLocation = portalLocations[1 - portalLocations.indexOf(portalLocation)]; var portalRowcol = getRowcol(level, portalLocation); var otherPortalRowcol = getRowcol(level, otherPortalLocation); var delta = {r:otherPortalRowcol.r - portalRowcol.r, c:otherPortalRowcol.c - portalRowcol.c}; var object = findObjectAtLocation(portalLocation); var newLocations = []; for (var i = 0; i < object.locations.length; i++) { var rowcol = getRowcol(level, object.locations[i]); var r = rowcol.r + delta.r; var c = rowcol.c + delta.c; if (!isInBounds(level, r, c)) return false; // out of bounds newLocations.push(getLocation(level, r, c)); } for (var i = 0; i < newLocations.length; i++) { var location = newLocations[i]; if (!isTileCodeAir(level.map[location])) return false; // blocked by tile var otherObject = findObjectAtLocation(location); if (otherObject != null && otherObject !== object) return false; // blocked by object } // zappo presto! var oldState = serializeObjectState(object); object.locations = newLocations; changeLog.push([object.type, object.id, oldState, serializeObjectState(object)]); animations.push([ "t" + object.type, // TELEPORT_SNAKE | TELEPORT_BLOCK object.id, delta.r, delta.c, ]); return true; } function isTileCodeAir(tileCode) { return tileCode === SPACE || tileCode === EXIT || tileCode === PORTAL; } function addIfNotPresent(array, element) { if (array.indexOf(element) !== -1) return; array.push(element); } function removeAnyObjectAtLocation(location, changeLog) { var object = findObjectAtLocation(location); if (object != null) removeObject(object, changeLog); } function removeAnimatedObject(object, changeLog) { removeObject(object, changeLog); freshlyRemovedAnimatedObjects.push(object); } function removeObject(object, changeLog) { removeFromArray(level.objects, object); changeLog.push([object.type, object.id, [object.dead, copyArray(object.locations)], [0,[]]]); if (object.type === SNAKE && object.id === activeSnakeId) { activateAnySnakePlease(); } if (object.type === BLOCK && paintBrushTileCode === BLOCK && paintBrushBlockId === object.id) { // no longer editing an object that doesn't exit paintBrushBlockId = null; } if (object.type === BLOCK) { delete blockSupportRenderCache[object.id]; } } function removeFromArray(array, element) { var index = array.indexOf(element); if (index === -1) throw unreachable(); array.splice(index, 1); } function findActiveSnake() { var snakes = getSnakes(); for (var i = 0; i < snakes.length; i++) { if (snakes[i].id === activeSnakeId) return snakes[i]; } throw unreachable(); } function findBlockById(id) { return findObjectOfTypeAndId(BLOCK, id); } function findSnakesOfColor(color) { return level.objects.filter(function(object) { if (object.type !== SNAKE) return false; return object.id % snakeColors.length === color; }); } function findObjectOfTypeAndId(type, id) { for (var i = 0; i < level.objects.length; i++) { var object = level.objects[i]; if (object.type === type && object.id === id) return object; } return null; } function findObjectAtLocation(location) { for (var i = 0; i < level.objects.length; i++) { var object = level.objects[i]; if (object.locations.indexOf(location) !== -1) return object; } return null; } function isUneatenFruit() { return getObjectsOfType(FRUIT).length > 0; } function getActivePortalLocations() { var portalLocations = getPortalLocations(); if (portalLocations.length !== 2) return []; // nice try return portalLocations; } function getPortalLocations() { var result = []; for (var i = 0; i < level.map.length; i++) { if (level.map[i] === PORTAL) result.push(i); } return result; } function countSnakes() { return getSnakes().length; } function getSnakes() { return getObjectsOfType(SNAKE); } function getBlocks() { return getObjectsOfType(BLOCK); } function getObjectsOfType(type) { return level.objects.filter(function(object) { return object.type == type; }); } function isDead() { if (animationQueue.length > 0 && animationQueue[animationQueue.length - 1][1][0] === INFINITE_LOOP) return true; return getSnakes().filter(function(snake) { return !!snake.dead; }).length > 0; } function isAlive() { return countSnakes() > 0 && !isDead(); } var snakeColors = [ "#f00", "#0f0", "#00f", "#ff0", ]; var blockForeground = ["#de5a6d","#fa65dd","#c367e3","#9c62fa","#625ff0"]; var blockBackground = ["#853641","#963c84","#753d88","#5d3a96","#3a3990"]; var activeSnakeId = null; var SLITHER_HEAD = "sh"; var SLITHER_TAIL = "st"; var MOVE_SNAKE = "ms"; var MOVE_BLOCK = "mb"; var TELEPORT_SNAKE = "ts"; var TELEPORT_BLOCK = "tb"; var EXIT_SNAKE = "es"; var DIE_SNAKE = "ds"; var DIE_BLOCK = "db"; var INFINITE_LOOP = "il"; var animationQueue = [ // // sequence of disjoint animation groups. // // each group completes before the next begins. // [ // 70, // duration of this animation group // // multiple things to animate simultaneously // [ // SLITHER_HEAD | SLITHER_TAIL | MOVE_SNAKE | MOVE_BLOCK | TELEPORT_SNAKE | TELEPORT_BLOCK, // objectId, // dr, // dc, // ], // [ // INFINITE_LOOP, // loopSizeNotIncludingThis, // ], // ], ]; var animationQueueCursor = 0; var animationStart = null; // new Date().getTime() var animationProgress; // 0.0 <= x < 1.0 var freshlyRemovedAnimatedObjects = []; // render the support beams for blocks into a temporary buffer, and remember it. // this is due to stencil buffers causing slowdown on some platforms. see #25. var blockSupportRenderCache = { // id: canvas, // "0": document.createElement("canvas"), }; function render() { if (level == null) return; if (animationQueueCursor < animationQueue.length) { var animationDuration = animationQueue[animationQueueCursor][0]; animationProgress = (new Date().getTime() - animationStart) / animationDuration; if (animationProgress >= 1.0) { // animation group complete animationProgress -= 1.0; animationQueueCursor++; if (animationQueueCursor < animationQueue.length && animationQueue[animationQueueCursor][1][0] === INFINITE_LOOP) { var infiniteLoopSize = animationQueue[animationQueueCursor][1][1]; animationQueueCursor -= infiniteLoopSize; } animationStart = new Date().getTime(); } } if (animationQueueCursor === animationQueue.length) animationProgress = 1.0; canvas.width = tileSize * level.width; canvas.height = tileSize * level.height; var context = canvas.getContext("2d"); context.fillStyle = "#88f"; // sky context.fillRect(0, 0, canvas.width, canvas.height); if (persistentState.showGrid && !persistentState.showEditor) { drawGrid(); } var activePortalLocations = getActivePortalLocations(); // normal render renderLevel(); if (persistentState.showGrid && persistentState.showEditor) { drawGrid(); } // active snake halo if (countSnakes() !== 0 && isAlive()) { var activeSnake = findActiveSnake(); var activeSnakeRowcol = getRowcol(level, activeSnake.locations[0]); drawCircle(activeSnakeRowcol.r, activeSnakeRowcol.c, 2, "rgba(256,256,256,0.3)"); } if (persistentState.showEditor) { if (paintBrushTileCode === BLOCK) { if (paintBrushBlockId != null) { // fade everything else away context.fillStyle = "rgba(0, 0, 0, 0.8)"; context.fillRect(0, 0, canvas.width, canvas.height); // and render just this object in focus var activeBlock = findBlockById(paintBrushBlockId); renderLevel([activeBlock]); } } else if (paintBrushTileCode === "select") { getSelectedLocations().forEach(function(location) { var rowcol = getRowcol(level, location); drawRect(rowcol.r, rowcol.c, "rgba(128, 128, 128, 0.3)"); }); } } // serialize if (!isDead()) { var serialization = stringifyLevel(level); document.getElementById("serializationTextarea").value = serialization; var link = location.href.substring(0, location.href.length - location.hash.length); link += "#level=" + compressSerialization(serialization); document.getElementById("shareLinkTextbox").value = link; } // throw this in there somewhere document.getElementById("showGridButton").textContent = (persistentState.showGrid ? "Hide" : "Show") + " Grid"; if (animationProgress < 1.0) requestAnimationFrame(render); return; // this is the end of the function proper function renderLevel(onlyTheseObjects) { var objects = level.objects; if (onlyTheseObjects != null) { objects = onlyTheseObjects; } else { objects = level.objects.concat(freshlyRemovedAnimatedObjects.filter(function(object) { // the object needs to have a future removal animation, or else, it's gone already. return hasFutureRemoveAnimation(object); })); } // begin by rendering the background connections for blocks objects.forEach(function(object) { if (object.type !== BLOCK) return; var animationDisplacementRowcol = findAnimationDisplacementRowcol(object.type, object.id); var minR = Infinity; var maxR = -Infinity; var minC = Infinity; var maxC = -Infinity; object.locations.forEach(function(location) { var rowcol = getRowcol(level, location); if (rowcol.r < minR) minR = rowcol.r; if (rowcol.r > maxR) maxR = rowcol.r; if (rowcol.c < minC) minC = rowcol.c; if (rowcol.c > maxC) maxC = rowcol.c; }); var image = blockSupportRenderCache[object.id]; if (image == null) { // render the support beams to a buffer blockSupportRenderCache[object.id] = image = document.createElement("canvas"); image.width = (maxC - minC + 1) * tileSize; image.height = (maxR - minR + 1) * tileSize; var bufferContext = image.getContext("2d"); // Make a stencil that excludes the insides of blocks. // Then when we render the support beams, we won't see the supports inside the block itself. bufferContext.beginPath(); // Draw a path around the whole screen in the opposite direction as the rectangle paths below. // This means that the below rectangles will be removing area from the greater rectangle. bufferContext.rect(image.width, 0, -image.width, image.height); for (var i = 0; i < object.locations.length; i++) { var rowcol = getRowcol(level, object.locations[i]); var r = rowcol.r - minR; var c = rowcol.c - minC; bufferContext.rect(c * tileSize, r * tileSize, tileSize, tileSize); } bufferContext.clip(); for (var i = 0; i < object.locations.length - 1; i++) { var rowcol1 = getRowcol(level, object.locations[i]); rowcol1.r -= minR; rowcol1.c -= minC; var rowcol2 = getRowcol(level, object.locations[i + 1]); rowcol2.r -= minR; rowcol2.c -= minC; var cornerRowcol = {r:rowcol1.r, c:rowcol2.c}; drawConnector(bufferContext, rowcol1.r, rowcol1.c, cornerRowcol.r, cornerRowcol.c, blockBackground[object.id % blockBackground.length]); drawConnector(bufferContext, rowcol2.r, rowcol2.c, cornerRowcol.r, cornerRowcol.c, blockBackground[object.id % blockBackground.length]); } } var r = minR + animationDisplacementRowcol.r; var c = minC + animationDisplacementRowcol.c; context.drawImage(image, c * tileSize, r * tileSize); }); // terrain if (onlyTheseObjects == null) { for (var r = 0; r < level.height; r++) { for (var c = 0; c < level.width; c++) { var location = getLocation(level, r, c); var tileCode = level.map[location]; drawTile(tileCode, r, c, level, location); } } } // objects objects.forEach(drawObject); // banners if (countSnakes() === 0) { context.fillStyle = "#ff0"; context.font = "100px Arial"; context.fillText("You Win!", 0, canvas.height / 2); } if (isDead()) { context.fillStyle = "#f00"; context.font = "100px Arial"; context.fillText("You Dead!", 0, canvas.height / 2); } // editor hover if (persistentState.showEditor && paintBrushTileCode != null && hoverLocation != null && hoverLocation < level.map.length) { var savedContext = context; var buffer = document.createElement("canvas"); buffer.width = canvas.width; buffer.height = canvas.height; context = buffer.getContext("2d"); var hoverRowcol = getRowcol(level, hoverLocation); var objectHere = findObjectAtLocation(hoverLocation); if (typeof paintBrushTileCode === "number") { if (level.map[hoverLocation] !== paintBrushTileCode) { drawTile(paintBrushTileCode, hoverRowcol.r, hoverRowcol.c, level, hoverLocation); } } else if (paintBrushTileCode === SNAKE) { if (!(objectHere != null && objectHere.type === SNAKE && objectHere.id === paintBrushSnakeColorIndex)) { drawObject(newSnake(paintBrushSnakeColorIndex, hoverLocation)); } } else if (paintBrushTileCode === BLOCK) { if (!(objectHere != null && objectHere.type === BLOCK && objectHere.id === paintBrushBlockId)) { drawObject(newBlock(hoverLocation)); } } else if (paintBrushTileCode === FRUIT) { if (!(objectHere != null && objectHere.type === FRUIT)) { drawObject(newFruit(hoverLocation)); } } else if (paintBrushTileCode === "resize") { void 0; // do nothing } else if (paintBrushTileCode === "select") { void 0; // do nothing } else if (paintBrushTileCode === "paste") { // show what will be pasted if you click var pastedData = previewPaste(hoverRowcol.r, hoverRowcol.c); pastedData.selectedLocations.forEach(function(location) { var tileCode = pastedData.level.map[location]; var rowcol = getRowcol(level, location); drawTile(tileCode, rowcol.r, rowcol.c, pastedData.level, location); }); pastedData.selectedObjects.forEach(drawObject); } else throw unreachable(); context = savedContext; context.save(); context.globalAlpha = 0.2; context.drawImage(buffer, 0, 0); context.restore(); } } function drawTile(tileCode, r, c, level, location) { switch (tileCode) { case SPACE: break; case WALL: drawWall(r, c, getAdjacentTiles()); break; case SPIKE: drawSpikes(r, c, level); break; case EXIT: var radiusFactor = isUneatenFruit() ? 0.7 : 1.2; drawQuarterPie(r, c, radiusFactor, "#f00", 0); drawQuarterPie(r, c, radiusFactor, "#0f0", 1); drawQuarterPie(r, c, radiusFactor, "#00f", 2); drawQuarterPie(r, c, radiusFactor, "#ff0", 3); break; case PORTAL: drawCircle(r, c, 0.8, "#888"); drawCircle(r, c, 0.6, "#111"); if (activePortalLocations.indexOf(location) !== -1) drawCircle(r, c, 0.3, "#666"); break; default: throw unreachable(); } function getAdjacentTiles() { return [ [getTile(r - 1, c - 1), getTile(r - 1, c + 0), getTile(r - 1, c + 1)], [getTile(r + 0, c - 1), null, getTile(r + 0, c + 1)], [getTile(r + 1, c - 1), getTile(r + 1, c + 0), getTile(r + 1, c + 1)], ]; } function getTile(r, c) { if (!isInBounds(level, r, c)) return null; return level.map[getLocation(level, r, c)]; } } function drawObject(object) { switch (object.type) { case SNAKE: var animationDisplacementRowcol = findAnimationDisplacementRowcol(object.type, object.id); var lastRowcol = null var color = snakeColors[object.id % snakeColors.length]; var headRowcol; for (var i = 0; i <= object.locations.length; i++) { var animation; var rowcol; if (i === 0 && (animation = findAnimation([SLITHER_HEAD], object.id)) != null) { // animate head slithering forward rowcol = getRowcol(level, object.locations[i]); rowcol.r += animation[2] * (animationProgress - 1); rowcol.c += animation[3] * (animationProgress - 1); } else if (i === object.locations.length) { // animated tail? if ((animation = findAnimation([SLITHER_TAIL], object.id)) != null) { // animate tail slithering to catch up rowcol = getRowcol(level, object.locations[i - 1]); rowcol.r += animation[2] * (animationProgress - 1); rowcol.c += animation[3] * (animationProgress - 1); } else { // no animated tail needed break; } } else { rowcol = getRowcol(level, object.locations[i]); } if (object.dead) rowcol.r += 0.5; rowcol.r += animationDisplacementRowcol.r; rowcol.c += animationDisplacementRowcol.c; if (i === 0) { // head headRowcol = rowcol; drawDiamond(rowcol.r, rowcol.c, color); } else { // middle var cx = (rowcol.c + 0.5) * tileSize; var cy = (rowcol.r + 0.5) * tileSize; context.fillStyle = color; var orientation; if (lastRowcol.r < rowcol.r) { orientation = 0; context.beginPath(); context.moveTo((lastRowcol.c + 0) * tileSize, (lastRowcol.r + 0.5) * tileSize); context.lineTo((lastRowcol.c + 1) * tileSize, (lastRowcol.r + 0.5) * tileSize); context.arc(cx, cy, tileSize/2, 0, Math.PI); context.fill(); } else if (lastRowcol.r > rowcol.r) { orientation = 2; context.beginPath(); context.moveTo((lastRowcol.c + 1) * tileSize, (lastRowcol.r + 0.5) * tileSize); context.lineTo((lastRowcol.c + 0) * tileSize, (lastRowcol.r + 0.5) * tileSize); context.arc(cx, cy, tileSize/2, Math.PI, 0); context.fill(); } else if (lastRowcol.c < rowcol.c) { orientation = 3; context.beginPath(); context.moveTo((lastRowcol.c + 0.5) * tileSize, (lastRowcol.r + 1) * tileSize); context.lineTo((lastRowcol.c + 0.5) * tileSize, (lastRowcol.r + 0) * tileSize); context.arc(cx, cy, tileSize/2, 1.5 * Math.PI, 2.5 * Math.PI); context.fill(); } else if (lastRowcol.c > rowcol.c) { orientation = 1; context.beginPath(); context.moveTo((lastRowcol.c + 0.5) * tileSize, (lastRowcol.r + 0) * tileSize); context.lineTo((lastRowcol.c + 0.5) * tileSize, (lastRowcol.r + 1) * tileSize); context.arc(cx, cy, tileSize/2, 2.5 * Math.PI, 1.5 * Math.PI); context.fill(); } } lastRowcol = rowcol; } // eye if (object.id === activeSnakeId) { drawCircle(headRowcol.r, headRowcol.c, 0.5, "#fff"); drawCircle(headRowcol.r, headRowcol.c, 0.2, "#000"); } break; case BLOCK: drawBlock(object); break; case FRUIT: var rowcol = getRowcol(level, object.locations[0]); drawCircle(rowcol.r, rowcol.c, 1, "#f0f"); break; default: throw unreachable(); } } function drawWall(r, c, adjacentTiles) { drawRect(r, c, "#844204"); // dirt context.fillStyle = "#282"; // grass drawTileOutlines(r, c, isWall, 0.2); function isWall(dc, dr) { var tileCode = adjacentTiles[1 + dr][1 + dc]; return tileCode == null || tileCode === WALL; } } function drawTileOutlines(r, c, isOccupied, outlineThickness) { var complement = 1 - outlineThickness; var outlinePixels = outlineThickness * tileSize; var complementPixels = (1 - 2 * outlineThickness) * tileSize; if (!isOccupied(-1, -1)) context.fillRect((c) * tileSize, (r) * tileSize, outlinePixels, outlinePixels); if (!isOccupied( 1, -1)) context.fillRect((c+complement) * tileSize, (r) * tileSize, outlinePixels, outlinePixels); if (!isOccupied(-1, 1)) context.fillRect((c) * tileSize, (r+complement) * tileSize, outlinePixels, outlinePixels); if (!isOccupied( 1, 1)) context.fillRect((c+complement) * tileSize, (r+complement) * tileSize, outlinePixels, outlinePixels); if (!isOccupied( 0, -1)) context.fillRect((c) * tileSize, (r) * tileSize, tileSize, outlinePixels); if (!isOccupied( 0, 1)) context.fillRect((c) * tileSize, (r+complement) * tileSize, tileSize, outlinePixels); if (!isOccupied(-1, 0)) context.fillRect((c) * tileSize, (r) * tileSize, outlinePixels, tileSize); if (!isOccupied( 1, 0)) context.fillRect((c+complement) * tileSize, (r) * tileSize, outlinePixels, tileSize); } function drawSpikes(r, c) { var x = c * tileSize; var y = r * tileSize; context.fillStyle = "#333"; context.beginPath(); context.moveTo(x + tileSize * 0.3, y + tileSize * 0.3); context.lineTo(x + tileSize * 0.4, y + tileSize * 0.0); context.lineTo(x + tileSize * 0.5, y + tileSize * 0.3); context.lineTo(x + tileSize * 0.6, y + tileSize * 0.0); context.lineTo(x + tileSize * 0.7, y + tileSize * 0.3); context.lineTo(x + tileSize * 1.0, y + tileSize * 0.4); context.lineTo(x + tileSize * 0.7, y + tileSize * 0.5); context.lineTo(x + tileSize * 1.0, y + tileSize * 0.6); context.lineTo(x + tileSize * 0.7, y + tileSize * 0.7); context.lineTo(x + tileSize * 0.6, y + tileSize * 1.0); context.lineTo(x + tileSize * 0.5, y + tileSize * 0.7); context.lineTo(x + tileSize * 0.4, y + tileSize * 1.0); context.lineTo(x + tileSize * 0.3, y + tileSize * 0.7); context.lineTo(x + tileSize * 0.0, y + tileSize * 0.6); context.lineTo(x + tileSize * 0.3, y + tileSize * 0.5); context.lineTo(x + tileSize * 0.0, y + tileSize * 0.4); context.lineTo(x + tileSize * 0.3, y + tileSize * 0.3); context.fill(); } function drawConnector(context, r1, c1, r2, c2, color) { // either r1 and r2 or c1 and c2 must be equal if (r1 > r2 || c1 > c2) { var rTmp = r1; var cTmp = c1; r1 = r2; c1 = c2; r2 = rTmp; c2 = cTmp; } var xLo = (c1 + 0.4) * tileSize; var yLo = (r1 + 0.4) * tileSize; var xHi = (c2 + 0.6) * tileSize; var yHi = (r2 + 0.6) * tileSize; context.fillStyle = color; context.fillRect(xLo, yLo, xHi - xLo, yHi - yLo); } function drawBlock(block) { var animationDisplacementRowcol = findAnimationDisplacementRowcol(block.type, block.id); var rowcols = block.locations.map(function(location) { return getRowcol(level, location); }); rowcols.forEach(function(rowcol) { var r = rowcol.r + animationDisplacementRowcol.r; var c = rowcol.c + animationDisplacementRowcol.c; context.fillStyle = blockForeground[block.id % blockForeground.length]; drawTileOutlines(r, c, isAlsoThisBlock, 0.3); function isAlsoThisBlock(dc, dr) { for (var i = 0; i < rowcols.length; i++) { var otherRowcol = rowcols[i]; if (rowcol.r + dr === otherRowcol.r && rowcol.c + dc === otherRowcol.c) return true; } return false; } }); } function drawQuarterPie(r, c, radiusFactor, fillStyle, quadrant) { var cx = (c + 0.5) * tileSize; var cy = (r + 0.5) * tileSize; context.fillStyle = fillStyle; context.beginPath(); context.moveTo(cx, cy); context.arc(cx, cy, radiusFactor * tileSize/2, quadrant * Math.PI/2, (quadrant + 1) * Math.PI/2); context.fill(); } function drawDiamond(r, c, fillStyle) { var x = c * tileSize; var y = r * tileSize; context.fillStyle = fillStyle; context.beginPath(); context.moveTo(x + tileSize/2, y); context.lineTo(x + tileSize, y + tileSize/2); context.lineTo(x + tileSize/2, y + tileSize); context.lineTo(x, y + tileSize/2); context.lineTo(x + tileSize/2, y); context.fill(); } function drawCircle(r, c, radiusFactor, fillStyle) { context.fillStyle = fillStyle; context.beginPath(); context.arc((c + 0.5) * tileSize, (r + 0.5) * tileSize, tileSize/2 * radiusFactor, 0, 2*Math.PI); context.fill(); } function drawRect(r, c, fillStyle) { context.fillStyle = fillStyle; context.fillRect(c * tileSize, r * tileSize, tileSize, tileSize); } function drawGrid() { var buffer = document.createElement("canvas"); buffer.width = canvas.width; buffer.height = canvas.height; var localContext = buffer.getContext("2d"); localContext.strokeStyle = "#fff"; localContext.beginPath(); for (var r = 0; r < level.height; r++) { localContext.moveTo(0, tileSize*r); localContext.lineTo(tileSize*level.width, tileSize*r); } for (var c = 0; c < level.width; c++) { localContext.moveTo(tileSize*c, 0); localContext.lineTo(tileSize*c, tileSize*level.height); } localContext.stroke(); context.save(); context.globalAlpha = 0.4; context.drawImage(buffer, 0, 0); context.restore(); } } function findAnimation(animationTypes, objectId) { if (animationQueueCursor === animationQueue.length) return null; var currentAnimation = animationQueue[animationQueueCursor]; for (var i = 1; i < currentAnimation.length; i++) { var animation = currentAnimation[i]; if (animationTypes.indexOf(animation[0]) !== -1 && animation[1] === objectId) { return animation; } } } function findAnimationDisplacementRowcol(objectType, objectId) { var dr = 0; var dc = 0; var animationTypes = [ "m" + objectType, // MOVE_SNAKE | MOVE_BLOCK "t" + objectType, // TELEPORT_SNAKE | TELEPORT_BLOCK ]; // skip the current one for (var i = animationQueueCursor + 1; i < animationQueue.length; i++) { var animations = animationQueue[i]; for (var j = 1; j < animations.length; j++) { var animation = animations[j]; if (animationTypes.indexOf(animation[0]) !== -1 && animation[1] === objectId) { dr += animation[2]; dc += animation[3]; } } } var movementAnimation = findAnimation(animationTypes, objectId); if (movementAnimation != null) { dr += movementAnimation[2] * (1 - animationProgress); dc += movementAnimation[3] * (1 - animationProgress); } return {r: -dr, c: -dc}; } function hasFutureRemoveAnimation(object) { var animationTypes = [ EXIT_SNAKE, DIE_BLOCK, ]; for (var i = animationQueueCursor; i < animationQueue.length; i++) { var animations = animationQueue[i]; for (var j = 1; j < animations.length; j++) { var animation = animations[j]; if (animationTypes.indexOf(animation[0]) !== -1 && animation[1] === object.id) { return true; } } } } function previewPaste(hoverR, hoverC) { var offsetR = hoverR - clipboardOffsetRowcol.r; var offsetC = hoverC - clipboardOffsetRowcol.c; var newLevel = JSON.parse(JSON.stringify(level)); var selectedLocations = []; var selectedObjects = []; clipboardData.selectedLocations.forEach(function(location) { var tileCode = clipboardData.level.map[location]; var rowcol = getRowcol(clipboardData.level, location); var r = rowcol.r + offsetR; var c = rowcol.c + offsetC; if (!isInBounds(newLevel, r, c)) return; var newLocation = getLocation(newLevel, r, c); newLevel.map[newLocation] = tileCode; selectedLocations.push(newLocation); }); clipboardData.selectedObjects.forEach(function(object) { var newLocations = []; for (var i = 0; i < object.locations.length; i++) { var rowcol = getRowcol(clipboardData.level, object.locations[i]); rowcol.r += offsetR; rowcol.c += offsetC; if (!isInBounds(newLevel, rowcol.r, rowcol.c)) { // this location is oob if (object.type === SNAKE) { // snakes must be completely in bounds return; } // just skip it continue; } var newLocation = getLocation(newLevel, rowcol.r, rowcol.c); newLocations.push(newLocation); } if (newLocations.length === 0) return; // can't have a non-present object var newObject = JSON.parse(JSON.stringify(object)); newObject.locations = newLocations; selectedObjects.push(newObject); }); return { level: newLevel, selectedLocations: selectedLocations, selectedObjects: selectedObjects, }; } function getNaiveOrthogonalPath(a, b) { // does not include a, but does include b. var rowcolA = getRowcol(level, a); var rowcolB = getRowcol(level, b); var path = []; if (rowcolA.r < rowcolB.r) { for (var r = rowcolA.r; r < rowcolB.r; r++) { path.push(getLocation(level, r + 1, rowcolA.c)); } } else { for (var r = rowcolA.r; r > rowcolB.r; r--) { path.push(getLocation(level, r - 1, rowcolA.c)); } } if (rowcolA.c < rowcolB.c) { for (var c = rowcolA.c; c < rowcolB.c; c++) { path.push(getLocation(level, rowcolB.r, c + 1)); } } else { for (var c = rowcolA.c; c > rowcolB.c; c--) { path.push(getLocation(level, rowcolB.r, c - 1)); } } return path; } function identityFunction(x) { return x; } function compareId(a, b) { return operatorCompare(a.id, b.id); } function operatorCompare(a, b) { return a < b ? -1 : a > b ? 1 : 0; } function clamp(value, min, max) { if (value < min) return min; if (value > max) return max; return value; } function copyArray(array) { return array.map(identityFunction); } function getSetIntersection(array1, array2) { if (array1.length * array2.length === 0) return []; return array1.filter(function(x) { return array2.indexOf(x) !== -1; }); } function makeScaleCoordinatesFunction(width1, width2) { return function(location) { return location + (width2 - width1) * Math.floor(location / width1); }; } var expectHash; window.addEventListener("hashchange", function() { if (location.hash === expectHash) { // We're in the middle of saveLevel() or saveReplay(). // Don't react to that event. expectHash = null; return; } // The user typed into the url bar or used Back/Forward browser buttons, etc. loadFromLocationHash(); }); function loadFromLocationHash() { var hashSegments = location.hash.split("#"); hashSegments.shift(); // first element is always "" if (!(1 <= hashSegments.length && hashSegments.length <= 2)) return false; var hashPairs = hashSegments.map(function(segment) { var equalsIndex = segment.indexOf("="); if (equalsIndex === -1) return ["", segment]; // bad return [segment.substring(0, equalsIndex), segment.substring(equalsIndex + 1)]; }); if (hashPairs[0][0] !== "level") return false; try { var level = parseLevel(hashPairs[0][1]); } catch (e) { alert(e); return false; } loadLevel(level); if (hashPairs.length > 1) { try { if (hashPairs[1][0] !== "replay") throw new Error("unexpected hash pair: " + hashPairs[1][0]); parseAndLoadReplay(hashPairs[1][1]); } catch (e) { alert(e); return false; } } return true; } // run test suite var testTime = new Date().getTime(); if (compressSerialization(stringifyLevel(parseLevel(testLevel_v0))) !== testLevel_v0_converted) throw new Error("v0 level conversion is broken"); // ask the debug console for this variable if you're concerned with how much time this wastes. testTime = new Date().getTime() - testTime; loadPersistentState(); if (!loadFromLocationHash()) { loadLevel(parseLevel(exampleLevel)); }