跳转至

006 联机版火柴人对战

作者:王世豪 | 最后修改:2026-05-22

重要提醒:

1、当你安装配置好智能体、规则和技能后,最终一定要参考验证智能体、规则和技能是否安装成功来验证安装配置是否正确;

2、Trae 中内置的免费大模型,会经常排队,并且性能不可控,容易出问题,所以推荐参考:001 发送会话请求时,提示排队,如何解决?的方法,订阅收费的大模型(目前每月 40 元);这样可以大大提高 AI 性能;如果不想使用收费模型,在非正常工作时间段内,使用内置的免费模型也能勉强凑合;

3、选择收费大模型时,经过我们的实际测试,根据工作任务的不同,可以按照如下建议选择(仅供参考,具体情况还需要根据你自己的实际使用情况来定):

  • 代码开发任务,优先选择 GLM(可能是使用的人数太多,有时候处理较慢);如果 GLM 处理太慢,再考虑切换到 MiniMax;
  • 其他任务,可以首先选择 ark-code-latest,其次选择 MiniMax,最后选择 GLM(可能是使用的人数太多,有时候处理较慢);

一、准备硬件环境

1、WIN10 以及 WIN10 以上的 Windows 操作系统电脑一台 2、合宙引擎主机 8000W 一块 或者 合宙引擎主机 1602 一块 + type-c 接口 usb 数据线一根;这一步的环境不是必须的,如果没有这个环境,可以直接在模拟器上开发调试 app;

二、准备软件环境

2.1 代码仓库

合宙引擎主机的项目代码在 LuatOS 仓库的 master 分支;

点击这里可以通过网络浏览器打开 LuatOS 代码仓库的 master 分支;

在这个代码分支中,以下三项内容和本项目直接相关:

1、app_engine/factory

  • 合宙引擎主机 8000W/1602 的默认出厂软件源码
  • 支持开机欢迎界面、待机界面、主菜单界面
  • 支持 WiFi 设置,系统设置,应用市场,网络测速功能
  • 如果把 合宙引擎主机比作一个手机,这里的 factory 就相当于是手机出厂自带的默认软件功能

2、 app_engine/app_store/vertical_app

  • 所有可以在 竖屏分辨率的合宙引擎主机项目上运行的 app,例如 airplane_battle(飞机大战)、game_2048(2048 游戏)、luatos_competition(LuatOS 知识竞赛),等等等等;
  • 这里的每个 app,不是默认出厂软件支持的功能;而是通过默认出厂软件中的应用市场,下载安装后,才可以在模拟器 或者 合宙引擎主机上运行;
  • 如果把 合宙引擎主机比作一个手机,这里的每个 app 就相当于手机通过应用商店下载安装的每一个应用;
  • 本文所描述的内容,最重要的一个目标就是:根据自己的 app 需求,开发调试 app 代码,调试通过后,把代码提交到 web 端应用市场以及 LuatOS 仓库的这个 vertical_app 目录下

3、 LuatOS/script/libs:LuatOS 扩展库,和 引擎主机的默认出出厂软件源码 一起在模拟器上运行或者烧录到 合宙引擎主机上运行;

具体到本项目,软件代码架构如下图所示,黄色背景的你将要开发的某个 app,就是参照本文所描述的内容,你要开发完成的某一个 app

2.2 模拟器

点击此处学习 LuatOS 模拟器

在本小节,使用 LuatOS 模拟器 + LuatOS 代码,可以正常运行起来一个 UI 项目就算达标;

2.3 AI 工具

AI 工具有很多种,有 Trae,Copilot 等等,每一种 AI 工具都可以配置不同的大模型,我们并不限制你使用哪一种 AI 工具;

各种 AI 工具使用的基本思路都是相同的,在本文,我们仅仅基于 Trae 这种 AI 工具来介绍,如果你使用其他 AI 工具,遇到不懂的问题,可以自行解决;

参考 Trae 的安装和智能体概念理解安装 luatos-docs-code 智能体、规则和技能 安装好 Trae,配置 luatos-docs-code 智能体,配置项目规则和技能;其中:在 Trae 的安装和智能体概念理解第三章节中,不用再新建项目,直接打开自己电脑上的 LuatOS-develop 目录即可;

Trae 的配置使用有以下三点特别重要:

1、当你安装配置好智能体、规则和技能后,最终一定要参考验证智能体、规则和技能是否安装成功来验证安装配置是否正确;

2、Trae 中内置的免费大模型,会经常排队,并且性能不可控,容易出问题,所以推荐参考:001 发送会话请求时,提示排队,如何解决?的方法,订阅收费的大模型(目前每月 40 元);这样可以大大提高 AI 性能;如果不想使用收费模型,在非正常工作时间段内,使用内置的免费模型也能勉强凑合;

3、选择收费大模型时,经过我们的实际测试,根据工作任务的不同,可以按照如下建议选择(仅供参考,具体情况还需要根据你自己的实际使用情况来定):

  • 代码开发任务,优先选择 GLM(可能是使用的人数太多,有时候处理较慢);如果 GLM 处理太慢,再考虑切换到 MiniMax;
  • 其他任务,可以首先选择 ark-code-latest,其次选择 MiniMax,最后选择 GLM(可能是使用的人数太多,有时候处理较慢);

三、代码讲解

本节将详细讲解联机火柴人格斗游戏的代码框架,帮助读者理解游戏的核心实现逻辑;

提示:如需了解如何借助 AI 工具完成开发,请跳转到第四章及后续章节查阅。

3.1 整体架构概述

3.1.1 项目文件结构

StickFighter_Online/
├── main.lua                          # 应用入口
├── meta.json                         # 应用元数据
├── icon.png                          # 应用图标
├── readme.md                         # 版本更新记录和游戏规则
├── network.lua                       # 纯网络层(MQTT + 积分API,不包含业务逻辑)
├── user/
│   └── stick_fighter_online_win.lua  # 业务层(游戏逻辑、UI、状态管理,约4500行)
└── res/
    └── ranking_list.png              # 排行榜图标

采用分层的结构,network.lua 作为网络模块,完全不知道任何游戏业务逻辑,它只负责把消息送出去,或者把收到的消息原封不动地回调给业务层。所有业务逻辑都在 stick_fighter_online_win.lua 中处理,包括状态管理、积分计算、UI 渲染等。

模块
职责
位置
network.lua
纯粹网络层,只负责网络通信和数据API
独立文件
stick_fighter_online_win.lua
业务层,负责所有游戏逻辑、UI、状态管理
user目录下

3.1.2 游戏架构

本游戏采用输入同步架构,而非状态同步:

1、输入同步:只发送玩家的操作指令(左/右/跳/拳/脚/防),对方在本地执行相同的动作 2、状态同步:不直接同步位置、血量等状态,而是通过输入在两端独立计算 3、优势:减少网络流量,降低延迟,提高同步精度

数据流向图:

玩家A按键 → 本地执行动作 → MQTT发送输入 → 玩家B接收输入 → 玩家B本地执行相同动作
                ↑                                                ↓
                └──────────── 双方各自独立计算游戏状态 ──────────────┘

3.2 全局状态与常量定义

3.2.1 游戏状态机

local STATE = {
    MENU = 'menu',              -- 主菜单
    DEVICE_LIST = 'device_list', -- 设备列表
    WAITING = 'waiting',         -- 等待对手准备
    CONNECTING = 'connecting',   -- 连接中
    COUNTDOWN = 'countdown',     -- 倒计时
    FIGHTING = 'fighting',       -- 对战中
    KO = 'ko'                    -- 游戏结束
}

local gameState = STATE.MENU    -- 当前游戏状态

状态流转逻辑:

MENU → DEVICE_LIST → WAITING → COUNTDOWN → FIGHTING → KO
 ↑                                    ↓         │
 └──────────────── reset_to_menu()  ←─┘         │
                                                ↓
                                          resetGame() → WAITING

3.2.2 MQTT 网络配置

local MQTT_SERVER = "lbsmqtt.airm2m.com"   -- 合宙公共MQTT服务器
local MQTT_PORT = 1884                      -- 端口
local MQTT_QOS = 0                          -- 最多一次投递(优先实时性)
local TASK_NAME = "stick_fighter_mqtt"      -- 任务名称

local TOPIC_PRESENCE = "stick_fighter/presence"     -- 设备在线广播主题
local TOPIC_DATA = "stick_fighter/data/"            -- 点对点数据传输主题前缀

主题设计说明:

  • presence:所有设备广播自己的在线状态,用于发现对手
  • data/{device_id}:每个设备订阅自己的专属主题,接收定向消息

3.2.3 游戏状态管理表

local game_state_mqtt = {
    online_devices = {},        -- 在线设备列表 {device_id = {nickname, last_seen, ...}}
    peer_connected = false,     -- 是否已连接对手
    peer_ready = false,         -- 对手是否准备就绪
    i_am_ready = false,         -- 自己是否准备就绪
    peer_device_id = nil,       -- 对手设备ID
    peer_device_model = nil,    -- 对手设备型号
    mqtt_client = nil,          -- MQTT客户端实例
    mqtt_ready = false,         -- MQTT是否就绪
    is_running = false,         -- 游戏是否运行中
    is_server = false           -- 是否为服务器端(决定角色颜色)
}

服务器/客户端判定逻辑:

-- 设备ID字典序较小的作为服务器(红方),较大的作为客户端(蓝方)
game_state_mqtt.is_server = my_device_id < peer_device_id

3.2.4 游戏状态流转详解

完整状态流转图:

┌─────────┐    点击"查找对手"      ┌─────────────┐
│  MENU   │ ───────────────────→ │ DEVICE_LIST │
│ (主菜单) │                      │ (设备列表)    │
└────┬────┘                      └──────┬──────┘
     ↑                                 │
     │ 点击"退出"或                       │ 点击"邀请"或
     │ 对方退出对战                       │ 接受邀请
     │                                  ▼
     │                           ┌─────────────┐
     │                           │   WAITING   │
     │                           │ (等待准备)   │
     │                           └──────┬──────┘
     │                                  │
     │                                  │ 双方发送ready
     │                                  ▼
     │                           ┌─────────────┐
     │                           │  COUNTDOWN  │
     │                           │  (倒计时)    │
     │                           │   3-2-1     │
     │                           └──────┬──────┘
     │                                  │
     │                                  │ 倒计时结束
     │                                  ▼
     │                           ┌─────────────┐
     │                           │  FIGHTING   │
     │                           │   (对战中)   │
     │                           └──────┬──────┘
     │                                  │
     │                                  │ 一方HP≤0
     │                                  ▼
     │                           ┌─────────────┐
     │                           │     KO      │
     │                           │  (战斗结束)  │
     │                           └──────┬──────┘
     │                                  │
     └──────────────────────────────────┘
          ↑                    │
          │                    │ 点击"重新开始"
          │                    ▼
          │              ┌─────────────┐
          │              │   WAITING   │
          │              │ (重新准备)   │
          │              └──────┬──────┘
          │                     │
          └─────────────────────┘
              reset_to_menu()
              (返回主菜单)

各状态说明:

状态
进入条件
退出条件
主要操作
MENU
游戏启动/返回菜单
点击"查找对手"
显示菜单按钮
DEVICE_LIST
点击"查找对手"
点击"邀请"/"关闭"
显示在线设备列表
WAITING
建立连接/重新开始
双方ready
等待准备信号,设置角色位置
COUNTDOWN
双方ready
倒计时结束
显示3-2-1-FIGHT倒计时
FIGHTING
倒计时结束
一方HP≤0
处理战斗逻辑,检测攻击命中
KO
一方被击败
点击重新开始
显示获胜界面,上传积分

关键状态转换函数:

-- MENU → WAITING (通过DEVICE_LIST)
start_game_connect(peer_device_id)

-- WAITING → COUNTDOWN
check_both_ready()  startCountdown()

-- COUNTDOWN → FIGHTING
update()中倒计时结束自动切换

-- FIGHTING → KO
checkAttackHit()HP0  triggerKO()

-- KO → WAITING
resetGame()

-- 任意状态 → MENU
reset_to_menu()

状态转换代码示例:

-- 1. 返回主菜单
local function reset_to_menu()
    game_state_mqtt.peer_connected = false
    game_state_mqtt.peer_ready = false
    game_state_mqtt.peer_device_id = nil

    player1:reset(80, GROUND_Y)
    player2:reset(240, GROUND_Y)
    player1:hide()
    player2:hide()

    myScore = 0
    particles = {}

    set_container_visible(koContainer, false)
    set_container_visible(menuContainer, true)

    gameState = STATE.MENU
end

-- 2. 建立连接后进入等待
local function start_game_connect(peer_device_id)
    game_state_mqtt.peer_device_id = peer_device_id
    game_state_mqtt.peer_connected = true
    game_state_mqtt.is_server = my_device_id < peer_device_id
    game_state_mqtt.i_am_ready = false
    game_state_mqtt.peer_ready = false
    gameState = STATE.WAITING

    -- 设置玩家位置(服务器=红方在左,客户端=蓝方在右)
    if game_state_mqtt.is_server then
        player1:reset(80, GROUND_Y)   -- 红方
        player2:reset(240, GROUND_Y)  -- 蓝方
    else
        player1:reset(240, GROUND_Y)  -- 蓝方
        player2:reset(80, GROUND_Y)   -- 红方
    end

    -- 发送ready信号
    sys.timerStart(function()
        game_state_mqtt.i_am_ready = true
        send_ready()
        check_both_ready()
    end, 200)
end

-- 3. 检查双方准备状态
check_both_ready = function()
    if game_state_mqtt.i_am_ready and game_state_mqtt.peer_ready then
        startCountdown()  -- 进入倒计时
    end
end

-- 4. 倒计时逻辑
local function startCountdown()
    gameState = STATE.COUNTDOWN
    countdownValue = 3
    countdownTimer = 0
    set_container_visible(countdownContainer, true)
end

-- 在update函数中处理倒计时
if gameState == STATE.COUNTDOWN then
    countdownTimer = countdownTimer + dt
    if countdownTimer >= 1.0 then
        countdownTimer = countdownTimer - 1.0
        countdownValue = countdownValue - 1
        if countdownValue <= 0 then
            gameState = STATE.FIGHTING
            set_container_visible(countdownContainer, false)
        end
    end
end

-- 5. KO处理
local function triggerKO(winner, loser)
    gameState = STATE.KO
    koTimer = 0
    koWinner = winner
    loser:setState('knockdown')

    spawnHitParticles(loser.x, loser:getHeadCenterY(), 20)
    shakeAmount = 12

    set_container_visible(koContainer, true)
    winnerLabel:set_text(winner.name .. ' 获胜!')

    upload_score()
end

-- 6. 重新开始
local function resetGame(fromRemote)
    game_state_mqtt.i_am_ready = false
    game_state_mqtt.peer_ready = false

    set_container_visible(koContainer, false)

    gameState = STATE.WAITING
    game_state_mqtt.i_am_ready = true
    send_ready()
    check_both_ready()
end

3.3 火柴人的类(StickFighter)详解

3.3.1 Lua 中的"类"概念

Lua 中没有内置的"类"关键字,但可以通过表(table)+ 元表(metatable)来模拟面向对象编程中的类。

什么是类?

类是一种代码组织方式,用来创建具有相同属性和方法的对象。可以把类想象成一个"模板"或"蓝图":

  • 定义了对象有什么属性(数据)
  • 定义了对象能做什么方法(行为)
  • 可以基于类创建多个实例(具体的对象)

Lua 中模拟类的三要素:

-- 1. 类表:存放类的方法
local StickFighter = {}

-- 2. __index元方法:让实例可以访问类的方法
StickFighter.__index = StickFighter

-- 3. new函数:创建实例的"构造函数"
function StickFighter.new(x, y, name)
    -- 创建空表作为实例
    local self = {}

    -- 设置元表,让实例可以访问类的方法
    setmetatable(self, StickFighter)

    -- 初始化属性
    self.x = x
    self.y = y
    self.name = name
    self.hp = 100

    return self
end

-- 定义方法
function StickFighter:move(dx, dy)
    self.x = self.x + dx
    self.y = self.y + dy
end

function StickFighter:takeDamage(damage)
    self.hp = self.hp - damage
end

创建实例:

-- 创建两个火柴人实例
local player1 = StickFighter.new(100, 200, "红方")
local player2 = StickFighter.new(300, 200, "蓝方")

-- 调用方法
player1:move(10, 0)           -- player1移动到(110, 200)
player2:takeDamage(20)        -- player2血量变为80

-- 访问属性
print(player1.name)           -- 输出:红方
print(player2.hp)             -- 输出:80

关键点解释:

概念
说明
StickFighter
类表,存放所有方法
__index
元方法,当实例找不到属性时,会去类表中查找
setmetatable
设置元表,建立实例和类的关联
self
指向当前实例,相当于其他语言的`this`
:
方法定义和调用时用冒号,会自动传入`self`

为什么要用类?

1、代码复用:定义一次,创建多个实例 2、数据封装:属性和方法绑定在一起 3、逻辑清晰:每个对象管理自己的状态

在本游戏中:

-- 两个玩家都是StickFighter类的实例
player1 = StickFighter.new(80, GROUND_Y, true, 0xff4455, '红方', ...)
player2 = StickFighter.new(240, GROUND_Y, false, 0x4499ff, '蓝方', ...)

-- 各自有独立的属性
player1.hp = 100    -- player1的血量
player2.hp = 100    -- player2的血量

-- 调用相同的方法
player1:update(dt)  -- 更新player1
player2:update(dt)  -- 更新player2

3.3.2 类的创建与初始化

local StickFighter = {}
StickFighter.__index = StickFighter

function StickFighter.new(x, y, facingRight, color, name, colorHex, root)
    local self = setmetatable({}, StickFighter)

    -- 位置属性
    self.x = x                          -- X坐标
    self.y = y                          -- Y坐标(地面高度)
    self.facingRight = facingRight      -- 朝向(true=右,false=左)
    self.groundY = y                    -- 地面Y坐标(跳跃用)

    -- 外观属性
    self.colorHex = colorHex            -- 颜色(0xRRGGBB格式)
    self.name = name                    -- 名称("红方"/"蓝方")

    -- 身体尺寸(固定值,所有火柴人统一)
    self.headRadius = 10
    self.bodyLen = 26
    self.upperLegLen = 16
    self.lowerLegLen = 14
    self.upperArmLen = 14
    self.lowerArmLen = 12

    -- 状态属性
    self.state = 'idle'                 -- 当前状态
    self.stateTimer = 0                 -- 状态计时器
    self.stateDuration = 999            -- 状态持续时间
    self.animPhase = 0                  -- 动画相位

    -- 战斗属性
    self.hp = 100                       -- 当前血量
    self.maxHp = 100                    -- 最大血量
    self.moveSpeed = 100                -- 移动速度(像素/秒)

    -- 攻击属性 - 拳击
    self.punchDamage = 8                -- 拳击伤害
    self.punchRange = 28                -- 拳击范围
    self.punchActiveStart = 0.25        -- 攻击判定开始时间(占动作总时长的比例)
    self.punchActiveEnd = 0.55          -- 攻击判定结束时间
    self.punchDuration = 0.35           -- 拳击动作总时长
    self.punchCooldown = 0.45           -- 拳击冷却时间

    -- 攻击属性 - 脚踢
    self.kickDamage = 12                -- 脚踢伤害
    self.kickRange = 35                 -- 脚踢范围
    self.kickActiveStart = 0.3
    self.kickActiveEnd = 0.6
    self.kickDuration = 0.48
    self.kickCooldown = 0.65

    -- 防御属性
    self.blockReduceRatio = 0.75        -- 格挡减伤比例(减少75%伤害)

    -- 跳跃属性
    self.jumpVelocity = 0               -- 当前垂直速度
    self.jumpGravity = 900              -- 重力加速度
    self.jumpInitialVelocity = -350     -- 起跳初速度(向上为负)
    self.isJumping = false              -- 是否正在跳跃

    -- 输入状态
    self.inputLeft = false
    self.inputRight = false
    self.inputJump = false
    self.inputPunch = false
    self.inputKick = false
    self.inputBlock = false

    -- 前一帧输入(用于检测按键按下)
    self.prevPunch = false
    self.prevKick = false
    self.prevJump = false

    -- 攻击命中标记(防止同一攻击多次命中)
    self.currentAttackHit = false
    self.currentAttackId = 0

    -- 受击效果
    self.flashTimer = 0                 -- 闪烁计时器(受伤时闪烁)
    self.bodyOffsetX = 0                -- 身体偏移X(受击震动)
    self.bodyOffsetY = 0                -- 身体偏移Y

    -- 网络同步用的目标位置(用于平滑插值)
    self.targetX = nil
    self.targetY = nil

    -- 创建UI元素(头部、躯干、四肢)
    self:createBodyParts(root)

    return self
end

3.3.3 攻击判定机制

攻击判定机制是格斗游戏的核心,决定了"什么时候能打中"和"能不能打中"。本游戏采用时间窗口 + 碰撞检测的双重判定系统。

3.3.3.1 为什么需要时间窗口?

真实格斗的动作分解:

想象你出拳打人,动作分为三个阶段:

阶段1: 伸臂期            阶段2: 攻击窗口          阶段3: 收臂期
    ↓                      ↓                      ↓
  ┌───┐                  ┌───┐                  ┌───┐
  │ ○ │                  │ ○ │                  │ ○ │
  │/│\│                  │/│\│                  │/│\│
   / \                    / \                    / \
   ↑                      ↑                      ↑
 手臂在身侧             手臂完全伸出            手臂收回
 逐渐伸出               保持不动                回到身侧
  • 阶段 1(伸臂期/前摇):手臂从身体侧面向前伸出,但还没到达最远点,打不到人
  • 阶段 2(攻击窗口):手臂完全伸出,能打到人
  • 阶段 3(收臂期/后摇):手臂收回,又打不到人

如果没有时间窗口:

  • 按下攻击键立即命中
  • 游戏变成"拼手速",谁按得快谁赢
  • 没有闪避的空间

有时间窗口的好处:

  • 需要预判对方位置
  • 对方可以在你的前摇期闪避或反击
3.3.3.2 时间窗口的具体实现

拳击的时间参数:

self.punchDuration = 0.35        -- 整个拳击动画0.35秒
self.punchActiveStart = 0.25     -- 从25%开始可以命中
self.punchActiveEnd = 0.55       -- 到55%结束命中判定

时间线可视化:

0%        25%              55%            100%
|----------|================|---------------|
   伸臂期      命中窗口期         收臂期
 0.09秒       0.11秒          0.16秒
   ↓            ↓               ↓
 手臂伸出     拳头有效           手臂收回

判定函数:

function StickFighter:isAttackActive()
    if self.state == 'punch' then
        -- 计算当前进度:已进行时间 / 总时间
        local progress = self.stateTimer / self.punchDuration

        -- 只有在25%~55%之间,攻击才有效
        return progress >= self.punchActiveStart 
           and progress <= self.punchActiveEnd
    end
    if self.state == 'kick' then
        local progress = self.stateTimer / self.kickDuration
        return progress >= self.kickActiveStart 
           and progress <= self.kickActiveEnd
    end
    return false
end

拳击 vs 脚踢的对比:

参数
拳击
脚踢
总时长
0.35秒
0.48秒
前摇比例
25% (0.09秒)
30% (0.14秒)
窗口比例
30% (0.11秒)
30% (0.14秒)
后摇比例
45% (0.16秒)
40% (0.19秒)
攻击范围
28像素
35像素
伤害
8点
12点

设计意图:

  • 拳击:前摇短、出手快,适合快速连击,但范围近、伤害低
  • 脚踢:前摇长、破绽大,但范围远、伤害高,需要更好的预判
3.3.3.3 攻击范围计算

碰撞盒(Hitbox)概念:

游戏中用矩形框来表示攻击范围,只有对方的身体进入这个框才算命中。

function StickFighter:getAttackRect()
    -- 只有拳击状态且在有效窗口期内才返回攻击范围
    if self.state == 'punch' and self:isAttackActive() then
        local shoulderY = self:getShoulderY() + self.bodyOffsetY
        local dir = self.facingRight and 1 or -1

        -- 攻击范围从肩部向前延伸
        local startX = self.x + self.bodyOffsetX + dir * 15
        local endX = self.x + self.bodyOffsetX + dir * self.punchRange

        return {
            left = math.min(startX, endX) - 10,
            right = math.max(startX, endX) + 10,
            top = shoulderY - 25,
            bottom = shoulderY + 15,
            active = true,
            type = 'punch'
        }
    end

    -- 脚踢类似,但范围更大
    if self.state == 'kick' and self:isAttackActive() then
        local hipY = self:getHipY() + self.bodyOffsetY
        local dir = self.facingRight and 1 or -1

        local startX = self.x + self.bodyOffsetX + dir * 20
        local endX = self.x + self.bodyOffsetX + dir * self.kickRange

        return {
            left = math.min(startX, endX) - 12,
            right = math.max(startX, endX) + 12,
            top = hipY - 20,
            bottom = hipY + 25,  -- 脚踢范围更低(踢腿)
            active = true,
            type = 'kick'
        }
    end

    -- 不在攻击状态或不在窗口期,返回无效范围
    return { active = false }
end

攻击范围可视化:

拳击范围(28像素)              脚踢范围(35像素)

         ┌──────────┐                  ┌─────────────┐
         │  拳击    │                   │    脚踢     │
         │  判定框  │                   │   判定框     │
         └──────────┘                  └─────────────┘
              ↑                             ↑
    头部高度(肩部)                腰部高度(髋部)
    范围较小                        范围较大
3.3.3.4 命中检测流程

完整的攻击判定流程:

-- 在update函数中每帧调用
function checkAttackHit(attacker, defender)
    -- 步骤1: 检查攻击者是否在攻击状态
    if attacker.state ~= 'punch' and attacker.state ~= 'kick' then
        return  -- 不在攻击状态,跳过
    end

    -- 步骤2: 检查是否在有效时间窗口
    if not attacker:isAttackActive() then
        return  -- 不在命中窗口期,跳过
    end

    -- 步骤3: 检查本次攻击是否已经命中过(防止重复伤害)
    if attacker.currentAttackHit then
        return  -- 已经命中过了,跳过
    end

    -- 步骤4: 获取攻击范围和防御者身体范围
    local attackRect = attacker:getAttackRect()
    local bodyRect = defender:getBodyRect()

    -- 步骤5: 矩形碰撞检测
    if rectsOverlap(attackRect, bodyRect) then
        -- 有碰撞,计算伤害
        local damage = attacker.state == 'punch' 
                       and attacker.punchDamage 
                       or attacker.kickDamage

        -- 步骤6: 检查防御者是否格挡
        if defender.state == 'block' then
            -- 格挡减伤75%
            damage = damage * (1 - defender.blockReduceRatio)
            defender:setState('block_hit')  -- 格挡受击状态
        else
            defender:setState('hit')  -- 普通受击状态
        end

        -- 步骤7: 应用伤害
        defender:takeDamage(damage)

        -- 步骤8: 标记本次攻击已命中
        attacker.currentAttackHit = true

        -- 步骤9: 触发特效
        spawnHitParticles(defender.x, defender:getHeadCenterY(), 10)
        shakeAmount = 5  -- 屏幕震动
    end
end

流程图:

开始攻击判定
    ↓
是否在攻击状态? ──否──→ 结束
    ↓是
是否在有效窗口? ──否──→ 结束
    ↓是
本次攻击已命中? ──是──→ 结束
    ↓否
获取攻击范围和防御者身体范围
    ↓
是否碰撞? ──────否──→ 结束
    ↓是
防御者是否格挡? 
    ↓是              ↓否
应用25%伤害      应用100%伤害
格挡受击动画     普通受击动画
    ↓                ↓
标记攻击已命中 ←──────┘
    ↓
触发特效(粒子+震动)
    ↓
结束
3.3.3.5 防止重复命中的机制

问题: 如果不做处理,一次攻击会在多帧内持续判定,造成多段伤害。

解决方案:currentAttackHit 标记

-- 攻击开始时重置标记
function StickFighter:startPunch()
    self:setState('punch')
    self.attackCooldown = self.punchCooldown
    self.currentAttackHit = false  -- 重置命中标记
end

-- 命中检测时检查标记
function checkAttackHit(attacker, defender)
    -- ...前面的检查...

    if attacker.currentAttackHit then
        return  -- 已经命中过,不再造成伤害
    end

    if rectsOverlap(attackRect, bodyRect) then
        defender:takeDamage(damage)
        attacker.currentAttackHit = true  -- 标记已命中
    end
end

效果: 一次攻击动画只能造成一次伤害,即使对方一直站在攻击范围内。

3.3.3.6 实际游戏场景分析

场景 1:完美命中

时间: 0.00s    0.10s    0.15s    0.20s
                ↓        ↓        ↓
玩家A: 按下拳击 → 前摇期 → 窗口期 → 命中!

玩家B: 原地站立 ───────────────→ 受击

结果: 命中,造成8点伤害

场景 2:挥空(距离不够)

时间: 0.00s    0.10s    0.15s    0.20s
                ↓        ↓        ↓
玩家A: 按下拳击 → 前摇期 → 窗口期 → 挥空

玩家B: 后退闪避 ←─────────────── 距离太远

结果: 未命中,进入0.45秒冷却

场景 3:反击(利用前摇)

时间: 0.00s    0.08s    0.15s    0.20s
                ↓        ↓        ↓
玩家A: 按下拳击 → 前摇期 → 窗口期 → ...
                      ↑
玩家B: 看到A出拳 → 快速近身 → 拳击命中!

结果: B在A的前摇期靠近并先命中,A的攻击被打断

场景 4:格挡成功

时间: 0.00s    0.10s    0.15s    0.20s
                ↓        ↓        ↓
玩家A: 按下拳击 → 前摇期 → 窗口期 → 命中!

玩家B: 按住格挡 ←──────────────── 格挡受击

结果: 命中,但只造成2点伤害(8 * 0.25)
3.3.3.7 设计总结

攻击判定系统的核心设计思想:

1、时间窗口 - 增加技巧性,不是按键即中 2、碰撞检测 - 精确判断是否在攻击范围内 3、单次命中 - 防止多段伤害 4、格挡减伤 - 提供防御手段 5、拳击 vs 脚踢差异 - 快慢、远近、高低的权衡

3.3.4 玩家更新逻辑

核心 update 函数(分本地玩家和远程玩家两种处理):

function StickFighter:update(dt)
    -- ==================== 远程玩家处理(player2)====================
    if self == player2 then
        -- 远程玩家跳过本地输入处理,只做:
        -- 1. 动画计时器更新
        -- 2. 跳跃物理计算
        -- 3. 位置平滑插值

        -- 动画计时器
        if self.stateTimer < self.stateDuration then
            self.stateTimer = self.stateTimer + dt
            if self.stateTimer >= self.stateDuration then
                self:onStateComplete()
            end
        end

        -- 跳跃物理(必须计算,否则看不到对方跳跃)
        if self.isJumping then
            self.y = self.y + self.jumpVelocity * dt
            self.jumpVelocity = self.jumpVelocity + self.jumpGravity * dt

            if self.y >= self.groundY then
                self.y = self.groundY
                self.isJumping = false
                self.jumpVelocity = 0
            end
        end

        -- 位置平滑插值(网络同步用)
        if self.targetX ~= nil then
            local dx = self.targetX - self.x
            if math.abs(dx) > 0.5 then
                self.x = self.x + dx * 0.3  -- 每帧移动30%距离
            else
                self.x = self.targetX
                self.targetX = nil
            end
        end

        return
    end

    -- ==================== 本地玩家处理(player1)====================

    -- 1. 更新各种计时器
    if self.attackCooldown > 0 then self.attackCooldown = self.attackCooldown - dt end
    if self.hitStun > 0 then self.hitStun = self.hitStun - dt end
    if self.invincible > 0 then self.invincible = self.invincible - dt end

    -- 2. 跳跃物理计算
    if self.isJumping then
        self.y = self.y + self.jumpVelocity * dt
        self.jumpVelocity = self.jumpVelocity + self.jumpGravity * dt

        if self.y >= self.groundY then
            self.y = self.groundY
            self.isJumping = false
            self.jumpVelocity = 0
            if self.state == 'jump' then self:setState('idle') end
        end
    end

    -- 3. 状态机处理
    if self.stateTimer < self.stateDuration then
        self.stateTimer = self.stateTimer + dt
        if self.stateTimer >= self.stateDuration then
            self:onStateComplete()  -- 状态完成,回到idle
        end
    end

    -- 4. 输入处理(只有canAct时才响应输入)
    if self:canAct() then
        -- 格挡
        if self.inputBlock and self.state ~= 'block' then
            self:setState('block')
        end
        if not self.inputBlock and self.state == 'block' then
            self:setState('idle')
        end

        -- 攻击(检测按键按下,而非按住)
        if self.inputPunch and not self.prevPunch and self.attackCooldown <= 0 then
            self:startPunch()
            self.inputPunch = false  -- 立即重置,准备下次点击
        end
        if self.inputKick and not self.prevKick and self.attackCooldown <= 0 then
            self:startKick()
            self.inputKick = false
        end
        if self.inputJump and not self.prevJump and not self.isJumping then
            self:startJump()
            self.inputJump = false
        end

        -- 移动
        if self.state == 'idle' or self.state == 'walk' or self.state == 'block' then
            local moveDir = 0
            if self.inputLeft then moveDir = -1 end
            if self.inputRight then moveDir = moveDir + 1 end

            if moveDir ~= 0 then
                local speed = self.state == 'block' and self.moveSpeed * 0.5 or self.moveSpeed
                self.x = self.x + moveDir * speed * dt
                if self.state == 'idle' then self:setState('walk') end
            elseif self.state == 'walk' then
                self:setState('idle')
            end
        end
    end

    -- 5. 保存当前输入作为前一帧输入
    self.prevPunch = self.inputPunch
    self.prevKick = self.inputKick
    self.prevJump = self.inputJump
end
3.3.4.1 本地玩家与远程玩家的区别

核心区别:

特性
player1(本地玩家)
player2(远程玩家)
输入来源
按键/触摸
MQTT网络消息
update处理
完整逻辑(输入+物理+动画)
仅物理+动画+插值
位置更新
直接计算
平滑插值到targetX
攻击判定
主动检测
被动响应

为什么要区分处理?

在网络对战中,两个玩家都在本地计算自己的状态,通过网络同步输入:

玩家A (本地)                    玩家B (本地)
   │                              │
   │  按下"左"键                   │
   ▼                              │
player1.inputLeft = true          │
   │                              │
   │  发送MQTT消息: {input="left"} │
   └─────────────────────────────→│
                                  │
                                  ▼
                            player2.inputLeft = true
                                  │
                                  ▼
                            player2:update()中处理

player2 只做三件事:

1、动画计时器更新 - 否则动画不播放 2、跳跃物理计算 - 否则看不到对方跳跃 3、位置平滑插值 - 网络同步关键,防止瞬移

3.3.4.2 输入处理与按键检测

输入的来源:

┌─────────────────────────────────────────────────────────┐
│  本地玩家 (player1)                                      │
│  ┌─────────┐    ┌─────────┐    ┌─────────────────────┐  │
│  │ 按键/触摸 │ → │ 输入处理 │ → │ self.inputXxx = true │  │
│  └─────────┘    └─────────┘    └─────────────────────┘  │
│         ↑                                              │
│    用户操作                                             │
└─────────────────────────────────────────────────────────┘
                           ↓
                    update()中检测
                           ↓
┌─────────────────────────────────────────────────────────┐
│  远程玩家 (player2)                                      │
│  ┌─────────┐    ┌─────────┐    ┌─────────────────────┐  │
│  │ MQTT消息 │ → │ 消息解析 │ → │ self.inputXxx = true │  │
│  └─────────┘    └─────────┘    └─────────────────────┘  │
│         ↑                                              │
│    对方发送的输入消息                                     │
└─────────────────────────────────────────────────────────┘

为什么要检测"按下"而不是"按住"?

-- 错误的方式:按住会持续触发
if self.inputPunch then  -- 只要按住就一直触发
    self:startPunch()     -- 每帧都出拳,变成机关枪
end

-- 正确的方式:只检测按下瞬间
if self.inputPunch and not self.prevPunch then  -- 只有按下的第一帧触发
    self:startPunch()     -- 只出一次拳
end

关键代码:

-- 检测"按下"事件(当前帧为true,上一帧为false)
if self.inputPunch and not self.prevPunch then
    self:startPunch()
    self.inputPunch = false  -- 立即重置,准备下次点击
end

-- 保存当前输入供下一帧比较
self.prevPunch = self.inputPunch
3.3.4.3 状态管理与 canAct()

状态转换图:

                   ┌─────────┐
         ┌─────────│  idle   │─────────┐
         │         │ (站立)   │         │
         │         └────┬────┘         │
         │              │              │
    按下格挡键      按下移动键      按下攻击键
         │              │              │
         ▼              ▼              ▼
    ┌─────────┐   ┌─────────┐   ┌─────────┐
    │  block  │   │  walk   │   │ punch/  │
    │ (格挡)  │   │ (行走)  │   │ kick    │
    └────┬────┘   └────┬────┘   └────┬────┘
         │              │              │
    松开格挡键      松开移动键      动画结束
         │              │              │
         └──────────────┴──────────────┘
                        │
                        │ 被攻击命中
                        ▼
                    ┌─────────┐
                    │   hit   │
                    │ (受击)   │
                    └────┬────┘
                         │
           ┌─────────────┴─────────────┐
           │                           │
       硬直结束                        血量≤0
           │                           │
           ▼                           ▼
      ┌─────────┐                  ┌─────────┐
      │  idle   │                  │   KO    │
      │ (恢复)  │                   │ (击倒)  │
      └─────────┘                  └─────────┘

canAct() 函数:

canAct() 用于判断玩家当前是否可以响应输入:

function StickFighter:canAct()
    -- 以下状态不能操作:
    -- 'punch' - 出拳中
    -- 'kick' - 踢腿中
    -- 'hit' - 受击硬直中
    -- 'knockdown' - 击倒状态
    if self.state == 'punch' or self.state == 'kick' or 
       self.state == 'hit' or self.state == 'knockdown' then
        return false
    end

    -- 受击硬直期间不能操作
    if self.hitStun > 0 then
        return false
    end

    return true
end

设计意图:

  • 攻击动作期间不能打断(必须做完动作)
  • 受击后有硬直时间,防止被无限连击
  • 格挡和行走可以随时切换
3.3.4.4 状态完成回调 onStateComplete()

当攻击或受击动画播放完毕时,自动回到 idle 状态:

function StickFighter:onStateComplete()
    -- 攻击或受击结束,回到idle
    if self.state == 'punch' or self.state == 'kick' or 
       self.state == 'hit' or self.state == 'block_hit' then
        self:setState('idle')
    end

    -- 击倒状态结束,回到idle并重置位置
    if self.state == 'knockdown' then
        self:setState('idle')
        -- 稍微后退一点,防止卡在对方身上
        local backDir = self.facingRight and -1 or 1
        self.x = self.x + backDir * 20
    end
end
3.3.4.5 远程玩家同步机制详解

player2 的特殊处理:

if self == player2 then
    -- 1. 更新动画计时器(必须做,否则动画不播放)
    if self.stateTimer < self.stateDuration then
        self.stateTimer = self.stateTimer + dt
        if self.stateTimer >= self.stateDuration then
            self:onStateComplete()
        end
    end

    -- 2. 跳跃物理(必须做,否则看不到对方跳跃)
    if self.isJumping then
        self.y = self.y + self.jumpVelocity * dt
        self.jumpVelocity = self.jumpVelocity + self.jumpGravity * dt
        -- ...落地检测
    end

    -- 3. 位置平滑插值(网络同步关键!)
    if self.targetX ~= nil then
        local dx = self.targetX - self.x
        if math.abs(dx) > 0.5 then
            self.x = self.x + dx * 0.3  -- 每帧移动30%距离
        else
            self.x = self.targetX
            self.targetX = nil
        end
    end

    return  -- 跳过后面的本地输入处理
end

为什么需要位置插值?

网络有延迟,如果直接设置位置,会看到对方"瞬移":

时间线:
─────────────────────────────────────►

无插值:
位置:80 ──────────────────► 120
      │                    │
      │    [瞬移!看不到中间过程]
      │                    ▼
显示: █                    █
      玩家看到对方突然从左边跳到右边

有插值:
位置:80 ──► 84 ──► 88 ──► 92 ──► ... ──► 120
      │      │      │      │           │
      │      │      │      │           │
      ▼      ▼      ▼      ▼           ▼
显示: █      █      █      █      ...  █
      玩家看到对方平滑地从左边移动到右边

插值公式:

-- 每帧将当前位置向目标位置移动10%
self.x = self.x + (targetX - self.x) * 0.1

-- 举例:
-- 当前位置:80,目标位置:120
-- 第1帧:80 + (120-80)*0.1 = 84
-- 第2帧:84 + (120-84)*0.1 = 87.6
-- 第3帧:87.6 + (120-87.6)*0.1 = 90.84
-- ...
-- 逐渐接近120,但不会超过

targetX/targetY 的设置:

当收到对方的输入消息时,设置目标位置:

-- 收到对方的移动输入
function onRemoteInput(data)
    if data.input == 'left' then
        player2.targetX = player2.x - player2.moveSpeed * 0.1
    elseif data.input == 'right' then
        player2.targetX = player2.x + player2.moveSpeed * 0.1
    end
end
3.3.4.6 移动和边界限制

移动速度计算:

-- 格挡时移动速度减半
local speed = self.state == 'block' and self.moveSpeed * 0.5 or self.moveSpeed

-- 计算新位置
self.x = self.x + moveDir * speed * dt

屏幕边界限制:

-- 限制在屏幕范围内
self.x = math.max(20, math.min(W - 20, self.x))

玩家间距限制:

-- 两个玩家不能太近(防止重叠)
function enforceDistance(p1, p2, minDist)
    local dx = p2.x - p1.x
    local dist = math.abs(dx)

    if dist < minDist then
        -- 推开对方
        local push = (minDist - dist) * 0.5
        local dir = dx > 0 and 1 or -1

        p1.x = p1.x - dir * push
        p2.x = p2.x + dir * push
    end
end

-- 在update中调用
if gameState == STATE.FIGHTING then
    enforceDistance(player1, player2, 30)  -- 最小间距30像素
end
3.3.4.7 设计总结

update 函数的设计要点:

1、区分本地和远程 - player2 只做物理和插值,不做输入处理 2、时间驱动 - 所有动画和冷却都用计时器 3、状态机管理 - 清晰的状态转换,防止非法操作 4、边缘检测 - 按键按下检测、边界限制、间距限制 5、网络同步 - 平滑插值让远程玩家移动更自然

3.4 网络通信详解

3.4.1 MQTT 连接管理

MQTT 连接流程图:

开始连接
    ↓
等待网络就绪 ──超时──→ 继续等待
    ↓
创建MQTT客户端
    ↓
设置认证信息(设备ID作为client_id)
    ↓
设置回调函数和心跳
    ↓
连接服务器 ──失败──→ 3秒后重试
    ↓
进入消息循环
    ↓
收到断开/错误消息?──是──→ 清理连接,3秒后重试
    ↓否
继续消息循环

代码说明:

local function mqtt_client_main_task_func()
    -- 外层循环:连接断开后自动重连
    while game_state_mqtt.is_running do
        -- 1. 等待网络就绪
        -- 设备需要连接到网络才能使用MQTT
        while game_state_mqtt.is_running and not socket.adapter(socket.dft()) do
            statusLabel:set_text('等待网络...')
            -- 等待IP_READY事件,最多1秒
            sys.waitUntil('IP_READY', 1000)
        end

        -- 2. 创建MQTT客户端
        -- 参数:ssl配置(nil表示不用)、服务器地址、端口
        local mqtt_client = mqtt.create(nil, MQTT_SERVER, MQTT_PORT)

        -- 3. 设置认证信息
        -- 使用任务名+设备ID作为client_id,确保唯一性
        -- 空用户名密码,clean_session=true
        mqtt_client:auth(TASK_NAME .. my_device_id, '', '', true)

        -- 4. 设置回调函数和心跳
        -- 回调函数处理连接、消息、断开等事件
        mqtt_client:on(mqtt_client_event_cbfunc)
        -- 60秒发送一次心跳包,保持连接
        mqtt_client:keepalive(60)

        -- 5. 连接服务器
        if mqtt_client:connect() then
            game_state_mqtt.mqtt_client = mqtt_client

            -- 6. 进入消息循环
            -- 阻塞等待MQTT事件,直到断开或出错
            while game_state_mqtt.is_running do
                local msg = sys.waitMsg(TASK_NAME, 'MQTT_EVENT')
                -- msg[2]是事件类型
                if msg[2] == 'DISCONNECTED' or msg[2] == 'ERROR' then
                    break  -- 退出内层循环,触发重连
                end
            end
        end

        -- 7. 清理连接,3秒后重试
        -- 关闭客户端,清理资源
        mqtt_client:close()
        game_state_mqtt.mqtt_client = nil
        -- 等待3秒后重新尝试连接
        sys.wait(3000)
    end
end

3.4.2 消息处理中心

消息类型总览:

3.4.3 输入消息发送

输入消息的作用:

输入消息是游戏中最核心的网络消息,用于同步双方的操作。只有输入同步,才能保证双方看到相同的游戏画面。

发送时机:

操作
发送时机
说明
移动
按下/松开方向键
开始移动和停止移动都要发送
跳跃
按下跳跃键
只发送一次,防两段跳
拳击
按下拳键
触发拳击动作
脚踢
按下脚键
触发脚踢动作
格挡
按下/松开格挡键
开始和结束格挡都要发送

优化策略:

问题:如果每帧都发送位置,网络带宽会被占满

解决方案:
1. 只发送输入事件(按下/松开),不发送每帧位置
2. 位置去重:变化小于3像素不发送
3. 输入消息附带位置:每次发送输入时,附带当前位置用于校准

代码说明:

-- 发送用户输入消息(封装调用network模块)
local function send_input_message(input_type, duration, customX, customY)
    -- 关键修复:如果正在跳跃中,不发送第二次跳跃消息(避免两段跳)
    if input_type == 'jump' and player1.isJumping then
        log.info('send_input', '跳过发送', '正在跳跃中,不重复发送')
        return
    end

    log.info('send_input', '准备发送', '动作:', input_type)

    local currentX = customX or player1.x
    local currentY = customY or player1.y

    -- 位置去重检查
    if (input_type == 'left_pos' or input_type == 'right_pos' or
        input_type == 'left_end' or input_type == 'right_end') and lastSentPosX then
        local delta = math.abs(currentX - lastSentPosX)
        if delta < 3 then
            return nil
        end
    end

    -- 调用network模块发送消息,传入对方设备ID
    local result = network.send_to_peer(get_peer_device_id(), {
        type = "input",
        input = input_type,
        duration = duration,
        x = currentX,
        y = currentY,
        state = player1.state,
        from_device = my_device_id
    })

    if result then
        lastSentPosX = result
        log.info('send_input', player1.name, '动作:', input_type, '位置:', string.format("%.1f", currentX))
    end
end

3.4.4 远程输入应用

为什么需要平滑插值?

问题:网络有延迟,收到消息时对方已经移动了一段距离

解决方案:
1. 记录目标位置(对方发送时的位置)
2. 本地平滑插值到目标位置
3. 这样既跟得上对方,又不会瞬移

输入映射关系:

收到的输入
本地操作
说明
left
inputLeft = true, setState('walk')
开始向左走
right
inputRight = true, setState('walk')
开始向右走
left_end
inputLeft = false
停止向左
right_end
inputRight = false
停止向右
jump
startJump()
跳跃
punch
startPunch()
出拳
kick
startKick()
踢腿
block
setState('block')
开始格挡
block_end
setState('idle')
结束格挡

代码说明:

local function applyRemoteInput(data)
    -- 检查player2是否存在
    if not player2 then return end

    local input = data.input

    -- 1. 设置目标位置(用于平滑插值)
    -- 收到对方发送的位置,作为插值目标
    -- 实际渲染时会平滑移动到这个位置
    if data.x then player2.targetX = data.x end
    if data.y then player2.targetY = data.y end

    -- 2. 应用输入到player2
    -- 注意:这里的操作和本地玩家完全一致
    -- 只是操作对象是player2(远程玩家)

    if input == 'left' then
        -- 对方按下左键
        player2.inputLeft = true
        player2:setState('walk')

    elseif input == 'right' then
        -- 对方按下右键
        player2.inputRight = true
        player2:setState('walk')

    elseif input == 'left_end' then
        -- 对方松开左键
        player2.inputLeft = false
        -- 如果没有按右键,回到idle状态
        if not player2.inputRight then 
            player2:setState('idle') 
        end

    elseif input == 'right_end' then
        -- 对方松开右键
        player2.inputRight = false
        -- 如果没有按左键,回到idle状态
        if not player2.inputLeft then 
            player2:setState('idle') 
        end

    elseif input == 'jump' then
        -- 对方跳跃
        player2:startJump()

    elseif input == 'punch' then
        -- 对方出拳
        player2:startPunch()

    elseif input == 'kick' then
        -- 对方踢腿
        player2:startKick()

    elseif input == 'block' then
        -- 对方开始格挡
        player2:setState('block')

    elseif input == 'block_end' then
        -- 对方结束格挡
        player2:setState('idle')
    end
end

平滑插值实现:

-- 在player2的update中
if self == player2 then
    -- 远程玩家使用插值
    if self.targetX then
        -- 线性插值:当前位置向目标位置移动10%
        -- 这样即使有延迟,移动也是平滑的
        self.x = self.x + (self.targetX - self.x) * 0.1
    end
    if self.targetY then
        self.y = self.y + (self.targetY - self.y) * 0.1
    end
else
    -- 本地玩家直接更新位置
    self.x = self.x + self.vx
    self.y = self.y + self.vy
end

关键设计点:

设计
说明
输入映射
远程输入和本地输入完全一致的处理逻辑
平滑插值
使用线性插值消除网络延迟带来的抖动
位置校准
定期发送位置,纠正累计误差
状态同步
通过输入间接同步状态,而非直接同步

3.5 游戏主循环

┌─────────────────────────────────────────────────────────┐
                    游戏主循环 (60 FPS)                   
                      16ms执行一次                      
├─────────────────────────────────────────────────────────┤
  1. 更新游戏逻辑 (update)                                
     ├── 屏幕震动衰减                                     
     ├── 处理等待/倒计时状态                               
     ├── 处理KO状态                                       
     ├── 攻击命中检测                                     
     ├── 更新玩家状态 (player1:update, player2:update)    
     ├── 距离限制(防止重叠)                              
     └── 自动调整朝向                                     
                                                         
  2. 更新UI显示 (updateUI)                                
     ├── 更新血条                                         
     ├── 更新积分显示                                     
     └── 更新动画帧                                       
└─────────────────────────────────────────────────────────┘

3.5.1 循环结构

local fixedDt = 1 / 60  -- 固定时间步长(60 FPS)

local function gameLoop()
    update(fixedDt)      -- 更新游戏逻辑
    updateUI()           -- 更新UI显示
end

-- 启动定时器,每16ms(约60fps)执行一次
sys.timerLoopStart(gameLoop, 16)

3.5.2 更新函数

local function update(dt)
    -- 1. 屏幕震动衰减
    if shakeAmount > 0.05 then
        shakeAmount = shakeAmount * shakeDecay
    else
        shakeAmount = 0
    end

    -- 2. 等待/倒计时状态:禁止输入
    if gameState == STATE.WAITING or gameState == STATE.COUNTDOWN then
        -- 清空所有输入
        player1.inputLeft = false
        player1.inputRight = false
        player1.inputJump = false
        player1.inputPunch = false
        player1.inputKick = false
        player1.inputBlock = false

        -- 倒计时逻辑
        if gameState == STATE.COUNTDOWN then
            countdownTimer = countdownTimer + dt
            if countdownTimer >= 1.0 then
                countdownTimer = countdownTimer - 1.0
                countdownValue = countdownValue - 1
                if countdownValue <= 0 then
                    gameState = STATE.FIGHTING
                    set_container_visible(countdownContainer, false)
                end
            end
            countdownLabel:set_text(countdownValue > 0 and tostring(countdownValue) or 'FIGHT!')
        end
        return
    end

    -- 3. KO状态
    if gameState == STATE.KO then
        koTimer = koTimer + dt
        return
    end

    -- 4. 对战状态
    if gameState ~= STATE.FIGHTING then return end

    -- 4.1 攻击命中检测(先检测,再更新状态)
    checkAttackHit(player1, player2)
    checkAttackHit(player2, player1)

    -- 4.2 更新玩家状态
    player1:update(dt)
    player2:update(dt)

    -- 4.3 距离限制(防止重叠)
    enforceDistance(player1, player2, 30)

    -- 4.4 自动调整朝向
    if player1.x < player2.x then
        if player1.state ~= 'punch' and player1.state ~= 'kick' then
            player1.facingRight = true
        end
        if player2.state ~= 'punch' and player2.state ~= 'kick' then
            player2.facingRight = false
        end
    else
        -- 相反方向...
    end
end

3.5.3 攻击命中检测

function checkAttackHit(attacker, defender)
    -- 检查攻击者是否处于可命中状态
    if not attacker:isAttackActive() then return end

    -- 检查防御者是否处于无敌状态
    if defender.invincible > 0 then return end

    -- 获取攻击范围和防御者身体范围
    local attackRect = attacker:getAttackRect()
    local bodyRect = defender:getBodyRect()

    -- AABB碰撞检测
    local overlapX = math.min(attackRect.right, bodyRect.right) - math.max(attackRect.left, bodyRect.left)
    local overlapY = math.min(attackRect.bottom, bodyRect.bottom) - math.max(attackRect.top, bodyRect.top)

    -- 碰撞判定阈值
    if overlapX > 8 and overlapY > 5 then
        -- 已经命中过,不重复计算
        if attacker.currentAttackHit then return end
        attacker.currentAttackHit = true

        -- 计算伤害
        local damage = (attackRect.type == 'punch') and attacker.punchDamage or attacker.kickDamage

        -- 格挡减伤
        if defender:isBlocking() then
            damage = damage * (1 - defender.blockReduceRatio)
        end

        -- 应用伤害
        defender:takeDamage(damage)

        -- 积分计算
        if attacker == player1 then
            -- 攻击方加分
            local scoreAdd = (attackRect.type == 'punch') and 2 or 3
            myScore = myScore + scoreAdd
        else
            -- 被攻击方扣分
            myScore = myScore - 1
            if myScore < 0 then myScore = 0 end
        end

        -- KO检测
        if defender.hp <= 0 then
            triggerKO(attacker, defender)
        end
    end
end

3.6 UI 系统与优化

3.6.1 UI 架构概述

┌─────────────────────────────────────────┐
│              顶层:弹窗层                 │
│    (查找对手、排行榜、邀请对话框)        │
├─────────────────────────────────────────┤
│              中层:游戏界面层              │
│   (血条、积分、倒计时、KO提示)           │
├─────────────────────────────────────────┤
│              底层:游戏场景层              │
│      (火柴人角色、地面、特效)            │
├─────────────────────────────────────────┤
│              基础层:控制按钮层            │
│    (左、右、跳、拳、脚、防、退出)        │
└─────────────────────────────────────────┘

UI 容器管理:

-- 不同界面使用不同的容器,通过显示/隐藏切换
local menuContainer       -- 主菜单
local deviceListContainer -- 设备列表
local gameContainer       -- 游戏主界面
local countdownContainer  -- 倒计时
local koContainer         -- KO提示
local rankContainer       -- 排行榜

3.6.2 主菜单界面

界面元素:

local function createMenuUI()
    -- 主标题
    titleLabel = Label:new({
        x = 60, y = 80,
        w = 200, h = 40,
        text = '火柴人格斗',
        font_size = 32,
        color = 0xffffff
    })

    -- 副标题
    subtitleLabel = Label:new({
        x = 130, y = 120,
        w = 60, h = 20,
        text = '联机版',
        font_size = 16,
        color = 0xff8800
    })

    -- 状态信息
    statusLabel = Label:new({
        x = 20, y = 200,
        w = 280, h = 60,
        text = '等待网络...',
        font_size = 14,
        color = 0xaaaaaa
    })

    -- "查找对手"按钮
    findButton = Button:new({
        x = 60, y = 280,
        w = 200, h = 44,
        text = '查找对手',
        bg_color = 0xffffff,
        text_color = 0x333333
    })
    findButton:set_on_click(function()
        showDeviceList()  -- 显示设备列表
    end)

    -- "积分排行榜"按钮
    rankButton = Button:new({
        x = 60, y = 340,
        w = 200, h = 44,
        text = '积分排行榜',
        bg_color = 0xffffff,
        text_color = 0x333333
    })
end

3.6.3 对战界面布局

界面分区:

┌──────────────────────────────────────────────────────────────────┐ 
│  红方 HP: ████████░░ 100/100    VS 蓝方 HP: ████████░░ 100/100    │ 
│                              本场积分: 0                          │ 顶部信息区 (60px)
├──────────────────────────────────────────────────────────────────┤
│                                                                  │ 
│                                                                  │ 
│                           ☆ 对战场景 ☆                           │  游戏场景区 (320px)
│                          🤺          🤺                         │
│                          红方        蓝方                         │
├──────────────────────────────────────────────────────────────────┤
│   [←]  [跳]  [→]  [拳]  [脚]  [防] [退]│                          │  控制按钮区 (100px)
└──────────────────────────────────────────────────────────────────┘

血条实现:

-- 创建血条(使用进度条组件)
local function createHPBar(x, y, color, label)
    local bar = ProgressBar:new({
        x = x, y = y,
        w = 120, h = 16,
        max = 100,
        value = 100,
        bg_color = 0x444444,      -- 背景色(深灰)
        fg_color = color,          -- 前景色(红/蓝)
        border_color = 0x888888,   -- 边框色
        border_width = 1
    })

    -- 血量文字
    local text = Label:new({
        x = x, y = y + 20,
        w = 120, h = 16,
        text = '100/100',
        font_size = 12,
        color = 0xffffff,
        align = 'center'
    })

    return bar, text
end

-- 创建红方和蓝方血条
hp1Bar, hp1Text = createHPBar(20, 10, 0xff4444, '红方')
hp2Bar, hp2Text = createHPBar(180, 10, 0x4488ff, '蓝方')

3.6.4 控制按钮实现

按钮布局:

local function createControlButtons()
    -- 左移按钮
    leftBtn = Button:new({
        x = 20, y = 400,
        w = 50, h = 50,
        text = '←',
        font_size = 24,
        bg_color = 0x666666,
        text_color = 0xffffff
    })

    -- 跳跃按钮(在左右按钮中间上方)
    jumpBtn = Button:new({
        x = 80, y = 380,
        w = 50, h = 50,
        text = '跳',
        font_size = 18,
        bg_color = 0x666666,
        text_color = 0xffffff
    })

    -- 右移按钮
    rightBtn = Button:new({
        x = 140, y = 400,
        w = 50, h = 50,
        text = '→',
        font_size = 24,
        bg_color = 0x666666,
        text_color = 0xffffff
    })

    -- 拳击按钮
    punchBtn = Button:new({
        x = 200, y = 400,
        w = 50, h = 50,
        text = '拳',
        font_size = 18,
        bg_color = 0xff6666,
        text_color = 0xffffff
    })

    -- 脚踢按钮
    kickBtn = Button:new({
        x = 260, y = 400,
        w = 50, h = 50,
        text = '脚',
        font_size = 18,
        bg_color = 0xff6666,
        text_color = 0xffffff
    })

    -- 格挡按钮
    blockBtn = Button:new({
        x = 260, y = 340,
        w = 50, h = 50,
        text = '防',
        font_size = 18,
        bg_color = 0x66aaff,
        text_color = 0xffffff
    })
end

按钮按下效果:

-- 设置按钮按下状态
leftBtn:set_on_press(function()
    leftBtn:set_bg_color(0x444444)  -- 变暗
    player1.inputLeft = true
    send_input_message('left')
end)

leftBtn:set_on_release(function()
    leftBtn:set_bg_color(0x666666)  -- 恢复
    player1.inputLeft = false
    send_input_message('left_end')
end)

3.6.5 UI 缓存机制

为什么需要缓存?

问题:60 FPS意味着每秒更新60次UI
       如果每次都调用set_text,会造成:
       1. 不必要的重绘
       2. 卡顿

解决方案:只有数值变化时才更新UI

缓存实现:

-- UI缓存表,存储上一次的显示值
local ui_cache = {
    hp1_value = -1,
    hp2_value = -1,
    hp1_text = '',
    hp2_text = '',
    score_text = '',
    peer_text = '',
    score_color = nil
}

local function updateUI()
    -- 左血条(红方)- 带缓存
    if hp1Bar then
        local hp1_value = game_state_mqtt.is_server and player1.hp or player2.hp
        if hp1_value ~= ui_cache.hp1_value then
            ui_cache.hp1_value = hp1_value
            hp1Bar:set_value(hp1_value, false)
        end
    end

    -- 右血条(蓝方)- 带缓存
    if hp2Bar then
        local hp2_value = game_state_mqtt.is_server and player2.hp or player1.hp
        if hp2_value ~= ui_cache.hp2_value then
            ui_cache.hp2_value = hp2_value
            hp2Bar:set_value(hp2_value, false)
        end
    end

    -- 积分显示 - 带缓存
    if scoreLabel and game_state_mqtt.peer_connected then
        local score_text = '本场积分: ' .. myScore
        if score_text ~= ui_cache.score_text then
            ui_cache.score_text = score_text
            scoreLabel:set_text(score_text)
        end

        -- 积分颜色(正数黄色,负数红色)
        local score_color = myScore >= 0 and 0xffcc00 or 0xff4444
        if score_color ~= ui_cache.score_color then
            ui_cache.score_color = score_color
            scoreLabel:set_color(score_color)
        end
    end

    -- 绘制火柴人
    player1:draw(player2.x)
    player2:draw(player1.x)
end

3.6.6 弹窗系统

查找对手弹窗:

local function showDeviceList()
    -- 隐藏主菜单
    set_container_visible(menuContainer, false)

    -- 创建设备列表容器
    deviceListContainer = Container:new({x=0, y=0, w=320, h=480})

    -- 标题
    local title = Label:new({
        x = 110, y = 20,
        w = 100, h = 24,
        text = '选择对手',
        font_size = 18,
        color = 0xffffff
    })
    deviceListContainer:add_child(title)

    -- 设备列表
    local yPos = 60
    for device_id, info in pairs(game_state_mqtt.online_devices) do
        -- 设备项背景
        local item = Container:new({
            x = 20, y = yPos,
            w = 280, h = 50,
            bg_color = 0x333333
        })

        -- 昵称
        local nameLabel = Label:new({
            x = 10, y = 15,
            w = 150, h = 20,
            text = info.nickname or '未知玩家',
            font_size = 14,
            color = 0xffffff
        })
        item:add_child(nameLabel)

        -- 邀请按钮
        local inviteBtn = Button:new({
            x = 180, y = 10,
            w = 80, h = 30,
            text = '邀请',
            bg_color = 0x4488ff,
            text_color = 0xffffff
        })
        inviteBtn:set_on_click(function()
            send_invite(device_id)
        end)
        item:add_child(inviteBtn)

        deviceListContainer:add_child(item)
        yPos = yPos + 60
    end

    -- 关闭按钮
    local closeBtn = Button:new({
        x = 130, y = 420,
        w = 60, h = 30,
        text = '关闭',
        bg_color = 0x666666
    })
    closeBtn:set_on_click(function()
        set_container_visible(deviceListContainer, false)
        set_container_visible(menuContainer, true)
    end)
    deviceListContainer:add_child(closeBtn)
end

3.6.7 UI 优化总结

优化点
实现方式
效果
容器切换
显示/隐藏不同容器
避免重复创建UI元素
缓存机制
对比旧值,变化才更新
减少90%不必要的更新
分层管理
弹窗/游戏/控制分层
逻辑清晰,易于维护
颜色反馈
积分正负显示不同颜色
提升用户体验

3.7 积分系统

积分系统用于记录玩家在对战中的表现,并在游戏结束时上传到服务器进行排名。

3.7.1 积分规则设计

积分计算规则:

行为
积分变化
说明
拳击命中
+2
用拳击中对方
脚踢命中
+3
用脚踢中对方(伤害更高,积分更多)
被击中
-1
被对方打中(最低扣到0,不会负数)

设计意图:

  • 鼓励进攻:主动攻击可以获得积分
  • 风险平衡:攻击时也可能被打,需要权衡
  • 防止刷分:被击中会扣分,不能只挨打不反击

代码实现:

-- 积分变量
local myScore = 0           -- 本场积分(本地累计)
local SCORE_CLS = 2         -- 积分记录类别(2=火柴人积分)
local pendingUpload = nil   -- 待上传的积分数据(异步上传用)

-- 积分变化函数
function addScore(delta)
    myScore = math.max(0, myScore + delta)  -- 最低为0
    updateScoreUI()  -- 更新UI显示
end

-- 在命中检测中调用
function checkAttackHit(attacker, defender)
    if 命中 then
        if attacker == player1 then
            -- 自己命中对方
            local delta = attacker.state == 'punch' and 2 or 3
            addScore(delta)
        elseif defender == player1 then
            -- 被对方命中
            addScore(-1)
        end
    end
end

3.7.2 积分 API 接口(network.lua 提供)

网络层只提供纯粹的 API,不包含业务逻辑:

-- network.lua

-- 直接上传积分(不做累加,由业务层处理)
function network.upload_score(account, nickname, score, callback)
    if not exapp then
        if callback then callback(false, "exapp not available") end
        return
    end

    exapp.add_record({
        cls = SCORE_CLS,
        uni_key = account,
        i1 = score,
        s1 = nickname,
    }, function(ok, result)
        if callback then callback(ok, result) end
    end)
end

-- 查询账号当前积分
function network.query_score(account, callback)
    if not exapp then
        if callback then callback(false, nil) end
        return
    end

    exapp.list_record({
        cls = SCORE_CLS,
        size = 1,
        filter = {
            aks = {"uni_key"},
            acs = {"eq"},
            avs = {account},
        },
    }, function(success, data)
        -- 直接回调给业务层处理
        if callback then
            callback(success, data)
        end
    end)
end

-- 删除账号积分
function network.delete_score(account, callback)
    if not exapp then
        if callback then callback(false, 0) end
        return
    end

    exapp.list_record({
        cls = SCORE_CLS,
        filter = {
            aks = {"uni_key"},
            acs = {"eq"},
            avs = {account},
        },
    }, function(success, data)
        -- 删除逻辑...
        if callback then callback(success, deleted_count) end
    end)
end

-- 查询排行榜
function network.query_leaderboard(page, callback, size)
    page = page or 1
    size = size or 11

    if not exapp then
        if callback then callback(false, "exapp not available") end
        return
    end

    exapp.list_record({
        cls = SCORE_CLS,
        sort = "i1 desc",
        size = size,
        offset = (page - 1) * size,
    }, function(success, data)
        -- 直接回调给业务层
        if callback then
            callback(success, data)
        end
    end)
end

3.7.3 积分上传流程

为什么需要"先查询后上传"?

因为积分是累加制,不是覆盖制:

  • 服务器存储的是"历史总积分"
  • 本场积分需要累加到历史积分上
  • 不能直接覆盖,否则会丢失之前的成绩

上传流程图:

游戏结束
    ↓
调用 upload_score()(业务层函数)
    ↓
检查是否已登录 ──否──→ 不上传,结束
    ↓是
获取当前账户信息
    ↓
调用 network.query_score(account, function()...)
    ↓
等待回调 ──失败──→ 不上传,结束
    ↓成功
读取历史总分(data.records[1].i1)
    ↓
【关键逻辑】计算新总分:
    如果刚删除过积分(justDeletedMyScore=true):
        直接用本场积分(避免累加旧的被删除数据)
    否则:
        新总分 = 历史总分 + 本场积分
    ↓
调用 network.upload_score() 上传新总分
    ↓
上传成功,重置本地积分 my_score = 0

代码实现:

-- stick_fighter_online_win.lua

function upload_score()
    log.info("积分", "【上传积分】开始,当前积分:", get_score())

    -- 本场无积分,不上传
    if get_score() == 0 then
        log.info("积分", "【上传积分】积分为0,无需上传")
        return
    end

    if not exapp then
        log.warn("积分", "【上传积分】exapp 不可用")
        return
    end

    -- 获取IOT账户信息
    local ok, info = pcall(exapp.iot_get_account_info())
    if not ok or not info or info.is_guest then
        log.warn("积分", "【上传积分】未登录或访客模式,不上传")
        return
    end

    local account = info.account or "unknown"
    local nickname = info.nickname or "unknown"
    local local_score = get_score()

    log.info("积分", "【上传积分】查询服务器积分 account:", account, "本场:", local_score)

    -- ========== 使用回调方式,不再依赖全局事件 ==========
    network.query_score(account, function(success, data)
        if not success then
            log.warn("积分", "【上传积分】查询失败,直接上传本场积分")
            network.upload_score(account, nickname, local_score)
            reset_score()
            justDeletedMyScore = false
            return
        end

        local server_score = 0
        if data and data.value and data.value.records and #data.value.records > 0 then
            server_score = tonumber(data.value.records[1].i1) or 0
        end

        -- 计算新积分:如果刚删除过,就不用累加,直接用本场
        local new_score = justDeletedMyScore and local_score or (server_score + local_score)
        log.info("积分", "【上传积分】服务器:", server_score, "本场:", local_score, "新总分:", new_score)

        -- 直接上传
        network.upload_score(account, nickname, new_score, function(ok)
            if ok then
                log.info("积分", "【上传积分】成功,总积分:", new_score)
            else
                log.warn("积分", "【上传积分】上传失败")
            end
        end)

        -- 重置状态
        reset_score()
        justDeletedMyScore = false
    end)
end

3.8 时序图

3.8.1 对战建立流程

玩家A                          MQTT服务器                           玩家B
  │                                 │                                │
  │ ───────── presence ───────────> │                                │
  │                                 │ ───────── presence ──────────> │
  │                                 │                                │
  │ 点击"邀请"                                                     │
  │ ───── connect_request ────────> │ ───── connect_request ───────> │
  │                                 │                                │
  │                                 │                                │ 显示邀请弹窗
  │                                 │                                │ 点击"同意"
  │                                 │ <──── connect_accept ────────  │
  │ <────── connect_accept ───────  │                                │
  │                                 │                                │
  │ start_game_connect()                                           │
  │ ──────── ready ─────────────>   │ ──────── ready ────────────>   │
  │                                 │                                │
  │ check_both_ready()              │                                │ check_both_ready()
  │ ─────── start_game ─────────>   │ ─────── start_game ────────>   │
  │                                 │                                │
  │ startCountdown()                                               │ startCountdown()
  │ 3... 2... 1... FIGHT!                                          │ 3... 2... 1... FIGHT!
  │                                 │                                │
  │ ════════════════════════ 对战开始 ═══════════════════════════  │

3.8.2 输入同步时序

玩家A(本地)                    网络                      玩家B(远程)
   │                              │                             │
   │ 按下"拳"按钮                 │                             │
   │ ───────┐                     │                             │
   │        │ 本地执行startPunch()│                             │
   │        │                     │                             │
   │        └──── send_input_message()
   │                              │                             │
   │                              │ ───── input:{punch} ──────> │
   │                              │                             │
   │                              │                             │ 接收input消息
   │                              │                             │ player2:startPunch()
   │                              │                             │
   │ ═══════ 双方同时看到拳击动作 ════════════════════════════════│

3.9 常见问题与解决方案

3.9.1 跳跃不同步问题

问题现象:一方跳跃,另一方看不到

根因:早期版本客户端没有执行 player2 的跳跃物理计算

解决方案

-- 无论服务器还是客户端,player2都要执行跳跃物理
function StickFighter:update(dt)
    if self == player2 then
        -- 必须包含跳跃物理计算
        if self.isJumping then
            self.y = self.y + self.jumpVelocity * dt
            self.jumpVelocity = self.jumpVelocity + self.jumpGravity * dt
            -- ...
        end
        return
    end
    -- ...
end

3.9.2 远程玩家动画闪烁

问题现象:对方移动时动画在 walk 和 idle 之间快速切换

根因:远程玩家的 inputLeft/Right 被本地逻辑重置

解决方案

-- 远程玩家跳过本地输入判断,只做动画和插值
if self == player2 then
    -- 不处理inputLeft/inputRight
    -- 只根据接收到的消息设置状态
    return
end

3.9.3 两段跳问题

问题现象:按一次跳按钮,角色跳了两次

解决方案

-- 发送端:跳跃中不发送第二次跳跃
if input_type == 'jump' and player1.isJumping then
    return
end

-- 接收端:强制重置跳跃状态
if input == 'jump' then
    player2.isJumping = false  -- 强制重置
    player2:startJump()
end

四、定义自己的 app 原始需求

如果需求不是特别明确,就简单描述一下即可,剩下的让 AI 写出来一版,根据 AI 提供的思路看是否采纳;如果需求特别明确,则详细描述每个页面如何设计,以及业务逻辑如何设计;

在本示例中,因为对整体页面布局以及需求比较清晰,所以给 AI 一个详细的描述词,这样能节省不少时间,如下所述:

注意:本文接下来的内容基于 320*480 分辨率设计;

设计一个基于MQTT网络通信的双人联机火柴人格斗游戏,输出可以交互的html页面,详细

【整体规格】
- 分辨率:320x480像素,竖屏布局
- 背景色:深蓝色

【页面结构】
1. 顶部血条区域(始终显示):
   - 左侧红方血条(红色 #ff4444),标签"红方",血量100/100
   - 右侧蓝方血条(蓝色 #4488ff),标签"蓝方",血量100/100
   - 中间VS标识
   - 积分显示"本场积分: 0"

2. 主菜单页:
   - 主标题"火柴人格斗"32px,白色,居中)
   - 副标题"联机版"20px,橙色,居中)
   - 状态信息:"MQTT已连接!""IOT: 未登录""ID: device_id"
   - 两个按钮:查找对手(白色)、积分排行榜(白色)

3. 查找对手弹窗:
   - 标题"选择对手"
   - 显示所有在线的对手项,昵称显示
   - 每个对手有"邀请"按钮
   - 点击邀请后关闭弹窗,进入对战

4. 积分排行榜弹窗:
    - 标题:火柴人积分排行榜
    - 显示玩家的积分,从上到下倒叙排列
    - 最下面需要三个按钮,分别是刷新,删除,关闭

5. 对战页:
   - 两个火柴人:红色在左,蓝色在右,对称分布
   - 火柴人样式:圆形头+矩形身体四肢
   - 地面显示

6. 倒计时页:
   - 中央显示数字321

7. KO页
   - 显示"K.O."红色大字
   - 获胜者信息
   - 点击重新开始按钮

【底部操作按钮区域】
- 跳按钮:在左/右按钮中间上方(按一下会跳起来)
- /右方向键:控制移动(按一下移动一步)
- 拳、脚、防:攻击动作按钮
- 退出按钮:在防按钮上方
- 所有按钮有按下下沉效果

【游戏功能】
- 点击"查找对手"→显示对手列表→点击连接→倒计时→开始对战
- 对战时可用按钮控制火柴人:
  - 左右移动
  - 跳跃(有重力物理效果)

五、根据 app 需求,借助 AI 工具,生成 html 文件和图片等资源文件

根据定义的 app 需求,可以使用 Trae,也可以使用网页版的豆包,也可以使用网页版的 deepseek,生成“包含业务逻辑、可交互体验”的 html 文件;

在这一章节,我仅演示使用 Trae 来生成 html 的过程

5.1 第一轮交互(原始需求)

5.1.1 输入

设计一个联机火柴人格斗游戏,输出可以交互的 html 页面,详细要求如下:

【整体规格】

  • 分辨率:320x480 像素,竖屏布局
  • 背景色:深蓝

【页面结构】

1、顶部血条区域(始终显示): - 左侧红方血条(红色 #ff4444),标签"红方",血量 100/100 - 右侧蓝方血条(蓝色 #4488ff),标签"蓝方",血量 100/100 - 中间 VS 标识 - 积分显示"本场积分: 0" 2、主菜单页:

  • 主标题"火柴人格斗"(32px,白色,居中)
  • 副标题"联机版"(20px,橙色,居中)
  • 状态信息:"MQTT 已连接!"、"IOT: 未登录"、"ID: device_id"
  • 两个按钮:查找对手(白色)、积分排行榜(白色) 3、查找对手弹窗:

  • 标题"选择对手"

  • 显示所有在线的对手项,昵称显示
  • 每个对手有"邀请"按钮
  • 点击邀请后关闭弹窗,进入对战 4、积分排行榜弹窗:

  • 标题:火柴人积分排行榜

  • 显示玩家的积分,从上到下倒序排列
  • 最下面需要三个按钮,分别是刷新,删除,关闭 5、对战页:

  • 两个火柴人:红色在左,蓝色在右,对称分布

  • 火柴人样式:圆形头 + 矩形身体四肢
  • 地面显示 6、倒计时页:

  • 中央显示数字 3、2、1 7、KO 页:

  • 显示"K.O."红色大字

  • 获胜者信息
  • 点击重新开始按钮

【底部操作按钮区域】

  • 跳按钮:在左/右按钮中间上方(按一下会跳起来)
  • 左/右方向键:控制移动(按一下移动一步)
  • 拳、脚、防:攻击动作按钮
  • 退出按钮:在防按钮上方

【游戏功能】

  • 点击"查找对手"→ 显示对手列表 → 点击连接 → 倒计时 → 开始对战
  • 对战时可用按钮控制火柴人:

  • 左右移动

  • 跳跃(有重力物理效果)

5.1.2 输出

等完全生成后,参考下图体验效果

5.1.3 存在的问题

可以看到,生成的 html 文件,直接显示 K.O.获胜页面了,没有正确显示菜单页面。

5.2 第二轮交互(让页面正确显示)

5.2.1 输入

当前主页面实现的是 KO 结算页面,不是主菜单页面

注意:最好也截个图发给 AI,这样 AI 能更清楚你的问题

5.2.2 输出

到这里,可以发现 AI 生成的 html 页面,和自己预期的差不多,虽然按钮布局有重叠,但是这一点小问题可以在后续写代码的时候修改位置即可,就不需要再让 AI 继续优化了。

5.3 总结

这个阶段,主要就是根据需求,不断的和 AI 交互,生成 html;

你自己的实际 app,根据自己的规划以及实际运行的效果,可能需要调整多次才行;

按照同样的交互思路,进行多轮交互即可,直到生成的 html 界面和交互逻辑可以满足你的需求;

具体到本项目,最终输出了 stickman_fighter.html 文件;

我们在接下来的编码环节会用到这个 html 文件;

六、根据 html 文件 + 代码仓库,让 AI 工具生成 app 代码

6.1 app 代码的基本格式要求

此处不再赘述,参考 001 智能售货机 5.1 章节 即可

6.2 Trae 生成 app 代码前的准备工作

此处不再赘述,参考 001 智能售货机 5.2 章节 即可

6.3 Trae 生成 app 代码

6.3.1 创建目录

1、在 LuatOS\app_engine\app_store\vertical_app 手动下创建一个 StickFighter_Online 目录; 2、在 StickFighter_Online 目录下创建 res,user,libs 目录 3、把第四章节生成的 html 文件复制过来

6.3.2 创建任务

1、打开 trae 客户端,新建一个会话任务,专门用来处理联机火柴人格斗 app 代码生成调试 2、打开编辑器窗口,可以查看 LuatOS 目录内容 3、右键点击 StickFighter_Online,添加到对话。

6.3.3 生成项目代码

1、在会话窗口输入以下内容(LuatOS 路径需要根据你自己电脑上的实际路径来修改,找不到的可以查看 2.1 章节),并且发送:

1、参考:E:\LuatOS\app_engine\app_store\vertical_app\StickFighter_Online\stickman_fighter.html 中的UI界面和交互逻辑,在StickFighter_Online中生成LuatOS代码 
2、代码文件格式以及内容参考E:\LuatOS\app_engine\app_store\vertical_app下的其他目录,包含main.lua,meta.json,StickFighter_Online\user目录下存储具体UI和业务功能的lua代码文件 
3、代码中需要的图片资源在StickFighter_Online\res目录下,代码中用到的图片资源,使用\luadb\xxx.png的路径方式

2、然后 luatos-docs-code-103 智能体就开始工作了,几分钟之后,会生成第一份代码,思考输出的过程如下图所示:

3、查看 StickFighter_Online 目录下 生成了 main.lua、meta.json、stickfighter_win.lua 几个文件,如下图所示:

4、点击查看生成的代码文件,单击保留

七、在模拟上运行 app,根据运行结果,让 Trae 不断的调试代码,直到运行通过

在本章节,我们在 LuatOS 模拟器上不断地运行刚才生成的 StickFighter_Online 代码;

如果发现问题,让 Trae 不断的调试,直到在模拟器上可以正常运行;

如果你忘记了模拟器怎么使用,再参考本文的 2.2 章节回顾学习一下;

7.1 将 app_store 目录复制到 LuatOS 模拟器所在的目录

如下图所示,将

E:\LuatOS\app_engine\app_store\vertical_app\

目录下的 StickFighter_Online 子目录复制到 LuatOS 模拟器根目录下的 app_store 目录中

LuatOS 模拟器根目录下的 app_store 目录,默认不存在,需要自己手动创建

复制成功后,模拟器目录结构如下面两张图片所示

7.2 使用 cmd 命令行 +LuatOS 模拟器运行:LuatOS 扩展库代码 + 合宙引擎主机默认出厂软件代码

1、双击打开模拟器所在目录的 cmd,如下图所示

2、在命令行窗口粘贴输入:

luatos-pc.exe E:\LuatOS\app_engine\factory\ E:\LuatOS\script\libs\

如下图所示

3、按回车键,就可以在模拟器上运行 合宙引擎主机的默认出厂软件,启动后如下图所示:

4、向左滑动,打开主菜单窗口,如下图所示,可以看到,出现了 火柴人格斗联机版 app

5、点击 火柴人格斗联机版 app 图标,就可以正式运行这个 app;

我们点击一下看看是否可以正常运行;点击之后,发现无法正常运行,接下来我们使用智能体来修复每一个问题,尝试修复之后,再次复制 StickFighter_Online 代码到模拟器所在目录下,再次运行模拟器来验证。

7.3 修复问题:应用无法正常打开

7.3.1 第一轮修复

输入:

点击应用图标,无法打开应用,日志如下: [2026-05-16 22:30:53.477][00000012.321] I/user.iw open app /app_store/StickFighter_Online/ [2026-05-16 22:30:53.477][00000012.322] I/user.eo app started: /app_store/StickFighter_Online/ [2026-05-16 22:30:53.478][00000012.322] I/user.[/app_store/StickFighter_Online/] exapp DB appid: StickFighter_Online [2026-05-16 22:30:53.478][00000012.323] I/user.[/app_store/StickFighter_Online/] exapp /app_store/StickFighter_Online/ ui 320 480 screen 720 1280 rotation 0 display_zoom adaptive scale 2.250000 2.666667 adapt false [2026-05-16 22:30:53.478][00000012.323] I/user.[/app_store/StickFighter_Online/] main STICKFIGHTER_ONLINE 001.001.000 [2026-05-16 22:30:53.479][00000012.323] I/user.[/app_store/StickFighter_Online/] require enter: stickfighter_win [2026-05-16 22:30:53.480][00000012.324] I/user.[/app_store/StickFighter_Online/] sys.run() disabled in sandbox [2026-05-16 22:30:53.480][00000012.324] I/user.[/app_store/StickFighter_Online/] app_task waitUntil close_req event /app_store/StickFighter_Online/ [2026-05-16 22:30:53.480][00000012.324] I/user.[/app_store/StickFighter_Online/] ee window opened, ID: 3 window count: 1

思考修复过程如下图:

从智能体的思考可以看出,第一点的参数使用方式,并不是关键;关键在于 2,3,4,可能是导致问题的原因,先根据 AI 修改的内容,运行以下看看效果,发现应用可以打开了,但是只显示血条。

7.4 修复问题:应用页面显示异常

7.4.1 第一轮修复

输入:

只显示了红蓝双方的血条,菜单页面,按钮均未显示,如下图所示

思考修复过程如下图:

从智能体的思考可以看出,存在函数定义,api 错误使用,布局参数等多个问题,还是先保留 Ai 的修改,看下运行的效果,此次运行发现可以正常显示血条状态栏,菜单栏和操作按钮栏了,但是还有几个小问题,分别是: (1)显示问题,没有全屏显示 (2)操作按钮布局问题,设计的是左,跳,右单个按钮,但是只显示了左跳和右跳,违背设计初衷

7.4.2 第二轮修复

输入:

存在两个问题: 1)显示问题,没有全屏显示 2)操作按钮布局问题,设计的是左,跳,右三个按钮,但是只显示了左跳和右跳,违背设计初衷 布局如下面的 html 文件所示: stickman_fighter.html

思考修复过程如下图:

此时可以把 html 文件再发出来让 AI 参考,能提高准确度,另外 AI 提示可能需要修改 meta.json,我们看下 meta.json 文件,发现 display_zoom 确实写的有问题,因为我们代码中并没有用到自适应缩放,所以此处应该填写 fixed_resolution,让 exapp 扩展库来实现自适应,修改后我们再运行模拟器看下效果。

可以看到页面正常显示了,但是火柴人界面显示,界面切换,积分上传仍存在问题,接下来逐个完善。

7.5 修复问题:火柴人界面显示和界面切换有问题

7.5.1 第一轮修复

输入:

查找对手有问题:

再次描述下我的需求:

1、联网对战基于 mqtt 协议

2、点击查找对手按钮后,弹窗显示所有在线对手列表

3、对手列表显示两个内容,对手昵称,后面带有一个邀请按钮

对手昵称使用 iot 昵称,获取接口:exapp.iot_get_account_info,具体使用方式可以参考 exapp.lua 和 exapp 扩展库文档;

3、邀请流程

发起方:点击"邀请" → 弹出邀请弹窗显示"已发送对战邀请",和"取消邀请按钮"

接收方:收到请求 → 弹出邀请弹窗显示"XXX 邀请你对战",和"同意","拒绝"按钮

接收方点击"同意" → 邀请方弹窗显示"XXX 已接受邀请",然后双方进入倒计时,开始战斗

接收方点击"拒绝" → 邀请方弹窗显示"对方拒绝了邀请",然后双方继续回到菜单页面

发起方点击"取消邀请" → 接收方弹窗显示"对方取消了邀请"

建立连接后:根据 device_id 大小决定主从(服务器/客户端)

思考修复过程如下图:

保留 AI 的修改,开启两个模拟器测试对战效果,结果如下:

主页面:

点击查找对手,可以正常显示在线对手列表:

左边的设备登录了 IOT 账号,右边的未登录,可以用右边设备邀请 hao1026:

同意邀请:

此时会发现有个问题,同意邀请后,双方应该同时开始倒计时,进入到战斗页面。

7.5.2 第二轮修复

输入:

一方同意邀请后,对战双方没有同时开始倒计时,并在倒计时结束后进入到战斗页面

思考修复过程如下图:

修复后发现被邀请方正常显示倒计时页面,并进入对战;但是邀请方没有显示倒计时页面,继续和 AI 交互

7.5.3 第三轮修复

输入:

邀请方没有显示倒计时

思考修复过程如下图:

修改后测试可以同时显示倒计时了,但是火柴人不显示,还需继续和 AI 交互优化。

7.5.4 第四轮修复

输入:

开始对战后红蓝双方火柴人不显示

对于火柴人显示,我的需求是:

1、火柴人头部用圆形表示,肢体,撒双臂,双腿用竖长的长方形显示即可,注意边角有弧度

2、颜色:红方火柴人通体红色,蓝方火柴人通体蓝色

3、点击跳,火柴人可以向上跳一段距离;点击左、右,分别可以向左、向右移动;点击拳,手臂向对方位置斜向上伸出;点击脚,腿向对方位置斜上方伸出;点击防,手臂垂直向上抬起防御

4、扣血规则:

拳击-8

脚踢-12

防御减伤 75%,持续时间 1.5s

5、动作同步:

本方做的动作,需要通过 mqtt 消息同步给对方,让对方 UI 显示的己方火柴人也可以同步动作,

采用输入同步的架构,只发送玩家的操作指令(左/右/跳/拳/脚/防),对方在本地执行相同的动作

思考修复过程如下图:

可以看到借助 AI 实现了火柴人的动作渲染,接下来实测看下效果:

可以看到火柴人正常显示了,但是测试火柴人移动还存在问题:

1、红方点击移动按钮,会一直移动

2、红方移动,蓝方也会跟着动

接下来继续借助 AI 分析问题。

7.5.5 第五轮修复

输入:

火柴人移动存在问题:

1、红方点击移动按钮,会一直移动

2、红方移动,蓝方也会跟着动

思考修复过程如下图:

修复后测试移火柴人移动,战斗没问题了,但是积分功能还没有,接下来借助 AI 继续完善积分系统功能。

注意:

其中有些小问题是手动修复,所以就不再记录 AI 交互过程,修复的问题分别是:

1、血条区域保持和战斗区域一样的深蓝色

2、网络模块修改文件名为 network.lua,更通俗

3、操作按钮调整坐标位置,达到自己满意的程度

4、主菜单的开始战斗按钮,改名为积分排行榜,为后续开发积分系统预留入口

5、为后续方便模拟器测试验证,直接将文件放到了模拟器目录下(非必须)

7.6 修复问题:完善积分系统功能

7.6.1 第一轮修复

输入:

加上积分系统,使用 exapp 扩展库进行上传积分,删除积分,查询积分

具体需求是:

1、积分规则:

加分:拳击 +2,脚踢 +3

扣分:被打/被踢 -1,积分最低只能被扣到 0

2、在游戏界面顶部显示"本场积分: X",正分黄色、负分红色

3、将玩家历史最高分保存到云端,支持上传、查询和删除

4、主菜单提供排行榜入口,分数从高到底显示所有玩家排名,积分排行榜页面下方,需要刷新,删除我的积分,关闭按钮(注意:进入排行榜页面,立刻查询积分刷新一次;进入排行榜页面点击刷新,也可以主动查询积分撒刷新一次)

5、排行榜支持分页查看,每页显示 10 条数据,最多显示前 30 名

6、排行榜前三名分别用金、银、铜色作为背景色,其余用灰色背景

思考修复过程如下图:

此时先全部保留 AI 的修复建议,测试看下效果再根据实际的问题,慢慢修复。

第一轮修复后,发现诸多问题,可以汇总下问题,让 AI 继续修复。

发现的问题分别是:

1、积分排行榜下方的三个按钮没有中心对称分布,关闭按钮无法正常关闭排行榜弹窗页面

2、主菜单页面的查找对手和积分排行榜 两个按钮未居中放置

3、进入对战页面,点击拳,脚出现异常,异常日志:

E/main ...ckFighter_Online_1.1.3/user/stick_fighter_online_win.lua:1568: attempt to call a nil value (global 'add_score')

E/main Lua VM exit!! reboot in 15000ms

7.6.2 第二轮修复

输入:

1、积分排行榜下方的三个按钮没有中心对称分布,关闭按钮无法正常关闭排行榜弹窗页面

2、主菜单页面的查找对手和积分排行榜 两个按钮未居中放置

3、进入对战页面,点击拳,脚出现异常,异常日志:

E/main ...ckFighter_Online_1.1.3/user/stick_fighter_online_win.lua:1568: attempt to call a nil value (global 'add_score')

E/main Lua VM exit!! reboot in 15000ms

思考修复过程如下图:

修复后测试验证:

双方对打,积分正常显示,但是积分排行榜不显示积分排名

7.6.3 第三轮修复

手动修复:

日志提示 exapp 的 app_id 的问题,正常应该是纯数字,经排查发现,app_id 是应用上传后,会由服务器写入到 meta.json,所以当前临时测试可以在 meta.json 加上 app_id 字段。

注意:正常上传应用时,不需要在 meta.json 加 app_id 字段,服务器会自动下发。

添加后测试发现没问题了,如下图所示:

7.7 总结

截止到这里,联机火柴人格斗的游戏已经全部完成了,但是第一个版本很难保证完全没问题,还是需要不断地测试验证,比如当前的联机版火柴人就经过了 12 个版本的迭代,到现在的 1.1.2 版本已经比较稳定了。

在使用 AI 协助的过程中,会存在 AI 一直无法解决的问题,这个时候就不能一直耗在 AI 上了,一是浪费自己的 token,二是 AI 会越改越乱,导致原本正常的功能也被搞乱了,所以还是需要自己检查代码,多加打印调试分析。 下面给出自己的一些建议和调试火柴人应用踩到的一些坑:

个人建议(仅供参考):

1、AI 在修复某个问题时,如果影响了其他功能,而且在后续交互几个回合后依旧无法还原 原本正常的功能,此时不要再继续交互下去了,只会越来越乱,可以回退到原本正常的代码,换个 AI 模型继续问。

2、代码一定要备份!

3、不要过度依赖 AI,一是 AI 有时候会瞎改,二是购买的 coding plan 难以支撑高强度的使用。

踩坑:

1、8000w 测试的延迟,是 1602 的十几倍

原因:1602 的 mcu.ticks()返回的不是毫秒值,1602 的 1 个 tick 是 20ms

解决方案:使用 mcu.ticks2(1) ,这个接口返回的是真实的毫秒值。

2、联机游戏卡顿严重

像火柴人格斗这种动作固定的游戏,完全用不到状态同步的方式。

什么是状态同步:

  • 持续发送当前玩家的完整状态(位置、状态、朝向等)
  • 接收方收到后直接更新对方状态。

举个例子:

比如一个跳的动作,从起跳到落地完整的动作流程,持续发消息给对方进行同步状态。所以越复杂的动作,延迟也就越大。

可以采用输入同步的方式:

  • 只在用户操作时发送一条输入消息
  • 接收方收到消息后,本地执行完整动画

火柴人格斗的动作参数是固定的,没必要用频繁的状态同步,所以可以在乙方点击动作的时候,发给对方一条信息,告知对方 "我要开始跳了",对方收到后立马在本地模拟一套跳的动作显示在 UI 上。

八、基本功能测试说明

1、app 内部有 退出 按钮,可以点击返回到 主菜单窗口;app 退出后,在主菜单窗口,再次点击,可以再次正常进入 app; 2、app 内部的基本业务逻辑和 UI 界面都正常 3、退出 app 后,app 内部的业务逻辑也要停止,不要在后台运行;如果自己的 app 内部有运行日志,可以通过日志判断是否在后台运行;

九、UI 分辨率自适应说明

1、“联机版火柴人格斗”app 的 UI 界面,目前基于竖屏 320*480 分辨率设计;

2、合宙有多款不同 lcd 分辨率的竖屏引擎主机,例如 Air8000W 引擎主机的 lcd 分辨率为 320480,Air1602 引擎主机的 lcd 分辨率为 7201280,后续可能还有其他型号的引擎主机,所以取个靠近中间的分辨率 480*800 作为"app 基准分辨率";

3、合宙引擎主机的默认出厂软件,可以根据“app 基准分辨率”,自适应为引擎主机硬件的 lcd 分辨率,这个自适应过程是自动完成的,不需要你的 app 做特殊处理;

4、也就是说,虽然 app 只是基于了某一种分辨率来设计,但是从理论上说,app 可以自动地在不同分辨率的引擎主机和模拟器上自适应运行;

5、虽然引擎主机的出厂软件支持自适应分辨率功能,不需要 app 内部再设计自适应;但是也不禁止你在 app 内部设计自适应分辨率功能,如果你在 app 内部设计了自适应分辨率功能,在 app 内的 meta.json 文件中,将 display_zoom 字段填写为 adaptive 即可;如果没有设计自适应分辨率功能,要在 app 内的 meta.json 文件中,将 display_zoom 字段填写为 fixed_resolution,否则就会出现 6.4 章节中我遇到的没有全屏显示的问题;

十、打包 app,在应用市场上架

此处不再赘述,参考 001 智能售货机 第九章节 即可

十一、在模拟器上,通过应用市场,下载安装 app,运行验证 app

此处不再赘述,参考 001 智能售货机 第十章节 即可

十二、在 合宙引擎主机上,通过应用市场,下载安装 app,运行验证 app

此处不再赘述,参考 001 智能售货机 第十一章节 即可

十三、参考联机火柴人格斗的代码,开发双人坦克大战游戏

敬请期待......