<!doctype html> <!-- prototyped this in ~24 hours to help so i don't have to manually enter lists of coordinates for gameboy sprite animations --> <html> <head> <title>curve editor for gameboy animation</title> <style> body { display: flex; flex-direction: row; gap: 1em; } #controls { display: flex; flex-direction: column; gap: 1em; } </style> </head> <body> <svg viewBox="0 0 176 176" style="border: 1px solid black; display: block; max-height: 700px" xmlns:xlink="http://www.w3.org/1999/xlink"> <defs> <g id="point"> <rect id="sprite" x="0" y="0" width="8" height="8" stroke="none" fill="gray" fill-opacity="25%"/> <circle cx="0.5" cy="0.5" r="2" fill="black" fill-opacity="25%"/> <circle cx="0.5" cy="0.5" r="0.5" fill="black" fill-opacity="100%"/> </g> <pattern id="smallGrid" width="1" height="1" patternUnits="userSpaceOnUse"> <path d="M 1 0 L 0 0 0 1" fill="none" stroke="darkseagreen" stroke-width="0.25"/> </pattern> <pattern id="grid" width="8" height="8" patternUnits="userSpaceOnUse"> <rect width="8" height="8" fill="url(#smallGrid)"/> <path d="M 8 0 L 0 0 0 8" fill="none" stroke="darkseagreen" stroke-width="0.5"/> </pattern> </defs> <image id="background-container" width="160" height="144" x="8" y="16" opacity="50%" xlink:href="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" /> <rect width="100%" height="100%" fill="url(#grid)" /> <rect x="8" y="16" width="160" height="144" style="fill: none; stroke: darkseagreen; stroke-width: 0.5"/> <g id="lines"> </g> <g id="sequence"> </g> </svg> <div id="controls"> <label for="background-selector">file for background</label> <input type="file" id="background-selector" name="background-selector"/> <fieldset> <legend>control mode</legend> <div> <input type="radio" name="mode" id="add" value="add" checked/> <label for="add"><strong>a</strong>dd points</label> </div> <div> <input type="radio" name="mode" id="delete" value="delete"/> <label for="delete"><strong>d</strong>elete points</label> </div> <div> <input type="radio" name="mode" id="move" value="move"/> <label for="move"><strong>g</strong>rab and move points</label> </div> </fieldset> <div> <label for="sprite_width">width in 8x8 sprites:</label> <input type="number" id="sprite_width" name="sprite_width" min="1" max="16" step="1" value="1" onchange="sprite_dims_change()"/> </div> <div> <label for="sprite_height">height in 8x8 sprites:</label> <input type="number" id="sprite_height" name="sprite_height" min="1" max="16" step="1" value="1" onchange="sprite_dims_change()"/> </div> <p>output is a length-prefixed list of y,x pairs.</p> <textarea id="output">db 0,</textarea> </div> </body> <script> var svg = null; var svg_group = null; var text_output = null; var moving_point_index = -1; var the_list = []; var current_viewport = {center: {x: 0, y: 0}, radius: {x: 176, y: 176}}; var max_viewport = {center: {x: 0, y: 0}, radius: {x: 176, y:176}}; function length(dx, dy) {return Math.sqrt(dx*dx + dy*dy);} const clicksvg = (event) => { const mode = document.querySelector('input[name=mode]:checked').value; // either 'add', 'delete', or 'move' const {x, y} = dom_to_svg_coords(svg, event.clientX, event.clientY); if (mode == "add") { the_list.push({x: Number((x-0.5).toFixed()),y: Number((y-0.5).toFixed())}); console.log(Number(x.toFixed())) regenerate_svg_list(); regenerate_text_list(); } if (mode == "delete") { let closest = -1; let distance = 3; the_list.forEach((point, index) => { let new_distance = length(point.x-x, point.y-y); if (new_distance < distance) { distance = new_distance; closest = index; } }); if (closest != -1) { the_list.splice(closest, 1); } regenerate_svg_list(); regenerate_text_list(); } if (mode == "move" && moving_point_index != -1) { const elem = document.querySelector(`g#sequence use:nth-child(${moving_point_index+1})`); let newX = Number(Number(elem.getAttributeNS(null, "x")).toFixed()); let newY = Number(Number(elem.getAttributeNS(null, "y")).toFixed()); the_list[moving_point_index] = {x: newX, y: newY}; moving_point_index = -1; regenerate_svg_list(); regenerate_text_list(); } } const mousemovesvg = (event) => { const mode = document.querySelector('input[name=mode]:checked').value; // either 'add', 'delete', or 'move' const {x, y} = dom_to_svg_coords(svg, event.clientX, event.clientY); if (mode == 'move' && moving_point_index != -1) { const previous_mouse_location = {x: event.clientX - event.movementX, y: event.clientY - event.movementY}; const prev_location_in_gbspace = dom_to_svg_coords(svg, previous_mouse_location.x, previous_mouse_location.y); const gb_movement = {x: x - prev_location_in_gbspace.x, y: y - prev_location_in_gbspace.y}; const point = the_list[moving_point_index]; const elem = document.querySelector(`g#sequence use:nth-child(${moving_point_index+1})`); let newX = Number(elem.getAttributeNS(null, "x")) + gb_movement.x; let newY = Number(elem.getAttributeNS(null, 'y')) + gb_movement.y; elem.setAttributeNS(null, "x", newX); elem.setAttributeNS(null, "y", newY); const lineTo = document.querySelector(`g#lines line:nth-child(${moving_point_index})`); if (lineTo) { lineTo.setAttributeNS(null, "x2", newX+0.5); lineTo.setAttributeNS(null, "y2", newY+0.5); } const lineFrom = document.querySelector(`g#lines line:nth-child(${moving_point_index+1})`); if (lineFrom) { lineFrom.setAttributeNS(null, "x1", newX+0.5); lineFrom.setAttributeNS(null, "y1", newY+0.5); } } } const mousedownsvg = (event) => { const mode = document.querySelector('input[name=mode]:checked').value; // either 'add', 'delete', or 'move' const {x, y} = dom_to_svg_coords(svg, event.clientX, event.clientY); let closest = -1; let distance = 3; the_list.forEach((point, index) => { let new_distance = length(point.x-x, point.y-y); if (new_distance < distance) { distance = new_distance; closest = index; } }); if (mode == 'move') { moving_point_index = closest; } } const changetextarea = (event) => { let raw = event.target.value; let stripped = raw.replace(/db [0-9]+,/, ""); let pairs = stripped.matchAll(/ ?[0-9]+, ?[0-9]+/g); let splits = pairs.map((x) => x[0].split(/,/)); let points = splits.map(([a, b]) => { return {y: Number(a), x: Number(b)}; } ); the_list = [... points]; regenerate_svg_list(); regenerate_text_list(); } const sprite_dims_change = (event) => { const width = document.querySelector('input#sprite_width').value; const height = document.querySelector('input#sprite_height').value; const rect = document.querySelector('g#point rect#sprite') rect.setAttributeNS(null, "width", width*8); rect.setAttributeNS(null, "height", height*8); } function regenerate_svg_list() { svg_group.textContent = ''; for (const {x, y} of the_list) { const newcircle = document.createElementNS("http://www.w3.org/2000/svg", "use"); newcircle.setAttributeNS(null, "href", "#point"); newcircle.setAttributeNS(null, "x", x); newcircle.setAttributeNS(null, "y", y); svg_group.appendChild(newcircle); } document.querySelector('g#lines').textContent = ''; for (const [second, first] of the_list.slice(1, the_list.length).map((e,i) => [e, the_list[i]])) { const newline = document.createElementNS("http://www.w3.org/2000/svg", "line"); newline.setAttributeNS(null, "x1", first.x+0.5); newline.setAttributeNS(null, "y1", first.y+0.5); newline.setAttributeNS(null, "x2", second.x+0.5); newline.setAttributeNS(null, "y2", second.y+0.5); newline.setAttributeNS(null, "stroke", "black"); newline.setAttributeNS(null, "stroke-width", "0.25"); newline.setAttributeNS(null, "stroke-opacity", "50%"); document.querySelector('g#lines').appendChild(newline); } } function regenerate_text_list() { s = `db ${the_list.length}, `; for (const {x, y} of the_list) { s += `${y}, ${x}, `; } text_output.value = s; } function dom_to_svg_coords(s, clientX, clientY) { // thanks to https://www.sitepoint.com/how-to-translate-from-dom-to-svg-coordinates-and-back-again/ const pt = s.createSVGPoint(); pt.x = clientX; pt.y = clientY; const svgP = pt.matrixTransform( s.getScreenCTM().inverse() ); return svgP; } window.onload = () => { svg = document.querySelector('svg'); svg.onclick = clicksvg; svg.onmousemove = mousemovesvg; svg.onmousedown = mousedownsvg; svg_group = document.querySelector('g#sequence'); text_output = document.querySelector('textarea#output'); text_output.onchange = changetextarea; sprite_dims_change(); let filePicker = document.querySelector('input#background-selector'); filePicker.onchange = (event) => { var file = event.target.files[0]; // setting up the reader var reader = new FileReader(); reader.readAsDataURL(file,'UTF-8'); // here we tell the reader what to do when it's done reading... reader.onload = readerEvent => { var content = readerEvent.target.result; // this is the content! document .querySelector('image#background-container') .setAttributeNS("http://www.w3.org/1999/xlink", "href", content); } } if (filePicker.files[0]) { filePicker.onchange( {target: filePicker} ); } document.onkeyup = (event) => { const add_radio = document.querySelector('input[name=mode]#add'); // either 'add', 'delete', or 'move' const delete_radio = document.querySelector('input[name=mode]#delete'); const move_radio = document.querySelector('input[name=mode]#move'); if (event.key == 'a') { add_radio.checked = true; } if (event.key == 'd') { delete_radio.checked = true; } if (event.key == 'g') { move_radio.checked = true; } } } </script> </html>