横版跳跃游戏现已推出多人模式! 当前处于Alpha阶段, 请多提建议.
项目投入时间:
目录如下
核心实现:
- 实现代码的模块化和函数化
- 在原型基础上实现了游戏的战斗系统
- 实现了多人游戏
- 用户端: 发送用户按键情况, 渲染服务器发来的地图和玩家信息
- 服务器端: 一切逻辑处理, 并将当前的情况广播
由于该处理方式使得服务器负担过大, 导致主机以外的玩家延迟过高, 体验极差
需要安装Node.js
运行main/server.js即可
之后会在432端口开启websocket服务器, 请确保该端口没有被占用

由于cross-origin问题, 客户要fetch map.json文件, 必须要有本地服务器
因此采用python临时搭建小服务器提供文件传输服务, 请确保安装Python3
之后运行main/client.bat即可, 会自动打开客户端服务器8088端口和游戏网页

服务器地址填主机的ip地址
- 原本游戏中的特殊砖块script实现
- 物理效果完善
- 各个玩家的死亡判定, 死亡计次的现实, 玩家退出的时候服务器删除ta的存在
- 客户端玩家面板的完善(死亡计次, 当前数值, 武器, 技能, 效果buff)
- 战斗的方式(子弹, 近战)
- 伤害计算
- 延迟计算
- 玩家名称, 血条头顶显示
- 光圈标记或是移动时的特效等的实现
- 玩家的死亡动画以及复活方式(死亡后的倒计时)
- 野怪, NPC机器人的设置
- 地图的改变, 在背景显示地图名称
- 武器拾取
- 抓墙跳
- 武器更换
- 右键按住后, 镜头随之有微小的跟随
- 右键按住的情况下, 光标悬浮在实体上, 显示实体的名称和信息
- 死亡掉落
- 主页面
- 击退子弹
- 各种武器, 技能的实现
- 转场特效
- 背景音乐, 音效, 按键音的设置
- 登陆界面, 角色选择
- 断线重连和统一ip多开
- 随机地图
- 优点
- 跑酷很有意思, 抓墙跳会很自然的引起玩家的攀爬欲望
- 抢夺装备很欢乐, 有时候战斗没开始就已经激烈了
- 缺点
- 跑酷难度过大, 引起手部不适
- 玩家会自然的连按跳跃, 事实上这样跳不高...
以酒馆机制为框架, 实现一个多人大乱斗的游戏
备战环节, 每位玩家可以通过商店获取道具, 并可以调配自己的装备(饰品等)
进入战斗阶段后, 会在地图上随机出现各种道具(或者材料/资源), 玩家自发进行抢夺, 形成混战
战斗阶段分为两个阶段, 白昼与黑夜, 因此玩家需要准备两套装备, 战斗风格也会因此而改变
血量为0的玩家会被淘汰, 最后存活的玩家获得胜利
为避免死亡后失去游戏参与感, 死亡的玩家将化为鬼魂(借鉴游戏: crawl)
鬼魂通过操作地图上的机关来干扰存货玩家, 如果造成伤害将会获得奖励, 有概率复活, 以此增强游戏性
如果仅一位玩家幸存, 游戏结束
考虑到鬼魂不断生成游戏无法结束的情况, 只允许鬼魂在晚上出现, 这样白天只有最后一个幸存者, 游戏结束
全游戏画风统一为扑克牌, 道具也以扑克牌的形式呈现, 分为黑桃, 红桃, 方片, 梅花四个花色
- 黑桃: 攻击类武器, 如: 宝剑 / 长枪 / 锤子 / 弓箭 ...
- 方片: 防具, 如: 盾牌 / 铠甲 / 靴子 / 头盔 ...
- 红桃: 主动道具, 如: 治疗药剂 / 护盾 / 药膏 ...
- 梅花: 被动道具, 如: 生命值加成 / 攻击力加成 / 防御力加成 / 移动速度加成 ...
部分道具还有黑白之分, 限定在白昼或黑夜才能使用
玩家可以拆散道具来获得点数, 以购买自己需要的道具
商店中随机出现道具, 价格也随机(考虑每轮备战环节提供5张牌)
商店暂时不设置等级(tier)机制, 后续考虑升级的好处
玩家身上只能携带各个花色各一个, 但是要准备两套装备, 一套用于白天, 一套用于黑夜
playerDic[ip] = {
key: {控制按键情况},
chara: {角色对象},
info: {玩家信息},
ws: ws
}info = {
name: "",
colour: "#000",
}chara = {
loc: {x: 0, y: 0}, // 实际坐标
vel: {x: 0, y: 0}, // 移动速度
name: "defaultPlayer",
colour: '#000',
current_mapId: 0,
can_jump: true,
doublejumpFlag: false,
can_doublejump: true,
can_dash: true,
doublejump_ability: true,
glide_ability: true,
dash_ability: true,
float_ability: true,
buff: [{buff效果}],
equipment: {
club: item,
heart: item,
spade: item,
diamond: item,
},
state: {
hp: 30,
mp: 10,
hp_max: 30,
mp_max: 10,
money: 0,
},
}item = {
name: "",
type: ENUM, // ITEM_TYPE_SPADE, ITEM_TYPE_CLUB, ITEM_TYPE_HEART, ITEM_TYPE_DIAMOND
class: ENUM, // ITEM_CLASS_WHITE, ITEM_CLASS_BLACK
// colour: "#000",
info: "效果说明",
id: 0,
tier: 0,
price: 0,
}bullet = {
loc: {x: 0, y: 0},
vel: {x: 0, y: 0},
acc: {x: 0, y: 0},
owner: player,
damage_direct: 0,
damage_slice: 0,
damage_continuous: 0,
damage_explosion: 0,
type: ENUM, // BULLET_TYPE_NORMAL, BULLET_TYPE_EXPLOSIVE, BULLET_TYPE_LASER
class: ENUM, // BULLET_CLASS_WHITE, BULLET_CLASS_BLACK
this.attribute = {
pierce: false, // 穿透
}
// 特殊效果
timer: 0,
effect: ENUM, // BULLET_EFFECT_NONE, BULLET_EFFECT_FREEZE, BULLET_EFFECT_BURN
colour: "#000",
size: 0,
shape: ENUM, // BULLET_SHAPE_CIRCLE, BULLET_SHAPE_RECT
}{
key: {控制按键情况},
name: "",
color: "#000",
}{
map_id: 1,
players: Object.values(playerDic).map((player) => player.chara),
yourIndex: {接收者的序号},
time: new Date().getTime()
}即move_player函数的实现原理
- 由速度
vel和当前位置loc计算出下一时刻的位置tX和tY - 由玩家的行动确定基本加速度
- 确定一些变量:
offset: 其值略小于单位砖块大小的一半, 用于检查边界tile: 玩家当前所处砖块的信息- 各个方向上的两个砖块(因为一个方向上会有两个砖块)
- 通过上下左右砖块的物理特性确定加速度
- 摩擦力会由$a_{friction} = -\mu * a_{player}$计算得到
- 根据当前砖块的
gravity改变加速度
- 结算加速度并更新速度, 此时速度会受到地图和玩家自身的
vel_limit的限制 - 玩家技能处理, 如冲刺等, 会突破速度限制
- 再取四周砖块的
bounce最大值作为bounce的值, 直接乘在玩家的速度上弹性会让速度反向, 因此放在最后处理
弹性的存在会让速度突破上限, 更具戏剧性 - 结算速度并更新位置
- 检测出图, 玩家会被判定为坠落
fallen状态 - 检测碰撞:
正常情况下, 玩家会和4个砖块发生碰撞*(玩家实际大小其实小于一个砖块)*
分别检测x和y方向上的碰撞, 如果发生碰撞, 会根据碰撞的砖块信息不断微调玩家的位置直至不发生碰撞
顺便还会有落地检测, 从而恢复玩家跳跃和冲刺的能力
此时位置为最终确定的位置
地图信息由较小的map.json文件提供, 在实际游戏中会被转化成较大的map对象(每个砖块都会由一个数字转化为一个对象), 这会造成大量的内存占用
为了实现一些特殊效果, 我期望在服务器修改砖块属性, 能够影响到所有的客户端
如果传递地图对象, 会导致大量的数据传输, 造成不必要的性能占用
当前想法是增量传输, 兼容性较好吧, 可以实现任意效果 每张图的砖块信息分为两种:
- 表层: 贴图 颜色 形状
- 底层: 物理属性 弹性 摩擦力
客户端看表层, 服务器看底层
如果要发生变化, 服务器就发送一个增量修改数组, 指出哪些坐标对应的砖块的新属性是什么
也就是原地图画在后面的图层, 新的修改数组画在前面的图层
游戏中一共涉及到4个坐标系:
- 原始数组坐标系: 数组形式存储的地图信息, 是以0开始的二维数组下标
- 数据坐标系: 将原始数据乘上砖块的大小, 得到的静态坐标系
- canvas坐标系: 按照相机渲染出的canvas上的坐标系
- html坐标系: 实际html中窗口的坐标系
他们之间的转换关系如下:
- 原始数组坐标系 乘上
tilesize-> 数据坐标系 - 数据坐标系 减去
camera-> canvas坐标系 - canvas坐标系 乘上
zoomIndex-> html坐标系
最重要的是数据坐标系, 静态且绝对 特别注意: 所有实体的anchor点处在实体的正中央, 鼠标的canvas坐标在转换到数据坐标系时应当考虑到半个格子的偏移量
特别注意: 由于所有方块的anchor打在正中间, 为了确定某个数据坐标对应哪个方块, 应当将其减去半个格子的大小, 再取整数
规定: 地图信息使用原始坐标, chara和item等物体使用数据坐标
其中camera基本跟踪玩家的静态坐标, 但是为了动态效果会有插值, 因此canvas坐标系是相对动态的
函数调用中:
- 存储下的坐标都是数据坐标系
- 绘制的坐标都是canvas坐标系
原先的实现思路: 将地图写在json中, 地图信息包括:
- 由编码表示的地图形状
- 各个编码对应的地图砖块的信息
服务端读取json文件, 然后逐一将地图中每个编码还原成对象, 储存在内存中
这样, 就能随时调用地图对象, 进行碰撞检测, 物理效果等计算, 也只需要发一次给客户端就行
但是这样的方法有两个缺点:
- 当地图较大时, 会占用大量内存
- 服务器修改地图信息后, 需要将整个地图对象传输给客户端(采取增量修改可能会好一点但不多)
考虑将所有编码所对应的砖块信息确定下来, 这样不用每张地图都来一套独立的砖块信息
然后编码由原先的数字改为字符串, 这样能拓展更多信息
我的想法是, 每个编码写作: "[type]_[bounce]_[friction]"
- 一般来说同一个
type(比如弹力块), 就完全可以用统一的颜色(对玩家也友好), 因此颜色就不用写了 solid也是基本和type有关的, 用type就能确定friction没必要两个值, 只需要一个
还有像抓墙跳的方块, 完全可以新增一个
type来表示
这样的话, 编码表就可以写在一个json文件中确定下来
这类砖块(例如传送门)需要搭配自定义脚本, (例如机关门)会产生地图上的交互效果
可以规定其编码为: "#[type]_[arg1]_[arg2]_[arg3]_..."
考虑到这些特殊砖块在不同地图中的独立性, 这些特定的脚本和编码应当写在相应的地图json文件中
核心思路: 多参数的预制地图块 和 宏观地图调整
预先手工定制一些地图块(map_module), 包含了一些特定的特征, 如:
- 一个房间的基本构造
- 房间的出入口位置
- 房间的宽度和高度
- 房间的机关位置
- 房间内的地图特殊砖块配置
一些想法:
- 由于本游戏当前设计是多人战斗游戏, 因此要设计一些用于混战的地图块, 如:
- 一个大房间, 多出入口, 中间有一些障碍物
- 一些单向的房间, 将玩家引向中心房间
- 一些狭窄的通道, 尽头是宝物
- 要有一定数量的宝物
- 玩家出生点要分散, 不能太靠近
- 整体风格统一, 地基以同一个砖块为主
在整体上调整地图的连通性, 使得地图更加有趣, 更有复杂性:
- 玩家由四周向中间进发, 因此要有一定的引导性
- 重生点要分散, 不能太近
- 宝物房间应当分散给每一组玩家(相邻重生点的玩家周围要有宝物)
- 先根据玩家数量确定地图的大小
- 选定一种地图框架(map_frame)(比如:大家都在高出, 集中点在下方, 或者在四周, 最后一起冲向中心)
- 基于框架, 确定玩家位置和集中点
- 随机取一些"必经点", 打上独立的
tag
tag表示地图块所属的集合, 尽量让路线独立开
对于一个
有$n-1$条水平的分隔线和$m-1$条垂直的分隔线
每个水平分隔线上有$m$个可打通的位置, 可以用一个$m$位的二进制表示是否打通
所以所有水平分隔线的打通状态可以用一个长度为$n-1$的二进制数数组表示(竖直分隔线类似)
let n = 3, m = 5;
let horSplits = [0b01001, 0b00010];
let verSplits = [0b001, 0b100, 0b010, 0b001];-
获取一个二进制数的特定位的值
function getBit(num, k) { return (num >> k) & 1; }
-
对于坐标为
(x,y)的房间, 返回其四周的分隔线状态function getRoomSplits(x, y) { return { left: x>0 ? getBit(verSplits[x-1], y) : 0, right: getBit(verSplits[x], y), top: y>0 ? getBit(horSplits[y-1], x) : 0, bottom: getBit(horSplits[y], x), } }
-
打通或者关闭特定的分隔线
function switchSplit(isHor, index, k) { if(isHor) { horSplits[index] ^= 1 << k; } else { verSplits[index] ^= 1 << k; } }
用BFS算法, 从集中点开始, 按照规则打通路, 目标是打通criticalPath:
规则:
- 宏观路线: 玩家出生点(birthRoom) > 必经点(criticalRoom) > 集中点(jointRoom); 第一部分叫探索区(exploreArea), 第二部分叫引导区(guideArea)
- 这个基本的路线称为criticalPath, 其他路线称为sidePath
- 在框架中, 各个区域互不相通, 仅由必经点互相连接
- 每个通道房间有且仅有两个出路口
- 每个房间都有一个
tag, 用于表示它属于哪个区块 - 区块的命名规则:
P1_C1_exp,C1_J_gui
流程:
- 对于一个房间单元(坐标为
(x,y)), 先检查其四周的分隔线状态 - 随便找一个方向打通
- 判断是否符合规则(对面房间的
tag是否合适, 是否符合两个出口的规则) - 如果不符合规则, 重新选择方向, 直到符合规则
- 无法从出发点打通到终点, 则打通失败, 整个重来(换种子重新开始)
- 打通一个就进行下一个房间(先保证是一条每个房间都只有2个出路口的通道)
此时对于尚未打通的房间, 说明它们是sidPath, 也就是非关键路径
选取一个房间, 设置为奖励房间/特殊房间, 用DFS连到guideArea区域
若还有未打通的房间, 则用BFS连接到任意一个已经打通的房间
循环直至所有房间都已打通
现在所有通路情况和区块已经确定了, 根据通路形状决定地图模块
在guideArea区域中的房间, 选取一些具有单向性的地图模块, 用于引导玩家
流程:
- 随机选择一个未确定的房间单元
c - 随机选择一个地图模块
m - 如果
c作为m的中的某个房间时,m能匹配当前的通路形状, 则放置m在c的位置上,m覆盖的所有房间都标记为已确定, 随机调整m的参数, 在最终的地图数组中确定下来 - 如果
m不能匹配, 重新选择c在m中的下一个位置 - 如果
m的所有位置都尝试过了, 且没有一个能匹配, 则重新选择m - 总有最基本的房间能匹配, 匹配成功后循环直至所有房间都已确定, 最终地图生成
仅专注于修改系统核心逻辑, 提升游戏性能
用户端负责处理物理引擎, 服务器端仅处理信息收发和逻辑判定
相较于V2.1, V2.2的通信只使用一个端口, 通过详细的type定义来区分不同的信息
bullets信息由player类代为储存, 无需另外传输
-
类型:
"player",data是用户的game.player对象(符合player类定义规范) -
类型:
"time",data是server发过去的时间戳time: server当时的时间戳
大包装均为{"type": string, "data": any}的形式
-
类型:
"game",data包含当前玩家需要渲染的信息players: 玩家信息数组items: 物品信息数组
服务器传出的players第一个必定是玩家自己
-
类型:
"map",data包含地图信息map_id: 地图idmap: 地图信息数组
-
类型:
"pic",data是图片对象pic_src: 图片的索引base64: 图片的base64编码
-
类型:
"time",data包含server的时间戳和server计算出的延迟time: server的时间戳latency: server计算出的延迟
-
类型:
"signal", "data"用于传递一些特殊信息content: 特殊信息"initialization_done": 服务器初始化该用户的信息完成
augment: 附加参数
大问题:
- 时间一长极卡, 有坏东西在占算力, 初步猜测为
deepcopy的迭代 - 现在客户端给服务端的反馈只有人的情况, "捡起了东西"这个消息就无法传达给服务器
- 捡武器后白屏, 也没报错, 很神奇
