dkl9 commited on 2025-209 17:33:57
Showing 4 changed files, with 173 additions and 39 deletions.
| ... | ... |
@@ -1,9 +1,13 @@ |
| 1 | 1 |
<!DOCTYPE html> |
| 2 | 2 |
<html> |
| 3 | 3 |
<head> |
| 4 |
+ <meta charset="utf-8"> |
|
| 4 | 5 |
<title>Functle</title> |
| 6 |
+ <link rel="stylesheet" href="/style.css"> |
|
| 7 |
+ <link rel="icon" type="image/png" href="/favicon.png"> |
|
| 5 | 8 |
<style> |
| 6 |
- body { font-family: sans-serif; }
|
|
| 9 |
+ body { font-family: sans-serif; margin: 0; width: 100%; }
|
|
| 10 |
+ #mobile td { min-width: min(16vw, 5rem); height: min(16vw, 5rem); text-align: center; }
|
|
| 7 | 11 |
#plot { border: 1px solid grey; flex-shrink: 0; width: min(90vw, 25em); height: min(90vw, 25em); flex-shrink: 0; }
|
| 8 | 12 |
#grid { border-collapse: separate; border-spacing: 3px 12px; margin-top: 10px; }
|
| 9 | 13 |
#grid td { min-width: min(13vw, 4rem); height: min(13vw, 4rem); border: 1px solid #ccc; text-align: center; vertical-align: middle; font-weight: bold; transition: transform 0.3s ease; }
|
| ... | ... |
@@ -20,8 +24,7 @@ |
| 20 | 24 |
75% { transform: translateX(-5px); }
|
| 21 | 25 |
100% { transform: translateX(0); }
|
| 22 | 26 |
} |
| 23 |
- @media (min-aspect-ratio: 4/3) { .container { display: flex; } }
|
|
| 24 |
- @media (prefers-color-scheme: dark) { body { color: white; background-color: black; } }
|
|
| 27 |
+ @media (min-aspect-ratio: 4/3) { .container { display: flex; flex-wrap: wrap; } }
|
|
| 25 | 28 |
</style> |
| 26 | 29 |
</head> |
| 27 | 30 |
<body> |
| ... | ... |
@@ -29,7 +32,7 @@ |
| 29 | 32 |
<section id="instructions"> |
| 30 | 33 |
<h1>How to play</h1> |
| 31 | 34 |
<p>Look at the green plot <var>y</var> = <var>f</var>(<var>x</var>) and guess the formula for <var>f</var>(<var>x</var>) in <a href="https://en.wikipedia.org/wiki/Reverse_Polish_notation#Explanation" target="_blank">postfix</a>. |
| 32 |
- Use one character per number or operator:</p> |
|
| 35 |
+ Type one character per number or operator:</p> |
|
| 33 | 36 |
<ul> |
| 34 | 37 |
<li><var>x</var>, 1, or 2 (values)</li> |
| 35 | 38 |
<li>q for "squared" (unary)</li> |
| ... | ... |
@@ -39,6 +42,8 @@ |
| 39 | 42 |
<li>l for natural log</li> |
| 40 | 43 |
<li>+, -, *, / for basic operations</li> |
| 41 | 44 |
</ul> |
| 45 |
+ <p>Or use the grid of buttons. |
|
| 46 |
+ Inputs to - and / are still taken in the usual order, so "2 x -" is two minus <var>x</var>.</p> |
|
| 42 | 47 |
<p>For example, write 1 / (sin² <var>x</var>) as "1 x s q /", or ln(2) <var>e</var><sup><var>x</var></sup> as "2 l x e *".</p> |
| 43 | 48 |
<p>Your answer will be coloured as feedback. |
| 44 | 49 |
<span class="green">Green: correct, right spot.</span> |
| ... | ... |
@@ -56,9 +61,18 @@ |
| 56 | 61 |
</div> |
| 57 | 62 |
<table id="grid"></table> |
| 58 | 63 |
</div> |
| 59 |
- <!-- <div class="side"> |
|
| 60 |
- TODO: comments and questions ... |
|
| 61 |
- </div> --> |
|
| 64 |
+ <div class="side"> |
|
| 65 |
+ <p>Questions on Functle are welcome. |
|
| 66 |
+ Email them to <a href="mailto:contact@dkl9.net">contact@dkl9.net</a>. |
|
| 67 |
+ <span style="display: none;">Avoid <a href="mailto:sucrose@dkl9.net">sucrose@dkl9.net</a>, unless you are a spambot.</span></p> |
|
| 68 |
+ <form action="/cgi-bin/feedback" method="post"> |
|
| 69 |
+ <p><label>Or write a comment — <input type="text" name="t" placeholder="comment here"> — and tell me what you think: |
|
| 70 |
+ <button type="submit" name="j" value="l">I like this</button> |
|
| 71 |
+ <button type="submit" name="j" value="d">I dislike this</button> |
|
| 72 |
+ <button type="submit" name="j" value="n">No judgment</button></p> |
|
| 73 |
+ </form> |
|
| 74 |
+ <p><a href="/">dkl9 home</a></p> |
|
| 75 |
+ </div> |
|
| 62 | 76 |
</div> |
| 63 | 77 |
<script src="guess.js"></script> |
| 64 | 78 |
<script src="plot.js"></script> |
| ... | ... |
@@ -7,17 +7,28 @@ const allTokens = [...allowed.terminals, ...allowed.unary, ...allowed.binary]; |
| 7 | 7 |
const tl = 5; |
| 8 | 8 |
let target; |
| 9 | 9 |
let puzzleNum; |
| 10 |
-if (true) {
|
|
| 10 |
+if (false) {
|
|
| 11 | 11 |
target = generateExpr(); |
| 12 | 12 |
puzzleNum = 0; |
| 13 | 13 |
} else {
|
| 14 | 14 |
(async () => {
|
| 15 | 15 |
const r = await fetch("https://dkl9.net/cgi-bin/fps_curr", { redirect: "follow" });
|
| 16 | 16 |
puzzleNum = parseInt(r.url.match(/n=(\d+)/)[1]); |
| 17 |
- target = await r.text().split(" ");
|
|
| 17 |
+ target = (await r.text()).trim().split(" ");
|
|
| 18 |
+ window.dispatchEvent(new Event("targetReady"));
|
|
| 19 |
+ const score = localStorage.getItem("score").split("\n");
|
|
| 20 |
+ if (playedToday(score)) {
|
|
| 21 |
+ const [g, m] = score[0].split(" ")[2].split("/");
|
|
| 22 |
+ const note = elemText(`You already played today and `); |
|
| 23 |
+ note.textContent += (g <= m) ? `got it in ${g} guess${g == 1 ? "" : "es"}` : `didn't figure it out`;
|
|
| 24 |
+ note.textContent += ". Come back tomorrow. Full score on clipboard."; |
|
| 25 |
+ container.prepend(note); |
|
| 26 |
+ navigator.clipboard.writeText(score); |
|
| 27 |
+ } |
|
| 18 | 28 |
})(); |
| 19 | 29 |
} |
| 20 | 30 |
const container = document.getElementsByClassName("container")[0];
|
| 31 |
+const message = document.getElementById("message");
|
|
| 21 | 32 |
const boxes = {grey: "ā¬ļø", yellow: "šØ", green: "š©"};
|
| 22 | 33 |
const congrats = ["Incredible", "Amazing", "Great", "Nice", "You got it", "Whew"]; |
| 23 | 34 |
const gotitButton = document.getElementById("gotit");
|
| ... | ... |
@@ -33,17 +44,29 @@ if (gotitButton) {
|
| 33 | 44 |
}); |
| 34 | 45 |
} |
| 35 | 46 |
|
| 47 |
+function playedToday(s) {
|
|
| 48 |
+ if (!s) |
|
| 49 |
+ s = (localStorage.getItem("score") || "").split("\n");
|
|
| 50 |
+ return s[0] && s[0].split(" ")[1] == puzzleNum;
|
|
| 51 |
+} |
|
| 52 |
+ |
|
| 53 |
+function elemText(s, e = "p") {
|
|
| 54 |
+ const d = document.createElement(e); |
|
| 55 |
+ d.textContent = s; |
|
| 56 |
+ return d; |
|
| 57 |
+} |
|
| 58 |
+ |
|
| 36 | 59 |
function submitGuess() {
|
| 37 | 60 |
const row = grid.rows[currentRow]; |
| 38 |
- document.getElementById("message").textContent = "";
|
|
| 61 |
+ message.textContent = ""; |
|
| 39 | 62 |
if (currentInput.length !== tl) {
|
| 40 |
- document.getElementById("message").textContent = `Use exactly ${tl} tokens.`;
|
|
| 63 |
+ message.textContent = `Use exactly ${tl} tokens.`;
|
|
| 41 | 64 |
shakeRow(row); |
| 42 | 65 |
return; |
| 43 | 66 |
} |
| 44 | 67 |
for (const t of currentInput) {
|
| 45 | 68 |
if (!allTokens.includes(t)) {
|
| 46 |
- document.getElementById("message").textContent = "Invalid token: " + t;
|
|
| 69 |
+ message.textContent = "Invalid token: " + t; |
|
| 47 | 70 |
shakeRow(row); |
| 48 | 71 |
return; |
| 49 | 72 |
} |
| ... | ... |
@@ -52,7 +75,7 @@ function submitGuess() {
|
| 52 | 75 |
try {
|
| 53 | 76 |
ys = xs.map(x => evalPostfix(currentInput, x)); |
| 54 | 77 |
} catch (e) {
|
| 55 |
- document.getElementById("message").textContent = "Syntax error.";
|
|
| 78 |
+ message.textContent = "Syntax error."; |
|
| 56 | 79 |
shakeRow(row); |
| 57 | 80 |
return; |
| 58 | 81 |
} |
| ... | ... |
@@ -63,12 +86,13 @@ function submitGuess() {
|
| 63 | 86 |
if (close) {
|
| 64 | 87 |
setTimeout(() => {
|
| 65 | 88 |
fillRow(currentInput, "green"); |
| 89 |
+ if (!playedToday()) |
|
| 66 | 90 |
winMessage(); |
| 67 | 91 |
}, 300 * tl); |
| 68 | 92 |
return; |
| 69 | 93 |
} |
| 70 | 94 |
currentRow++; |
| 71 |
- if (currentRow >= maxGuesses) loseMessage(); |
|
| 95 |
+ if (currentRow >= maxGuesses && !playedToday()) loseMessage(); |
|
| 72 | 96 |
currentInput = []; |
| 73 | 97 |
} |
| 74 | 98 |
|
| ... | ... |
@@ -179,40 +203,66 @@ function hideInstructions(button) {
|
| 179 | 203 |
button.remove(); |
| 180 | 204 |
const heading = inst.firstElementChild; |
| 181 | 205 |
heading.remove(); |
| 182 |
- const summary = document.createElement("summary");
|
|
| 183 |
- summary.textContent = heading.textContent; |
|
| 206 |
+ const summary = elemText(heading.textContent, "summary"); |
|
| 184 | 207 |
const newInst = document.createElement("details");
|
| 185 | 208 |
newInst.append(summary); |
| 186 | 209 |
for (const c of inst.childNodes) newInst.append(c); |
| 187 | 210 |
container.append(newInst); |
| 188 | 211 |
} |
| 189 | 212 |
|
| 213 |
+function infix(expr) {
|
|
| 214 |
+ const stack = []; |
|
| 215 |
+ for (const token of expr) {
|
|
| 216 |
+ if (token === '+' || token === '-') {
|
|
| 217 |
+ const b = stack.pop(); |
|
| 218 |
+ const a = stack.pop(); |
|
| 219 |
+ stack.push(`(${a} ${token} ${b})`);
|
|
| 220 |
+ } else if (token === '*' || token === '/') {
|
|
| 221 |
+ const b = stack.pop(); |
|
| 222 |
+ const a = stack.pop(); |
|
| 223 |
+ stack.push(`${a} ${token} ${b}`);
|
|
| 224 |
+ } else if (token === 'sin' || token === 'exp' || token === 'ln') {
|
|
| 225 |
+ const a = stack.pop(); |
|
| 226 |
+ stack.push(`${token}(${a})`);
|
|
| 227 |
+ } else if (token === '^2' || token === '^3') {
|
|
| 228 |
+ const a = stack.pop(); |
|
| 229 |
+ stack.push(`(${a})${token}`);
|
|
| 230 |
+ } else {
|
|
| 231 |
+ stack.push(token); |
|
| 232 |
+ } |
|
| 233 |
+ } |
|
| 234 |
+ return stack[0]; |
|
| 235 |
+} |
|
| 236 |
+ |
|
| 237 |
+function humane(f = target) {
|
|
| 238 |
+ return `${infix(f)} (input as ${f.join(" ")})`;
|
|
| 239 |
+} |
|
| 240 |
+ |
|
| 190 | 241 |
function winMessage() {
|
| 191 | 242 |
let boast = `Functle ${puzzleNum} ${currentRow + 1}/${maxGuesses}\n`;
|
| 192 | 243 |
for (i = 0; i <= currentRow; i++) {
|
| 193 |
- r = grid.rows[i]; |
|
| 194 |
- for (c of r.cells) {
|
|
| 244 |
+ const r = grid.rows[i]; |
|
| 245 |
+ for (const c of r.cells) {
|
|
| 195 | 246 |
const l = c.classList; |
| 196 | 247 |
let p = "?"; |
| 197 | 248 |
for (o in boxes) if (l.contains(o)) p = boxes[o]; |
| 198 |
- boast = boast + p; |
|
| 249 |
+ boast += p; |
|
| 199 | 250 |
} |
| 200 | 251 |
boast = boast + "\n"; |
| 201 | 252 |
} |
| 202 |
- boast = boast + "https://DKL9.net/functle/"; |
|
| 253 |
+ boast += "https://DKL9.net/functle/"; |
|
| 254 |
+ localStorage.setItem("score", boast);
|
|
| 203 | 255 |
navigator.clipboard.writeText(boast); |
| 204 |
- const w = document.createElement("section");
|
|
| 205 |
- w.append(`${congrats[currentRow]}! Share your score. It's on your clipboard. `);
|
|
| 206 |
- const b = document.createElement("button");
|
|
| 207 |
- b.textContent = "Copy again"; |
|
| 256 |
+ const w = elemText(`${congrats[currentRow]}! The function was ${humane()}. Share your score. It's on your clipboard. `, "section");
|
|
| 257 |
+ const b = elemText("Copy again", "button");
|
|
| 208 | 258 |
b.type = "button"; |
| 209 | 259 |
b.addEventListener("click", () => navigator.clipboard.writeText(boast));
|
| 210 | 260 |
w.append(b); |
| 261 |
+ w.append(" Come back tomorrow for the next function.");
|
|
| 211 | 262 |
container.prepend(w); |
| 212 | 263 |
} |
| 213 | 264 |
|
| 214 | 265 |
function loseMessage() {
|
| 215 |
- const d = document.createElement("p");
|
|
| 216 |
- d.textContent = `Better luck next time. The function was ${target.join(" ")}`;
|
|
| 217 |
- container.prepend(d); |
|
| 266 |
+ localStorage.setItem("score", `Functle ${puzzleNum} ${maxGuesses + 1}/${maxGuesses}`);
|
|
| 267 |
+ container.prepend(elemText(`Better luck next time. The function was ${humane()}`));
|
|
| 218 | 268 |
} |
| ... | ... |
@@ -1,5 +1,57 @@ |
| 1 | 1 |
const keyToToken = { x:"x", 1:"1", 2:"2", q:"^2", c:"^3", s:"sin", e:"exp", l:"ln", "+":"+", "-":"-", "*":"*", "/":"/" };
|
| 2 | 2 |
const maxGuesses = 6; |
| 3 |
+const mobile = /Mobi|Android/i.test(navigator.userAgent); |
|
| 4 |
+const buttonKeys = [["x", "1", "2", "q"], ["c", "s", "e", "l"], ["+", "-", "*", "/"]]; |
|
| 5 |
+const grid = document.getElementById("grid");
|
|
| 6 |
+ |
|
| 7 |
+if (mobile) {
|
|
| 8 |
+ const ib = document.createElement("table");
|
|
| 9 |
+ ib.id = "mobile"; |
|
| 10 |
+ for (let i = 0; i < 3; i++) {
|
|
| 11 |
+ const r = document.createElement("tr");
|
|
| 12 |
+ for (let j = 0; j < 4; j++) {
|
|
| 13 |
+ const t = keyToToken[buttonKeys[i][j]]; |
|
| 14 |
+ r.append(makeButton(t, 3.5 - 0.5 * t.length, () => {
|
|
| 15 |
+ const row = grid.rows[currentRow]; |
|
| 16 |
+ if (currentRow >= maxGuesses) {
|
|
| 17 |
+ } else if (currentInput.length < tl) {
|
|
| 18 |
+ currentInput.push(t); |
|
| 19 |
+ const c = row.cells[currentInput.length - 1]; |
|
| 20 |
+ c.style.fontSize = `${3.5 - 0.5 * t.length}em`;
|
|
| 21 |
+ c.textContent = t; |
|
| 22 |
+ } else {
|
|
| 23 |
+ shakeRow(row); |
|
| 24 |
+ } |
|
| 25 |
+ })); |
|
| 26 |
+ } |
|
| 27 |
+ ib.append(r); |
|
| 28 |
+ } |
|
| 29 |
+ const lr = document.createElement("tr");
|
|
| 30 |
+ const dc = makeButton("delete", 3, () => {
|
|
| 31 |
+ const row = grid.rows[currentRow]; |
|
| 32 |
+ if (currentInput.length > 0) {
|
|
| 33 |
+ currentInput.pop(); |
|
| 34 |
+ row.cells[currentInput.length].textContent = ""; |
|
| 35 |
+ } else {
|
|
| 36 |
+ shakeRow(row); |
|
| 37 |
+ } |
|
| 38 |
+ }); |
|
| 39 |
+ dc.colSpan = buttonKeys[0].length / 2; |
|
| 40 |
+ lr.append(dc); |
|
| 41 |
+ const sc = makeButton("check", 3, submitGuess);
|
|
| 42 |
+ sc.colSpan = buttonKeys[0].length / 2; |
|
| 43 |
+ lr.append(sc); |
|
| 44 |
+ ib.append(lr); |
|
| 45 |
+ grid.after(ib); |
|
| 46 |
+} |
|
| 47 |
+ |
|
| 48 |
+function makeButton(label, size, handler) {
|
|
| 49 |
+ const b = document.createElement("td");
|
|
| 50 |
+ b.textContent = label; |
|
| 51 |
+ b.style.fontSize = `${size}em`;
|
|
| 52 |
+ b.addEventListener("click", handler);
|
|
| 53 |
+ return b; |
|
| 54 |
+} |
|
| 3 | 55 |
|
| 4 | 56 |
function shakeRow(row) {
|
| 5 | 57 |
row.classList.remove("shake");
|
| ... | ... |
@@ -7,7 +59,6 @@ function shakeRow(row) {
|
| 7 | 59 |
row.classList.add("shake");
|
| 8 | 60 |
} |
| 9 | 61 |
|
| 10 |
-const grid = document.getElementById("grid");
|
|
| 11 | 62 |
for (let i = 0; i < maxGuesses; i++) {
|
| 12 | 63 |
const tr = document.createElement("tr");
|
| 13 | 64 |
for (let j = 0; j < tl; j++) {
|
| ... | ... |
@@ -3,24 +3,33 @@ const ctx = document.getElementById("plot").getContext("2d");
|
| 3 | 3 |
const bound = 10; |
| 4 | 4 |
const xs = []; |
| 5 | 5 |
const targetYs = []; |
| 6 |
+const clampLow = 1.5; |
|
| 7 |
+const clampHigh = 20; |
|
| 8 |
+const guessYsList = []; |
|
| 9 |
+const darkTheme = matchMedia('(prefers-color-scheme: dark)').matches;
|
|
| 10 |
+let yMin = -clampHigh, yMax = clampHigh; |
|
| 11 |
+ |
|
| 12 |
+ctx.textBaseline = "middle"; |
|
| 13 |
+ctx.textAlign = "center"; |
|
| 14 |
+ctx.fillStyle = darkTheme ? "white" : "black"; |
|
| 15 |
+ctx.font = `${height * 0.15}px sans-serif`;
|
|
| 16 |
+ctx.fillText("Loading ...", width / 2, height / 2);
|
|
| 17 |
+ |
|
| 18 |
+window.addEventListener("targetReady", () => {
|
|
| 6 | 19 |
for (let i = 0; i <= width; i++) {
|
| 7 | 20 |
const x = bound * (2 * i / width - 1); |
| 8 | 21 |
xs.push(x); |
| 9 | 22 |
const yv = evalPostfix(target, x); |
| 10 | 23 |
targetYs.push(yv); |
| 11 | 24 |
} |
| 12 |
-const clampLow = 1.5; |
|
| 13 |
-const clampHigh = 20; |
|
| 14 |
-let yMin = Math.min(...targetYs); |
|
| 15 |
-let yMax = Math.max(...targetYs); |
|
| 25 |
+ yMin = Math.min(...targetYs); |
|
| 26 |
+ yMax = Math.max(...targetYs); |
|
| 16 | 27 |
if (isNaN(yMin) || yMin < -clampHigh) yMin = -clampHigh; |
| 17 | 28 |
else if (yMin > -clampLow) yMin = -clampLow; |
| 18 | 29 |
if (isNaN(yMax) || yMax > clampHigh) yMax = clampHigh; |
| 19 | 30 |
else if (yMax < clampLow) yMax = clampLow; |
| 20 |
-const guessYsList = []; |
|
| 21 |
-const darkTheme = matchMedia('(prefers-color-scheme: dark)').matches;
|
|
| 22 |
- |
|
| 23 | 31 |
redrawAll(); |
| 32 |
+}); |
|
| 24 | 33 |
|
| 25 | 34 |
function drawAxes() {
|
| 26 | 35 |
ctx.clearRect(0, 0, width, height); |
| ... | ... |
@@ -46,12 +55,22 @@ function drawAxes() {
|
| 46 | 55 |
ctx.stroke(); |
| 47 | 56 |
} |
| 48 | 57 |
|
| 49 |
-function drawCurve(ys, color, width = 4) {
|
|
| 58 |
+function drawCurve(ys, color, width = 4, threshold = 1e3) {
|
|
| 50 | 59 |
ctx.beginPath(); |
| 60 |
+ let prevValid = false; |
|
| 51 | 61 |
for (let i = 0; i < xs.length; i++) {
|
| 52 |
- const px = mapX(xs[i]), py = mapY(ys[i]); |
|
| 53 |
- if (i === 0) ctx.moveTo(px, py); |
|
| 54 |
- else ctx.lineTo(px, py); |
|
| 62 |
+ const y = ys[i]; |
|
| 63 |
+ if (isFinite(y)) {
|
|
| 64 |
+ const px = mapX(xs[i]), py = mapY(y); |
|
| 65 |
+ if (prevValid && Math.abs(y - ys[i - 1]) < threshold) {
|
|
| 66 |
+ ctx.lineTo(px, py); |
|
| 67 |
+ } else {
|
|
| 68 |
+ ctx.moveTo(px, py); |
|
| 69 |
+ } |
|
| 70 |
+ prevValid = true; |
|
| 71 |
+ } else {
|
|
| 72 |
+ prevValid = false; |
|
| 73 |
+ } |
|
| 55 | 74 |
} |
| 56 | 75 |
ctx.lineWidth = width; |
| 57 | 76 |
ctx.strokeStyle = color; |
| 58 | 77 |