概述
CONTENTS
- 1. 项目原理
- 2. 基础文件
- 3. ac_game_object框架
- 4. 游戏地图与玩家模型的创建
- 5. 角色状态的实现
- 6. 角色基础状态动画实现
- 7. 角色攻击与被攻击状态实现
- 8. 前端组件的补充及计时结束后双方胜负的判断
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.css
、base.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();
}
至此,整个项目实现完毕。
最后
以上就是贤惠糖豆为你收集整理的Web学习笔记-中期项目(拳皇)的全部内容,希望文章能够帮你解决Web学习笔记-中期项目(拳皇)所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复