从零开始制作一个网页版拳皇格斗游戏

awkker 发布于 23 天前 50 次阅读


本教程完整代码https://github.com/awkker/kof 本博客学习来源https://www.acwing.com/activity/content/1150/

前言

本博客将记录我从零开始,使用原生 JavaScript、Canvas API 和 jQuery 制作一个完整的2D格斗游戏。通过这个项目,我学习到游戏开发的核心概念,包括游戏循环、角色控制、碰撞检测、动画系统等。

技术栈

  • HTML5 Canvas(用于游戏渲染)
  • JavaScript ES6+(模块化开发)
  • jQuery(DOM操作)
  • GIF.js(GIF动画解析)

最终效果

  • 双人对战格斗游戏
  • 流畅的角色动画(站立、移动、跳跃、攻击、受击、死亡)
  • 物理引擎(重力、跳跃)
  • 攻击判定系统
  • 血量与计时器系统
  • 自动转身面向对手

第一章:项目架构设计

1.1 项目目录结构

拳皇/
├── miku/
│   └── index.html          # 游戏入口页面
├── static/
│   ├── css/
│   │   └── base.css        # 样式文件
│   ├── images/
│   │   ├── background/     # 背景图
│   │   └── player/         # 角色动画帧
│   │       └── kyo/        # 草薙京的GIF动画
│   └── js/
│       ├── base.js         # 游戏主类
│       ├── gif.js          # GIF解析库
│       ├── game_object/
│       │   └── base.js     # 游戏对象基类
│       ├── controller/
│       │   └── base.js     # 控制器
│       ├── map/
│       │   └── base.js     # 游戏地图
│       └── player/
│           ├── base.js     # 玩家基类
│           └── kyo.js      # 具体角色类

1.2 核心设计思想

游戏对象系统

所有游戏中的实体(地图、角色等)都继承自 GameObject 基类,这样可以:

  • 统一管理所有游戏对象
  • 统一的生命周期(start → update → destroy)
  • 自动执行游戏循环

面向对象设计

GameObject (基类)
    ├── GameMap (地图)
    └── Player (玩家基类)
            └── Kyo (具体角色)

第二章:游戏核心系统

2.1 游戏对象基类 (game_object/base.js)

这是整个游戏最核心的部分,实现了游戏循环机制。

let GAME_OBJECT = []

class GameObject {
    constructor() {
        GAME_OBJECT.push(this);  // 自动注册到全局对象数组
        this.timedata = 0;        // 帧间隔时间
        this.has_call_start = false;
    }

    start() {}    // 初始化方法,只执行一次
    update() {}   // 每帧执行
    destroy() {   // 销毁对象
        for(let i in GAME_OBJECT) {
            if(GAME_OBJECT[i] == this) {
                GAME_OBJECT.splice(i, 1);
                break;
            }
        }
    }
}

游戏循环

let last_timestamp;

let GAME_OBJECT_FRAME = (timestamp) => {
    for(let obj of GAME_OBJECT) {
        if(!obj.has_call_start) {
            obj.start();  // 首次调用 start
            obj.has_call_start = true;
        } else {
            obj.timedata = timestamp - last_timestamp;  // 计算帧间隔
            obj.update();  // 更新游戏状态
        }
    }
    last_timestamp = timestamp;
    requestAnimationFrame(GAME_OBJECT_FRAME);  // 下一帧
}

requestAnimationFrame(GAME_OBJECT_FRAME);

关键点:

  • requestAnimationFrame 提供60fps的流畅动画
  • timedata 记录帧间隔,用于实现与帧率无关的物理运算
  • 所有继承 GameObject 的对象自动加入游戏循环

2.2 控制器系统 (controller/base.js)

监听键盘输入,使用 Set 数据结构存储当前按下的键。

export class Controller {
    constructor($canvas) {
        this.$canvas = $canvas;
        this.pressed_keys = new Set();  // 存储正在按下的键
        this.start();
    }

    start() {
        let outer = this;

        this.$canvas.keydown(function(e) {
            outer.pressed_keys.add(e.key);  // 按下时添加
        })

        this.$canvas.keyup(function(e) {
            outer.pressed_keys.delete(e.key);  // 释放时删除
        })
    }
}

为什么使用 Set?

  • 自动去重,避免重复添加
  • 快速查找(has 方法)
  • 支持同时按下多个键

2.3 游戏地图 (map/base.js)

负责创建Canvas、管理控制器、更新计时器。

export class GameMap extends GameObject {
    constructor(root) {
        super();
        this.root = root;

        // 创建Canvas
        this.$canvas = $('<canvas width="1280" height="720" tabindex=0></canvas>');
        this.ctx = this.$canvas[0].getContext('2d');
        this.root.$kof.append(this.$canvas);
        this.$canvas.focus();

        // 创建控制器
        this.controller = new Controller(this.$canvas);

        this.width = this.$canvas.width();
        this.height = this.$canvas.height();

        // 计时器
        this.time_left = 60000;  // 60秒
        this.$timer = this.root.$kof.find('.kof-header-timer');
    }

    update() {
        // 更新计时器
        this.time_left -= this.timedata;

        if(this.time_left < 0) {
            this.time_left = 0;
            let [a, b] = this.root.players;

            // 时间到,双方平局
            if(a.status !== 6 && b.status !== 6) {
                a.status = 6;
                b.status = 6;
                a.frame_current_cnt = b.frame_current_cnt = 0;
                a.vx = b.vx = 0;
            }
        }

        this.$timer.text(parseInt(this.time_left / 1000));
        this.render();
    }

    render() {
        // 清空画布
        this.ctx.clearRect(0, 0, this.width, this.height);
    }
}

第三章:角色系统

3.1 角色状态设计

角色有7种状态:

// 0:站立
// 1:前进
// 2:后退
// 3:跳跃
// 4:攻击
// 5:受击
// 6:死亡

每种状态对应一个GIF动画文件。

3.2 玩家基类 (player/base.js)

构造函数

export class Player extends GameObject {
    constructor(root, info) {
        super();
        this.root = root;

        // 位置和尺寸
        this.x = info.x;
        this.y = info.y;
        this.width = info.width;
        this.height = info.height;
        this.id = info.id;

        // 运动属性
        this.vx = 0;  // 水平速度
        this.vy = 0;  // 垂直速度
        this.speedx = 400;     // 水平移动速度
        this.speedy = -1000;   // 跳跃初速度
        this.gravity = 50;     // 重力加速度

        // 朝向(1=右,-1=左)
        this.direction = 1;

        // 游戏状态
        this.status = 3;  // 初始为跳跃状态
        this.frame_current_cnt = 0;  // 当前帧计数

        // 战斗属性
        this.hp = 100;
        this.is_attack_hit = false;  // 当前攻击是否已命中

        // UI元素
        this.$hp = this.root.$kof.find(`.kof-header-hp${this.id}>div`);
        this.$hp_div = this.$hp.find('div');

        // 动画系统
        this.animations = new Map();
        this.ctx = this.root.game_map.ctx;
        this.pressed_keys = this.root.game_map.controller.pressed_keys;
    }
}

3.3 控制输入处理

update_control() {
    let w, a, d, space;

    // 根据玩家ID分配不同按键
    if(this.id === 0) {
        w = this.pressed_keys.has('w');
        a = this.pressed_keys.has('a');
        d = this.pressed_keys.has('d');
        space = this.pressed_keys.has(' ');
    } else {
        w = this.pressed_keys.has('ArrowUp');
        a = this.pressed_keys.has('ArrowLeft');
        d = this.pressed_keys.has('ArrowRight');
        space = this.pressed_keys.has('Enter');
    }

    // 只有在站立或移动状态才能响应输入
    if(this.status === 0 || this.status === 1) {
        if(space) {
            // 攻击
            this.status = 4;
            this.vx = 0;
            this.frame_current_cnt = 0;
            this.is_attack_hit = false;  // 重置攻击命中标记
        } else if(w) {
            // 跳跃
            if(d) {
                this.vx = this.speedx;   // 向右跳
            } else if(a) {
                this.vx = -this.speedx;  // 向左跳
            } else {
                this.vx = 0;             // 垂直跳
            }
            this.vy = this.speedy;
            this.status = 3;
        } else if(d) {
            // 向右移动
            this.vx = this.speedx;
            this.status = 1;
        } else if(a) {
            // 向左移动
            this.vx = -this.speedx;
            this.status = 1;
        } else {
            // 站立
            this.vx = 0;
            this.status = 0;
        }
    }
}

3.4 物理系统

update_move() {
    // 应用重力
    this.vy += this.gravity;

    // 根据速度更新位置(使用timedata实现帧率无关)
    this.x += this.vx * this.timedata / 1000;
    this.y += this.vy * this.timedata / 1000;

    // 地面碰撞检测
    if(this.y > 450) {
        this.y = 450;
        this.vy = 0;
        if(this.status === 3) {
            this.status = 0;  // 落地后回到站立状态
        }
    }

    // 边界检测
    if(this.x < 0) {
        this.x = 0;
    } else if(this.x + this.width > this.root.game_map.width) {
        this.x = this.root.game_map.width - this.width;
    }
}

物理公式:

  • 速度积分:位置 = 位置 + 速度 × 时间
  • 加速度积分:速度 = 速度 + 加速度 × 时间

3.5 自动转身系统

update_direction() {
    // 死亡状态不转身
    if(this.status === 6) return;

    let players = this.root.players;
    if(players[0] && players[1]) {
        let me = this, you = players[1 - this.id];
        // 始终面向对手
        if(me.x < you.x) me.direction = 1;   // 对手在右边,朝右
        else me.direction = -1;               // 对手在左边,朝左
    }
}

格斗游戏的经典设计:角色始终面向对手,无论位置如何变化。


第四章:攻击与碰撞系统

4.1 矩形碰撞检测

使用AABB(轴对齐包围盒)算法:

is_collision(r1, r2) {
    // 水平方向不重叠
    if(Math.max(r1.x1, r2.x1) > Math.min(r1.x2, r2.x2))
        return false;
    // 垂直方向不重叠
    if(Math.max(r1.y1, r2.y1) > Math.min(r1.y2, r2.y2))
        return false;
    // 两个方向都重叠,发生碰撞
    return true;
}

原理:

  • 如果两个矩形的左边界最大值 > 右边界最小值,说明水平不重叠
  • 如果两个矩形的上边界最大值 > 下边界最小值,说明垂直不重叠
  • 两个方向都重叠,才发生碰撞

4.2 攻击判定系统

update_attack() {
    // 攻击判定窗口:第17-19帧
    if(this.status === 4 && 
       (this.frame_current_cnt === 17 || 
        this.frame_current_cnt === 18 || 
        this.frame_current_cnt === 19)) {

        // 防止一次攻击造成多次伤害
        if(this.is_attack_hit) return;

        let me = this, you = this.root.players[1 - this.id];
        let r1;

        // 根据朝向设置攻击范围
        if(this.direction > 0) {
            r1 = {
                x1: me.x + 120,
                y1: me.y + 40,
                x2: me.x + 120 + 100,
                y2: me.y + 40 + 20,
            };
        } else {
            r1 = {
                x1: me.x + me.width - 120 - 100,
                y1: me.y + 40,
                x2: me.x + me.width - 120,
                y2: me.y + 40 + 20,
            };
        }

        // 对手的碰撞盒
        let r2 = {
            x1: you.x,
            y1: you.y,
            x2: you.x + you.width,
            y2: you.y + you.height,
        };

        // 碰撞检测
        if(this.is_collision(r1, r2)) {
            you.is_attack();
            this.is_attack_hit = true;  // 标记已命中
        }
    }
}

延迟刀机制:

  • 攻击判定不是在按下攻击键的瞬间,而是在动画的特定帧
  • 判定窗口持续3帧,提高容错率
  • 使用 is_attack_hit 标记防止重复命中

4.3 受击处理

is_attack() {
    this.status = 5;  // 切换到受击状态
    this.frame_current_cnt = 0;
    this.hp = Math.max(this.hp - 10, 0);  // 扣血

    // 血条动画(双层效果)
    this.$hp_div.animate({
        width: this.$hp.parent().width() * this.hp / 100
    }, 300);  // 内层快速减少

    this.$hp.animate({
        width: this.$hp.parent().width() * this.hp / 100
    }, 600);  // 外层慢速减少

    // 死亡判定
    if(this.hp <= 0) {
        this.status = 6;
        this.frame_current_cnt = 0;
        this.vx = 0;
    }
}

血条设计:

  • 双层血条:内层立即减少,外层延迟减少
  • 让玩家清楚看到扣了多少血
  • 使用jQuery的 animate 实现平滑过渡

第五章:动画系统

5.1 GIF动画加载

// kyo.js
init_animations() {
    let outer = this;
    let offsets = [0, -22, -22, -120, 0, 0, 0];  // 每个状态的Y轴偏移

    for(let i = 0; i < 7; i++) {
        let gif = GIF();
        gif.load(`/static/images/player/kyo/${i}.gif`);

        this.animations.set(i, {
            gif: gif,
            frame_cnt: 0,        // 总帧数
            frame_rate: 5,       // 每5帧切换一次
            offset_y: offsets[i],
            loaded: false,
            scale: 2,            // 放大2倍
        });

        gif.onload = function() {
            let obj = outer.animations.get(i);
            obj.frame_cnt = gif.frames.length;
            obj.loaded = true;

            // 攻击动画帧率更快
            if(i === 3) {
                obj.frame_rate = 4;
            }
        }
    }
}

5.2 渲染系统

render() {
    let status = this.status;
    let obj = this.animations.get(status);

    if(obj && obj.loaded) {
        // 计算当前应该显示第几帧
        let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
        let image = obj.gif.frames[k].image;

        if(this.direction > 0) {
            // 朝右,正常绘制
            this.ctx.drawImage(
                image, 
                this.x, 
                this.y + obj.offset_y, 
                image.width * obj.scale, 
                image.height * obj.scale
            );
        } else {
            // 朝左,水平翻转
            this.ctx.save();
            this.ctx.scale(-1, 1);  // X轴翻转

            this.ctx.drawImage(
                image, 
                -(this.x + this.width),  // 翻转后的坐标
                this.y + obj.offset_y, 
                image.width * obj.scale, 
                image.height * obj.scale
            );

            this.ctx.restore();
        }

        // 动画结束处理
        if((this.status === 4 || this.status === 5 || this.status === 6) && 
           k === obj.frame_cnt - 1) {

            if(this.status === 6) {
                // 死亡动画停在最后一帧
                this.frame_current_cnt--;
            } else {
                // 其他动画结束后回到站立
                this.status = 0;
            }
        }
    }

    this.frame_current_cnt++;  // 帧计数器递增
}

关键技术:

  1. 帧率控制frame_current_cnt / frame_rate 控制动画速度
  2. 水平翻转:使用 scale(-1, 1) 翻转画布,注意坐标系变化
  3. 动画循环:使用取模运算 % frame_cnt 实现循环播放
  4. 定帧:死亡动画通过 frame_current_cnt-- 停在最后一帧

第六章:UI系统

6.1 HTML结构

<div id="kof">
    <div class="kof-header">
        <!-- 玩家1血条 -->
        <div class="kof-header-hp0">

<div></div>  <!-- 外层:延迟减少 -->
        </div>

        <!-- 计时器 -->
        <div class="kof-header-timer">60</div>

        <!-- 玩家2血条 -->
        <div class="kof-header-hp1">

<div></div>
        </div>
    </div>
</div>

6.2 CSS样式

#kof {
    width: 1280px;
    height: 720px;
    background-image: url('../images/background/0.gif');
    background-size: 200% 100%;
    background-position: center;
}

/* 血条容器(红色背景) */
.kof-header-hp0, .kof-header-hp1 {
    height: 40px;
    background-color: red;
    width: calc(50% - 60px);
    border: white solid 5px;
    box-sizing: border-box;
}

/* 外层血条(橙色,慢速减少) */
.kof-header-hp0 > div, .kof-header-hp1 > div {
    background-color: orange;
    height: 100%;
    width: 100%;
}

/* 内层血条(绿色,快速减少) */
.kof-header-hp0 > div > div, .kof-header-hp1 > div > div {
    background-color: lightgreen;
    height: 100%;
    width: 100%;
}

/* 计时器 */
.kof-header-timer {
    height: 60px;
    width: 80px;
    background-color: orange;
    border: white solid 5px;
    color: white;
    font-size: 30px;
    font-weight: 800;
    text-align: center;
    line-height: 50px;
}

三层血条设计:

  1. 最外层(红色):血量上限
  2. 中间层(橙色):延迟显示,600ms过渡
  3. 最内层(绿色):实时血量,300ms过渡

第七章:游戏主类

7.1 KOF类

import { GameMap } from './map/base.js';
import { Kyo } from './player/kyo.js';

class KOF {
    constructor(id) {
        this.$kof = $('#' + id);

        // 创建地图
        this.game_map = new GameMap(this);

        // 创建两个玩家
        this.players = [
            new Kyo(this, {
                id: 0,
                x: 200,
                y: 0,
                width: 120,
                height: 200,
                color: 'blue',
            }),
            new Kyo(this, {
                id: 1,
                x: 900,
                y: 0,
                width: 120,
                height: 200,
                color: 'red',
            }),
        ];
    }
}

export { KOF }

7.2 初始化游戏

<script type="module">
    import {KOF} from '../static/js/base.js';
    let kof = new KOF('kof');
</script>

第八章:常见问题与调试技巧

8.1 角色不转身

问题:定义了 update_direction() 但角色朝向不变 原因:忘记在 update() 中调用 解决

update() {
    this.update_control();
    this.update_move();
    this.update_direction();  // 记得调用
    this.update_attack();
    this.render();
}

8.2 攻击后卡住

问题:变量作用域错误 原因k 在 if/else 块内定义,外部无法访问 解决:把变量定义提到外层

// 错误
if(this.direction > 0) {
    let k = ...;
}
if(k === last_frame) { ... }  // k未定义

// 正确
let k = ...;
if(this.direction > 0) { ... }
if(k === last_frame) { ... }

8.3 一拳打死

问题:一次攻击造成多次伤害 原因:判定窗口3帧都触发了攻击 解决:添加命中标记

if(this.is_attack_hit) return;  // 已命中则不再判定
// ... 碰撞检测 ...
this.is_attack_hit = true;

8.4 血条不动

问题:jQuery选择器错误 原因:class名称拼写错误 解决:确保选择器与HTML一致

// 错误:.kof-head-hp
// 正确:.kof-header-hp
this.$hp = this.root.$kof.find(`.kof-header-hp${this.id}>div`);

8.5 翻转时位置错误

问题scale(-1,1) 后图片位置不对 原因:坐标系翻转后需要重新计算坐标 解决

// 翻转后的x坐标 = -(原x坐标 + 角色宽度)
this.ctx.drawImage(image, -(this.x + this.width), this.y, ...);

第九章:扩展与优化

9.1 添加新角色

  1. 准备7个GIF动画文件(对应7种状态)
  2. 创建新角色类继承 Player
  3. init_animations() 中加载动画
  4. 调整 offset_y 和攻击判定框
export class NewCharacter extends Player {
    constructor(root, info) {
        super(root, info);
        this.init_animations();
    }

    init_animations() {
        // 加载你的角色动画
        let offsets = [0, -20, -20, -100, 0, 0, 0];
        // ... 与 Kyo 类似的加载逻辑
    }
}

9.2 添加技能系统

// 在 Player 类中添加
this.skills = {
    fireball: { cd: 0, cd_max: 3000 },  // 技能冷却
};

// 释放技能
if(press_j && this.skills.fireball.cd === 0) {
    this.create_fireball();
    this.skills.fireball.cd = this.skills.fireball.cd_max;
}

// 更新冷却
this.skills.fireball.cd = Math.max(0, this.skills.fireball.cd - this.timedata);

9.3 音效系统

// 添加音效
let audio = new Audio('/static/audio/punch.mp3');
audio.play();

// 背景音乐
let bgm = new Audio('/static/audio/bgm.mp3');
bgm.loop = true;
bgm.play();

9.4 性能优化

// 1. 只渲染可见对象
if(this.x + this.width < 0 || this.x > canvas.width) return;

// 2. 对象池复用
class ObjectPool {
    constructor(create_func, size) {
        this.pool = [];
        for(let i = 0; i < size; i++) {
            this.pool.push(create_func());
        }
    }

    get() {
        return this.pool.pop() || null;
    }

    release(obj) {
        this.pool.push(obj);
    }
}

// 3. 减少DOM操作
// 批量更新UI,不要每帧都操作DOM

第十章:完整更新流程

每一帧执行顺序:
┌─────────────────────────────────┐
│  requestAnimationFrame          │
│  ↓                               │
│  遍历所有GameObject              │
│  ├─ GameMap                     │
│  │   ├─ 更新计时器               │
│  │   ├─ 清空画布                 │
│  │   └─ 检测游戏结束             │
│  │                               │
│  ├─ Player 1                    │
│  │   ├─ update_control()  [输入] │
│  │   ├─ update_move()     [物理] │
│  │   ├─ update_direction()[转身] │
│  │   ├─ update_attack()   [攻击] │
│  │   └─ render()          [渲染] │
│  │                               │
│  └─ Player 2                    │
│      └─ (同上)                   │
│                                  │
└─────────────────────────────────┘

总结

通过这个项目,我学到了:

核心技术

  1. 游戏循环:使用 requestAnimationFrame 实现60fps游戏循环
  2. 面向对象:GameObject 基类统一管理生命周期
  3. 物理引擎:重力、速度、加速度的简单实现
  4. 碰撞检测:AABB矩形碰撞算法
  5. 动画系统:GIF帧动画播放与控制
  6. Canvas绘图:图片绘制、坐标变换、翻转

游戏设计

  1. 状态机:角色状态切换逻辑
  2. 延迟判定:攻击判定窗口设计
  3. 双层血条:提升用户体验的UI设计
  4. 自动转身:格斗游戏经典机制

工程实践

  1. 模块化开发:ES6 Module 分离关注点
  2. 继承与多态:Player → Kyo 的扩展
  3. 调试技巧:常见bug的排查与解决

下一步学习

  1. 添加更多角色:不同的技能和攻击判定
  2. 联机对战:使用 WebSocket 实现网络对战
  3. AI对手:实现简单的电脑AI
  4. 完整游戏流程:开始菜单、角色选择、胜利结算
  5. 特效系统:攻击特效、粒子系统
  6. 关卡系统:不同的场景和背景

参考资源

计算机小白一枚
最后更新于 2025-11-24