回到首页 返回首页
回到顶部 回到顶部
返回上一页 返回上一页

【花雕动手做】Kitronik 可编程游戏开发板基于 ARCADE MakeCode 之动画沙子游戏 简单

头像 驴友花雕 2025.09.17 4 0

Kitronik ARCADE 是一款由英国教育科技公司 Kitronik 精心打造的可编程游戏机开发板,专为编程教学与创客实践而设计。该设备原生支持微软的 MakeCode Arcade 平台,用户可通过图形化或 JavaScript 编程方式,轻松创建、下载并运行复古风格的街机游戏。

它集成了彩色 LCD 显示屏、方向控制键、功能按键、蜂鸣器和震动马达等交互组件,提供完整的游戏输入输出体验。无论是初学者进行编程启蒙,还是创客群体开发交互式作品,Kitronik ARCADE 都能作为理想的硬件载体,助力创意实现。

凭借其开源友好、易于上手、兼容性强等特点,该开发板广泛应用于中小学编程课程、创客工作坊、游戏开发教学以及个人项目原型设计,深受教育者与技术爱好者的喜爱。

00 (2).jpg
00 (4).jpg

作为学习、练习与尝试,这里创建一个动画沙子的小游戏。
打开网页版:https://arcade.makecode.com/,设置项目名称:动画沙子

JavaScript 实验代码

代码
// Animated LED sand ported from
// https://learn.adafruit.com/animated-led-sand/code

const N_GRAINS = 80;
const grainImg = img`
    b b b b b b b .
    b d d d d d b .
    b d d d d d b c
    b d d d d d b c
    b d d d d d b c
    b d d d d d b c
    b b b b b b b c
    . . c c c c c c
`;
const GRAIN_RADIUS = grainImg.width;
const WIDTH = Math.idiv(screen.width, GRAIN_RADIUS); // Display width in pixels
const HEIGHT = Math.idiv(screen.height, GRAIN_RADIUS); // Display height in pixels

// The 'sand' grains exist in an integer coordinate space that's 256X
// the scale of the pixel grid, allowing them to move and interact at
// less than whole-pixel increments.
const MAX_X = (WIDTH * 256 - 1); // Maximum X coordinate in grain space
const MAX_Y = (HEIGHT * 256 - 1); // Maximum Y coordinate
class Grain {
    constructor(public x: number, public y: number, public vx: number, public vy: number)
    { }
}
const grain: Grain[] = [];
const imgbuf = control.createBuffer(WIDTH * HEIGHT);

// SETUP - RUNS ONCE AT PROGRAM START --------------------------------------
function setup() {
    for (let i = 0; i < N_GRAINS; i++) {  // For each sand grain...
        grain.push(new Grain(0, 0, 0, 0));
        let j = 0;
        do {
            grain[i].x = randint(0, (WIDTH << 8) - 1); // Assign random position within
            grain[i].y = randint(0, (HEIGHT << 8) - 1); // the 'grain' coordinate space
            // Check if corresponding pixel position is already occupied...
            for (j = 0; (j < i) && (((grain[i].x >> 8) != (grain[j].x >> 8)) ||
                ((grain[i].y >> 8) != (grain[j].y >> 8))); j++);
        } while (j < i); // Keep retrying until a clear spot is found
        imgbuf[(grain[i].y >> 8) * WIDTH + (grain[i].x >> 8)] = 0xff; // Mark it
        grain[i].vx = grain[i].vy = 0; // Initial velocity is zero
    }
}
setup();
// MAIN LOOP - RUNS ONCE PER FRAME OF ANIMATION ----------------------------

game.onUpdate(function () {
    // Limit the animation frame rate to MAX_FPS.  Because the subsequent sand
    // calculations are non-deterministic (don't always take the same amount
    // of time, depending on their current states), this helps ensure that
    // things like gravity appear constant in the simulation.

    // Read accelerometer...
    let ax = controller.acceleration(ControllerDimension.X) >> 8;
    let ay = -controller.acceleration(ControllerDimension.Y) >> 8;
    let az = Math.idiv(Math.abs(controller.acceleration(ControllerDimension.Z)), 2048);
    az = (az >= 3) ? 1 : 4 - az;      // Clip & invert
    ax -= az;                         // Subtract motion factor from X, Y
    ay -= az;
    let az2 = az * 2 + 1;         // Range of random motion to add back in

    // ...and apply 2D accel vector to grain velocities...
    let v2; // Velocity squared
    let v;  // Absolute velocity
    for (let i = 0; i < N_GRAINS; i++) {
        const graini = grain[i];
        graini.vx += ax + randint(0, az2); // A little randomness makes
        graini.vy += ay + randint(0, az2); // tall stacks topple better!
        // Terminal velocity (in any direction) is 256 units -- equal to
        // 1 pixel -- which keeps moving grains from passing through each other
        // and other such mayhem.  Though it takes some extra math, velocity is
        // clipped as a 2D vector (not separately-limited X & Y) so that
        // diagonal movement isn't faster
        v2 = graini.vx * graini.vx + graini.vy * graini.vy;
        if (v2 > 65536) { // If v^2 > 65536, then v > 256
            //v = Math.sqrt(v2) | 0; // Velocity vector magnitude
            // sqrt expensive on hw
            v = Math.max(graini.vx, graini.vy);
            graini.vx = Math.idiv(graini.vx, v) >> 8; // Maintain heading
            graini.vy = Math.idiv(graini.vy, v) >> 8; // Limit magnitude
        }
    }

    // ...then update position of each grain, one at a time, checking for
    // collisions and having them react.  This really seems like it shouldn't
    // work, as only one grain is considered at a time while the rest are
    // regarded as stationary.  Yet this naive algorithm, taking many not-
    // technically-quite-correct steps, and repeated quickly enough,
    // visually integrates into something that somewhat resembles physics.
    // (I'd initially tried implementing this as a bunch of concurrent and
    // "realistic" elastic collisions among circular grains, but the
    // calculations and volument of code quickly got out of hand for both
    // the tiny 8-bit AVR microcontroller and my tiny dinosaur brain.)

    for (let i = 0; i < N_GRAINS; i++) {
        const graini = grain[i];
        let newx = graini.x + graini.vx; // New position in grain space
        let newy = graini.y + graini.vy;
        if (newx > MAX_X) {               // If grain would go out of bounds
            newx = MAX_X;          // keep it inside, and
            graini.vx = - graini.vx >> 1;             // give a slight bounce off the wall
        } else if (newx < 0) {
            newx = 0;
            graini.vx = - graini.vx >> 1;
        }
        if (newy > MAX_Y) {
            newy = MAX_Y;
            graini.vy = - graini.vy >> 1;
        } else if (newy < 0) {
            newy = 0;
            graini.vy = - graini.vy >> 1;
        }

        let oldidx = (graini.y >> 8) * WIDTH + (graini.x >> 8); // Prior pixel #
        let newidx = (newy >> 8) * WIDTH + (newx >> 8); // New pixel #
        if ((oldidx != newidx) && // If grain is moving to a new pixel...
            imgbuf[newidx]) {       // but if that pixel is already occupied...
            let delta = Math.abs(newidx - oldidx); // What direction when blocked?
            if (delta == 1) {            // 1 pixel left or right)
                newx = graini.x;  // Cancel X motion
                graini.vx = -graini.vx >> 1;          // and bounce X velocity (Y is OK)
                newidx = oldidx;      // No pixel change
            } else if (delta == WIDTH) { // 1 pixel up or down
                newy = graini.y;  // Cancel Y motion
                graini.vy = -graini.vy >> 1;          // and bounce Y velocity (X is OK)
                newidx = oldidx;      // No pixel change
            } else { // Diagonal intersection is more tricky...
                // Try skidding along just one axis of motion if possible (start w/
                // faster axis).  Because we've already established that diagonal
                // (both-axis) motion is occurring, moving on either axis alone WILL
                // change the pixel index, no need to check that again.
                if ((Math.abs(graini.vx) - Math.abs(graini.vy)) >= 0) { // X axis is faster
                    newidx = (graini.y >> 8) * WIDTH + (newx >> 8);
                    if (!imgbuf[newidx]) { // That pixel's free!  Take it!  But...
                        newy = graini.y; // Cancel Y motion
                        graini.vy = - graini.vy >> 1;         // and bounce Y velocity
                    } else { // X pixel is taken, so try Y...
                        newidx = (newy >> 8) * WIDTH + (graini.x >> 8);
                        if (!imgbuf[newidx]) { // Pixel is free, take it, but first...
                            newx = graini.x; // Cancel X motion
                            graini.vx = - graini.vx >> 1;         // and bounce X velocity
                        } else { // Both spots are occupied
                            newx = graini.x; // Cancel X & Y motion
                            newy = graini.y;
                            graini.vx = - graini.vx >> 1;         // Bounce X & Y velocity
                            graini.vy /= - graini.vy >> 1;
                            newidx = oldidx;     // Not moving
                        }
                    }
                } else { // Y axis is faster, start there
                    newidx = (newy >> 8) * WIDTH + (graini.x >> 8);
                    if (!imgbuf[newidx]) { // Pixel's free!  Take it!  But...
                        newx = graini.x; // Cancel X motion
                        graini.vy = - graini.vy >> 1;         // and bounce X velocity
                    } else { // Y pixel is taken, so try X...
                        newidx = (graini.y >> 8) * WIDTH + (newx >> 8);
                        if (!imgbuf[newidx]) { // Pixel is free, take it, but first...
                            newy = graini.y; // Cancel Y motion
                            graini.vy = - graini.vy >> 1;         // and bounce Y velocity
                        } else { // Both spots are occupied
                            newx = graini.x; // Cancel X & Y motion
                            newy = graini.y;
                            graini.vx = - graini.vx >> 1;         // Bounce X & Y velocity
                            graini.vy = - graini.vy >> 1;
                            newidx = oldidx;     // Not moving
                        }
                    }
                }
            }
        }
        graini.x = newx; // Update grain position
        graini.y = newy;
        imgbuf[oldidx] = 0;    // Clear old spot (might be same as new, that's OK)
        imgbuf[newidx] = 0xff;  // Set new spot
    }
});

game.onPaint(function () {
    for (let x = 0; x < WIDTH; ++x) {
        const xs = x * GRAIN_RADIUS;
        for (let y = 0; y < HEIGHT; ++y) {
            const ys = y * GRAIN_RADIUS;
            if (imgbuf[y * WIDTH + x])
                screen.drawImage(grainImg, xs, ys)
        }
    }
})

ARCADE MakeCode 之动画沙子游戏代码解读
这是一个模拟沙子物理效果的动画程序,使用加速度计控制沙粒的运动。

代码结构分析

1. 常量和全局变量定义
javascript
const N_GRAINS = 80; // 沙粒数量
const grainImg = img`...`; // 单个沙粒的图像
const GRAIN_RADIUS = grainImg.width; // 沙粒半径
const WIDTH = Math.idiv(screen.width, GRAIN_RADIUS); // 屏幕宽度(以沙粒为单位)
const HEIGHT = Math.idiv(screen.height, GRAIN_RADIUS); // 屏幕高度(以沙粒为单位)

// 沙粒坐标空间是像素网格的256倍,允许亚像素精度的移动
const MAX_X = (WIDTH * 256 - 1); // 沙粒空间中的最大X坐标
const MAX_Y = (HEIGHT * 256 - 1); // 沙粒空间中的最大Y坐标

2. 沙粒类定义
javascript
class Grain {
constructor(public x: number, public y: number, public vx: number, public vy: number)
{ }
}
每个沙粒对象包含位置(x,y)和速度(vx,vy)信息。

3. 全局数据结构
javascript
const grain: Grain[] = []; // 沙粒对象数组
const imgbuf = control.createBuffer(WIDTH * HEIGHT); // 缓冲区,记录每个像素位置是否有沙粒

4. 初始化函数
javascript
function setup() {
for (let i = 0; i < N_GRAINS; i++) { // 为每个沙粒...
grain.push(new Grain(0, 0, 0, 0));
let j = 0;
do {
// 在"沙粒"坐标空间内分配随机位置
grain[i].x = randint(0, (WIDTH 8) != (grain[j].x >> 8)) ||
((grain[i].y >> 8) != (grain[j].y >> 8))); j++);
} while (j < i); // 不断重试直到找到空位

imgbuf[(grain[i].y >> 8) * WIDTH + (grain[i].x >> 8)] = 0xff; // 标记位置
grain[i].vx = grain[i].vy = 0; // 初始速度为零
}
}
setup(); // 执行初始化

5. 主更新循环
javascript
game.onUpdate(function () {
// 读取加速度计数据...
let ax = controller.acceleration(ControllerDimension.X) >> 8;
let ay = -controller.acceleration(ControllerDimension.Y) >> 8;
let az = Math.idiv(Math.abs(controller.acceleration(ControllerDimension.Z)), 2048);
az = (az >= 3) ? 1 : 4 - az; // 裁剪和反转
ax -= az; // 从X、Y中减去运动因子
ay -= az;
let az2 = az * 2 + 1; // 要添加回的随机运动范围

// 将2D加速度向量应用于沙粒速度...
let v2; // 速度平方
let v; // 绝对速度
for (let i = 0; i < N_GRAINS; i++) {
const graini = grain[i];
graini.vx += ax + randint(0, az2); // 一点随机性使
graini.vy += ay + randint(0, az2); // 高堆更容易倒塌!

// 限制终端速度(任何方向)为256单位 - 等于1像素
v2 = graini.vx * graini.vx + graini.vy * graini.vy;
if (v2 > 65536) { // 如果v^2 > 65536,则v > 256
// 在硬件上sqrt很昂贵,所以使用近似值
v = Math.max(graini.vx, graini.vy);
graini.vx = Math.idiv(graini.vx, v) >> 8; // 保持方向
graini.vy = Math.idiv(graini.vy, v) >> 8; // 限制大小
}
}

// 更新每个沙粒的位置,检查碰撞并做出反应
for (let i = 0; i < N_GRAINS; i++) {
const graini = grain[i];
let newx = graini.x + graini.vx; // 沙粒空间中的新位置
let newy = graini.y + graini.vy;

// 边界检查
if (newx > MAX_X) {
newx = MAX_X;
graini.vx = - graini.vx >> 1; // 轻微反弹
} else if (newx < 0) {
newx = 0;
graini.vx = - graini.vx >> 1;
}
if (newy > MAX_Y) {
newy = MAX_Y;
graini.vy = - graini.vy >> 1;
} else if (newy < 0) {
newy = 0;
graini.vy = - graini.vy >> 1;
}

let oldidx = (graini.y >> 8) * WIDTH + (graini.x >> 8); // 先前像素索引
let newidx = (newy >> 8) * WIDTH + (newx >> 8); // 新像素索引

// 碰撞检测和处理
if ((oldidx != newidx) && // 如果沙粒移动到新像素...
imgbuf[newidx]) { // 但该像素已被占用...
let delta = Math.abs(newidx - oldidx); // 被阻挡时的方向?
if (delta == 1) { // 左右1像素
newx = graini.x; // 取消X运动
graini.vx = -graini.vx >> 1; // 反弹X速度(Y可以)
newidx = oldidx; // 无像素变化
} else if (delta == WIDTH) { // 上下1像素
newy = graini.y; // 取消Y运动
graini.vy = -graini.vy >> 1; // 反弹Y速度(X可以)
newidx = oldidx; // 无像素变化
} else { // 对角线交叉更复杂...
// 尝试沿一个运动轴滑动(从较快的轴开始)
if ((Math.abs(graini.vx) - Math.abs(graini.vy)) >= 0) { // X轴更快
newidx = (graini.y >> 8) * WIDTH + (newx >> 8);
if (!imgbuf[newidx]) { // 该像素空闲!占据它!
newy = graini.y; // 取消Y运动
graini.vy = - graini.vy >> 1; // 反弹Y速度
} else { // X像素被占用,尝试Y...
newidx = (newy >> 8) * WIDTH + (graini.x >> 8);
if (!imgbuf[newidx]) { // 像素空闲,占据它
newx = graini.x; // 取消X运动
graini.vx = - graini.vx >> 1; // 反弹X速度
} else { // 两个位置都被占用
newx = graini.x; // 取消X和Y运动
newy = graini.y;
graini.vx = - graini.vx >> 1; // 反弹X和Y速度
graini.vy = - graini.vy >> 1;
newidx = oldidx; // 不移动
}
}
} else { // Y轴更快,从那里开始
newidx = (newy >> 8) * WIDTH + (graini.x >> 8);
if (!imgbuf[newidx]) { // 像素空闲!占据它!
newx = graini.x; // 取消X运动
graini.vy = - graini.vy >> 1; // 反弹X速度
} else { // Y像素被占用,尝试X...
newidx = (graini.y >> 8) * WIDTH + (newx >> 8);
if (!imgbuf[newidx]) { // 像素空闲,占据它
newy = graini.y; // 取消Y运动
graini.vy = - graini.vy >> 1; // 反弹Y速度
} else { // 两个位置都被占用
newx = graini.x; // 取消X和Y运动
newy = graini.y;
graini.vx = - graini.vx >> 1; // 反弹X和Y速度
graini.vy = - graini.vy >> 1;
newidx = oldidx; // 不移动
}
}
}
}
}

// 更新沙粒位置和缓冲区
graini.x = newx;
graini.y = newy;
imgbuf[oldidx] = 0; // 清除旧位置
imgbuf[newidx] = 0xff; // 设置新位置
}
});

6. 渲染函数
javascript
game.onPaint(function () {
for (let x = 0; x < WIDTH; ++x) {
const xs = x * GRAIN_RADIUS;
for (let y = 0; y < HEIGHT; ++y) {
const ys = y * GRAIN_RADIUS;
if (imgbuf[y * WIDTH + x]) // 如果该位置有沙粒
screen.drawImage(grainImg, xs, ys) // 绘制沙粒图像
}
}
})

技术要点解析
亚像素精度:沙粒在256倍于像素网格的坐标空间中移动,实现平滑的亚像素运动
碰撞检测:使用缓冲区(imgbuf)记录每个像素位置是否有沙粒,实现高效的碰撞检测

物理模拟:
加速度计数据影响沙粒运动
速度限制防止沙粒相互穿透
碰撞时速度反弹并衰减

性能优化:
使用近似计算代替昂贵的平方根运算
使用缓冲区而不是直接像素操作提高性能
简化的物理模型在视觉上仍然逼真。

通过模拟器,调试与模拟运行

00213.gif


实验场景记录

140 (1).jpg
140 (2).jpg

评论

user-avatar
icon 他的勋章
    展开更多