Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eacad97240 | ||
|
|
4e39e1912a |
@@ -1,209 +1,51 @@
|
|||||||
# Xiaomi Vela Hello World for Watch S4
|
# SNAKE — classic snake for Xiaomi Watch S4
|
||||||
|
|
||||||
This is the smallest practical Xiaomi Vela JS watch application. It shows two centered lines on a round black screen:
|
Classic snake on a 16×16 grid rendered on the round 466px screen (320px board,
|
||||||
|
20px cells). Swipe to steer; the crown is NOT used (confirmed unsupported by the
|
||||||
|
Vela quick-app API on this device). Tap the overlay to restart after a crash.
|
||||||
|
|
||||||
|
**Status: RENDERS AND PLAYS ON WATCH** (user-confirmed since 0.2/0.3).
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- **Swipe** in any direction to turn (180° reversals are blocked in the logic)
|
||||||
|
- **Tap** the game-over overlay to restart
|
||||||
|
- Crown does nothing — no rotary API exists in Vela quick apps (triple-confirmed
|
||||||
|
against docs + the largest open-source S4 app)
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Hello World!
|
src/
|
||||||
Xiaomi Watch S4
|
├── app.ux # app lifecycle
|
||||||
```
|
├── manifest.json # com.ilias.snake, watch, designWidth 466
|
||||||
|
|
||||||
It compiles entirely from a Linux terminal. Xiaomi AIoT-IDE is not required.
|
|
||||||
|
|
||||||
## 1. What you need
|
|
||||||
|
|
||||||
- A 64-bit Linux system
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
On Ubuntu or Debian, install the basic tools:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y curl openssl
|
|
||||||
```
|
|
||||||
|
|
||||||
Install Node.js using your preferred package manager or a version manager such as `nvm`. Confirm that it works:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --version
|
|
||||||
npm --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Understand the tiny project
|
|
||||||
|
|
||||||
```text
|
|
||||||
xiaomi-vela-s4-hello-world/
|
|
||||||
├── package.json
|
|
||||||
├── README.md
|
|
||||||
├── sign/
|
|
||||||
└── src/
|
|
||||||
├── app.ux
|
|
||||||
├── manifest.json
|
|
||||||
├── common/
|
├── common/
|
||||||
│ ├── icon.png
|
│ ├── snake.js # pure game logic: step/turn/spawnFood — unit-tested
|
||||||
│ └── icon.svg
|
│ └── icon.png # 192×192 circular app icon
|
||||||
└── pages/
|
└── pages/index/index.ux # board rendering (for-directive), swipe input, game loop
|
||||||
└── index/
|
tests/
|
||||||
└── index.ux
|
├── snake.cjs # logic: self-collision, food, 180° block, wrap rules
|
||||||
|
└── page.cjs # wiring: template binds, handlers, state machine
|
||||||
```
|
```
|
||||||
|
|
||||||
- `package.json` installs Xiaomi's command-line build tool and defines the build commands.
|
The game ticks on a 300ms `setInterval`, cleared on hide/destroy.
|
||||||
- `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`.
|
## Build & test
|
||||||
|
|
||||||
## 3. Compile a debug RPK without Xiaomi's IDE
|
|
||||||
|
|
||||||
Open a terminal in the project folder and run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm ci
|
||||||
npm run build
|
npm run release # signed .rpk → dist/
|
||||||
|
node tests/snake.cjs # logic tests
|
||||||
|
node tests/page.cjs # page wiring tests
|
||||||
```
|
```
|
||||||
|
|
||||||
The important output is created under `dist/` and ends in `.debug.rpk`:
|
## Install
|
||||||
|
|
||||||
```bash
|
Side-load `dist/com.ilias.snake.release.*.rpk` via AstroBox.
|
||||||
find dist -maxdepth 1 -type f -name '*.rpk' -print
|
Releases: https://gitea.oimcloud.myaddr.tools/codex-bot/vela-snake
|
||||||
```
|
|
||||||
|
|
||||||
The `build/` directory contains intermediate generated files. You normally do not edit either `build/` or `dist/`.
|
## Version notes
|
||||||
|
|
||||||
To include a separate source map while debugging:
|
- **0.2** — board renders on watch (user confirmed); ring undersized
|
||||||
|
- **0.3** — designWidth 480→466 fixed the undersized bezel
|
||||||
```bash
|
- Crown-based input was tried and disproven — swipe is the only reliable input
|
||||||
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
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 5. Change the app
|
|
||||||
|
|
||||||
Edit `src/pages/index/index.ux`. For example, replace:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<text class="hello">Hello World!</text>
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<text class="hello">My first app</text>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then rebuild:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -34,6 +34,30 @@ 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) {
|
||||||
|
const D = { '1,0': 'e', '-1,0': 'w', '0,-1': 'n', '0,1': 's' }
|
||||||
|
const opp = { e: 'w', w: 'e', n: 's', s: 'n' }
|
||||||
|
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[(prev.x - s.x) + ',' + (prev.y - s.y)] // side toward head
|
||||||
|
if (!next) return 'tail_' + opp[from] // tip points away from body
|
||||||
|
const to = D[(next.x - s.x) + ',' + (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 }]
|
||||||
@@ -56,4 +80,4 @@ function spawnFood(snake, N) {
|
|||||||
return { x, y }
|
return { x, y }
|
||||||
}
|
}
|
||||||
|
|
||||||
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.6",
|
||||||
"versionCode": 5,
|
"versionCode": 6,
|
||||||
"minAPILevel": 1,
|
"minAPILevel": 1,
|
||||||
"deviceTypeList": [
|
"deviceTypeList": [
|
||||||
"watch"
|
"watch"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<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"></image>
|
||||||
<div class="food" style="left: {{ food.left }}px; top: {{ food.top }}px;"></div>
|
<image class="food" src="/common/tex/apple.png" style="left: {{ food.left }}px; top: {{ food.top }}px;"></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 }}" style="left: {{ seg.left }}px; top: {{ seg.top }}px;"></image>
|
||||||
<div class="overlay" show="{{ over }}" onclick="onTap">
|
<div class="overlay" show="{{ over }}" onclick="onTap">
|
||||||
<text class="overText">GAME OVER</text>
|
<text class="overText">GAME OVER</text>
|
||||||
<text class="overSub">{{ scoreText }}</text>
|
<text class="overSub">{{ scoreText }}</text>
|
||||||
@@ -88,19 +88,20 @@ export default {
|
|||||||
if (this.over) this.restart()
|
if (this.over) this.restart()
|
||||||
},
|
},
|
||||||
rebuild() {
|
rebuild() {
|
||||||
|
const pieces = snake.renderPieces(this.model, this.dir)
|
||||||
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.food = {
|
||||||
left: (OFFSET + this.foodModel.x * CELL + 3) + 'px',
|
left: (OFFSET + this.foodModel.x * CELL) + 'px',
|
||||||
top: (OFFSET + this.foodModel.y * CELL + 3) + 'px'
|
top: (OFFSET + this.foodModel.y * CELL) + 'px'
|
||||||
}
|
}
|
||||||
this.scoreText = 'SCORE ' + (this.model.length - 3)
|
this.scoreText = 'SCORE ' + (this.model.length - 3)
|
||||||
}
|
}
|
||||||
@@ -117,27 +118,16 @@ 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;
|
position: absolute; left: 0px; top: 24px;
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
|
After Width: | Height: | Size: 14 KiB |