用threejs+cannon来写一个幸存者游戏吧。

53 阅读3分钟

前言

不喜欢玩游戏的技术宅不是一个好的技术宅。为了使得学习threejs不那么枯燥,遂以制作一款生存者游戏为目标,来达成学习的目的。

效果展示

a.png

b.png

c.png

d.png

源代码

仓库地址: gitee

部分逻辑参考了这位大佬: steve

体验地址: game

一些关键点

  1. threejs实现基本的渲染逻辑,cannon负责物理系统
  2. UI图标绘制用canvas实现
  3. 弹窗用dom元素和样式实现
  4. 判断设备类别分别实现wasd移动操作和摇杆操作
  5. 一些技能算法的实现思路
  6. 后续可玩操作加载glb模型和动画替代主角,添加socket通信框架实现多人游戏等。

献丑

整体目录架构

│  main.ts
│  style.css
│  typescript.svg
│  vite-env.d.ts
├─assets  //资源文件目录
│  ├─audio   //音频文件
│  │   └─background.mp3
│  └─textures //贴图文件
│      ├─ground //地形贴图
│      ├─items  //物品贴图
│      ├─monsters //怪物贴图
│      └─skills  //技能贴图
├─base //基础类
│      BodyGC.ts  //物理世界回收器
│      constants.ts //常量定义
│      emitter.ts   //全局通知事件
│      loader.ts  //加载器
│      registry.ts //动态自定义注册类
├─core //单例核心模块 
│      index.ts
├─data //数据定义config
│      config.ts
├─design //设计器
│  ├─items //物品设计逻辑实现
│  ├─monsters //怪物设计逻辑实现
│  └─skills //技能设计逻辑实现
│          BaseSkill.ts
│          BurningGround.ts
│          Fireball.ts
│          FlyingSword.ts
│          index.ts
│          laser.ts
│          LightChain.ts
│          LightningComet.ts
│          Longinus.ts
├─modules //业务模块
│      Audio.ts
│      character.ts
│      ItemManager.ts
│      LevelManager.ts
│      world.ts
└─ui //UI控制器
   ├─FormUI.ts
   ├─index.ts
   └─ui.css

加载器预加载资源以及缓存

在src/core/index.ts里定义核心模块功能,预先将指定目录下的贴图等资源加载并缓存起来

     private async _loadTextures() {
        // 1. 拿到所有静态资源模块
        const modules = import.meta.glob('/src/assets/**', { eager: true, as: 'url' });
        await Promise.all(
            Object.entries(modules).map(async ([fullPath, url]) => {
                const fileName: any = fullPath.split('/').pop();
                if (fullPath.includes('audio')) {
                    const audio: any = await this.loader.audio_loader.loadAsync(url);
                    this.maps[fileName] = audio;
                } else {
                    const texture: any = await this.loader.texture_loader.loadAsync(url);
                    texture.colorSpace = SRGBColorSpace;
                    texture.wrapS = texture.wrapT = RepeatWrapping;
                    this.maps[fileName] = texture;
                }
            })
        );
    } 

后续各个模块就可以通过 this.core.maps[key]来直接调用贴图。

核心模块作为单例模式可以被各个模块引用,这个模块主要是初始化threejs主对象和camera,rennder,scene等,以及cannon的world对象。

世界管理器

src/modules/world.ts实现了全局场景管理器,在这里对角色模块以及技能系统,场景系统进行初始化。并且实现对各类事件的监听。

export default class World {
    //世界核心
    core!: Core
    //是否进入场景的flag
    enterWorld: boolean = false;
    character!: Character
    private _width: number;
    private _height: number;
    ground!: THREE.Mesh
    levelManager!: LevelManager
    gc!: BodyGC
    formUI!: FormUI
    itemManager: ItemManager
    audio: Audio
    constructor(width: number, height: number) {
        this._width = width;
        this._height = height;
        this.core = new Core();
        this.core.$on(ON_LOAD_PROGRESS, this._handleLoadProgress.bind(this))
        this.core.$on(ON_LOAD_MODEL_FINISH, this._handleLoadFinish.bind(this))
        this.core.$on(ON_ENTER_APP, this._onEnterApp.bind(this))
        this.core.$on(ON_CLICK_RAY_CAST, this._onClickRayCast.bind(this))
        this.core.$on(ON_SHOW_TOOLTIP, this._onShowToolTip.bind(this))
        this.core.$on(ON_HIDE_TOOLTIP, this._onHideToolTip.bind(this))
        this.core.$on(ON_GAME_OVER, this._onGameOver.bind(this))
        this.core.$on(ON_LEVEL_UP_OPEN, this._onLevelUpOpen.bind(this))
        this.core.$on(ON_LEVEL_UP_CLOSE, this._onLevelUpClose.bind(this))
        this.core.$on(ON_TOUCH_ITEMS, this._onTouchItems.bind(this))

        this.gc = new BodyGC();
        this.character = new Character();

        this.levelManager = new LevelManager();
        this.itemManager = new ItemManager();
        this.formUI = new FormUI();
        this.audio = new Audio();
        this._initLight();
        this._initHelper();
        setTimeout(() => {
            this.core.$emit(ON_LOAD_MODEL_FINISH, {});
        }, 1000);

    }
}

技能系统

通过装饰器工厂模式可以便捷快速的实例化技能对象。

type Class = new (...args: any[]) => any;
interface Creator {
  new (...args: any[]): any;
  // 额外放个体型标记,方便调试
  className?: string;
}
/**
 * 全局注册表
 */
const Repo = new Map<string, Class>();
/**
 * 装饰器工厂:把类丢进 Repo
 */
export function register(key?: string) {
  return <T extends Creator>(target: T) => {
    const k = key ?? target.name;
    target.className = k;          // 调试用
    Repo.set(k, target);
    return target;
  };
}
/**
 * 创建实例:把 ...args 原样交给构造函数
 */
export function createInstance<T extends Class>(
  name: string,
  ...args: ConstructorParameters<T>
): InstanceType<T> {
  const C = Repo.get(name) as T | undefined;
  if (!C) throw new Error(`Class "${name}" not registered`);
  return new C(...args) as InstanceType<T>;
}

/**
 * 查看已注册列表
 */
export function listRegistered() {
  return [...Repo.keys()];
}

只需要在技能类上标注@register("Fireball"),然后调用装饰器的createInstance方法就可以实现指定技能的实例化。

@register("Fireball")
export default class Fireball extends BaseSkill {
    mesh!: THREE.Object3D;
    body!: CANNON.Body;
    constructor(options: SkillConfig) {}
}    

UI绘制

通过挂载canvas对象调用2d模式实现叠加层UI的绘制。

export default class FormUI {
    private canvas;
    private ctx;
    private w = 400
    private h = 100
    private iconSize: number = 48;
    private role: Character
    private time: number = 0
    constructor() {
        this.canvas = document.createElement('canvas');
        this.ctx = this.canvas.getContext('2d')!;
        this.canvas.width = this.w
        this.canvas.height = this.h
        this.role = new Character();

        Object.assign(this.canvas.style, {
            position: 'fixed',
            left: '50%',
            bottom: '10px',
            transform: 'translateX(-50%)',
            zIndex: 15,
            pointerEvents: 'none' // 让点击穿透到页面
        })
        document.body.appendChild(this.canvas)
    }
}
//具体的绘制方法
private _draw() {

}

结束语