Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7081b933 | ||
|
|
eacad97240 | ||
|
|
4e39e1912a |
@@ -1,209 +1,95 @@
|
|||||||
# Xiaomi Vela Hello World for Watch S4
|
# SNAKE — Xiaomi Watch S4
|
||||||
|
|
||||||
This is the smallest practical Xiaomi Vela JS watch application. It shows two centered lines on a round black screen:
|
Textured Snake on a 16×16 grid: 20px tiles, 320px board, centered on a round
|
||||||
|
466px screen. Existing PNG board, directional snake pieces, apple and icon are
|
||||||
|
preserved. Package identity: `com.ilias.snake`.
|
||||||
|
|
||||||
```text
|
**Version 0.7 (versionCode 7): locally tested and release-built; the new features
|
||||||
Hello World!
|
still need an on-watch check.** Earlier versions rendered and played on the
|
||||||
Xiaomi Watch S4
|
user's Watch S4 41mm. Node tests and numeric layout checks are not device tests.
|
||||||
```
|
|
||||||
|
|
||||||
It compiles entirely from a Linux terminal. Xiaomi AIoT-IDE is not required.
|
## Play
|
||||||
|
|
||||||
## 1. What you need
|
- **Swipe** to steer. A two-turn queue accepts rapid corners, consumes one turn
|
||||||
|
per tick, and rejects duplicate directions and reversals against the last
|
||||||
|
queued direction. Excess inputs are ignored, not allowed to replace a turn.
|
||||||
|
- **Tap the board, snake or apple** to pause. Tap **RESUME** (or an exposed part
|
||||||
|
of the board) to resume. Pausing clears queued turns.
|
||||||
|
- In the pause or game-over panel, select **CLASSIC** (walls end the run) or
|
||||||
|
**WRAP** (cross an edge to appear on the opposite edge). Changing mode starts
|
||||||
|
a fresh, paused game; tap RESUME to begin. Selecting the current mode does not
|
||||||
|
reset the run. The current mode is shown below the board.
|
||||||
|
- Both modes end on self-collision. Moving into the vacating tail is legal.
|
||||||
|
- **PLAY AGAIN** is a large 216×56px retry target. Retry keeps the selected mode,
|
||||||
|
resets score/speed/queued turns, and starts playing. Filling the board displays
|
||||||
|
**YOU WIN** and hides the apple safely.
|
||||||
|
- Leaving the page pauses it and stops the timer. Returning requires an explicit
|
||||||
|
resume; game-over never resumes automatically. The system back action remains
|
||||||
|
untouched. No crown, rotary, side-button or back interception APIs are used.
|
||||||
|
|
||||||
- A 64-bit Linux system
|
## Score, speed and persistence
|
||||||
- Node.js and npm
|
|
||||||
- OpenSSL, but only if you want a signed release package
|
|
||||||
- Internet access for the first `npm install`
|
|
||||||
|
|
||||||
Node.js 20 LTS is a conservative choice. The included project has also been build-tested with Node.js 24 and npm 11.
|
Each apple is one point. The always-visible header shows score and best. One
|
||||||
|
best score is shared across Classic and Wrap, saved through documented
|
||||||
|
[`@system.storage`](https://iot.mi.com/vela/quickapp/en/features/data/storage.html)
|
||||||
|
under `snake.best`. Mode is session-only; a newly created page defaults to Classic.
|
||||||
|
|
||||||
On Ubuntu or Debian, install the basic tools:
|
The movement interval starts at **300ms**, decreases by **10ms per apple**, and
|
||||||
|
never goes below **120ms**. Only one movement timer runs; none runs while hidden,
|
||||||
```bash
|
paused, finished or destroyed. Old timer callbacks are invalidated.
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y curl openssl
|
Storage reads merge with the in-memory maximum; writes wait for the initial
|
||||||
```
|
read and are serialized/coalesced so slow callbacks cannot overwrite a newer
|
||||||
|
record. The coordinator survives page recreation within the module lifetime.
|
||||||
Install Node.js using your preferred package manager or a version manager such as `nvm`. Confirm that it works:
|
Invalid saved values are ignored. Errors/exceptions do not interrupt gameplay:
|
||||||
|
failed reads/writes retry on later apples or page-show activity, without a retry
|
||||||
```bash
|
loop. A failed/uncompleted write or immediate app termination may lose the
|
||||||
node --version
|
unsaved record. Storage persistence still needs verification on the watch.
|
||||||
npm --version
|
|
||||||
```
|
## Lightweight rendering
|
||||||
|
|
||||||
## 2. Understand the tiny project
|
Directional PNG tiles use only `left`/`top` positioning, no transforms or new
|
||||||
|
art assets. Wrapped neighbors are normalized before classifying seam textures.
|
||||||
```text
|
Food placement takes at most 256 cell checks, including a nearly full board.
|
||||||
xiaomi-vela-s4-hello-world/
|
The HUD, footer, board and overlay fit the circular chord numerically with
|
||||||
├── package.json
|
at least an 8px total horizontal safety allowance.
|
||||||
├── README.md
|
|
||||||
├── sign/
|
## Build and test
|
||||||
└── src/
|
|
||||||
├── app.ux
|
|
||||||
├── manifest.json
|
|
||||||
├── common/
|
|
||||||
│ ├── icon.png
|
|
||||||
│ └── icon.svg
|
|
||||||
└── pages/
|
|
||||||
└── index/
|
|
||||||
└── index.ux
|
|
||||||
```
|
|
||||||
|
|
||||||
- `package.json` installs Xiaomi's command-line build tool and defines the build commands.
|
|
||||||
- `src/manifest.json` defines the package, icon, watch target, design width, and start page.
|
|
||||||
- `src/app.ux` contains application lifecycle hooks.
|
|
||||||
- `src/pages/index/index.ux` contains the UI and its styles.
|
|
||||||
- `sign/` is where release certificate files go. Keep private keys private.
|
|
||||||
|
|
||||||
Vela `.ux` files resemble small single-file web components. A page may contain `template`, `style`, and `script` blocks. This Hello World page needs only `template` and `style`.
|
|
||||||
|
|
||||||
## 3. Compile a debug RPK without Xiaomi's IDE
|
|
||||||
|
|
||||||
Open a terminal in the project folder and run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
The important output is created under `dist/` and ends in `.debug.rpk`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
find dist -maxdepth 1 -type f -name '*.rpk' -print
|
|
||||||
```
|
|
||||||
|
|
||||||
The `build/` directory contains intermediate generated files. You normally do not edit either `build/` or `dist/`.
|
|
||||||
|
|
||||||
To include a separate source map while debugging:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build:source-map
|
|
||||||
```
|
|
||||||
|
|
||||||
To see every compiler option supported by your installed toolkit:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run help
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Create a signed release RPK
|
|
||||||
|
|
||||||
The release builder expects these two files:
|
|
||||||
|
|
||||||
```text
|
|
||||||
sign/private.pem
|
|
||||||
sign/certificate.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate a local development certificate from the project root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl req \
|
|
||||||
-newkey rsa:2048 \
|
|
||||||
-nodes \
|
|
||||||
-keyout sign/private.pem \
|
|
||||||
-x509 \
|
|
||||||
-days 3650 \
|
|
||||||
-out sign/certificate.pem \
|
|
||||||
-subj '/CN=Hello Vela Development/O=Example/C=US'
|
|
||||||
```
|
|
||||||
|
|
||||||
Then compile the release package:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm test
|
||||||
npm run release
|
npm run release
|
||||||
find dist -maxdepth 1 -type f -name '*.release.*.rpk' -print
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not commit or share `sign/private.pem`. If a companion Android application communicates with the watch app, Xiaomi's wearable integration may require matching signing identity; changing certificates later can also prevent an update from being accepted as the same app.
|
`npm test` runs the original core, piece and page tests, 13 incremental upgrade
|
||||||
|
regressions, the numeric layout suite, and deterministic randomized property tests. Upgrade coverage includes rapid
|
||||||
|
swipes, lifecycle/timer races, storage failures and async ordering, mode changes,
|
||||||
|
all four Wrap edges, seam textures, full-board victory and bounded food spawning.
|
||||||
|
New behaviors were exercised through failing-then-passing test cycles.
|
||||||
|
|
||||||
## 5. Change the app
|
Release output: `dist/com.ilias.snake.release.0.7.rpk`. The locked
|
||||||
|
`aiot-toolkit` 2.0.5 uses the existing `sign/private.pem` and `sign/certificate.pem`;
|
||||||
|
do not regenerate or commit those keys. Release uses plain JS (not JSC/protobuf),
|
||||||
|
matching the previously working toolchain. The dependency lock is unchanged;
|
||||||
|
`npm ci` currently reports 14 development-toolchain audit findings (3 low,
|
||||||
|
11 high). No forced toolkit upgrade was applied.
|
||||||
|
|
||||||
Edit `src/pages/index/index.ux`. For example, replace:
|
## Files
|
||||||
|
|
||||||
```xml
|
- `src/pages/index/index.ux`: round-screen UI, swipe queue, game/lifecycle state.
|
||||||
<text class="hello">Hello World!</text>
|
- `src/common/snake.js`: movement, collision, Wrap piece classification, food.
|
||||||
```
|
- `src/common/best.js`: asynchronous storage coordinator.
|
||||||
|
- `src/common/tex/`: preserved textured artwork.
|
||||||
|
- `tests/{snake,pieces,page,upgrade,layout,properties}.cjs`: Node regression suites.
|
||||||
|
|
||||||
with:
|
Install the signed RPK through AstroBox. Source/releases live at
|
||||||
|
https://gitea.oimcloud.myaddr.tools/codex-bot/vela-snake — publication is a
|
||||||
|
separate step after review; a local build is not evidence of a published release.
|
||||||
|
|
||||||
```xml
|
## Earlier versions
|
||||||
<text class="hello">My first app</text>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then rebuild:
|
- 0.2/0.3: board played on watch; designWidth 480→466 fixed undersizing.
|
||||||
|
- 0.6: directional textured tiles and preserved user-supplied icon.
|
||||||
```bash
|
- 0.7: queued swipes, live/persisted records, capped acceleration, pause/resume,
|
||||||
npm run build
|
lifecycle safety, retry/win panels and selectable Classic/Wrap.
|
||||||
```
|
|
||||||
|
|
||||||
When publishing an update, increase `versionCode` in `src/manifest.json`. Also update `versionName` when you want the visible version string to change.
|
|
||||||
|
|
||||||
## 6. Important Vela rules
|
|
||||||
|
|
||||||
- A template must have exactly one root element.
|
|
||||||
- Visible text must be inside a `<text>` component.
|
|
||||||
- Vela uses flex layout, but its CSS support is smaller than a browser's CSS support.
|
|
||||||
- Package names should use reverse-domain form, such as `com.yourname.hello`.
|
|
||||||
- `designWidth: 480` lets Vela scale the 480-pixel design to the physical display.
|
|
||||||
- APIs such as sensors, storage, network, or vibration must usually be declared in the manifest's `features` array before use. This Hello World app requests no APIs.
|
|
||||||
|
|
||||||
## 7. Installing on a real Watch S4
|
|
||||||
|
|
||||||
Compilation and installation are separate. The terminal workflow above reliably creates the `.rpk`; transferring it to a physical watch depends on the exact Watch S4 model, firmware region, Mi Fitness version, and whether developer or debugging access is enabled for that firmware.
|
|
||||||
|
|
||||||
Do not treat the `.rpk` as an Android APK, and do not flash random firmware. Use Xiaomi's supported wearable deployment route for your device/account. Some retail or global firmware builds do not expose the same development or sideload controls as Chinese developer setups.
|
|
||||||
|
|
||||||
## 8. Troubleshooting
|
|
||||||
|
|
||||||
### `aiot: command not found`
|
|
||||||
|
|
||||||
Always run the local tool through npm:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not rely on a global `aiot` installation.
|
|
||||||
|
|
||||||
### Dependency installation fails
|
|
||||||
|
|
||||||
Clear only this project's installed dependencies and retry:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm -rf node_modules
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
If you use a company proxy, configure npm for that proxy instead of disabling TLS verification.
|
|
||||||
|
|
||||||
### No `.rpk` appears
|
|
||||||
|
|
||||||
Read the first compiler error, then verify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx aiot build -h
|
|
||||||
find src -maxdepth 4 -type f -print
|
|
||||||
```
|
|
||||||
|
|
||||||
The required files are `src/manifest.json`, `src/app.ux`, and the routed page component.
|
|
||||||
|
|
||||||
### Release build says signing files are missing
|
|
||||||
|
|
||||||
Run the OpenSSL command in section 4 and confirm:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
test -s sign/private.pem && test -s sign/certificate.pem && echo 'Signing files exist'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Official references
|
|
||||||
|
|
||||||
- Xiaomi Vela JS documentation: <https://iot.mi.com/vela/quickapp/en/>
|
|
||||||
- Project structure: <https://iot.mi.com/vela/quickapp/zh/guide/start/project-overview.html>
|
|
||||||
- Manifest configuration: <https://iot.mi.com/vela/quickapp/zh/guide/framework/manifest.html>
|
|
||||||
- Compiler parameters: <https://iot.mi.com/vela/quickapp/zh/guide/start/toolkit-params.html>
|
|
||||||
- UI introduction: <https://iot.mi.com/vela/quickapp/zh/guide/start/user-interface.html>
|
|
||||||
|
|
||||||
The project intentionally uses only the public `aiot-toolkit` command-line package. It does not require Xiaomi AIoT-IDE.
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
"name": "xiaomi-vela-s4-hello-world",
|
"name": "xiaomi-vela-s4-hello-world",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Minimal Xiaomi Vela JS Hello World app for Xiaomi Watch S4",
|
"description": "Textured Snake for the round Xiaomi Watch S4 screen",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "node tests/snake.cjs && node tests/pieces.cjs && node tests/page.cjs && node --test tests/upgrade.cjs && node tests/layout.cjs && node tests/properties.cjs",
|
||||||
"build": "aiot build",
|
"build": "aiot build",
|
||||||
"release": "aiot release",
|
"release": "aiot release",
|
||||||
"build:source-map": "aiot build --devtool=source-map",
|
"build:source-map": "aiot build --devtool=source-map",
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Vela storage callbacks return raw strings, not { data } wrappers.
|
||||||
|
// https://iot.mi.com/vela/quickapp/en/features/data/storage.html
|
||||||
|
function createStore(storage) {
|
||||||
|
let value = 0
|
||||||
|
let saved = 0
|
||||||
|
let loaded = false
|
||||||
|
let reading = false
|
||||||
|
let writing = false
|
||||||
|
let listener = null
|
||||||
|
function notify() { if (listener) listener(value) }
|
||||||
|
function flush() {
|
||||||
|
if (!loaded || writing || value <= saved) return
|
||||||
|
writing = true
|
||||||
|
const target = value
|
||||||
|
let settled = false
|
||||||
|
const fail = () => { if (!settled) { settled = true; writing = false } }
|
||||||
|
try {
|
||||||
|
storage.set({ key: 'snake.best', value: String(target), success() {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
writing = false
|
||||||
|
saved = target
|
||||||
|
flush()
|
||||||
|
}, fail })
|
||||||
|
} catch (e) { fail() }
|
||||||
|
}
|
||||||
|
function sync() {
|
||||||
|
if (loaded) { flush(); return }
|
||||||
|
if (reading) return
|
||||||
|
reading = true
|
||||||
|
let settled = false
|
||||||
|
const fail = () => { if (!settled) { settled = true; reading = false } }
|
||||||
|
try {
|
||||||
|
storage.get({ key: 'snake.best', default: '0', success(raw) {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
reading = false
|
||||||
|
const n = Number(raw)
|
||||||
|
saved = Number.isInteger(n) && n >= 0 && n <= 253 ? n : 0
|
||||||
|
value = Math.max(value, saved)
|
||||||
|
loaded = true
|
||||||
|
notify()
|
||||||
|
flush()
|
||||||
|
}, fail })
|
||||||
|
} catch (e) { fail() }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subscribe(fn) { listener = fn; notify() },
|
||||||
|
record(score) { value = Math.max(value, score); notify(); sync() },
|
||||||
|
sync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One coordinator per native storage module, retained across page recreation.
|
||||||
|
let sharedAPI
|
||||||
|
let sharedStore
|
||||||
|
function getStore(storage) {
|
||||||
|
if (storage !== sharedAPI) {
|
||||||
|
sharedAPI = storage
|
||||||
|
sharedStore = createStore(storage)
|
||||||
|
}
|
||||||
|
return sharedStore
|
||||||
|
}
|
||||||
|
module.exports = { getStore }
|
||||||
@@ -8,11 +8,14 @@ function initialSnake() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Advance the snake one cell in `dir` ({x,y} unit vector). Returns { snake, ate, alive }.
|
// Advance the snake one cell in `dir` ({x,y} unit vector). Returns { snake, ate, alive }.
|
||||||
function step(snake, dir, food, N) {
|
function step(snake, dir, food, N, mode) {
|
||||||
const head = snake[0]
|
const head = snake[0]
|
||||||
const nx = head.x + dir.x
|
let nx = head.x + dir.x
|
||||||
const ny = head.y + dir.y
|
let ny = head.y + dir.y
|
||||||
if (nx < 0 || ny < 0 || nx >= N || ny >= N) {
|
if (mode === 'wrap') {
|
||||||
|
nx = (nx + N) % N
|
||||||
|
ny = (ny + N) % N
|
||||||
|
} else if (nx < 0 || ny < 0 || nx >= N || ny >= N) {
|
||||||
return { snake, ate: false, alive: false }
|
return { snake, ate: false, alive: false }
|
||||||
}
|
}
|
||||||
const ate = (nx === food.x && ny === food.y)
|
const ate = (nx === food.x && ny === food.y)
|
||||||
@@ -34,6 +37,31 @@ function turn(current, desired) {
|
|||||||
return desired
|
return desired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Classify every segment as a texture piece (head/body/corner/tail + compass side),
|
||||||
|
// so the page can render each cell with the right PNG. Returns one name per segment.
|
||||||
|
// Sides are screen compass (y grows down): e/w/n/s = right/left/up/down.
|
||||||
|
function renderPieces(snakeCells, dir, N) {
|
||||||
|
const D = { '1,0': 'e', '-1,0': 'w', '0,-1': 'n', '0,1': 's' }
|
||||||
|
const opp = { e: 'w', w: 'e', n: 's', s: 'n' }
|
||||||
|
const delta = d => N && d === N - 1 ? -1 : N && d === 1 - N ? 1 : d
|
||||||
|
return snakeCells.map((s, i) => {
|
||||||
|
if (i === 0) return 'head_' + D[dir.x + ',' + dir.y]
|
||||||
|
const prev = snakeCells[i - 1]
|
||||||
|
const next = snakeCells[i + 1]
|
||||||
|
const from = D[delta(prev.x - s.x) + ',' + delta(prev.y - s.y)] // side toward head
|
||||||
|
if (!next) return 'tail_' + opp[from] // tip points away from body
|
||||||
|
const to = D[delta(next.x - s.x) + ',' + delta(next.y - s.y)] // side toward tail tip
|
||||||
|
if ((from === 'e' || from === 'w') && (to === 'e' || to === 'w')) return 'body_h'
|
||||||
|
if ((from === 'n' || from === 's') && (to === 'n' || to === 's')) return 'body_v'
|
||||||
|
// corner: name = the two sides it connects; pairs are alphabetically sorted
|
||||||
|
const pair = [from, to].sort()
|
||||||
|
if (pair[0] === 'e' && pair[1] === 'n') return 'corner_en'
|
||||||
|
if (pair[0] === 'n' && pair[1] === 'w') return 'corner_nw'
|
||||||
|
if (pair[0] === 'e' && pair[1] === 's') return 'corner_es'
|
||||||
|
return 'corner_sw'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Rotate a direction 90°: clockwise order right->down->left->up. Always ±90°, never a reversal.
|
// Rotate a direction 90°: clockwise order right->down->left->up. Always ±90°, never a reversal.
|
||||||
function rotateDir(dir, clockwise) {
|
function rotateDir(dir, clockwise) {
|
||||||
const order = [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 0, y: -1 }]
|
const order = [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 0, y: -1 }]
|
||||||
@@ -48,12 +76,17 @@ function rotateDir(dir, clockwise) {
|
|||||||
function spawnFood(snake, N) {
|
function spawnFood(snake, N) {
|
||||||
const occupied = new Set(snake.map(s => s.x + ',' + s.y))
|
const occupied = new Set(snake.map(s => s.x + ',' + s.y))
|
||||||
if (occupied.size >= N * N) return null
|
if (occupied.size >= N * N) return null
|
||||||
let x, y
|
// Select an empty-cell rank uniformly; at most N*N checks, even almost full.
|
||||||
do {
|
let rank = Math.floor(Math.random() * (N * N - occupied.size))
|
||||||
x = Math.floor(Math.random() * N)
|
for (let y = 0; y < N; y++) {
|
||||||
y = Math.floor(Math.random() * N)
|
for (let x = 0; x < N; x++) {
|
||||||
} while (occupied.has(x + ',' + y))
|
if (!occupied.has(x + ',' + y)) {
|
||||||
return { x, y }
|
if (rank === 0) return { x, y }
|
||||||
|
rank--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { initialSnake, step, turn, rotateDir, spawnFood }
|
module.exports = { initialSnake, step, turn, renderPieces, rotateDir, spawnFood }
|
||||||
|
|||||||
|
After Width: | Height: | Size: 902 B |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 672 B |
|
After Width: | Height: | Size: 694 B |
|
After Width: | Height: | Size: 727 B |
|
After Width: | Height: | Size: 306 B |
|
After Width: | Height: | Size: 321 B |
|
After Width: | Height: | Size: 237 B |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 841 B |
|
After Width: | Height: | Size: 918 B |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 731 B |
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 848 B |
|
After Width: | Height: | Size: 794 B |
|
After Width: | Height: | Size: 673 B |
@@ -2,8 +2,8 @@
|
|||||||
"package": "com.ilias.snake",
|
"package": "com.ilias.snake",
|
||||||
"name": "SNAKE",
|
"name": "SNAKE",
|
||||||
"icon": "/common/icon.png",
|
"icon": "/common/icon.png",
|
||||||
"versionName": "0.5",
|
"versionName": "0.7",
|
||||||
"versionCode": 5,
|
"versionCode": 7,
|
||||||
"minAPILevel": 1,
|
"minAPILevel": 1,
|
||||||
"deviceTypeList": [
|
"deviceTypeList": [
|
||||||
"watch"
|
"watch"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"features": [
|
"features": [
|
||||||
{
|
{
|
||||||
"name": "system.router"
|
"name": "system.storage"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" onswipe="onSwipe">
|
<div class="page" onswipe="onSwipe">
|
||||||
<stack class="dial">
|
<stack class="dial">
|
||||||
<div class="board"></div>
|
<image class="board" src="/common/tex/board.png" onclick="onTap"></image>
|
||||||
<div class="food" style="left: {{ food.left }}px; top: {{ food.top }}px;"></div>
|
<image class="food" src="/common/tex/apple.png" show="{{ hasFood }}" onclick="onTap" style="left: {{ food.left }}; top: {{ food.top }};"></image>
|
||||||
<div class="{{ seg.cls }}" for="{{ seg in snake }}" style="left: {{ seg.left }}px; top: {{ seg.top }}px;"></div>
|
<image class="seg" src="{{ seg.src }}" for="{{ seg in snake }}" onclick="onTap" style="left: {{ seg.left }}; top: {{ seg.top }};"></image>
|
||||||
<div class="overlay" show="{{ over }}" onclick="onTap">
|
<div class="hud">
|
||||||
<text class="overText">GAME OVER</text>
|
|
||||||
<text class="overSub">{{ scoreText }}</text>
|
|
||||||
<text class="overHint">tap to play again</text>
|
|
||||||
</div>
|
|
||||||
<text class="score">{{ scoreText }}</text>
|
<text class="score">{{ scoreText }}</text>
|
||||||
<text class="hint">swipe to move</text>
|
<text class="best">{{ bestText }}</text>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<text class="modeLabel">{{ modeText }}</text>
|
||||||
|
<text class="hint">swipe / tap to pause</text>
|
||||||
|
</div>
|
||||||
|
<div class="overlay" show="{{ paused && !over }}">
|
||||||
|
<text class="overText">PAUSED</text>
|
||||||
|
<text class="overSub">{{ scoreText }}</text>
|
||||||
|
<text class="retry" onclick="onTap">RESUME</text>
|
||||||
|
<text class="overHint">Change mode: new game</text>
|
||||||
|
<div class="modes">
|
||||||
|
<text class="modeButton" onclick="selectMode('classic')">CLASSIC</text>
|
||||||
|
<text class="modeButton" onclick="selectMode('wrap')">WRAP</text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overlay" show="{{ over }}">
|
||||||
|
<text class="overText">{{ endText }}</text>
|
||||||
|
<text class="overSub">{{ scoreText }}</text>
|
||||||
|
<text class="retry" onclick="restart">PLAY AGAIN</text>
|
||||||
|
<text class="overHint">Change mode: new game</text>
|
||||||
|
<div class="modes">
|
||||||
|
<text class="modeButton" onclick="selectMode('classic')">CLASSIC</text>
|
||||||
|
<text class="modeButton" onclick="selectMode('wrap')">WRAP</text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</stack>
|
</stack>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import snake from '../../common/snake.js'
|
import snake from '../../common/snake.js'
|
||||||
|
import storage from '@system.storage'
|
||||||
|
import best from '../../common/best.js'
|
||||||
|
|
||||||
const CELL = 20
|
const CELL = 20
|
||||||
const GRID = 16
|
const GRID = 16
|
||||||
@@ -25,56 +48,103 @@ export default {
|
|||||||
private: {
|
private: {
|
||||||
snake: [],
|
snake: [],
|
||||||
food: { left: '73px', top: '73px' },
|
food: { left: '73px', top: '73px' },
|
||||||
|
hasFood: true,
|
||||||
|
endText: 'GAME OVER',
|
||||||
scoreText: 'SCORE 0',
|
scoreText: 'SCORE 0',
|
||||||
|
bestText: 'BEST 0',
|
||||||
|
mode: 'classic',
|
||||||
|
modeText: 'CLASSIC',
|
||||||
|
paused: false,
|
||||||
over: false
|
over: false
|
||||||
},
|
},
|
||||||
onInit() {
|
onInit() {
|
||||||
this.dir = { x: 1, y: 0 }
|
this.dir = { x: 1, y: 0 }
|
||||||
this.pending = null
|
this.queue = []
|
||||||
|
this.records = best.getStore(storage)
|
||||||
|
this.records.subscribe(value => { if (!this.destroyed) this.bestText = 'BEST ' + value })
|
||||||
this.reset()
|
this.reset()
|
||||||
|
this.records.sync()
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
this.timer = setInterval(() => this.tick(), 300)
|
if (this.destroyed) return
|
||||||
|
this.visible = true
|
||||||
|
this.records.sync()
|
||||||
|
this.startTimer()
|
||||||
|
},
|
||||||
|
stopTimer() {
|
||||||
|
this.timerEpoch = (this.timerEpoch || 0) + 1
|
||||||
|
clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
},
|
||||||
|
startTimer() {
|
||||||
|
this.stopTimer()
|
||||||
|
if (this.destroyed || !this.visible || this.paused || this.over) return
|
||||||
|
const epoch = this.timerEpoch
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
if (epoch === this.timerEpoch) this.tick()
|
||||||
|
}, Math.max(120, 300 - (this.model.length - 3) * 10))
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
clearInterval(this.timer)
|
this.visible = false
|
||||||
|
this.paused = true
|
||||||
|
this.queue = []
|
||||||
|
this.stopTimer()
|
||||||
},
|
},
|
||||||
onDestroy() {
|
onDestroy() {
|
||||||
clearInterval(this.timer)
|
this.onHide()
|
||||||
|
this.destroyed = true
|
||||||
|
this.records.subscribe(null)
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
this.model = snake.initialSnake()
|
this.model = snake.initialSnake()
|
||||||
this.foodModel = snake.spawnFood(this.model, GRID)
|
this.foodModel = snake.spawnFood(this.model, GRID)
|
||||||
this.dir = { x: 1, y: 0 }
|
this.dir = { x: 1, y: 0 }
|
||||||
this.pending = null
|
this.queue = []
|
||||||
this.over = false
|
this.over = false
|
||||||
|
this.endText = 'GAME OVER'
|
||||||
|
this.paused = false
|
||||||
this.rebuild()
|
this.rebuild()
|
||||||
},
|
},
|
||||||
tick() {
|
tick() {
|
||||||
if (this.over) return
|
if (this.over || this.paused || this.destroyed || this.visible === false) return
|
||||||
if (this.pending) {
|
if (this.queue.length) this.dir = this.queue.shift()
|
||||||
this.dir = snake.turn(this.dir, this.pending)
|
const r = snake.step(this.model, this.dir, this.foodModel, GRID, this.mode)
|
||||||
this.pending = null
|
|
||||||
}
|
|
||||||
const r = snake.step(this.model, this.dir, this.foodModel, GRID)
|
|
||||||
if (!r.alive) {
|
if (!r.alive) {
|
||||||
this.over = true
|
this.over = true
|
||||||
clearInterval(this.timer)
|
this.stopTimer()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.model = r.snake
|
this.model = r.snake
|
||||||
if (r.ate) {
|
if (r.ate) {
|
||||||
this.foodModel = snake.spawnFood(this.model, GRID)
|
this.foodModel = snake.spawnFood(this.model, GRID)
|
||||||
|
if (!this.foodModel) {
|
||||||
|
this.over = true
|
||||||
|
this.endText = 'YOU WIN'
|
||||||
|
this.stopTimer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.rebuild()
|
this.rebuild()
|
||||||
|
if (r.ate) {
|
||||||
|
this.records.record(this.model.length - 3)
|
||||||
|
this.startTimer()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectMode(mode) {
|
||||||
|
if (this.destroyed || !this.visible || (!this.paused && !this.over)) return
|
||||||
|
if ((mode !== 'classic' && mode !== 'wrap') || mode === this.mode) return
|
||||||
|
this.mode = mode
|
||||||
|
this.modeText = mode === 'wrap' ? 'WRAP' : 'CLASSIC'
|
||||||
|
this.reset()
|
||||||
|
this.paused = true
|
||||||
|
this.stopTimer()
|
||||||
},
|
},
|
||||||
restart() {
|
restart() {
|
||||||
|
if (this.destroyed || this.visible === false) return
|
||||||
this.reset()
|
this.reset()
|
||||||
clearInterval(this.timer)
|
this.startTimer()
|
||||||
this.timer = setInterval(() => this.tick(), 300)
|
|
||||||
},
|
},
|
||||||
onSwipe(e) {
|
onSwipe(e) {
|
||||||
if (this.over) return
|
if (this.over || this.paused || this.destroyed || this.visible === false) return
|
||||||
const d = e.direction
|
const d = e.direction
|
||||||
let v
|
let v
|
||||||
if (d === 'left') v = { x: -1, y: 0 }
|
if (d === 'left') v = { x: -1, y: 0 }
|
||||||
@@ -82,25 +152,33 @@ export default {
|
|||||||
else if (d === 'up') v = { x: 0, y: -1 }
|
else if (d === 'up') v = { x: 0, y: -1 }
|
||||||
else if (d === 'down') v = { x: 0, y: 1 }
|
else if (d === 'down') v = { x: 0, y: 1 }
|
||||||
else return
|
else return
|
||||||
this.pending = v
|
const last = this.queue.length ? this.queue[this.queue.length - 1] : this.dir
|
||||||
|
if (this.queue.length >= 2 || (last.x === v.x && last.y === v.y)) return
|
||||||
|
if (snake.turn(last, v) === last) return
|
||||||
|
this.queue.push(v)
|
||||||
},
|
},
|
||||||
onTap() {
|
onTap() {
|
||||||
if (this.over) this.restart()
|
if (this.destroyed || !this.visible || this.over) return
|
||||||
|
this.paused = !this.paused
|
||||||
|
this.queue = []
|
||||||
|
this.startTimer()
|
||||||
},
|
},
|
||||||
rebuild() {
|
rebuild() {
|
||||||
|
const pieces = snake.renderPieces(this.model, this.dir, this.mode === 'wrap' ? GRID : 0)
|
||||||
const out = []
|
const out = []
|
||||||
for (let i = 0; i < this.model.length; i++) {
|
for (let i = 0; i < this.model.length; i++) {
|
||||||
const s = this.model[i]
|
const s = this.model[i]
|
||||||
out.push({
|
out.push({
|
||||||
left: (OFFSET + s.x * CELL + 1) + 'px',
|
left: (OFFSET + s.x * CELL) + 'px',
|
||||||
top: (OFFSET + s.y * CELL + 1) + 'px',
|
top: (OFFSET + s.y * CELL) + 'px',
|
||||||
cls: i === 0 ? 'seg head' : 'seg'
|
src: '/common/tex/' + pieces[i] + '.png'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.snake = out
|
this.snake = out
|
||||||
this.food = {
|
this.hasFood = !!this.foodModel
|
||||||
left: (OFFSET + this.foodModel.x * CELL + 3) + 'px',
|
if (this.hasFood) this.food = {
|
||||||
top: (OFFSET + this.foodModel.y * CELL + 3) + 'px'
|
left: (OFFSET + this.foodModel.x * CELL) + 'px',
|
||||||
|
top: (OFFSET + this.foodModel.y * CELL) + 'px'
|
||||||
}
|
}
|
||||||
this.scoreText = 'SCORE ' + (this.model.length - 3)
|
this.scoreText = 'SCORE ' + (this.model.length - 3)
|
||||||
}
|
}
|
||||||
@@ -117,64 +195,73 @@ export default {
|
|||||||
width: 466px; height: 466px;
|
width: 466px; height: 466px;
|
||||||
}
|
}
|
||||||
.board {
|
.board {
|
||||||
position: absolute; left: 71px; top: 71px;
|
position: absolute; left: 73px; top: 73px;
|
||||||
width: 320px; height: 320px;
|
width: 320px; height: 320px;
|
||||||
border-radius: 26px;
|
|
||||||
border-width: 2px;
|
|
||||||
border-color: #2a3040;
|
|
||||||
background-color: #0a0f1a;
|
|
||||||
}
|
}
|
||||||
.seg {
|
.seg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 18px; height: 18px;
|
width: 20px; height: 20px;
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #35c26a;
|
|
||||||
}
|
|
||||||
.seg.head {
|
|
||||||
background-color: #7cf0a0;
|
|
||||||
}
|
}
|
||||||
.food {
|
.food {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 14px; height: 14px;
|
width: 20px; height: 20px;
|
||||||
border-radius: 7px;
|
|
||||||
background-color: #ff5a5a;
|
|
||||||
}
|
}
|
||||||
.score {
|
.score {
|
||||||
position: absolute; left: 0px; top: 24px;
|
width: 188px; height: 22px;
|
||||||
width: 466px;
|
color: #d8edce; font-size: 18px; text-align: center;
|
||||||
color: #9aa0aa;
|
}
|
||||||
font-size: 22px;
|
.best {
|
||||||
text-align: center;
|
width: 188px; height: 22px;
|
||||||
|
color: #f3bd60; font-size: 17px; text-align: center;
|
||||||
|
}
|
||||||
|
.hud {
|
||||||
|
position: absolute; left: 139px; top: 24px;
|
||||||
|
width: 188px; height: 44px;
|
||||||
|
flex-direction: column; align-items: center;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
position: absolute; left: 135px; top: 400px;
|
||||||
|
width: 196px; height: 40px;
|
||||||
|
flex-direction: column; align-items: center;
|
||||||
|
}
|
||||||
|
.modeLabel {
|
||||||
|
width: 196px; height: 20px;
|
||||||
|
color: #cee4bd; font-size: 17px; text-align: center;
|
||||||
}
|
}
|
||||||
.hint {
|
.hint {
|
||||||
position: absolute; left: 0px; top: 408px;
|
width: 196px; height: 20px;
|
||||||
width: 466px;
|
color: #adb7c6; font-size: 16px; text-align: center;
|
||||||
color: #3a4052;
|
|
||||||
font-size: 18px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
position: absolute; left: 0px; top: 160px;
|
position: absolute; left: 93px; top: 123px;
|
||||||
width: 466px;
|
width: 280px; height: 220px;
|
||||||
flex-direction: column;
|
background-color: #101923; border-radius: 18px;
|
||||||
align-items: center;
|
flex-direction: column; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
.overText {
|
.overText {
|
||||||
height: 40px;
|
width: 260px; height: 40px;
|
||||||
color: #ffffff;
|
color: #ffffff; font-size: 30px; text-align: center;
|
||||||
font-size: 32px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.overSub {
|
.overSub {
|
||||||
height: 32px;
|
width: 260px; height: 30px;
|
||||||
color: #f3a633;
|
color: #f3bd60; font-size: 22px; text-align: center;
|
||||||
font-size: 24px;
|
}
|
||||||
text-align: center;
|
.retry {
|
||||||
|
width: 216px; height: 56px;
|
||||||
|
background-color: #80bf55; border-radius: 12px;
|
||||||
|
color: #101923; font-size: 23px; text-align: center;
|
||||||
}
|
}
|
||||||
.overHint {
|
.overHint {
|
||||||
height: 30px;
|
width: 260px; height: 30px;
|
||||||
color: #9aa0aa;
|
color: #adb7c6; font-size: 17px; text-align: center;
|
||||||
font-size: 20px;
|
}
|
||||||
text-align: center;
|
.modes {
|
||||||
|
width: 240px; height: 44px;
|
||||||
|
flex-direction: row; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.modeButton {
|
||||||
|
width: 116px; height: 44px;
|
||||||
|
background-color: #304252; border-radius: 10px;
|
||||||
|
color: #ffffff; font-size: 18px; text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const ux = fs.readFileSync('src/pages/index/index.ux','utf8')
|
||||||
|
const css = ux.match(/<style>([\s\S]*?)<\/style>/)[1]
|
||||||
|
function rule(name) {
|
||||||
|
const m = css.match(new RegExp('\\.'+name+'\\s*\\{([^}]+)\\}'))
|
||||||
|
assert.ok(m, 'style exists: '+name)
|
||||||
|
return Object.fromEntries(m[1].split(';').filter(s=>s.includes(':')).map(s=>s.trim().split(/:\s*/)))
|
||||||
|
}
|
||||||
|
function px(r,k) { assert.match(r[k] || '', /^\d+px$/, k); return Number(r[k].slice(0,-2)) }
|
||||||
|
for (const name of ['board','hud','footer','overlay']) {
|
||||||
|
const r = rule(name), x=px(r,'left'), y=px(r,'top'), w=px(r,'width'), h=px(r,'height')
|
||||||
|
const dy = Math.max(Math.abs(y-233), Math.abs(y+h-233))
|
||||||
|
const chord = 2*Math.sqrt(233*233-dy*dy)
|
||||||
|
const used = 2*Math.max(Math.abs(x-233), Math.abs(x+w-233))
|
||||||
|
assert.ok(used <= chord-8, `${name}: ${used} > chord ${chord} minus 8px safety`)
|
||||||
|
console.log(`${name}: y=${y}..${y+h}, width=${w}, chord=${chord.toFixed(2)}, margin=${((chord-used)/2).toFixed(2)}px`)
|
||||||
|
}
|
||||||
|
const overlay = rule('overlay'), retry = rule('retry'), modes = rule('modes'), button = rule('modeButton')
|
||||||
|
assert.ok(px(retry,'width') >= 200 && px(retry,'height') >= 56, 'large retry touch target')
|
||||||
|
assert.ok(px(button,'height') >= 44 && px(button,'width') >= 100)
|
||||||
|
assert.ok(2*px(button,'width') <= px(modes,'width'))
|
||||||
|
const total = ['overText','overSub','retry','overHint','modes'].reduce((n,k)=>n+px(rule(k),'height'),0)
|
||||||
|
assert.ok(total <= px(overlay,'height'), 'overlay children fit without clipping')
|
||||||
|
assert.ok(px(retry,'width') <= px(overlay,'width'))
|
||||||
|
assert.doesNotMatch(ux, /\}\}px/, 'style bindings already contain px: no double px')
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync('src/manifest.json')).config.designWidth, 466)
|
||||||
|
console.log('ROUND LAYOUT PASS (numeric geometry, not hardware rendering)')
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Bake snake textures. Working resolution 80px, saved at 20px (cell size).
|
||||||
|
# Base tiles: body, head (eyes), apple, plus rotated variants written into src/common/tex/.
|
||||||
|
import os
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
TILE = 80
|
||||||
|
OUT = os.path.expanduser('~/vela-snake/src/common/tex')
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
def rr(d, box, rad, fill):
|
||||||
|
d.rounded_rectangle(box, radius=rad, fill=fill)
|
||||||
|
|
||||||
|
def body():
|
||||||
|
W = TILE
|
||||||
|
img = Image.new('RGBA', (W, W), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
rr(d, (0, 10, W, W - 10), 14, (60, 183, 90, 255))
|
||||||
|
for cx, cy in ((18, 26), (W - 30, W - 38)):
|
||||||
|
d.ellipse((cx - 14, cy - 14, cx + 14, cy + 14), fill=(46, 160, 74, 255))
|
||||||
|
d.ellipse((cx - 6, cy - 6, cx + 6, cy + 6), fill=(60, 183, 90, 255))
|
||||||
|
return img
|
||||||
|
|
||||||
|
def head():
|
||||||
|
W = TILE
|
||||||
|
img = Image.new('RGBA', (W, W), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
rr(d, (10, 12, W, W - 12), 22, (64, 188, 110, 255))
|
||||||
|
d.ellipse((W - 52, 24, W - 32, 44), fill=(255, 255, 255, 255))
|
||||||
|
d.ellipse((W - 46, 30, W - 38, 38), fill=(30, 30, 30, 255))
|
||||||
|
return img
|
||||||
|
|
||||||
|
def apple():
|
||||||
|
W = TILE
|
||||||
|
img = Image.new('RGBA', (W, W), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.ellipse((8, 14, W - 8, W - 6), fill=(224, 62, 62, 255))
|
||||||
|
d.ellipse((16, 24, 34, 42), fill=(255, 255, 255, 70))
|
||||||
|
d.polygon(((W // 2 - 4, 12), (W // 2 + 18, 4), (W // 2 + 10, 16)), fill=(56, 128, 56, 255))
|
||||||
|
d.rectangle((W // 2 - 3, 10, W // 2 + 1, 18), fill=(90, 60, 30, 255))
|
||||||
|
return img
|
||||||
|
|
||||||
|
def head_open():
|
||||||
|
W = TILE
|
||||||
|
img = head()
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.rectangle((0, 0, 10, W), fill=(0, 0, 0, 0))
|
||||||
|
d.rectangle((10, 36, 34, 44), fill=(226, 62, 62, 255))
|
||||||
|
d.polygon(((10, 36), (26, 28), (30, 40)), fill=(226, 62, 62, 255))
|
||||||
|
return img
|
||||||
|
|
||||||
|
def save20(img, name):
|
||||||
|
img.resize((20, 20), Image.LANCZOS).save(os.path.join(OUT, name))
|
||||||
|
|
||||||
|
# base tiles: body + head drawn mouth-E (E = right); rotated variants cover all directions
|
||||||
|
b = body()
|
||||||
|
save20(b, 'body.png') # 4-way base body tile
|
||||||
|
h = head_open() # head with tongue + eyes, W edge open
|
||||||
|
save20(h, 'head_e.png') # facing east
|
||||||
|
save20(h.transpose(Image.FLIP_LEFT_RIGHT), 'head_w.png')
|
||||||
|
save20(h.transpose(Image.ROTATE_90), 'head_n.png') # E->N under ccw
|
||||||
|
save20(h.transpose(Image.ROTATE_90).transpose(Image.FLIP_TOP_BOTTOM), 'head_s.png')
|
||||||
|
save20(apple(), 'apple.png')
|
||||||
|
print('base tiles done')
|
||||||
@@ -6,11 +6,11 @@ const snake = require('../src/common/snake.js');
|
|||||||
|
|
||||||
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
|
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
|
||||||
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
|
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||||
.replace(/import\s+snake\s+from\s+'[^']*';?/, '')
|
.replace(/import\s+\w+\s+from\s+'[^']*';?/g, '')
|
||||||
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '')
|
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '')
|
||||||
.replace('export default', '');
|
.replace('export default', '');
|
||||||
|
|
||||||
const sandbox = { snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
|
const sandbox = { best: require('../src/common/best.js'), storage: {get() {}, set() {}}, snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
|
||||||
CELL: 20, GRID: 16, OFFSET: 73 };
|
CELL: 20, GRID: 16, OFFSET: 73 };
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
const spec = vm.runInContext('(' + body + ')', sandbox);
|
const spec = vm.runInContext('(' + body + ')', sandbox);
|
||||||
@@ -25,10 +25,11 @@ function mk() {
|
|||||||
let c = mk();
|
let c = mk();
|
||||||
assert.equal(c.model.length, 3, 'initialized to length 3');
|
assert.equal(c.model.length, 3, 'initialized to length 3');
|
||||||
assert.equal(c.snake.length, 3, 'rendered 3 segments');
|
assert.equal(c.snake.length, 3, 'rendered 3 segments');
|
||||||
assert.equal(c.snake[0].cls, 'seg head', 'head flagged');
|
assert.match(c.snake[0].src, /head_e\.png$/, 'head texture');
|
||||||
assert.equal(c.snake[1].cls, 'seg', 'body flagged');
|
assert.match(c.snake[1].src, /body_h\.png$/, 'body texture');
|
||||||
|
assert.match(c.snake[2].src, /tail_w\.png$/, 'tail texture');
|
||||||
assert.equal(c.scoreText, 'SCORE 0');
|
assert.equal(c.scoreText, 'SCORE 0');
|
||||||
assert.equal(c.snake[0].left, (73 + 6 * 20 + 1) + 'px', 'head x mapped to px');
|
assert.equal(c.snake[0].left, (73 + 6 * 20) + 'px', 'head x mapped to px');
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) c.tick();
|
for (let i = 0; i < 5; i++) c.tick();
|
||||||
assert.equal(c.model[0].x, 11, 'head moved right 5 steps from x=6');
|
assert.equal(c.model[0].x, 11, 'head moved right 5 steps from x=6');
|
||||||
@@ -39,6 +40,7 @@ c.tick();
|
|||||||
assert.equal(c.dir.x, 0, 'dir x');
|
assert.equal(c.dir.x, 0, 'dir x');
|
||||||
assert.equal(c.dir.y, 1, 'direction turned down');
|
assert.equal(c.dir.y, 1, 'direction turned down');
|
||||||
assert.equal(c.model[0].y, 8, 'head moved down');
|
assert.equal(c.model[0].y, 8, 'head moved down');
|
||||||
|
assert.match(c.snake[0].src, /head_s\.png$/, 'head faces south after turn');
|
||||||
|
|
||||||
c.onSwipe({ direction: 'up' });
|
c.onSwipe({ direction: 'up' });
|
||||||
c.tick();
|
c.tick();
|
||||||
@@ -57,11 +59,13 @@ assert.equal(c.over, false);
|
|||||||
assert.equal(c.model.length, 3);
|
assert.equal(c.model.length, 3);
|
||||||
assert.equal(c.scoreText, 'SCORE 0');
|
assert.equal(c.scoreText, 'SCORE 0');
|
||||||
|
|
||||||
// template uses documented directives/events, no transforms, no crown remnants
|
// template uses image components for textures + documented directives/events
|
||||||
|
assert.match(ux, /<image class="board" src="\/common\/tex\/board\.png"[^>]*>/);
|
||||||
|
assert.match(ux, /<image class="seg" src="\{\{ seg\.src \}\}"/);
|
||||||
assert.match(ux, /for="\{\{ seg in snake \}\}"/);
|
assert.match(ux, /for="\{\{ seg in snake \}\}"/);
|
||||||
assert.match(ux, /show="\{\{ over \}\}"/);
|
assert.match(ux, /show="\{\{ over \}\}"/);
|
||||||
assert.match(ux, /onswipe="onSwipe"/);
|
assert.match(ux, /onswipe="onSwipe"/);
|
||||||
assert.doesNotMatch(ux, /crownrotationchanged/i);
|
assert.doesNotMatch(ux, /crownrotationchanged/i);
|
||||||
assert.doesNotMatch(ux, /transform\s*:/);
|
assert.doesNotMatch(ux, /transform\s*:/);
|
||||||
|
|
||||||
console.log('PAGE WIRING PASS (movement, turn, reversal, game over, restart, directives)');
|
console.log('PAGE WIRING PASS (textures, movement, turn, reversal, game over, restart, directives)');
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// TDD: piece classification for textured rendering.
|
||||||
|
// renderPieces(snake, dir) -> array of texture names, one per segment (head first).
|
||||||
|
const assert = require('assert/strict')
|
||||||
|
const fs = require('fs')
|
||||||
|
const vm = require('vm')
|
||||||
|
|
||||||
|
const src = fs.readFileSync(__dirname + '/../src/common/snake.js', 'utf8')
|
||||||
|
const sandbox = { module: { exports: {} } }
|
||||||
|
vm.runInNewContext(src, sandbox)
|
||||||
|
const snake = sandbox.module.exports
|
||||||
|
|
||||||
|
// compass sides: e/w/n/s (screen coords, y grows downward)
|
||||||
|
function pieces(list, dir) { return snake.renderPieces(list, dir) }
|
||||||
|
|
||||||
|
// -- initial snake: head east, horizontal body, tail pointing west
|
||||||
|
assert.deepEqual(
|
||||||
|
pieces([{ x: 6, y: 7 }, { x: 5, y: 7 }, { x: 4, y: 7 }], { x: 1, y: 0 }),
|
||||||
|
['head_e', 'body_h', 'tail_w'])
|
||||||
|
|
||||||
|
// -- vertical snake moving south
|
||||||
|
assert.deepEqual(
|
||||||
|
pieces([{ x: 3, y: 5 }, { x: 3, y: 4 }, { x: 3, y: 4 + 1 }], { x: 0, y: 1 })
|
||||||
|
.map((p, i) => i), // shape check below with explicit list instead
|
||||||
|
[0, 1, 2])
|
||||||
|
assert.deepEqual(
|
||||||
|
pieces([{ x: 3, y: 5 }, { x: 3, y: 6 }, { x: 3, y: 7 }], { x: 0, y: 1 }),
|
||||||
|
['head_s', 'body_v', 'tail_s'])
|
||||||
|
|
||||||
|
// -- corner: head turned from east to south
|
||||||
|
// snake [(6,8),(6,7),(5,7)]: neck connects south (to head) and west (to rest);
|
||||||
|
// tail cell (5,7)'s neighbor is east, so its tip points west
|
||||||
|
assert.deepEqual(
|
||||||
|
pieces([{ x: 6, y: 8 }, { x: 6, y: 7 }, { x: 5, y: 7 }], { x: 0, y: 1 }),
|
||||||
|
['head_s', 'corner_sw', 'tail_w'])
|
||||||
|
|
||||||
|
// -- corner: head turned from east to north
|
||||||
|
assert.deepEqual(
|
||||||
|
pieces([{ x: 6, y: 6 }, { x: 6, y: 7 }, { x: 5, y: 7 }], { x: 0, y: -1 }),
|
||||||
|
['head_n', 'corner_nw', 'tail_w'])
|
||||||
|
|
||||||
|
// -- corner heading west turning south (es corner mirrored)
|
||||||
|
// tail cell (5,7)'s neighbor is west, so its tip points east
|
||||||
|
assert.deepEqual(
|
||||||
|
pieces([{ x: 4, y: 8 }, { x: 4, y: 7 }, { x: 5, y: 7 }], { x: -1, y: 0 }),
|
||||||
|
['head_w', 'corner_es', 'tail_e'])
|
||||||
|
|
||||||
|
// -- double corner (S shape): head moving south, west, then north —
|
||||||
|
// hand-verified: every middle cell of this tight zigzag is a corner
|
||||||
|
// [(6,8) head][ (6,7) sw ][ (5,7) en ][ (5,6) sw ][ (4,6) en ][ (4,5) tail ]
|
||||||
|
assert.deepEqual(
|
||||||
|
pieces([{ x: 6, y: 8 }, { x: 6, y: 7 }, { x: 5, y: 7 }, { x: 5, y: 6 }, { x: 4, y: 6 }, { x: 4, y: 5 }], { x: 0, y: 1 }),
|
||||||
|
['head_s', 'corner_sw', 'corner_en', 'corner_sw', 'corner_en', 'tail_n'])
|
||||||
|
|
||||||
|
// -- every returned piece maps to a texture that must exist
|
||||||
|
const TEX = ['head_e', 'head_w', 'head_n', 'head_s',
|
||||||
|
'body_h', 'body_v',
|
||||||
|
'corner_en', 'corner_es', 'corner_nw', 'corner_sw',
|
||||||
|
'tail_e', 'tail_w', 'tail_n', 'tail_s']
|
||||||
|
const dirTex = __dirname + '/../src/common/tex'
|
||||||
|
for (const p of TEX) {
|
||||||
|
assert.ok(fs.existsSync(dirTex + '/' + p + '.png'), 'missing texture ' + p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- food + board textures
|
||||||
|
assert.ok(fs.existsSync(dirTex + '/apple.png'), 'missing apple texture')
|
||||||
|
assert.ok(fs.existsSync(dirTex + '/board.png'), 'missing board texture')
|
||||||
|
|
||||||
|
console.log('PIECE CLASSIFICATION PASS')
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const snake = require('../src/common/snake.js')
|
||||||
|
let seed=83971
|
||||||
|
const rand=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296}
|
||||||
|
const dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}]
|
||||||
|
let moves=0, seams=0
|
||||||
|
for(const mode of ['classic','wrap']) for(let game=0;game<1000;game++) {
|
||||||
|
let cells=snake.initialSnake(),dir=dirs[0]
|
||||||
|
for(let tick=0;tick<100;tick++) {
|
||||||
|
dir=snake.turn(dir,dirs[Math.floor(rand()*4)])
|
||||||
|
const h=cells[0]
|
||||||
|
const food=rand()<0.3?{x:(h.x+dir.x+16)%16,y:(h.y+dir.y+16)%16}:{x:0,y:0}
|
||||||
|
const r=snake.step(cells,dir,food,16,mode)
|
||||||
|
if(!r.alive)break
|
||||||
|
if(Math.abs(r.snake[0].x-h.x)>1||Math.abs(r.snake[0].y-h.y)>1)seams++
|
||||||
|
cells=r.snake
|
||||||
|
assert.equal(new Set(cells.map(c=>c.x+','+c.y)).size,cells.length)
|
||||||
|
for(const c of cells)assert.ok(c.x>=0&&c.x<16&&c.y>=0&&c.y<16)
|
||||||
|
for(const name of snake.renderPieces(cells,dir,16))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name)
|
||||||
|
moves++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({randomizedValidMoves:moves,edgeCrossings:seams}))
|
||||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,173 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const snake = require('../src/common/snake.js')
|
||||||
|
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8')
|
||||||
|
function page(storage = { get() {}, set() {} }) {
|
||||||
|
const timers = new Map(); let id = 0
|
||||||
|
const context = { best: require('../src/common/best.js'), snake, storage, console, setInterval(fn, ms) { timers.set(++id, {fn, ms}); return id }, clearInterval(id) { timers.delete(id) } }
|
||||||
|
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/import\s+\w+\s+from\s+'[^']*';?/g, '').replace('export default', 'globalThis.spec =')
|
||||||
|
vm.runInNewContext(body, context)
|
||||||
|
const p = Object.assign({}, context.spec, JSON.parse(JSON.stringify(context.spec.private)))
|
||||||
|
p.onInit(); p.onShow()
|
||||||
|
p.foodModel = {x: 0, y: 0}
|
||||||
|
return {p, timers}
|
||||||
|
}
|
||||||
|
test('food selection has bounded work even when only one empty cell remains', () => {
|
||||||
|
const cells = []; for(let y=0;y<16;y++) for(let x=0;x<16;x++) if(x!==15 || y!==15) cells.push({x,y})
|
||||||
|
const original = Math.random; let calls = 0
|
||||||
|
try {
|
||||||
|
Math.random = () => { assert.ok(++calls <= 1, 'at most one random draw'); return 0 }
|
||||||
|
assert.deepEqual(snake.spawnFood(cells,16), {x:15,y:15})
|
||||||
|
cells.push({x:15,y:15}); assert.equal(snake.spawnFood(cells,16), null)
|
||||||
|
} finally {Math.random=original}
|
||||||
|
})
|
||||||
|
test('page recreation shares pending best writes instead of racing an older page', () => {
|
||||||
|
const reads=[], writes=[]
|
||||||
|
const api = {get(o) {reads.push(o)}, set(o) {writes.push(o)}}
|
||||||
|
const {p:old} = page(api); reads[0].success('0'); eatTo(old, 5); old.onDestroy()
|
||||||
|
const {p} = page(api); assert.equal(p.bestText, 'BEST 5'); assert.equal(reads.length, 1)
|
||||||
|
eatTo(p, 7); assert.equal(writes.length, 1); writes[0].success()
|
||||||
|
assert.equal(writes[1].value, '7'); writes[1].success(); assert.equal(p.bestText, 'BEST 7')
|
||||||
|
})
|
||||||
|
test('retired timer callbacks cannot advance a resumed or restarted run', () => {
|
||||||
|
const {p, timers} = page(); const retired = [...timers.values()][0].fn
|
||||||
|
p.onTap(); p.onTap(); retired(); assert.equal(p.model[0].x, 6)
|
||||||
|
const oldRun = [...timers.values()][0].fn
|
||||||
|
p.restart(); oldRun(); assert.equal(p.model[0].x, 6)
|
||||||
|
p.onHide(); const model=p.model; p.restart(); assert.equal(p.model, model, 'hidden retry ignored')
|
||||||
|
})
|
||||||
|
test('filling the board wins without null food crashes and retry clears the result', () => {
|
||||||
|
const {p, timers} = page()
|
||||||
|
// A serpentine Hamilton path, head (14,0), final food (15,0).
|
||||||
|
const path = []
|
||||||
|
for (let y=0;y<16;y++) for (let k=0;k<16;k++) path.push({x:y%2 ? k : 15-k,y})
|
||||||
|
p.model = path.slice(1); p.dir = {x:1,y:0}; p.foodModel = {x:15,y:0}
|
||||||
|
p.tick(); assert.equal(p.over, true); assert.equal(p.endText, 'YOU WIN')
|
||||||
|
assert.equal(p.scoreText, 'SCORE 253'); assert.equal(p.bestText, 'BEST 253')
|
||||||
|
assert.equal(p.hasFood, false); assert.equal(p.snake.length, 256); assert.equal(timers.size, 0)
|
||||||
|
p.tick(); p.onShow(); assert.equal(timers.size, 0)
|
||||||
|
p.restart(); assert.equal(p.hasFood, true); assert.equal(p.endText, 'GAME OVER'); assert.equal(p.model.length, 3)
|
||||||
|
assert.match(ux, /show="\{\{ hasFood \}\}"/)
|
||||||
|
assert.match(ux, /\{\{ endText \}\}/)
|
||||||
|
})
|
||||||
|
test('select modes while paused or over, resetting safely, and retry keeps mode', () => {
|
||||||
|
const {p, timers} = page()
|
||||||
|
assert.equal(p.mode, 'classic')
|
||||||
|
p.selectMode('wrap'); assert.equal(p.mode, 'classic', 'no mid-run mode change')
|
||||||
|
p.onTap(); p.selectMode('garbage'); assert.equal(p.mode, 'classic')
|
||||||
|
p.selectMode('wrap'); assert.equal(p.mode, 'wrap'); assert.equal(p.paused, true); assert.equal(timers.size, 0)
|
||||||
|
p.onTap(); p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick()
|
||||||
|
assert.equal(p.model[0].x, 0); assert.match(p.snake[1].src, /body_h/)
|
||||||
|
p.restart(); assert.equal(p.mode, 'wrap'); assert.equal(timers.size, 1)
|
||||||
|
p.onTap(); p.selectMode('classic'); p.onTap()
|
||||||
|
p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick()
|
||||||
|
assert.equal(p.over, true); assert.equal(timers.size, 0)
|
||||||
|
p.onShow(); p.onTap(); assert.equal(p.over, true); assert.equal(timers.size, 0)
|
||||||
|
p.restart(); assert.equal(p.over, false); assert.equal(timers.size, 1)
|
||||||
|
for (const cls of ['board','food','seg']) assert.match(ux, new RegExp('class="'+cls+'"[^>]*onclick="onTap"'))
|
||||||
|
assert.match(ux, /class="retry" onclick="restart"/)
|
||||||
|
assert.match(ux, /onclick="selectMode\('classic'\)"/)
|
||||||
|
assert.match(ux, /onclick="selectMode\('wrap'\)"/)
|
||||||
|
assert.doesNotMatch(ux, /<(?:div|stack)[^>]*onclick=/, 'actions cannot bubble into a second action')
|
||||||
|
assert.match(ux, /\{\{ bestText \}\}/)
|
||||||
|
assert.match(ux, /show="\{\{ paused && !over \}\}"/)
|
||||||
|
})
|
||||||
|
test('Wrap crosses every edge, still collides with body and renders seam connections', () => {
|
||||||
|
const cases = [
|
||||||
|
[[15,7],[14,7],[13,7]], [[0,7],[1,7],[2,7]],
|
||||||
|
[[7,15],[7,14],[7,13]], [[7,0],[7,1],[7,2]]
|
||||||
|
]
|
||||||
|
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]
|
||||||
|
const expected = [{x:0,y:7},{x:15,y:7},{x:7,y:0},{x:7,y:15}]
|
||||||
|
cases.forEach((coords,i) => {
|
||||||
|
const cells = coords.map(([x,y])=>({x,y}))
|
||||||
|
const r = snake.step(cells, dirs[i], {x:8,y:8}, 16, 'wrap')
|
||||||
|
assert.equal(r.alive, true); assert.deepEqual(r.snake[0], expected[i])
|
||||||
|
assert.equal(snake.step(cells, dirs[i], {x:8,y:8}, 16, 'classic').alive, false)
|
||||||
|
})
|
||||||
|
assert.equal(snake.step([{x:15,y:7},{x:0,y:7},{x:1,y:7}], dirs[0], {x:8,y:8}, 16, 'wrap').alive, false)
|
||||||
|
assert.equal(snake.step([{x:15,y:7},{x:15,y:8},{x:0,y:8},{x:0,y:7}], dirs[0], {x:8,y:8}, 16, 'wrap').alive, true)
|
||||||
|
// At (15,7), neighbor (0,7) is east; neighbor (15,8) is south.
|
||||||
|
assert.deepEqual(snake.renderPieces([{x:0,y:7},{x:15,y:7},{x:15,y:8}], dirs[0], 16), ['head_e','corner_es','tail_s'])
|
||||||
|
assert.deepEqual(snake.renderPieces([{x:7,y:0},{x:7,y:15},{x:6,y:15}], dirs[2], 16), ['head_s','corner_sw','tail_w'])
|
||||||
|
assert.deepEqual(snake.renderPieces([{x:0,y:7},{x:15,y:7}], dirs[0], 16), ['head_e','tail_w'])
|
||||||
|
})
|
||||||
|
function eatTo(p, score) {
|
||||||
|
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))]
|
||||||
|
p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick()
|
||||||
|
}
|
||||||
|
test('late best read cannot lower local record; writes are serialized', () => {
|
||||||
|
const reads = [], writes = []
|
||||||
|
const {p} = page({get(o) {reads.push(o)}, set(o) {writes.push(o)}})
|
||||||
|
eatTo(p, 5); assert.equal(writes.length, 0, 'wait for stored high water before writing')
|
||||||
|
reads[0].success('3'); assert.equal(p.bestText, 'BEST 5'); assert.equal(writes[0].value, '5')
|
||||||
|
eatTo(p, 6); eatTo(p, 7); assert.equal(writes.length, 1)
|
||||||
|
writes[0].success(); assert.equal(writes.length, 2); assert.equal(writes[1].value, '7')
|
||||||
|
writes[1].success(); p.restart(); assert.equal(p.bestText, 'BEST 7')
|
||||||
|
})
|
||||||
|
test('read/write failures retry on later lifecycle activity without erasing a saved record', () => {
|
||||||
|
const reads = [], writes = []
|
||||||
|
const {p} = page({get(o) {reads.push(o)}, set(o) {writes.push(o)}})
|
||||||
|
reads[0].fail('offline', 1); eatTo(p, 5); assert.equal(writes.length, 0)
|
||||||
|
reads[1].success('8'); assert.equal(p.bestText, 'BEST 8'); assert.equal(writes.length, 0)
|
||||||
|
eatTo(p, 9); writes[0].fail('full', 1); assert.equal(writes.length, 1)
|
||||||
|
p.onHide(); p.onShow(); assert.equal(writes.length, 2); assert.equal(writes[1].value, '9')
|
||||||
|
p.onDestroy(); const text = p.bestText; writes[1].success(); assert.equal(p.bestText, text)
|
||||||
|
})
|
||||||
|
test('storage throws and corrupt values never crash or poison gameplay', () => {
|
||||||
|
const {p} = page({get() {throw Error('offline')}, set() {throw Error('full')}})
|
||||||
|
eatTo(p, 1); assert.equal(p.bestText, 'BEST 1')
|
||||||
|
for (const value of ['oops', '-4', '2.5', 'Infinity', '99999', '', '{}']) {
|
||||||
|
const {p} = page({get(o) {o.success(value)}, set() {throw Error('full')}})
|
||||||
|
assert.equal(p.bestText, 'BEST 0'); eatTo(p, 1); assert.equal(p.bestText, 'BEST 1')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('best score loads raw storage value and persists only new records', () => {
|
||||||
|
let get; const writes = []
|
||||||
|
const {p} = page({get(o) { get=o }, set(o) { writes.push(o) }})
|
||||||
|
assert.ok(get, 'best score requested'); assert.equal(get.default, '0')
|
||||||
|
get.success('4'); assert.equal(p.bestText, 'BEST 4')
|
||||||
|
p.model = [{x:6,y:7}, ...Array.from({length:6}, (_,i) => ({x:i,y:12}))]
|
||||||
|
p.foodModel = {x:7,y:7}; p.tick()
|
||||||
|
assert.equal(p.bestText, 'BEST 5'); assert.equal(writes.length, 1)
|
||||||
|
assert.equal(writes[0].value, '5'); assert.equal(writes[0].key, get.key)
|
||||||
|
writes[0].success(); p.restart(); assert.equal(p.bestText, 'BEST 5')
|
||||||
|
assert.match(ux, /import storage from '@system.storage'/)
|
||||||
|
assert.ok(JSON.parse(fs.readFileSync('src/manifest.json')).features.some(f => f.name === 'system.storage'))
|
||||||
|
})
|
||||||
|
test('eating updates live score and accelerates to a capped 120ms', () => {
|
||||||
|
const {p, timers} = page()
|
||||||
|
assert.equal([...timers.values()][0].ms, 300)
|
||||||
|
for (let score = 1; score <= 20; score++) {
|
||||||
|
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))]
|
||||||
|
p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick()
|
||||||
|
assert.equal(p.scoreText, 'SCORE ' + score)
|
||||||
|
assert.equal(timers.size, 1); assert.equal([...timers.values()][0].ms, Math.max(120, 300-score*10))
|
||||||
|
}
|
||||||
|
p.restart(); assert.equal([...timers.values()][0].ms, 300)
|
||||||
|
})
|
||||||
|
test('pause stops timers, resume is explicit after hide, destroy is final', () => {
|
||||||
|
const {p, timers} = page()
|
||||||
|
assert.equal(timers.size, 1); p.onShow(); assert.equal(timers.size, 1)
|
||||||
|
const stale = [...timers.values()][0].fn
|
||||||
|
p.onSwipe({direction:'up'}); p.onTap(); assert.equal(p.paused, true); assert.equal(timers.size, 0)
|
||||||
|
stale(); p.tick(); assert.equal(p.model[0].x, 6)
|
||||||
|
p.onSwipe({direction:'down'}); p.onTap(); assert.equal(timers.size, 1)
|
||||||
|
p.tick(); assert.equal(p.model[0].x, 7)
|
||||||
|
p.onHide(); assert.equal(timers.size, 0); p.onShow(); assert.equal(p.paused, true); assert.equal(timers.size, 0)
|
||||||
|
p.onTap(); assert.equal(timers.size, 1)
|
||||||
|
p.onDestroy(); p.onShow(); p.onTap(); assert.equal(timers.size, 0)
|
||||||
|
assert.doesNotMatch(ux, /onBackPress|transform\s*:/)
|
||||||
|
assert.match(ux, /class="board"[^>]*onclick="onTap"/)
|
||||||
|
})
|
||||||
|
test('two safe turns are queued and consumed one per tick', () => {
|
||||||
|
const {p} = page()
|
||||||
|
for (const direction of ['up', 'down', 'up', 'left', 'down', 'garbage']) p.onSwipe({direction})
|
||||||
|
p.tick(); assert.equal(p.model[0].y, 6); assert.equal(p.model[0].x, 6)
|
||||||
|
p.tick(); assert.equal(p.model[0].x, 5); assert.equal(p.model[0].y, 6)
|
||||||
|
p.tick(); assert.equal(p.model[0].x, 4); assert.equal(p.model[0].y, 6)
|
||||||
|
p.onSwipe({direction:'down'}); p.restart(); p.tick()
|
||||||
|
assert.equal(p.model[0].x, 7); assert.equal(p.model[0].y, 7)
|
||||||
|
})
|
||||||