nou-cli 0.5.3 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/dist/src/cli.js +33 -0
- package/dist/src/config/index.js +3 -0
- package/dist/src/diagnostics/providers.js +23 -2
- package/dist/src/engines/codex.js +28 -1
- package/dist/src/games/index.js +220 -30
- package/dist/src/guide/index.js +100 -0
- package/dist/src/syntax/index.js +17 -17
- package/dist/src/ui/App.js +398 -54
- package/dist/src/ui/Chat.js +103 -19
- package/dist/src/ui/CodeView.js +70 -19
- package/dist/src/ui/Editor.js +44 -7
- package/dist/src/ui/FileTree.js +4 -2
- package/dist/src/ui/GamePanel.js +42 -14
- package/dist/src/ui/Header.js +5 -2
- package/dist/src/ui/ProblemsView.js +4 -2
- package/dist/src/ui/Settings.js +10 -3
- package/dist/src/ui/Terminal.js +5 -2
- package/dist/src/ui/clipboard.js +19 -0
- package/dist/src/ui/commands.js +21 -1
- package/dist/src/ui/focus.js +59 -0
- package/dist/src/ui/redraw.js +16 -0
- package/dist/src/ui/render-options.js +3 -0
- package/dist/src/ui/text.js +76 -3
- package/dist/src/ui/theme.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,7 +50,9 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
|
|
|
50
50
|
| 🔀 **Live diffs** | Every edit Nou proposes shows up side by side, and the files it is editing shimmer in the file tree while it works. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
|
|
51
51
|
| ✍️ **Built-in editor** | Press <kbd>e</kbd> to edit the open file yourself, with undo/redo and **auto-save**. |
|
|
52
52
|
| 📍 **Shows you where things are** | Ask "where is the LiveKit integration?" and Nou finds it and opens it in the middle panel, jumped to the exact lines and highlighted. Any file a reply mentions (`src/a.ts:12`) is a link: click it. `/open name[:line]` opens a file yourself. |
|
|
53
|
-
|
|
|
53
|
+
| 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. `/close` closes one; it stays in `/resume`. |
|
|
54
|
+
| 🧑🏫 **Show me how** | `/showme add a --verbose flag` and Nou finds where the change goes, then walks you through typing it yourself: each step opens the file at the right line, says why, and shows the code to type dim ahead of your cursor, like autocomplete (<kbd>Tab</kbd> fills in a line). It moves on when what you typed matches, and saves as it goes. `/showme next` skips a step, `/showme stop` ends it. Works with every engine (Nou's, Claude Code, Codex and pi): any edit one makes anyway is put back. `/teach` does the same. |
|
|
55
|
+
| 🎮 **Games for a break** | Add Pong, Snake, Breakout (it gets faster each wall you clear), Dodge, Hop and Pairs in settings → Games, then `/games` opens a panel docked under the chat: it splits that column instead of covering anything. Drag its title onto the files, code or chat to dock it there, and × at its top right closes it. Drag its top edge to make it taller or shorter. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. Your best score in each is kept, and a panel too small for a game says how much room it needs. |
|
|
54
56
|
| 🧠 **A capable engine** | Reads and searches in parallel, numbered reads for exact line references, edits that survive small whitespace mismatches, several edits to a file as one change, a plan for longer tasks, full command output (stderr too) with exit codes and timeouts, retries when a provider is overloaded or a connection drops (Codex too: it picks the turn back up instead of stopping at "Reconnecting..."), and conversation caching with Claude. It follows your repo's `AGENTS.md`, `CLAUDE.md` and `NOU.md` (and `~/.nou/NOU.md`). |
|
|
55
57
|
| 🔎 **Finds its way around** | The agent searches your code (`grep`, `glob`) without asking, reads only what it needs, runs your tests to check its work, and summarizes long conversations to stay within the model's context (`/compact` does it on demand). |
|
|
56
58
|
| ↶ **Undo anything** | Every change, Nou's or yours, is recorded. <kbd>u</kbd> undoes Nou's last edit, `/rewind` undoes its whole last turn, and your own later edits are kept. |
|
|
@@ -65,6 +67,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
|
|
|
65
67
|
| 🛞 **Pick your agent** | Nou's own, or Claude Code, Codex or pi, switchable like models, with the same diffs, approvals and undo. |
|
|
66
68
|
| 🧩 **Plugins** | Add your own tools and slash commands with a single file. |
|
|
67
69
|
| 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> fuzzy-finds any file. |
|
|
70
|
+
| 🔋 **Easy on your battery** | Idle, Nou uses next to no CPU. When its window is in the background (in terminals that report focus: iTerm2, Terminal.app, kitty, Ghostty, WezTerm, Windows Terminal, VS Code), spinners and git polling pause until you come back, and TypeScript checks are incremental. |
|
|
68
71
|
|
|
69
72
|
Every change is reviewed like a code review:
|
|
70
73
|
|
|
@@ -98,11 +101,14 @@ Every change is reviewed like a code review:
|
|
|
98
101
|
| <kbd>c</kbd> | in the file tree: show only changed files |
|
|
99
102
|
| <kbd>f</kbd> | follow mode (off by default): watch along as Nou works. Each file it reads (the exact lines, highlighted) is shown a step at a time, a little behind it, and its edits as they happen. The status bar shows how many steps behind it is; set the speed in settings → Preferences (slow · normal · fast · instant) |
|
|
100
103
|
| <kbd><</kbd> / <kbd>></kbd> / <kbd>=</kbd> | make the focused pane narrower / wider / reset the layout |
|
|
101
|
-
| <kbd>`</kbd> | a terminal under the code, like VS Code's (also `/terminal`): Enter runs a command, Ctrl+C stops it, ↑↓ history, Esc back to the code, × closes it. Commands run through your shell one at a time, so full-screen programs like vim don't draw there |
|
|
104
|
+
| <kbd>`</kbd> | a terminal under the code, like VS Code's (also `/terminal`): Enter runs a command, Ctrl+C stops it, ↑↓ history, Esc back to the code, × closes it; drag its top border (or <kbd>Alt</kbd>+↑/↓) to make it taller or shorter. Commands run through your shell one at a time, so full-screen programs like vim don't draw there |
|
|
105
|
+
| <kbd>z</kbd> | in the code view: fold every function and block to its first line, or open them all again (also `/fold`, `/unfold`); click a ▾/▸ beside a line number to fold or open just that one |
|
|
106
|
+
| <kbd>Ctrl</kbd>+<kbd>L</kbd> | draw the whole screen again (also `/redraw`), if it ever looks shifted or clicks land a row off. Output from libraries goes to `~/.nou/nou.log` instead of the screen |
|
|
107
|
+
| <kbd>Ctrl</kbd>+<kbd>O</kbd> | in the chat: open or fold every "▸ N steps" line (what the agent read and ran between replies folds into one; click one to open just it) |
|
|
102
108
|
| <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal (in the chat, just drag over the text: it's copied when you let go) |
|
|
103
109
|
| <kbd>s</kbd> | settings: model, preferences, plugins |
|
|
104
110
|
| <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command, from any pane; the rest of Nou keeps working while it waits (<kbd>o</kbd> shows the edit) |
|
|
105
|
-
| <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes, together with anything else you queued, as one message (<kbd>Alt</kbd>+<kbd>Enter</kbd>: stop Nou and send it now) |
|
|
111
|
+
| <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes, together with anything else you queued, as one message; commands that only change the screen, like `/terminal`, `/games` or `/open`, run right away (<kbd>Alt</kbd>+<kbd>Enter</kbd>: stop Nou and send it now) |
|
|
106
112
|
| <kbd>Esc</kbd> | stop Nou (anything queued goes back in the input) · close a diff · back to chat |
|
|
107
113
|
| <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
|
|
108
114
|
|
|
@@ -120,11 +126,11 @@ In the editor:
|
|
|
120
126
|
| <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
|
|
121
127
|
| <kbd>Esc</kbd> | clear the selection, or leave the editor |
|
|
122
128
|
|
|
123
|
-
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/help`
|
|
129
|
+
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/showme <what you want>` · `/help`
|
|
124
130
|
|
|
125
131
|
New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol), or type `\` and press <kbd>Enter</kbd> in any terminal.
|
|
126
132
|
|
|
127
|
-
Conversations are saved per folder after each reply. `/resume` lists them and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
|
|
133
|
+
Conversations are saved per folder after each reply. `/resume` lists them (then type a number to pick one) and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
|
|
128
134
|
|
|
129
135
|
## 🧠 Models
|
|
130
136
|
|
|
@@ -167,6 +173,8 @@ The engine is the agent that answers in the chat. Nou's own works with every mod
|
|
|
167
173
|
| `pi` | your Nou model, local or cloud | ✅ |
|
|
168
174
|
| `codex` (Codex SDK) | OpenAI; your API key or ChatGPT login | ❌ edits in its sandbox; review and undo after |
|
|
169
175
|
|
|
176
|
+
Codex's sandbox can write to the project and the repo's `.git` (so it can commit, stash and pull) and reach the network.
|
|
177
|
+
|
|
170
178
|
Other engines install on first use into `~/.nou/engines/`, so Nou itself stays small. Switching engines starts a new conversation. Compare them on the same tasks with `npm run eval -- --engine nou --engine claude-code`.
|
|
171
179
|
|
|
172
180
|
## 🔄 Updating
|
package/dist/src/cli.js
CHANGED
|
@@ -14,6 +14,34 @@ import { lsp } from "./lsp/index.js";
|
|
|
14
14
|
import { App } from "./ui/App.js";
|
|
15
15
|
import { installFastOutput } from "./ui/fast-output.js";
|
|
16
16
|
import { RENDER_OPTIONS } from "./ui/render-options.js";
|
|
17
|
+
import { setScreenClear } from "./ui/redraw.js";
|
|
18
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
19
|
+
import path from "path";
|
|
20
|
+
import { format } from "util";
|
|
21
|
+
import { NOU_HOME } from "./config/index.js";
|
|
22
|
+
/**
|
|
23
|
+
* Sends console output and stderr to `file` instead of the terminal, until the returned function puts them
|
|
24
|
+
* back. Nou draws the whole screen; a stray line from a library would scroll it out from under the mouse.
|
|
25
|
+
*/
|
|
26
|
+
function quietConsole(file) {
|
|
27
|
+
const saved = { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug, stderr: process.stderr.write };
|
|
28
|
+
const toFile = (text) => {
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
31
|
+
appendFileSync(file, text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// nowhere to keep it: dropping it still beats scrambling the screen
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
for (const name of ["log", "info", "warn", "error", "debug"])
|
|
38
|
+
console[name] = (...args) => toFile(`${new Date().toISOString()} ${name}: ${format(...args)}\n`);
|
|
39
|
+
process.stderr.write = ((chunk) => (toFile(String(chunk)), true));
|
|
40
|
+
return () => {
|
|
41
|
+
Object.assign(console, { log: saved.log, info: saved.info, warn: saved.warn, error: saved.error, debug: saved.debug });
|
|
42
|
+
process.stderr.write = saved.stderr;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
17
45
|
const HELP = `nou ${currentVersion()}: an AI coding assistant for your terminal
|
|
18
46
|
|
|
19
47
|
Usage:
|
|
@@ -109,8 +137,13 @@ async function main() {
|
|
|
109
137
|
await ensureProjectContext(choice);
|
|
110
138
|
const permContext = await loadPermanentContext();
|
|
111
139
|
await installFastOutput();
|
|
140
|
+
// While Nou is on screen, anything a library prints (console.log, warnings, stderr) goes to a log file:
|
|
141
|
+
// written into the terminal, it would scroll the screen out from under the mouse.
|
|
142
|
+
const restoreOutput = quietConsole(path.join(NOU_HOME, "nou.log"));
|
|
112
143
|
const app = render(createElement(App, { initialModel: choice, permContext, ...(args.includes("--continue") || args.includes("-c") ? { resume: "last" } : args.includes("--resume") || args.includes("-r") ? { resume: "list" } : {}) }), RENDER_OPTIONS);
|
|
144
|
+
setScreenClear(() => app.clear());
|
|
113
145
|
await app.waitUntilExit();
|
|
146
|
+
restoreOutput();
|
|
114
147
|
lsp.stopAll();
|
|
115
148
|
p.outro(chalk.cyan("Bye bye!"));
|
|
116
149
|
}
|
package/dist/src/config/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { existsSync, mkdirSync } from "fs";
|
|
2
3
|
import path from "path";
|
|
4
|
+
import { NOU_HOME } from "../config/index.js";
|
|
3
5
|
import { onPath, projectBin, run } from "./process.js";
|
|
4
6
|
const rel = (file) => path.relative(process.cwd(), path.resolve(process.cwd(), file)).replace(/\\/g, "/");
|
|
5
7
|
/** ESLint, using the project's own install and config, so results match `npm run lint`. */
|
|
@@ -38,6 +40,18 @@ export const eslintProvider = {
|
|
|
38
40
|
},
|
|
39
41
|
};
|
|
40
42
|
/** The TypeScript compiler over the whole project (type errors need the full picture). */
|
|
43
|
+
/**
|
|
44
|
+
* tsc runs after every edit, and a full check of a mid-sized project is many CPU-seconds (on a laptop,
|
|
45
|
+
* battery). Incremental checks reuse what didn't change. The build info lives in ~/.nou/cache, one file per
|
|
46
|
+
* project, so the project's own files (and any tsBuildInfoFile it sets) are left alone.
|
|
47
|
+
*/
|
|
48
|
+
let incremental = true;
|
|
49
|
+
function buildInfoFile() {
|
|
50
|
+
const id = createHash("sha1").update(process.cwd()).digest("hex").slice(0, 16);
|
|
51
|
+
const dir = path.join(NOU_HOME, "cache", "tsc");
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
return path.join(dir, `${id}.tsbuildinfo`);
|
|
54
|
+
}
|
|
41
55
|
export const typescriptProvider = {
|
|
42
56
|
id: "typescript",
|
|
43
57
|
name: "TypeScript",
|
|
@@ -49,7 +63,14 @@ export const typescriptProvider = {
|
|
|
49
63
|
const bin = projectBin("typescript", "tsc");
|
|
50
64
|
if (!bin)
|
|
51
65
|
return [];
|
|
52
|
-
const
|
|
66
|
+
const args = [bin, "--noEmit", "-p", ".", "--pretty", "false"];
|
|
67
|
+
let result = await run(process.execPath, incremental ? [...args, "--incremental", "--tsBuildInfoFile", buildInfoFile()] : args, { timeout: 180_000 });
|
|
68
|
+
// An option this project's tsconfig rules out (a TS5xxx/TS6xxx error that names no file): check it the
|
|
69
|
+
// plain way from now on.
|
|
70
|
+
if (incremental && /^error TS[56]\d{3}:/m.test(result.stdout)) {
|
|
71
|
+
incremental = false;
|
|
72
|
+
result = await run(process.execPath, args, { timeout: 180_000 });
|
|
73
|
+
}
|
|
53
74
|
const diagnostics = [];
|
|
54
75
|
for (const line of result.stdout.split(/\r?\n/)) {
|
|
55
76
|
const m = line.match(/^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "child_process";
|
|
2
|
-
import { statSync } from "fs";
|
|
2
|
+
import { realpathSync, statSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
3
4
|
import { transientMessage } from "../agent/index.js";
|
|
4
5
|
import { listAllFiles } from "../ui/QuickOpen.js";
|
|
5
6
|
import { ExternalEngine } from "./external.js";
|
|
@@ -9,6 +10,31 @@ const MAX_FILES = 5000;
|
|
|
9
10
|
const MAX_BYTES = 512 * 1024;
|
|
10
11
|
/** Times a turn that failed for a passing reason (a dropped stream, a failing server) is picked up again. */
|
|
11
12
|
const RESUMES = 5;
|
|
13
|
+
/**
|
|
14
|
+
* The repo's git folders. Codex's "workspace-write" sandbox keeps `.git` read-only inside the folder it may
|
|
15
|
+
* write to, so `git commit`, `git stash` and `git pull` fail ("cannot write to .git"). Handing it the git folder
|
|
16
|
+
* as a folder of its own makes it writable (the read-only rule is for a `.git` inside a writable folder).
|
|
17
|
+
* Worktrees keep their state in the main repo's git folder, so that one goes too.
|
|
18
|
+
*/
|
|
19
|
+
export function gitDirs(cwd = process.cwd()) {
|
|
20
|
+
try {
|
|
21
|
+
const out = execFileSync("git", ["rev-parse", "--absolute-git-dir", "--git-common-dir"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
22
|
+
// Real paths, so one folder reached two ways (macOS's /var is /private/var, Windows' short RUNNER~1 names)
|
|
23
|
+
// is listed once.
|
|
24
|
+
const real = (d) => { try {
|
|
25
|
+
return realpathSync.native(d);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return d;
|
|
29
|
+
} };
|
|
30
|
+
const dirs = out.split("\n").map((d) => d.trim()).filter(Boolean).map((d) => real(path.resolve(cwd, d)));
|
|
31
|
+
const key = (d) => (process.platform === "win32" ? d.toLowerCase() : d);
|
|
32
|
+
return dirs.filter((d, i) => dirs.findIndex((e) => key(e) === key(d)) === i);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return []; // not a git repo, or git isn't installed
|
|
36
|
+
}
|
|
37
|
+
}
|
|
12
38
|
/** Project files small enough to keep a copy of (git's list when there is one, so ignored files are skipped). */
|
|
13
39
|
function projectFiles() {
|
|
14
40
|
let files;
|
|
@@ -79,6 +105,7 @@ export class CodexEngine extends ExternalEngine {
|
|
|
79
105
|
// resolve registry.npmjs.org"); Nou's other engines can reach it, so Codex can too.
|
|
80
106
|
networkAccessEnabled: true,
|
|
81
107
|
approvalPolicy: "never",
|
|
108
|
+
additionalDirectories: gitDirs(),
|
|
82
109
|
...(this.choice.provider.id === "openai" ? { model: this.choice.model } : {}),
|
|
83
110
|
...(this.effort && EFFORTS.has(this.effort) ? { modelReasoningEffort: this.effort } : {}),
|
|
84
111
|
};
|
package/dist/src/games/index.js
CHANGED
|
@@ -12,8 +12,13 @@ function rng(seed) {
|
|
|
12
12
|
return (s >>> 0) / 4294967296;
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
-
const blank = (w, h) => Array.from({ length: h }, () => Array(w).fill(" "));
|
|
15
|
+
const blank = (w, h) => Array.from({ length: Math.max(0, h) }, () => Array(Math.max(0, w)).fill(" "));
|
|
16
16
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
17
|
+
/** Draws a character if (x, y) is on the grid: games never write outside it, whatever its size. */
|
|
18
|
+
const put = (grid, x, y, ch) => {
|
|
19
|
+
if (grid[y]?.[x] !== undefined)
|
|
20
|
+
grid[y][x] = ch;
|
|
21
|
+
};
|
|
17
22
|
const PADDLE = 3;
|
|
18
23
|
const PONG_WIN = 7;
|
|
19
24
|
function serve(s, toward) {
|
|
@@ -26,8 +31,11 @@ export const pong = {
|
|
|
26
31
|
blurb: "The classic: you on the left, the computer on the right. First to 7.",
|
|
27
32
|
controls: "↑↓ or w/s move",
|
|
28
33
|
tickMs: 50,
|
|
34
|
+
minWidth: 16,
|
|
35
|
+
minHeight: 6,
|
|
29
36
|
colors: { "█": "#61afef", "●": "#e5c07b", "┊": "#5c6370" },
|
|
30
37
|
create(w, h, seed = Date.now()) {
|
|
38
|
+
[w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
|
|
31
39
|
const s = { w, h, you: Math.floor((h - PADDLE) / 2), them: Math.floor((h - PADDLE) / 2), bx: 0, by: 0, vx: 0, vy: 0, scores: [0, 0], random: rng(seed) };
|
|
32
40
|
return serve(s, -1);
|
|
33
41
|
},
|
|
@@ -65,18 +73,16 @@ export const pong = {
|
|
|
65
73
|
view(s) {
|
|
66
74
|
const grid = blank(s.w, s.h);
|
|
67
75
|
for (let y = 0; y < s.h; y += 2)
|
|
68
|
-
grid
|
|
76
|
+
put(grid, Math.floor(s.w / 2), y, "┊");
|
|
69
77
|
for (let k = 0; k < PADDLE; k++) {
|
|
70
|
-
grid
|
|
71
|
-
grid
|
|
78
|
+
put(grid, 1, s.you + k, "█");
|
|
79
|
+
put(grid, s.w - 2, Math.round(s.them) + k, "█");
|
|
72
80
|
}
|
|
73
|
-
|
|
74
|
-
const by = Math.round(s.by);
|
|
75
|
-
if (grid[by]?.[bx] !== undefined)
|
|
76
|
-
grid[by][bx] = "●";
|
|
81
|
+
put(grid, Math.round(s.bx), Math.round(s.by), "●");
|
|
77
82
|
const [a, b] = s.scores;
|
|
78
83
|
return {
|
|
79
84
|
lines: grid.map((r) => r.join("")),
|
|
85
|
+
score: a,
|
|
80
86
|
status: `you ${a} · ${b} computer ↑↓ move`,
|
|
81
87
|
...(a >= PONG_WIN ? { over: "You win! ✦" } : b >= PONG_WIN ? { over: "The computer wins." } : {}),
|
|
82
88
|
};
|
|
@@ -98,8 +104,13 @@ export const snake = {
|
|
|
98
104
|
blurb: "Eat, grow, don't bite yourself or the walls.",
|
|
99
105
|
controls: "arrows or wasd steer",
|
|
100
106
|
tickMs: 110,
|
|
107
|
+
minWidth: 10,
|
|
108
|
+
minHeight: 5,
|
|
109
|
+
// A little faster with every few eaten, down to a floor.
|
|
110
|
+
speed: (s) => Math.max(55, 110 - Math.floor(s.score / 3) * 8),
|
|
101
111
|
colors: { "█": "#98c379", "▓": "#7ec16e", "◆": "#e06c75" },
|
|
102
112
|
create(w, h, seed = Date.now()) {
|
|
113
|
+
[w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
|
|
103
114
|
const y = Math.floor(h / 2);
|
|
104
115
|
const s = { w, h, body: [{ x: 4, y }, { x: 3, y }, { x: 2, y }], dir: "right", food: { x: 0, y: 0 }, score: 0, dead: false, random: rng(seed) };
|
|
105
116
|
return { ...s, food: placeFood(s) };
|
|
@@ -110,7 +121,7 @@ export const snake = {
|
|
|
110
121
|
let dir = s.dir;
|
|
111
122
|
// One turn per tick (the first that isn't straight back), so quick double taps don't fold it onto itself.
|
|
112
123
|
for (const i of inputs) {
|
|
113
|
-
if (i !== OPPOSITE[dir] && i !== dir) {
|
|
124
|
+
if (i !== "action" && i !== OPPOSITE[dir] && i !== dir) {
|
|
114
125
|
dir = i;
|
|
115
126
|
break;
|
|
116
127
|
}
|
|
@@ -126,36 +137,42 @@ export const snake = {
|
|
|
126
137
|
},
|
|
127
138
|
view(s) {
|
|
128
139
|
const grid = blank(s.w, s.h);
|
|
129
|
-
grid
|
|
130
|
-
s.body.forEach((b, i) =>
|
|
131
|
-
|
|
132
|
-
grid[b.y][b.x] = i === 0 ? "█" : "▓";
|
|
133
|
-
});
|
|
134
|
-
return { lines: grid.map((r) => r.join("")), status: `length ${s.body.length} · score ${s.score}`, ...(s.dead ? { over: `Game over: ${s.score} eaten.` } : {}) };
|
|
140
|
+
put(grid, s.food.x, s.food.y, "◆");
|
|
141
|
+
s.body.forEach((b, i) => put(grid, b.x, b.y, i === 0 ? "█" : "▓"));
|
|
142
|
+
return { lines: grid.map((r) => r.join("")), score: s.score, status: `length ${s.body.length} · score ${s.score}`, ...(s.dead ? { over: `Game over: ${s.score} eaten.` } : {}) };
|
|
135
143
|
},
|
|
136
144
|
};
|
|
137
145
|
const PAD = 7;
|
|
138
146
|
const BRICK_ROWS = 4;
|
|
139
147
|
function launch(s) {
|
|
140
|
-
|
|
148
|
+
// Each level the ball starts a little faster.
|
|
149
|
+
const pace = 0.6 * (1 + (s.level - 1) * 0.12);
|
|
150
|
+
return { ...s, bx: s.paddle + PAD / 2, by: s.h - 3, vx: (s.random() < 0.5 ? -1 : 1) * pace, vy: -pace };
|
|
141
151
|
}
|
|
152
|
+
const wall = (w) => Array.from({ length: BRICK_ROWS }, () => Array(Math.floor(w / 3)).fill(true));
|
|
142
153
|
export const breakout = {
|
|
143
154
|
id: "breakout",
|
|
144
155
|
title: "Breakout",
|
|
145
|
-
blurb: "Knock out every brick with the ball. Three lives.",
|
|
156
|
+
blurb: "Knock out every brick with the ball; each wall you clear brings a faster one. Three lives.",
|
|
146
157
|
controls: "←→ or a/d move",
|
|
147
158
|
tickMs: 45,
|
|
159
|
+
minWidth: 18,
|
|
160
|
+
minHeight: 9,
|
|
148
161
|
colors: { "▀": "#e06c75", "▄": "#d19a66", "▬": "#61afef", "●": "#e5c07b" },
|
|
149
162
|
create(w, h, seed = Date.now()) {
|
|
163
|
+
[w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
|
|
150
164
|
const s = {
|
|
151
165
|
w, h, paddle: Math.floor((w - PAD) / 2), bx: 0, by: 0, vx: 0, vy: 0,
|
|
152
|
-
bricks:
|
|
166
|
+
bricks: wall(w), lives: 3, score: 0, level: 1, random: rng(seed),
|
|
153
167
|
};
|
|
154
168
|
return launch(s);
|
|
155
169
|
},
|
|
156
170
|
tick(s, inputs) {
|
|
157
|
-
if (s.lives <= 0
|
|
171
|
+
if (s.lives <= 0)
|
|
158
172
|
return s;
|
|
173
|
+
// A cleared wall: the next level, a little faster.
|
|
174
|
+
if (s.bricks.every((r) => r.every((b) => !b)))
|
|
175
|
+
return launch({ ...s, bricks: wall(s.w), level: s.level + 1 });
|
|
159
176
|
let paddle = s.paddle;
|
|
160
177
|
for (const i of inputs)
|
|
161
178
|
paddle += i === "left" ? -2 : i === "right" ? 2 : 0;
|
|
@@ -195,23 +212,196 @@ export const breakout = {
|
|
|
195
212
|
s.bricks.forEach((r, y) => r.forEach((alive, x) => {
|
|
196
213
|
if (alive)
|
|
197
214
|
for (let k = 0; k < 3; k++)
|
|
198
|
-
|
|
199
|
-
grid[y + 1][x * 3 + k] = y % 2 ? "▄" : "▀";
|
|
215
|
+
put(grid, x * 3 + k, y + 1, y % 2 ? "▄" : "▀");
|
|
200
216
|
}));
|
|
201
217
|
for (let k = 0; k < PAD; k++)
|
|
202
|
-
grid
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (s.lives > 0 && grid[by]?.[bx] !== undefined)
|
|
206
|
-
grid[by][bx] = "●";
|
|
207
|
-
const cleared = s.bricks.every((r) => r.every((b) => !b));
|
|
218
|
+
put(grid, s.paddle + k, s.h - 1, "▬");
|
|
219
|
+
if (s.lives > 0)
|
|
220
|
+
put(grid, Math.round(s.bx), Math.round(s.by), "●");
|
|
208
221
|
return {
|
|
209
222
|
lines: grid.map((r) => r.join("")),
|
|
210
|
-
|
|
211
|
-
|
|
223
|
+
score: s.score,
|
|
224
|
+
status: `level ${s.level} · score ${s.score} · lives ${"♥".repeat(Math.max(0, s.lives))}`,
|
|
225
|
+
...(s.lives <= 0 ? { over: `Game over: ${s.score} points, level ${s.level}.` } : {}),
|
|
212
226
|
};
|
|
213
227
|
},
|
|
214
228
|
};
|
|
229
|
+
/** Rocks fall; slide along the bottom to dodge them and catch the stars. It gets busier as you last. */
|
|
230
|
+
export const dodge = {
|
|
231
|
+
id: "dodge",
|
|
232
|
+
title: "Dodge",
|
|
233
|
+
blurb: "Rocks fall faster and thicker the longer you last. Catch ★ for points.",
|
|
234
|
+
controls: "←→ or a/d move",
|
|
235
|
+
tickMs: 90,
|
|
236
|
+
minWidth: 12,
|
|
237
|
+
minHeight: 6,
|
|
238
|
+
speed: (s) => Math.max(45, 90 - Math.floor(s.ticks / 60) * 5),
|
|
239
|
+
colors: { "▲": "#61afef", "●": "#d19a66", "★": "#e5c07b" },
|
|
240
|
+
create(w, h, seed = Date.now()) {
|
|
241
|
+
[w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
|
|
242
|
+
return { w, h, x: Math.floor(w / 2), rocks: [], stars: [], score: 0, ticks: 0, dead: false, random: rng(seed) };
|
|
243
|
+
},
|
|
244
|
+
tick(s, inputs) {
|
|
245
|
+
if (s.dead)
|
|
246
|
+
return s;
|
|
247
|
+
let x = s.x;
|
|
248
|
+
for (const i of inputs)
|
|
249
|
+
x += i === "left" ? -1 : i === "right" ? 1 : 0;
|
|
250
|
+
x = clamp(x, 0, s.w - 1);
|
|
251
|
+
const fall = (p) => ({ x: p.x, y: p.y + 1 });
|
|
252
|
+
const rocks = s.rocks.map(fall).filter((p) => p.y < s.h);
|
|
253
|
+
let stars = s.stars.map(fall).filter((p) => p.y < s.h);
|
|
254
|
+
// More rocks per row as the game goes on.
|
|
255
|
+
const chance = Math.min(0.55, 0.18 + s.ticks / 900);
|
|
256
|
+
if (s.random() < chance)
|
|
257
|
+
rocks.push({ x: Math.floor(s.random() * s.w), y: 0 });
|
|
258
|
+
if (s.random() < 0.05)
|
|
259
|
+
stars.push({ x: Math.floor(s.random() * s.w), y: 0 });
|
|
260
|
+
const at = (p) => p.x === x && p.y === s.h - 1;
|
|
261
|
+
let score = s.score + (s.ticks % 10 === 9 ? 1 : 0);
|
|
262
|
+
if (stars.some(at)) {
|
|
263
|
+
score += 10;
|
|
264
|
+
stars = stars.filter((p) => !at(p));
|
|
265
|
+
}
|
|
266
|
+
const dead = rocks.some(at);
|
|
267
|
+
return { ...s, x, rocks, stars, score, ticks: s.ticks + 1, dead };
|
|
268
|
+
},
|
|
269
|
+
view(s) {
|
|
270
|
+
const grid = blank(s.w, s.h);
|
|
271
|
+
for (const r of s.rocks)
|
|
272
|
+
put(grid, r.x, r.y, "●");
|
|
273
|
+
for (const r of s.stars)
|
|
274
|
+
put(grid, r.x, r.y, "★");
|
|
275
|
+
put(grid, s.x, s.h - 1, "▲");
|
|
276
|
+
return { lines: grid.map((r) => r.join("")), score: s.score, status: `score ${s.score}`, ...(s.dead ? { over: `Hit! ${s.score} points.` } : {}) };
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
const HOP_X = 3;
|
|
280
|
+
/** Run along the ground and hop the walls that come at you; they come faster as you go. */
|
|
281
|
+
export const hop = {
|
|
282
|
+
id: "hop",
|
|
283
|
+
title: "Hop",
|
|
284
|
+
blurb: "Run and hop over the walls coming at you. One hit and it's over.",
|
|
285
|
+
controls: "↑ or Enter hops",
|
|
286
|
+
tickMs: 70,
|
|
287
|
+
minWidth: 20,
|
|
288
|
+
minHeight: 6,
|
|
289
|
+
speed: (s) => Math.max(35, 70 - Math.floor(s.score / 5) * 4),
|
|
290
|
+
colors: { "◉": "#98c379", "█": "#c678dd", "▔": "#5c6370" },
|
|
291
|
+
create(w, h, seed = Date.now()) {
|
|
292
|
+
[w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
|
|
293
|
+
return { w, h, y: 0, vy: 0, walls: [], score: 0, ticks: 0, dead: false, random: rng(seed) };
|
|
294
|
+
},
|
|
295
|
+
tick(s, inputs) {
|
|
296
|
+
if (s.dead)
|
|
297
|
+
return s;
|
|
298
|
+
// Height above the ground; a hop goes up and comes back down.
|
|
299
|
+
let { y, vy } = s;
|
|
300
|
+
if (y === 0 && inputs.some((i) => i === "up" || i === "action"))
|
|
301
|
+
vy = 1.15;
|
|
302
|
+
y = Math.max(0, y + vy);
|
|
303
|
+
vy = y === 0 ? 0 : vy - 0.22;
|
|
304
|
+
const walls = s.walls.map((wl) => ({ ...wl, x: wl.x - 1 })).filter((wl) => wl.x >= 0);
|
|
305
|
+
const passed = s.walls.length - walls.length;
|
|
306
|
+
const last = walls[walls.length - 1];
|
|
307
|
+
// A new wall, far enough from the last that every gap can be cleared.
|
|
308
|
+
if ((!last || last.x < s.w - 9) && s.random() < 0.12)
|
|
309
|
+
walls.push({ x: s.w - 1, tall: 1 + Math.floor(s.random() * Math.min(2, s.h - 3)) });
|
|
310
|
+
const dead = walls.some((wl) => wl.x === HOP_X && Math.round(y) < wl.tall);
|
|
311
|
+
return { ...s, y, vy, walls, score: s.score + passed, ticks: s.ticks + 1, dead };
|
|
312
|
+
},
|
|
313
|
+
view(s) {
|
|
314
|
+
const grid = blank(s.w, s.h);
|
|
315
|
+
const ground = s.h - 1;
|
|
316
|
+
for (let x = 0; x < s.w; x++)
|
|
317
|
+
put(grid, x, ground, "▔");
|
|
318
|
+
for (const wl of s.walls)
|
|
319
|
+
for (let k = 1; k <= wl.tall; k++)
|
|
320
|
+
put(grid, wl.x, ground - k, "█");
|
|
321
|
+
put(grid, HOP_X, ground - 1 - Math.round(s.y), "◉");
|
|
322
|
+
return { lines: grid.map((r) => r.join("")), score: s.score, status: `walls hopped ${s.score}`, ...(s.dead ? { over: `Bonk! ${s.score} hopped.` } : {}) };
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
const SYMBOLS = ["◆", "●", "▲", "■", "★", "♥", "♣", "♠", "☀", "☂", "♪", "✿"];
|
|
326
|
+
/** Cards face down: flip two at a time and find every matching pair in as few moves as you can. */
|
|
327
|
+
export const pairs = {
|
|
328
|
+
id: "pairs",
|
|
329
|
+
title: "Pairs",
|
|
330
|
+
blurb: "Flip two cards at a time; find every matching pair in as few moves as you can.",
|
|
331
|
+
controls: "arrows move · Enter flips",
|
|
332
|
+
tickMs: 100,
|
|
333
|
+
minWidth: 16,
|
|
334
|
+
minHeight: 5,
|
|
335
|
+
colors: { "◆": "#e06c75", "●": "#e5c07b", "▲": "#61afef", "■": "#98c379", "★": "#e5c07b", "♥": "#e06c75", "♣": "#98c379", "♠": "#c678dd", "☀": "#d19a66", "☂": "#56b6c2", "♪": "#c678dd", "✿": "#56b6c2", "▒": "#5c6370", "▸": "#e5c07b", "◂": "#e5c07b" },
|
|
336
|
+
create(w, h, seed = Date.now()) {
|
|
337
|
+
[w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
|
|
338
|
+
// As many cards as fit (each 4 wide, rows 2 apart), an even number, up to every symbol twice.
|
|
339
|
+
const cols = Math.max(2, Math.min(6, Math.floor(w / 4)));
|
|
340
|
+
const rows = Math.max(2, Math.min(4, Math.floor((h + 1) / 2)));
|
|
341
|
+
const count = Math.min(SYMBOLS.length * 2, cols * rows - ((cols * rows) % 2));
|
|
342
|
+
const random = rng(seed);
|
|
343
|
+
const deck = SYMBOLS.slice(0, count / 2).flatMap((sym) => [sym, sym]);
|
|
344
|
+
for (let i = deck.length - 1; i > 0; i--) {
|
|
345
|
+
const j = Math.floor(random() * (i + 1));
|
|
346
|
+
[deck[i], deck[j]] = [deck[j], deck[i]];
|
|
347
|
+
}
|
|
348
|
+
return { cols, rows, w, h, cards: deck, found: deck.map(() => false), open: [], cursor: 0, moves: 0, wait: 0 };
|
|
349
|
+
},
|
|
350
|
+
tick(s, inputs) {
|
|
351
|
+
if (s.found.every(Boolean))
|
|
352
|
+
return s;
|
|
353
|
+
let { cursor, open, found, moves, wait } = s;
|
|
354
|
+
// Two face-up cards that don't match stay shown a moment, then turn back over.
|
|
355
|
+
if (open.length === 2 && wait > 0) {
|
|
356
|
+
wait--;
|
|
357
|
+
if (wait === 0)
|
|
358
|
+
open = [];
|
|
359
|
+
return { ...s, open, wait };
|
|
360
|
+
}
|
|
361
|
+
const n = s.cards.length;
|
|
362
|
+
for (const i of inputs) {
|
|
363
|
+
if (i === "left")
|
|
364
|
+
cursor = (cursor + n - 1) % n;
|
|
365
|
+
else if (i === "right")
|
|
366
|
+
cursor = (cursor + 1) % n;
|
|
367
|
+
else if (i === "up")
|
|
368
|
+
cursor = cursor - s.cols >= 0 ? cursor - s.cols : cursor;
|
|
369
|
+
else if (i === "down")
|
|
370
|
+
cursor = cursor + s.cols < n ? cursor + s.cols : cursor;
|
|
371
|
+
else if (i === "action" && !found[cursor] && !open.includes(cursor) && open.length < 2) {
|
|
372
|
+
open = [...open, cursor];
|
|
373
|
+
if (open.length === 2) {
|
|
374
|
+
moves++;
|
|
375
|
+
if (s.cards[open[0]] === s.cards[open[1]]) {
|
|
376
|
+
found = found.map((f, k) => f || open.includes(k));
|
|
377
|
+
open = [];
|
|
378
|
+
}
|
|
379
|
+
else
|
|
380
|
+
wait = 8;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { ...s, cursor, open, found, moves, wait };
|
|
385
|
+
},
|
|
386
|
+
view(s) {
|
|
387
|
+
const grid = blank(s.w, s.h);
|
|
388
|
+
s.cards.forEach((card, i) => {
|
|
389
|
+
const x = (i % s.cols) * 4;
|
|
390
|
+
const y = Math.floor(i / s.cols) * 2;
|
|
391
|
+
const shown = s.found[i] || s.open.includes(i);
|
|
392
|
+
if (i === s.cursor) {
|
|
393
|
+
put(grid, x, y, "▸");
|
|
394
|
+
put(grid, x + 2, y, "◂");
|
|
395
|
+
}
|
|
396
|
+
put(grid, x + 1, y, s.found[i] ? card : shown ? card : "▒");
|
|
397
|
+
});
|
|
398
|
+
const done = s.found.every(Boolean);
|
|
399
|
+
const pairsLeft = s.found.filter((f) => !f).length / 2;
|
|
400
|
+
// Fewer moves is better: the score counts what you saved.
|
|
401
|
+
const score = Math.max(0, s.cards.length * 2 - s.moves);
|
|
402
|
+
return { lines: grid.map((r) => r.join("")), score, status: `moves ${s.moves} · ${pairsLeft} pairs left`, ...(done ? { over: `All found in ${s.moves} moves ✦` } : {}) };
|
|
403
|
+
},
|
|
404
|
+
};
|
|
215
405
|
/** Every game Nou has, in the order settings lists them. */
|
|
216
|
-
export const GAMES = [pong, snake, breakout];
|
|
406
|
+
export const GAMES = [pong, snake, breakout, dodge, hop, pairs];
|
|
217
407
|
export const gameById = (id) => GAMES.find((g) => g.id === id);
|