本教程完整代码: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++; // 帧计数器递增
}
关键技术:
- 帧率控制:
frame_current_cnt / frame_rate控制动画速度 - 水平翻转:使用
scale(-1, 1)翻转画布,注意坐标系变化 - 动画循环:使用取模运算
% frame_cnt实现循环播放 - 定帧:死亡动画通过
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;
}
三层血条设计:
- 最外层(红色):血量上限
- 中间层(橙色):延迟显示,600ms过渡
- 最内层(绿色):实时血量,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 添加新角色
- 准备7个GIF动画文件(对应7种状态)
- 创建新角色类继承
Player - 在
init_animations()中加载动画 - 调整
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 │
│ └─ (同上) │
│ │
└─────────────────────────────────┘
总结
通过这个项目,我学到了:
核心技术
- 游戏循环:使用
requestAnimationFrame实现60fps游戏循环 - 面向对象:GameObject 基类统一管理生命周期
- 物理引擎:重力、速度、加速度的简单实现
- 碰撞检测:AABB矩形碰撞算法
- 动画系统:GIF帧动画播放与控制
- Canvas绘图:图片绘制、坐标变换、翻转
游戏设计
- 状态机:角色状态切换逻辑
- 延迟判定:攻击判定窗口设计
- 双层血条:提升用户体验的UI设计
- 自动转身:格斗游戏经典机制
工程实践
- 模块化开发:ES6 Module 分离关注点
- 继承与多态:Player → Kyo 的扩展
- 调试技巧:常见bug的排查与解决
下一步学习
- 添加更多角色:不同的技能和攻击判定
- 联机对战:使用 WebSocket 实现网络对战
- AI对手:实现简单的电脑AI
- 完整游戏流程:开始菜单、角色选择、胜利结算
- 特效系统:攻击特效、粒子系统
- 关卡系统:不同的场景和背景
Comments NOTHING