Web学习笔记-中期项目(拳皇)

1. 项目原理

游戏中一个物体运动的原理是浏览器每秒钟刷新60次,每次我们单独计算这个物体新的位置,然后把他刷新出来,这样最终人眼看起来就是移动的效果。

对于二维的移动,我们一般抽象出某个点比如左上角的坐标 ( x , y ) (x,y) (x,y),并记下物体的宽高 w , h w,h w,h和沿 x , y x,y x,y方向的速度 v x , v y v_x,v_y vx,vy,加入物体在水平方向上匀速运动,那么位移就为: x = x 0 + v x t x=x_0+v_xt x=x0+vxt

2. 基础文件

首先我们创建主界面index.html以及基础CSS、JS文件base.cssbase.js。然后设置好主界面的大小和背景(此时JS文件没有功能,只用于测试):

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>King of Fighters</title>

    <link rel="stylesheet" href="/static/css/base.css">
    <script src="/static/js/jquery-3.6.1.min.js"></script>
</head>

<body>
    <div id="kof"></div>

    <script type="module">
        import { KOF } from '/static/js/base.js';

        let kof = new KOF('kof');
    </script>
</body>

</html>
#kof {
    width: 1280px;
    height: 720px;

    background-image: url('/static/images/background/1.gif');
    background-size: 100% 100%;
    background-position: top;
}
class KOF {
    constructor(id) {
        this.$kof = $('#' + id);
        console.log(this.$kof);
    }
}

export {
    KOF
}

3. ac_game_object框架

项目中的背景、两个玩家一共三个元素,对于这三个元素我们都需要实现每秒钟刷新60次,所以我们可以让这三个元素继承至同一个元素,我们在/static/js中创建一个新的文件夹ac_game_object,并在该文件夹创建base.js(为了区分之后称为ac_game_object/base.js)。该文件框架代码如下:

let AC_GAME_OBJECTS = [];

class AcGameObject {
    constructor() {
        AC_GAME_OBJECTS.push(this);

        this.timedelta = 0;  // 存储当前这帧距离上一帧的时间间隔
        this.has_call_start = false;  // 表示当前对象是否执行过start()
    }

    start() {  // 初始化执行一次

    }

    update() {  // 除第一帧外每帧执行一次

    }

    destroy() {  // 删除当前对象
        for (let i in AC_GAME_OBJECTS) {
            if (AC_GAME_OBJECTS[i] === this) {
                AC_GAME_OBJECTS.splice(i, 1);
                break;
            }
        }
    }
}

let last_timestamp;  // 记录上一帧在什么时间执行

let AC_GAME_OBJECTS_FRAME = (timestamp) => {
    for (let obj of AC_GAME_OBJECTS) {
        if (!obj.has_call_start) {
            obj.start();
            obj.has_call_start = true;
        } else {
            obj.timedelta = timestamp - last_timestamp;
            obj.update();
        }
    }

    last_timestamp = timestamp;

    requestAnimationFrame(AC_GAME_OBJECTS_FRAME);
}

requestAnimationFrame(AC_GAME_OBJECTS_FRAME);

4. 游戏地图与玩家模型的创建

使用canvas设计基本的地图和玩家,2D平面一般使用一个矩形来表示一个玩家模型所占的区域,/static/js/game_map/base.js代码如下:

import { AcGameObject } from '/static/js/ac_game_object/base.js'

class GameMap extends AcGameObject {
    constructor(root) {
        super();

        this.root = root;
        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();
    }

    start() {

    }

    update() {
        this.render();
    }

    render() {  // 渲染函数
        // 每一帧需要清空地图,不然看到的效果就不是物体在移动,而是拖出一条线
        this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
        this.ctx.fillStyle = 'black';
        this.ctx.fillRect(0, 0, this.$canvas.width(), this.$canvas.height());
    }
}

export {
    GameMap
}

/static/js/player/base.js代码如下:

import { AcGameObject } from "/static/js/ac_game_object/base.js";

class Player extends AcGameObject {
    constructor(root, info) {
        super();

        this.root = root;
        this.id = info.id;
        this.x = info.x;
        this.y = info.y;
        this.width = info.width;
        this.height = info.height;
        this.color = info.color;

        this.vx = 0;
        this.vy = 0;

        this.speedx = 350;  // 水平速度
        this.speedy = -1400;  // 跳起的初始速度

        this.ctx = this.root.game_map.ctx;
    }

    start() {

    }

    update() {
        this.render();
    }

    render() {
        this.ctx.fillStyle = this.color;
        this.ctx.fillRect(this.x, this.y, this.width, this.height);
    }
}

export {
    Player
}

主文件base.js代码如下:

import { GameMap } from '/static/js/game_map/base.js';
import { Player } from '/static/js/player/base.js';

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

        this.game_map = new GameMap(this);
        this.players = [
            new Player(this, {
                id: 0,
                x: 200,
                y: this.$kof.height() - 200,  // 之后需要改成0,然后设置角色初始状态为跳跃,根据重力让其自由落体
                width: 120,
                height: 200,
                color: 'blue'
            }),
            new Player(this, {
                id: 1,
                x: 900,
                y: this.$kof.height() - 200,  // 之后需要改成0,然后设置角色初始状态为跳跃,根据重力让其自由落体
                width: 120,
                height: 200,
                color: 'red'
            }),
        ]
    }
}

export {
    KOF
}

此时的效果如下图所示:

在这里插入图片描述

5. 角色状态的实现

由于游戏中角色有静止不动、移动、跳跃等多种状态,因此我们需要使用状态机加以区分,先考虑静止,移动(包括左右移动),跳跃这三种状态,我们分别用0,1,3表示这三个状态,且设定在跳跃状态时无法进行其它操作,状态机如下图所示:

在这里插入图片描述

首先我们需要实现按住某个键角色连续移动的功能,如果只靠keydown判断那么是一连串离散的键值(例如按住某个键后第一至第二下输入有明显间隔),实现控制角色的类/static/js/controller/base/js代码如下:

class Controller {  // 用于读取键盘输入
    constructor($canvas) {
        this.$canvas = $canvas;

        this.pressed_keys = new Set();
        this.start();
    }

    start() {
        let outer = this;
        this.$canvas.on('keydown', function (e) {
            outer.pressed_keys.add(e.key);
        });

        this.$canvas.on('keyup', function (e) {
            outer.pressed_keys.delete(e.key);
        });
    }
}

export {
    Controller
}

然后在GameMap类中创建一个Controller类,然后实现角色的基本操作逻辑,/static/js/player/base.js代码如下:

import { AcGameObject } from "/static/js/ac_game_object/base.js";

class Player extends AcGameObject {
    constructor(root, info) {
        super();

        this.root = root;
        this.id = info.id;
        this.x = info.x;
        this.y = info.y;
        this.width = info.width;
        this.height = info.height;
        this.color = info.color;

        this.direction = 1;  // 角色的方向,正方向为1,反方向为-1

        this.vx = 0;  // 当前水平速度
        this.vy = 0;  // 当前垂直速度

        this.speedx = 350;  // 水平移动速度
        this.speedy = -1400;  // 跳起的初始速度

        this.gravity = 25;  // 重力

        this.ctx = this.root.game_map.ctx;

        this.pressed_keys = this.root.game_map.controller.pressed_keys;

        this.status = 3;  // 0: idle,1: forward,2: backward,3: jump,4: attack,5: be attacked,6: die
        this.animations = new Map();  // 表示每个状态的动作
    }

    start() {

    }

    update_move() {
        this.vy += this.gravity;

        this.x += this.vx * this.timedelta / 1000;
        this.y += this.vy * this.timedelta / 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.$canvas.width()) {
            this.x = this.root.game_map.$canvas.width() - this.width;
        }
    }

    update_control() {
        let w, a, d, j;  // 表示这些键是否按住
        if (this.id === 0) {
            w = this.pressed_keys.has('w');
            a = this.pressed_keys.has('a');
            d = this.pressed_keys.has('d');
            j = this.pressed_keys.has('j');
        } else {
            w = this.pressed_keys.has('ArrowUp');
            a = this.pressed_keys.has('ArrowLeft');
            d = this.pressed_keys.has('ArrowRight');
            j = this.pressed_keys.has('1');
        }

        if (this.status === 0 || this.status === 1) {  /// 假设角色在跳跃状态无法操控
            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;
                this.frame_current_cnt = 0;  // 从第0帧开始渲染
            } else if (j) {
                this.status = 4;
                this.vx = 0;
                this.frame_current_cnt = 0;  // 从第0帧开始渲染
            } 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;
            }
        }
    }

    update() {
        this.update_control();
        this.update_move();

        this.render();
    }

    render() {
        this.ctx.fillStyle = this.color;
        this.ctx.fillRect(this.x, this.y, this.width, this.height);
    }
}

export {
    Player
}

效果如下:

在这里插入图片描述

6. 角色基础状态动画实现

首先在网上找到在canvas中加入.gif文件的工具JS代码(点此跳转),/static/js/utils/gif.js文件代码如下:

const GIF = function () {
    // **NOT** for commercial use.
    var timerID;                          // timer handle for set time out usage
    var st;                               // holds the stream object when loading.
    var interlaceOffsets = [0, 4, 2, 1]; // used in de-interlacing.
    var interlaceSteps = [8, 8, 4, 2];
    var interlacedBufSize;  // this holds a buffer to de interlace. Created on the first frame and when size changed
    var deinterlaceBuf;
    var pixelBufSize;    // this holds a buffer for pixels. Created on the first frame and when size changed
    var pixelBuf;
    const GIF_FILE = { // gif file data headers
        GCExt: 0xF9,
        COMMENT: 0xFE,
        APPExt: 0xFF,
        UNKNOWN: 0x01, // not sure what this is but need to skip it in parser
        IMAGE: 0x2C,
        EOF: 59,   // This is entered as decimal
        EXT: 0x21,
    };
    // simple buffered stream used to read from the file 
    var Stream = function (data) {
        this.data = new Uint8ClampedArray(data);
        this.pos = 0;
        var len = this.data.length;
        this.getString = function (count) { // returns a string from current pos of len count
            var s = "";
            while (count--) { s += String.fromCharCode(this.data[this.pos++]) }
            return s;
        };
        this.readSubBlocks = function () { // reads a set of blocks as a string
            var size, count, data = "";
            do {
                count = size = this.data[this.pos++];
                while (count--) { data += String.fromCharCode(this.data[this.pos++]) }
            } while (size !== 0 && this.pos < len);
            return data;
        }
        this.readSubBlocksB = function () { // reads a set of blocks as binary
            var size, count, data = [];
            do {
                count = size = this.data[this.pos++];
                while (count--) { data.push(this.data[this.pos++]); }
            } while (size !== 0 && this.pos < len);
            return data;
        }
    };
    // LZW decoder uncompressed each frames pixels
    // this needs to be optimised.
    // minSize is the min dictionary as powers of two
    // size and data is the compressed pixels
    function lzwDecode(minSize, data) {
        var i, pixelPos, pos, clear, eod, size, done, dic, code, last, d, len;
        pos = pixelPos = 0;
        dic = [];
        clear = 1 << minSize;
        eod = clear + 1;
        size = minSize + 1;
        done = false;
        while (!done) { // JavaScript optimisers like a clear exit though I never use 'done' apart from fooling the optimiser
            last = code;
            code = 0;
            for (i = 0; i < size; i++) {
                if (data[pos >> 3] & (1 << (pos & 7))) { code |= 1 << i }
                pos++;
            }
            if (code === clear) { // clear and reset the dictionary
                dic = [];
                size = minSize + 1;
                for (i = 0; i < clear; i++) { dic[i] = [i] }
                dic[clear] = [];
                dic[eod] = null;
            } else {
                if (code === eod) { done = true; return }
                if (code >= dic.length) { dic.push(dic[last].concat(dic[last][0])) }
                else if (last !== clear) { dic.push(dic[last].concat(dic[code][0])) }
                d = dic[code];
                len = d.length;
                for (i = 0; i < len; i++) { pixelBuf[pixelPos++] = d[i] }
                if (dic.length === (1 << size) && size < 12) { size++ }
            }
        }
    };
    function parseColourTable(count) { // get a colour table of length count  Each entry is 3 bytes, for RGB.
        var colours = [];
        for (var i = 0; i < count; i++) { colours.push([st.data[st.pos++], st.data[st.pos++], st.data[st.pos++]]) }
        return colours;
    }
    function parse() {        // read the header. This is the starting point of the decode and async calls parseBlock
        var bitField;
        st.pos += 6;
        gif.width = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        gif.height = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        bitField = st.data[st.pos++];
        gif.colorRes = (bitField & 0b1110000) >> 4;
        gif.globalColourCount = 1 << ((bitField & 0b111) + 1);
        gif.bgColourIndex = st.data[st.pos++];
        st.pos++;                    // ignoring pixel aspect ratio. if not 0, aspectRatio = (pixelAspectRatio + 15) / 64
        if (bitField & 0b10000000) { gif.globalColourTable = parseColourTable(gif.globalColourCount) } // global colour flag
        setTimeout(parseBlock, 0);
    }
    function parseAppExt() { // get application specific data. Netscape added iterations and terminator. Ignoring that
        st.pos += 1;
        if ('NETSCAPE' === st.getString(8)) { st.pos += 8 }  // ignoring this data. iterations (word) and terminator (byte)
        else {
            st.pos += 3;            // 3 bytes of string usually "2.0" when identifier is NETSCAPE
            st.readSubBlocks();     // unknown app extension
        }
    };
    function parseGCExt() { // get GC data
        var bitField;
        st.pos++;
        bitField = st.data[st.pos++];
        gif.disposalMethod = (bitField & 0b11100) >> 2;
        gif.transparencyGiven = bitField & 0b1 ? true : false; // ignoring bit two that is marked as  userInput???
        gif.delayTime = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        gif.transparencyIndex = st.data[st.pos++];
        st.pos++;
    };
    function parseImg() {                           // decodes image data to create the indexed pixel image
        var deinterlace, frame, bitField;
        deinterlace = function (width) {                   // de interlace pixel data if needed
            var lines, fromLine, pass, toline;
            lines = pixelBufSize / width;
            fromLine = 0;
            if (interlacedBufSize !== pixelBufSize) {      // create the buffer if size changed or undefined.
                deinterlaceBuf = new Uint8Array(pixelBufSize);
                interlacedBufSize = pixelBufSize;
            }
            for (pass = 0; pass < 4; pass++) {
                for (toLine = interlaceOffsets[pass]; toLine < lines; toLine += interlaceSteps[pass]) {
                    deinterlaceBuf.set(pixelBuf.subarray(fromLine, fromLine + width), toLine * width);
                    fromLine += width;
                }
            }
        };
        frame = {}
        gif.frames.push(frame);
        frame.disposalMethod = gif.disposalMethod;
        frame.time = gif.length;
        frame.delay = gif.delayTime * 10;
        gif.length += frame.delay;
        if (gif.transparencyGiven) { frame.transparencyIndex = gif.transparencyIndex }
        else { frame.transparencyIndex = undefined }
        frame.leftPos = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        frame.topPos = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        frame.width = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        frame.height = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        bitField = st.data[st.pos++];
        frame.localColourTableFlag = bitField & 0b10000000 ? true : false;
        if (frame.localColourTableFlag) { frame.localColourTable = parseColourTable(1 << ((bitField & 0b111) + 1)) }
        if (pixelBufSize !== frame.width * frame.height) { // create a pixel buffer if not yet created or if current frame size is different from previous
            pixelBuf = new Uint8Array(frame.width * frame.height);
            pixelBufSize = frame.width * frame.height;
        }
        lzwDecode(st.data[st.pos++], st.readSubBlocksB()); // decode the pixels
        if (bitField & 0b1000000) {                        // de interlace if needed
            frame.interlaced = true;
            deinterlace(frame.width);
        } else { frame.interlaced = false }
        processFrame(frame);                               // convert to canvas image
    };
    function processFrame(frame) { // creates a RGBA canvas image from the indexed pixel data.
        var ct, cData, dat, pixCount, ind, useT, i, pixel, pDat, col, frame, ti;
        frame.image = document.createElement('canvas');
        frame.image.width = gif.width;
        frame.image.height = gif.height;
        frame.image.ctx = frame.image.getContext("2d");
        ct = frame.localColourTableFlag ? frame.localColourTable : gif.globalColourTable;
        if (gif.lastFrame === null) { gif.lastFrame = frame }
        useT = (gif.lastFrame.disposalMethod === 2 || gif.lastFrame.disposalMethod === 3) ? true : false;
        if (!useT) { frame.image.ctx.drawImage(gif.lastFrame.image, 0, 0, gif.width, gif.height) }
        cData = frame.image.ctx.getImageData(frame.leftPos, frame.topPos, frame.width, frame.height);
        ti = frame.transparencyIndex;
        dat = cData.data;
        if (frame.interlaced) { pDat = deinterlaceBuf }
        else { pDat = pixelBuf }
        pixCount = pDat.length;
        ind = 0;
        for (i = 0; i < pixCount; i++) {
            pixel = pDat[i];
            col = ct[pixel];
            if (ti !== pixel) {
                dat[ind++] = col[0];
                dat[ind++] = col[1];
                dat[ind++] = col[2];
                dat[ind++] = 255;      // Opaque.
            } else
                if (useT) {
                    dat[ind + 3] = 0; // Transparent.
                    ind += 4;
                } else { ind += 4 }
        }
        frame.image.ctx.putImageData(cData, frame.leftPos, frame.topPos);
        gif.lastFrame = frame;
        if (!gif.waitTillDone && typeof gif.onload === "function") { doOnloadEvent() }// if !waitTillDone the call onload now after first frame is loaded
    };
    // **NOT** for commercial use.
    function finnished() { // called when the load has completed
        gif.loading = false;
        gif.frameCount = gif.frames.length;
        gif.lastFrame = null;
        st = undefined;
        gif.complete = true;
        gif.disposalMethod = undefined;
        gif.transparencyGiven = undefined;
        gif.delayTime = undefined;
        gif.transparencyIndex = undefined;
        gif.waitTillDone = undefined;
        pixelBuf = undefined; // dereference pixel buffer
        deinterlaceBuf = undefined; // dereference interlace buff (may or may not be used);
        pixelBufSize = undefined;
        deinterlaceBuf = undefined;
        gif.currentFrame = 0;
        if (gif.frames.length > 0) { gif.image = gif.frames[0].image }
        doOnloadEvent();
        if (typeof gif.onloadall === "function") {
            (gif.onloadall.bind(gif))({ type: 'loadall', path: [gif] });
        }
        if (gif.playOnLoad) { gif.play() }
    }
    function canceled() { // called if the load has been cancelled
        finnished();
        if (typeof gif.cancelCallback === "function") { (gif.cancelCallback.bind(gif))({ type: 'canceled', path: [gif] }) }
    }
    function parseExt() {              // parse extended blocks
        const blockID = st.data[st.pos++];
        if (blockID === GIF_FILE.GCExt) { parseGCExt() }
        else if (blockID === GIF_FILE.COMMENT) { gif.comment += st.readSubBlocks() }
        else if (blockID === GIF_FILE.APPExt) { parseAppExt() }
        else {
            if (blockID === GIF_FILE.UNKNOWN) { st.pos += 13; } // skip unknow block
            st.readSubBlocks();
        }

    }
    function parseBlock() { // parsing the blocks
        if (gif.cancel !== undefined && gif.cancel === true) { canceled(); return }

        const blockId = st.data[st.pos++];
        if (blockId === GIF_FILE.IMAGE) { // image block
            parseImg();
            if (gif.firstFrameOnly) { finnished(); return }
        } else if (blockId === GIF_FILE.EOF) { finnished(); return }
        else { parseExt() }
        if (typeof gif.onprogress === "function") {
            gif.onprogress({ bytesRead: st.pos, totalBytes: st.data.length, frame: gif.frames.length });
        }
        setTimeout(parseBlock, 0); // parsing frame async so processes can get some time in.
    };
    function cancelLoad(callback) { // cancels the loading. This will cancel the load before the next frame is decoded
        if (gif.complete) { return false }
        gif.cancelCallback = callback;
        gif.cancel = true;
        return true;
    }
    function error(type) {
        if (typeof gif.onerror === "function") { (gif.onerror.bind(this))({ type: type, path: [this] }) }
        gif.onload = gif.onerror = undefined;
        gif.loading = false;
    }
    function doOnloadEvent() { // fire onload event if set
        gif.currentFrame = 0;
        gif.nextFrameAt = gif.lastFrameAt = new Date().valueOf(); // just sets the time now
        if (typeof gif.onload === "function") { (gif.onload.bind(gif))({ type: 'load', path: [gif] }) }
        gif.onerror = gif.onload = undefined;
    }
    function dataLoaded(data) { // Data loaded create stream and parse
        st = new Stream(data);
        parse();
    }
    function loadGif(filename) { // starts the load
        var ajax = new XMLHttpRequest();
        ajax.responseType = "arraybuffer";
        ajax.onload = function (e) {
            if (e.target.status === 404) { error("File not found") }
            else if (e.target.status >= 200 && e.target.status < 300) { dataLoaded(ajax.response) }
            else { error("Loading error : " + e.target.status) }
        };
        ajax.open('GET', filename, true);
        ajax.send();
        ajax.onerror = function (e) { error("File error") };
        this.src = filename;
        this.loading = true;
    }
    function play() { // starts play if paused
        if (!gif.playing) {
            gif.paused = false;
            gif.playing = true;
            playing();
        }
    }
    function pause() { // stops play
        gif.paused = true;
        gif.playing = false;
        clearTimeout(timerID);
    }
    function togglePlay() {
        if (gif.paused || !gif.playing) { gif.play() }
        else { gif.pause() }
    }
    function seekFrame(frame) { // seeks to frame number.
        clearTimeout(timerID);
        gif.currentFrame = frame % gif.frames.length;
        if (gif.playing) { playing() }
        else { gif.image = gif.frames[gif.currentFrame].image }
    }
    function seek(time) { // time in Seconds  // seek to frame that would be displayed at time
        clearTimeout(timerID);
        if (time < 0) { time = 0 }
        time *= 1000; // in ms
        time %= gif.length;
        var frame = 0;
        while (time > gif.frames[frame].time + gif.frames[frame].delay && frame < gif.frames.length) { frame += 1 }
        gif.currentFrame = frame;
        if (gif.playing) { playing() }
        else { gif.image = gif.frames[gif.currentFrame].image }
    }
    function playing() {
        var delay;
        var frame;
        if (gif.playSpeed === 0) {
            gif.pause();
            return;
        } else {
            if (gif.playSpeed < 0) {
                gif.currentFrame -= 1;
                if (gif.currentFrame < 0) { gif.currentFrame = gif.frames.length - 1 }
                frame = gif.currentFrame;
                frame -= 1;
                if (frame < 0) { frame = gif.frames.length - 1 }
                delay = -gif.frames[frame].delay * 1 / gif.playSpeed;
            } else {
                gif.currentFrame += 1;
                gif.currentFrame %= gif.frames.length;
                delay = gif.frames[gif.currentFrame].delay * 1 / gif.playSpeed;
            }
            gif.image = gif.frames[gif.currentFrame].image;
            timerID = setTimeout(playing, delay);
        }
    }
    var gif = {                      // the gif image object
        onload: null,       // fire on load. Use waitTillDone = true to have load fire at end or false to fire on first frame
        onerror: null,       // fires on error
        onprogress: null,       // fires a load progress event
        onloadall: null,       // event fires when all frames have loaded and gif is ready
        paused: false,      // true if paused
        playing: false,      // true if playing
        waitTillDone: true,       // If true onload will fire when all frames loaded, if false, onload will fire when first frame has loaded
        loading: false,      // true if still loading
        firstFrameOnly: false,      // if true only load the first frame
        width: null,       // width in pixels
        height: null,       // height in pixels
        frames: [],         // array of frames
        comment: "",         // comments if found in file. Note I remember that some gifs have comments per frame if so this will be all comment concatenated
        length: 0,          // gif length in ms (1/1000 second)
        currentFrame: 0,          // current frame. 
        frameCount: 0,          // number of frames
        playSpeed: 1,          // play speed 1 normal, 2 twice 0.5 half, -1 reverse etc...
        lastFrame: null,       // temp hold last frame loaded so you can display the gif as it loads
        image: null,       // the current image at the currentFrame
        playOnLoad: true,       // if true starts playback when loaded
        // functions
        load: loadGif,    // call this to load a file
        cancel: cancelLoad, // call to stop loading
        play: play,       // call to start play
        pause: pause,      // call to pause
        seek: seek,       // call to seek to time
        seekFrame: seekFrame,  // call to seek to frame
        togglePlay: togglePlay, // call to toggle play and pause state
    };
    return gif;
}

export {
    GIF
}

创建角色Kyo,将每个动画加载出来,由于有的动画高度不同,因此需要设置不同动画在 y y y轴上的偏移量,且由于动画帧数不多,网页每秒渲染帧数太多,因此需要设置浏览器渲染几帧时再渲染角色的动画,/static/js/player/kyo.js代码如下:

import { Player } from '/static/js/player/base.js';
import { GIF } from '/static/js/utils/gif.js';

class Kyo extends Player {
    constructor(root, info) {
        super(root, info);

        this.init_animations();
    }

    init_animations() {
        let outer = this;
        let offsets = [0, -22, -22, -100, 0, 0, 0];
        for (let i = 0; i < 7; i++) {  // 一共7个动画
            let gif = GIF();
            gif.load(`/static/images/player/kyo/${i}.gif`);
            this.animations.set(i, {
                gif: gif,
                frame_cnt: 0,  // 表示gif中的总图片数
                frame_rate: 12,  // 表示每12帧渲染一次
                offset_y: offsets[i],  // y方向的偏移量
                loaded: false,  // 表示是否加载完成
                scale: 2  // 角色放大倍数
            });

            if (i === 3) this.animations.get(i).frame_rate = 10;

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

export {
    Kyo
}

然后将主JS文件base.js的内容修改为:

import { GameMap } from '/static/js/game_map/base.js';
import { Kyo } from '/static/js/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
}

然后针对不同的角色状态,设置相应的动画效果,在设置角色朝向的时候,由于动画无法翻转,因此需要将canvas进行翻转,翻转后需要注意调整角色参考坐标的映射,具体操作为先沿canvas中轴对称,然后再减去角色宽度,如下图所示:

在这里插入图片描述

此时实现的功能在/static/js/player/base.js核心代码如下:

class Player extends AcGameObject {
    constructor(root, info) {
        super();

        this.direction = 1;  // 角色的方向,正方向为1,反方向为-1

        this.ctx = this.root.game_map.ctx;

        this.status = 3;  // 0: idle,1: forward,2: backward,3: jump,4: attack,5: be attacked,6: die
        this.animations = new Map();  // 表示每个状态的动作
        this.frame_current_cnt = 0;  // 表示当前记录了多少帧
    }

    start() {

    }

    update_direction() {
        if (this.status === 6) return;  // die后不再改变方向

        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;
        }
    }

    update() {
        this.update_control();
        this.update_direction();
        this.update_move();

        this.render();
    }

    render() {
        // 测试玩家模型
        // this.ctx.fillStyle = this.color;
        // this.ctx.fillRect(this.x, this.y, this.width, this.height);

        let status = this.status;

        if (this.status === 1 && this.direction * this.vx < 0) {  // 如果角色方向和水平速度方向乘积为负说明是后退
            status = 2;
        }

        let obj = this.animations.get(status);
        if (obj && obj.loaded) {
            if (this.direction > 0) {
                let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;  // 循环渲染,且控制其不每帧渲染一次,否则动作速度太快
                let image = obj.gif.frames[k].image;
                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轴坐标乘上-1,y轴坐标不变
                this.ctx.translate(-this.root.game_map.$canvas.width(), 0);

                let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
                let image = obj.gif.frames[k].image;
                this.ctx.drawImage(image, this.root.game_map.$canvas.width() - this.x - this.width, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);

                this.ctx.restore();
            }
        }

        // 跳跃和攻击动画结束后应回到静止状态
        if ((status === 3 || status === 4 || status === 5) && this.frame_current_cnt === obj.frame_rate * (obj.frame_cnt - 1)) {
            this.status = 0;
        }

        this.frame_current_cnt++;
    }
}

export {
    Player
}

7. 角色攻击与被攻击状态实现

我们用一个矩形区域表示角色的挥拳范围,当攻击时我们判断攻击角色的攻击区域和另一名角色的模型矩形区域有交集,那么另一名角色受到攻击,被攻击函数中我们设置相应的被打状态以及血量之类的修改,此时实现的功能在/static/js/player/base.js核心代码如下:

class Player extends AcGameObject {
    constructor(root, info) {
        super();

        this.ctx = this.root.game_map.ctx;

        this.status = 3;  // 0: idle,1: forward,2: backward,3: jump,4: attack,5: be attacked,6: die
        this.animations = new Map();  // 表示每个状态的动作
        this.frame_current_cnt = 0;  // 表示当前记录了多少帧

        this.hp = 100;
        this.$hp_outer = this.root.$kof.find(`.kof-head>.kof-head-hp-${this.id}>.kof-head-hp-${this.id}-inner>.kof-head-hp-${this.id}-outer`);  // 外层血条
        this.$hp_inner = this.root.$kof.find(`.kof-head>.kof-head-hp-${this.id}>.kof-head-hp-${this.id}-inner`);  // 内层血条
    }

    start() {

    }

    is_attacked() {  // 被攻击
        if (this.status === 6) return;  // die后不再被攻击

        this.status = 5;
        this.frame_current_cnt = 0;

        this.hp = Math.max(this.hp - 20, 0);

        // 使用transition控制血条衰减的速度
        this.$hp_outer.css({
            width: this.$hp_inner.parent().width() * this.hp / 100,
        })
        this.$hp_inner.css({
            width: this.$hp_inner.parent().width() * this.hp / 100,
            transition: '1500ms'
        })

        // 使用animate控制血条衰减的速度
        // this.$hp_outer.width(this.$hp_inner.parent().width() * this.hp / 100);
        // this.$hp_inner.animate({
        //     width: this.$hp_inner.parent().width() * this.hp / 100
        // }, 1500);

        this.vx = 100 * (-this.direction);  // 向反方向的击退效果

        if (this.hp === 0) {
            this.status = 6;
            this.frame_current_cnt = 0;
        }
    }

    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;
    }

    update_attack() {
        if (this.status === 4 && this.frame_current_cnt === 38) {  // 攻击动画到第38帧的时候检测碰撞
            let me = this, you = this.root.players[1 - this.id];

            let r1;
            if (me.direction > 0) {
                r1 = {
                    x1: me.x + 120,  // (x1, y1)为攻击区域的左上角坐标
                    y1: me.y + 40,
                    x2: me.x + 120 + 100,  // (x2, y2)为攻击区域的右下角坐标
                    y2: me.y + 40 + 20
                }
            } else {
                r1 = {
                    x1: this.x + this.width - 220,
                    y1: me.y + 40,
                    x2: this.x + this.width - 220 + 100,
                    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_attacked();
            }
        }
    }

    update() {
        this.update_control();
        this.update_direction();
        this.update_move();
        this.update_attack();

        this.render();
    }

    render() {
        // 测试玩家模型
        // this.ctx.fillStyle = this.color;
        // this.ctx.fillRect(this.x, this.y, this.width, this.height);

        // 测试出拳碰撞模型
        // if (this.direction > 0) {
        //     this.ctx.fillStyle = 'red';
        //     this.ctx.fillRect(this.x + 120, this.y + 40, 100, 20);
        // } else {
        //     this.ctx.fillStyle = 'red';
        //     this.ctx.fillRect(this.x + this.width - 220, this.y + 40, 100, 20);
        // }

        let status = this.status;

        if (this.status === 1 && this.direction * this.vx < 0) {  // 如果角色方向和水平速度方向乘积为负说明是后退
            status = 2;
        }

        let obj = this.animations.get(status);
        if (obj && obj.loaded) {
            if (this.direction > 0) {
                let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;  // 循环渲染,且控制其不每帧渲染一次,否则动作速度太快
                let image = obj.gif.frames[k].image;
                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轴坐标乘上-1,y轴坐标不变
                this.ctx.translate(-this.root.game_map.$canvas.width(), 0);

                let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
                let image = obj.gif.frames[k].image;
                this.ctx.drawImage(image, this.root.game_map.$canvas.width() - this.x - this.width, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);

                this.ctx.restore();
            }
        }

        // 跳跃和攻击动画结束后应回到静止状态
        if ((status === 3 || status === 4 || status === 5) && this.frame_current_cnt === obj.frame_rate * (obj.frame_cnt - 1)) {
            this.status = 0;
        }

        // die的最后一帧后应倒地不起
        if (status === 6 && this.frame_current_cnt === obj.frame_rate * (obj.frame_cnt - 1)) {
            this.frame_current_cnt--;  // 和后面的this.frame_current_cnt++抵消
            this.vx = 0;  // die后不再有击退效果
        }

        this.frame_current_cnt++;
    }
}

export {
    Player
}

8. 前端组件的补充及计时结束后双方胜负的判断

我们需要在页面上加上两个玩家的血条以及计时器,血条的设计需要三层div的设计,最外层的.kof-head-hp-0表示玩家0血条的边框,第二层的.kof-head-hp-0>.kof-head-hp-0-inner表示血条底层的红条,最内层的.kof-head-hp-0>.kof-head-hp-0-inner>.kof-head-hp-0-outer表示血条表层(覆盖在最上面)的黄条,当掉血时,控制黄条以更快的速度衰减,红条以更慢的速度衰减即可。

代码如下:

this.$hp_outer = this.root.$kof.find(`.kof-head>.kof-head-hp-${this.id}>.kof-head-hp-${this.id}-inner>.kof-head-hp-${this.id}-outer`);  // 外层血条
this.$hp_inner = this.root.$kof.find(`.kof-head>.kof-head-hp-${this.id}>.kof-head-hp-${this.id}-inner`);  // 内层血条

// 使用transition控制血条衰减的速度
this.$hp_outer.css({
    width: this.$hp_inner.parent().width() * this.hp / 100,
})
this.$hp_inner.css({
    width: this.$hp_inner.parent().width() * this.hp / 100,
    transition: '1500ms'
})

当倒计时结束时,如果双方血量相同则同时倒地,否则血量少的一方倒地,然后同时更新血条即可,/static/js/game_map/base.js核心代码如下:

update_hp(player) {
    this.$hp_outer = this.root.$kof.find(`.kof-head>.kof-head-hp-${player.id}>.kof-head-hp-${player.id}-inner>.kof-head-hp-${player.id}-outer`);
    this.$hp_inner = this.root.$kof.find(`.kof-head>.kof-head-hp-${player.id}>.kof-head-hp-${player.id}-inner`);
    this.$hp_outer.css({
        width: this.$hp_inner.parent().width() * player.hp / 100,
    })
    this.$hp_inner.css({
        width: this.$hp_inner.parent().width() * player.hp / 100,
        transition: '1500ms'
    })
}

update() {
    let [a, b] = this.root.players;

    if (this.time_left > 0 && a.status !== 6 && b.status !== 6) {  // 没人die时计时
        this.time_left -= this.timedelta;
    } else if (this.time_left < 0 && this.time_left > -500) {  // 时间结束后血少的玩家die,血相同一起die,只执行一次
        this.time_left = -500;

        if (a.hp !== b.hp) {
            let lower = (a.hp > b.hp) ? b : a;
            lower.hp = 0;
            lower.status = 6;
            lower.frame_current_cnt = 0;

            this.update_hp(lower);
        } else {
            a.status = b.status = 6;
            a.hp = b.hp = 0;
            a.frame_current_cnt = b.frame_current_cnt = 0;

            this.update_hp(a);
            this.update_hp(b);
        }
    }

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

    this.render();
}

至此,整个项目实现完毕。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柃歌

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值