Skip to content

zhyDaDa/Jumpin-Ball_multi-players

Repository files navigation

Jumpin-Ball 多人模式

横版跳跃游戏现已推出多人模式! 当前处于Alpha阶段, 请多提建议. 项目投入时间: wakatime


目录如下

Version 2.1

核心实现:

  • 实现代码的模块化和函数化
  • 在原型基础上实现了游戏的战斗系统
  • 实现了多人游戏
    • 用户端: 发送用户按键情况, 渲染服务器发来的地图和玩家信息
    • 服务器端: 一切逻辑处理, 并将当前的情况广播

由于该处理方式使得服务器负担过大, 导致主机以外的玩家延迟过高, 体验极差

使用说明

服务端

需要安装Node.js 运行main/server.js即可
之后会在432端口开启websocket服务器, 请确保该端口没有被占用 服务器启动示意图

客户端

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

服务器地址填主机的ip地址

TODO清单

  • 原本游戏中的特殊砖块script实现
  • 物理效果完善
  • 各个玩家的死亡判定, 死亡计次的现实, 玩家退出的时候服务器删除ta的存在
  • 客户端玩家面板的完善(死亡计次, 当前数值, 武器, 技能, 效果buff)
  • 战斗的方式(子弹, 近战)
  • 伤害计算
  • 延迟计算
  • 玩家名称, 血条头顶显示
  • 光圈标记或是移动时的特效等的实现
  • 玩家的死亡动画以及复活方式(死亡后的倒计时)
  • 野怪, NPC机器人的设置
  • 地图的改变, 在背景显示地图名称
  • 武器拾取
  • 抓墙跳
  • 武器更换
  • 右键按住后, 镜头随之有微小的跟随
  • 右键按住的情况下, 光标悬浮在实体上, 显示实体的名称和信息
  • 死亡掉落
  • 主页面
  • 击退子弹
  • 各种武器, 技能的实现
  • 转场特效
  • 背景音乐, 音效, 按键音的设置
  • 登陆界面, 角色选择
  • 断线重连和统一ip多开
  • 随机地图

alpha测试反馈

  • 优点
    • 跑酷很有意思, 抓墙跳会很自然的引起玩家的攀爬欲望
    • 抢夺装备很欢乐, 有时候战斗没开始就已经激烈了
  • 缺点
    • 跑酷难度过大, 引起手部不适
    • 玩家会自然的连按跳跃, 事实上这样跳不高...

新的游戏设想

酒馆机制为框架, 实现一个多人大乱斗的游戏

概述

备战环节, 每位玩家可以通过商店获取道具, 并可以调配自己的装备(饰品等)
进入战斗阶段后, 会在地图上随机出现各种道具(或者材料/资源), 玩家自发进行抢夺, 形成混战 战斗阶段分为两个阶段, 白昼与黑夜, 因此玩家需要准备两套装备, 战斗风格也会因此而改变

游戏设定

胜利/存活条件

血量为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,
}

Buff对象

Bullet对象

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函数的实现原理

  1. 由速度vel和当前位置loc计算出下一时刻的位置tXtY
  2. 由玩家的行动确定基本加速度
  3. 确定一些变量:
    • offset: 其值略小于单位砖块大小的一半, 用于检查边界
    • tile: 玩家当前所处砖块的信息
    • 各个方向上的两个砖块(因为一个方向上会有两个砖块)
  4. 通过上下左右砖块的物理特性确定加速度
    • 摩擦力会由$a_{friction} = -\mu * a_{player}$计算得到
    • 根据当前砖块的gravity改变加速度
  5. 结算加速度并更新速度, 此时速度会受到地图和玩家自身的vel_limit的限制
  6. 玩家技能处理, 如冲刺等, 会突破速度限制
  7. 再取四周砖块的bounce最大值作为bounce的值, 直接乘在玩家的速度上

    弹性会让速度反向, 因此放在最后处理
    弹性的存在会让速度突破上限, 更具戏剧性

  8. 结算速度并更新位置
  9. 检测出图, 玩家会被判定为坠落fallen状态
  10. 检测碰撞:
    正常情况下, 玩家会和4个砖块发生碰撞*(玩家实际大小其实小于一个砖块)*
    分别检测xy方向上的碰撞, 如果发生碰撞, 会根据碰撞的砖块信息不断微调玩家的位置直至不发生碰撞
    顺便还会有落地检测, 从而恢复玩家跳跃和冲刺的能力
    此时位置为最终确定的位置

开发中遇到的问题

地图对象

地图信息由较小的map.json文件提供, 在实际游戏中会被转化成较大的map对象(每个砖块都会由一个数字转化为一个对象), 这会造成大量的内存占用
为了实现一些特殊效果, 我期望在服务器修改砖块属性, 能够影响到所有的客户端
如果传递地图对象, 会导致大量的数据传输, 造成不必要的性能占用

当前想法是增量传输, 兼容性较好吧, 可以实现任意效果 每张图的砖块信息分为两种:

  • 表层: 贴图 颜色 形状
  • 底层: 物理属性 弹性 摩擦力 客户端看表层, 服务器看底层 如果要发生变化, 服务器就发送一个增量修改数组, 指出哪些坐标对应的砖块的新属性是什么
    也就是原地图画在后面的图层, 新的修改数组画在前面的图层

视图转换

游戏中一共涉及到4个坐标系:

  • 原始数组坐标系: 数组形式存储的地图信息, 是以0开始的二维数组下标
  • 数据坐标系: 将原始数据乘上砖块的大小, 得到的静态坐标系
  • canvas坐标系: 按照相机渲染出的canvas上的坐标系
  • html坐标系: 实际html中窗口的坐标系

他们之间的转换关系如下:

  • 原始数组坐标系 乘上 tilesize -> 数据坐标系
  • 数据坐标系 减去 camera -> canvas坐标系
  • canvas坐标系 乘上 zoomIndex -> html坐标系

最重要的是数据坐标系, 静态且绝对 特别注意: 所有实体的anchor点处在实体的正中央, 鼠标的canvas坐标在转换到数据坐标系时应当考虑到半个格子的偏移量
特别注意: 由于所有方块的anchor打在正中间, 为了确定某个数据坐标对应哪个方块, 应当将其减去半个格子的大小, 再取整数

规定: 地图信息使用原始坐标, charaitem等物体使用数据坐标

其中camera基本跟踪玩家的静态坐标, 但是为了动态效果会有插值, 因此canvas坐标系是相对动态的 函数调用中:

  1. 存储下的坐标都是数据坐标系
  2. 绘制的坐标都是canvas坐标系

UI设计

参考了 "死亡细胞" 的UI
死亡细胞

加载地图的方法

原地图

原先的实现思路: 将地图写在json中, 地图信息包括:

  • 由编码表示的地图形状
  • 各个编码对应的地图砖块的信息

服务端读取json文件, 然后逐一将地图中每个编码还原成对象, 储存在内存中
这样, 就能随时调用地图对象, 进行碰撞检测, 物理效果等计算, 也只需要发一次给客户端就行

但是这样的方法有两个缺点:

  1. 当地图较大时, 会占用大量内存
  2. 服务器修改地图信息后, 需要将整个地图对象传输给客户端(采取增量修改可能会好一点但不多)

地图调用的优化

考虑将所有编码所对应的砖块信息确定下来, 这样不用每张地图都来一套独立的砖块信息
然后编码由原先的数字改为字符串, 这样能拓展更多信息
我的想法是, 每个编码写作: "[type]_[bounce]_[friction]"

  • 一般来说同一个type(比如弹力块), 就完全可以用统一的颜色(对玩家也友好), 因此颜色就不用写了
  • solid也是基本和type有关的, 用type就能确定
  • friction没必要两个值, 只需要一个

还有像抓墙跳的方块, 完全可以新增一个type来表示

这样的话, 编码表就可以写在一个json文件中确定下来

带有特殊机关的地图砖块

这类砖块(例如传送门)需要搭配自定义脚本, (例如机关门)会产生地图上的交互效果
可以规定其编码为: "#[type]_[arg1]_[arg2]_[arg3]_..." 考虑到这些特殊砖块在不同地图中的独立性, 这些特定的脚本和编码应当写在相应的地图json文件中

随机地图的实现

主要参考《Dead Cells》的随机地图生成

核心思路: 多参数的预制地图块宏观地图调整

预制地图块

预先手工定制一些地图块(map_module), 包含了一些特定的特征, 如:

  • 一个房间的基本构造
  • 房间的出入口位置
  • 房间的宽度和高度
  • 房间的机关位置
  • 房间内的地图特殊砖块配置

一些想法:

  • 由于本游戏当前设计是多人战斗游戏, 因此要设计一些用于混战的地图块, 如:
    • 一个大房间, 多出入口, 中间有一些障碍物
    • 一些单向的房间, 将玩家引向中心房间
    • 一些狭窄的通道, 尽头是宝物
  • 要有一定数量的宝物
  • 玩家出生点要分散, 不能太靠近
  • 整体风格统一, 地基以同一个砖块为主

宏观地图调整

在整体上调整地图的连通性, 使得地图更加有趣, 更有复杂性:

  • 玩家由四周向中间进发, 因此要有一定的引导性
  • 重生点要分散, 不能太近
  • 宝物房间应当分散给每一组玩家(相邻重生点的玩家周围要有宝物)

实现细节

框架搭建
  1. 先根据玩家数量确定地图的大小
  2. 选定一种地图框架(map_frame)(比如:大家都在高出, 集中点在下方, 或者在四周, 最后一起冲向中心)
  3. 基于框架, 确定玩家位置和集中点
  4. 随机取一些"必经点", 打上独立的tag

tag表示地图块所属的集合, 尽量让路线独立开

打通路
通路与否的表示方法

对于一个 $n*m$ (n行m列) 的地图(指最小房间的数量)
有$n-1$条水平的分隔线和$m-1$条垂直的分隔线
每个水平分隔线上有$m$个可打通的位置, 可以用一个$m$位的二进制表示是否打通 所以所有水平分隔线的打通状态可以用一个长度为$n-1$的二进制数数组表示(竖直分隔线类似)

let n = 3, m = 5;
let horSplits = [0b01001, 0b00010]; 
let verSplits = [0b001, 0b100, 0b010, 0b001];

随机地图打通路示意图

工具函数
  1. 获取一个二进制数的特定位的值

    function getBit(num, k) {
      return (num >> k) & 1;
    }
  2. 对于坐标为(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),
      }
    }
  3. 打通或者关闭特定的分隔线

    function switchSplit(isHor, index, k) {
      if(isHor) {
        horSplits[index] ^= 1 << k;
      } else {
        verSplits[index] ^= 1 << k;
      }
    }
打通criticalPath的算法

BFS算法, 从集中点开始, 按照规则打通路, 目标是打通criticalPath:
规则:

  • 宏观路线: 玩家出生点(birthRoom) > 必经点(criticalRoom) > 集中点(jointRoom); 第一部分叫探索区(exploreArea), 第二部分叫引导区(guideArea)
  • 这个基本的路线称为criticalPath, 其他路线称为sidePath
  • 在框架中, 各个区域互不相通, 仅由必经点互相连接
  • 每个通道房间有且仅有两个出路口
  • 每个房间都有一个tag, 用于表示它属于哪个区块
  • 区块的命名规则: P1_C1_exp, C1_J_gui

流程:

  • 对于一个房间单元(坐标为(x,y)), 先检查其四周的分隔线状态
  • 随便找一个方向打通
  • 判断是否符合规则(对面房间的tag是否合适, 是否符合两个出口的规则)
  • 如果不符合规则, 重新选择方向, 直到符合规则
  • 无法从出发点打通到终点, 则打通失败, 整个重来(换种子重新开始)
  • 打通一个就进行下一个房间(先保证是一条每个房间都只有2个出路口的通道)
完善地图的sidPath

此时对于尚未打通的房间, 说明它们是sidPath, 也就是非关键路径
选取一个房间, 设置为奖励房间/特殊房间, 用DFS连到guideArea区域
若还有未打通的房间, 则用BFS连接到任意一个已经打通的房间
循环直至所有房间都已打通

匹配地图模块

现在所有通路情况和区块已经确定了, 根据通路形状决定地图模块
guideArea区域中的房间, 选取一些具有单向性的地图模块, 用于引导玩家

流程:

  1. 随机选择一个未确定的房间单元c
  2. 随机选择一个地图模块m
  3. 如果c作为m的中的某个房间时, m能匹配当前的通路形状, 则放置mc的位置上, m覆盖的所有房间都标记为已确定, 随机调整m的参数, 在最终的地图数组中确定下来
  4. 如果m不能匹配, 重新选择cm中的下一个位置
  5. 如果m的所有位置都尝试过了, 且没有一个能匹配, 则重新选择m
  6. 总有最基本的房间能匹配, 匹配成功后循环直至所有房间都已确定, 最终地图生成

Version 2.2

仅专注于修改系统核心逻辑, 提升游戏性能
用户端负责处理物理引擎, 服务器端仅处理信息收发和逻辑判定

使用方法

数据收发规范

相较于V2.1, V2.2的通信只使用一个端口, 通过详细的type定义来区分不同的信息
bullets信息由player类代为储存, 无需另外传输

用户向服务器发送

  1. 类型: "player", data是用户的game.player对象(符合player类定义规范)

  2. 类型: "time", data是server发过去的时间戳

    • time: server当时的时间戳

服务器向用户发送

大包装均为{"type": string, "data": any}的形式

  1. 类型: "game", data包含当前玩家需要渲染的信息

    • players: 玩家信息数组
    • items: 物品信息数组

    服务器传出的players第一个必定是玩家自己

  2. 类型: "map", data包含地图信息

    • map_id: 地图id
    • map: 地图信息数组
  3. 类型: "pic", data是图片对象

    • pic_src: 图片的索引
    • base64: 图片的base64编码
  4. 类型: "time", data包含server的时间戳和server计算出的延迟

    • time: server的时间戳
    • latency: server计算出的延迟
  5. 类型: "signal", "data"用于传递一些特殊信息

    • content: 特殊信息
      • "initialization_done": 服务器初始化该用户的信息完成
    • augment: 附加参数

开发进度

Static Badge

大问题:

  1. 时间一长极卡, 有坏东西在占算力, 初步猜测为deepcopy的迭代
  2. 现在客户端给服务端的反馈只有人的情况, "捡起了东西"这个消息就无法传达给服务器
  3. 捡武器后白屏, 也没报错, 很神奇

About

JumpinBall multi-player version!

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors