文來源于:前端新世界 ;作者:前端新世界
如有侵權,聯系刪除
今天我將介紹如何用JavaScript編寫一個簡單的2D格斗游戲。
此項目在CSS方面非常簡單,所以我將使用原生CSS并省去構建相關CSS的步驟。
默認使用TypeScript。所以還需要一個構建工具來編譯JavaScript。我選擇的是ESBuild。
#!/usr/bin/env node
const watchFlag = process.argv.indexOf("--watch") > -1;
require("esbuild")
.build({
entryPoints: ["src/ts/main.ts", "src/ts/sw.ts"],
bundle: true,
outdir: "public",
watch: watchFlag,
})
.catch(() => process.exit(1));
? attacke git:(main) yarn build
yarn run v1.22.10
$ ./esbuild.js
? Done in 0.47s.
提供<canvas>的網站并沒有什么特別之處。唯一重要的元素是canvas本身。它本身不能獲得焦點,需要tabindex才能通過鍵盤訪問。點擊鍵盤上下鍵將上下移動頁面。而我們需要避免canvas有焦點的這種情況,否則頁面會隨著角色移動而上下跳躍。寬度和高度也是固定的,canvas可能不會以全高清顯示,但其尺寸是畫布坐標系的端點,因此需要計算位置。
我還添加了一個Loading加載器,以獲得更流暢的游戲啟動體驗。
<div class="loader">
<progress value="0" max="100"></progress>
</div>
<canvas tabindex="0" id="canvas" width="1920" height="1080"></canvas>
JavaScript的實時游戲需要游戲循環:遞歸函數在每一幀調用自身。即如果我們想保持在60fps或達到每33ms 30fps的目標,那么渲染一幀的性能預算是16ms。循環本身沒有游戲邏輯。因而我打算每一幀發送一個tick事件。游戲的所有其他部分都可以偵聽該事件。
然后,第一次嘗試我失敗了。
export class Renderer {
ctx: CanvasRenderingContext2D;
ticker: number;
constructor(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
this.ticker = setInterval(() => {
const tick = new Event("tick", {
bubbles: true,
cancelable: true,
composed: false,
});
ctx.canvas.dispatchEvent(tick);
}, 1000 / 60); // aim for 60fps
}
}
我使用了一個定時器來調用游戲循環。這在Chrome上運行良好,但在Firefox和Safari上就崩潰了。Firefox在drawImage()方面表現不佳,因此我決定繪制sprites。不過,雖然Safari即使在每幀繪制大圖像時,也能夠以60fps的速度渲染,但有時卻實現不了。原因是Macbooks默認啟用節電模式,只要未連接電源線,Safari就會限制為30fps。我花了一段時間才搞清楚這一點。
這兩個問題的解決方法都是使用requestAnimationFrame取代setInterval。
constructor(ctx: CanvasRenderingContext2D, theme: Theme) {
this.ctx = ctx;
this.theme = theme;
this.fps = 60; // aim for 60fps
this.counter = 0;
this.initTicker();
}
private initTicker() {
window.requestAnimationFrame(() => {
this.tick();
this.initTicker();
});
}
現在雖然游戲在這些瀏覽器中都能流暢運行,但是游戲速度仍然不同。30fps的瀏覽器將以一半的速度運行游戲。下面我將通過測量幀之間的時間并將跳過的幀數注入計算來解決這個問題。
private tick() {
const timeStamp = performance.now();
const secondsPassed = (timeStamp - this.oldTimeStamp) / 1000;
this.oldTimeStamp = timeStamp;
// Calculate fps
const fps = Math.round(1 / secondsPassed);
const frameSkip = clamp(Math.round((60 - fps) / fps), 0, 30);
// to allow for animations lasting 1s
if (this.counter >= this.fps * 2) {
this.counter = 0;
}
const tick: TickEvent = new CustomEvent("tick", {
bubbles: true,
cancelable: true,
composed: false,
detail: {
frameCount: this.counter,
frameSkip: frameSkip,
},
});
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
this.ctx.canvas.dispatchEvent(tick);
this.counter++;
}
每個游戲角色都會在各自的character類實例中被調用。它控制玩家的行為、動作、外觀和聲音。
在現實世界中的對象,當角色四處移動時,不是立即從零加速到最高速度。這中間有一個加速和減速的過程。且以一定的速度移動。這些要求反映到類上就是:
class Character {
position: coordinates;
orientation: number;
speed: number;
maxVelocity: number;
velocity: coordinates;
obstacle: Obstacle;
action: {
movingX: number;
movingY: number;
};
//...
}
當按下移動鍵時,action.movingX|Y屬性設置為+-1。釋放鍵時,該屬性設置為0。這可作為玩家開始或繼續移動的指示器。
// 向左移動
config.controls[this.player].left.forEach((key: string) => {
document.addEventListener("keydown", (event: KeyboardEvent) => {
this.captureEvent(event);
if (event.code === key && event.repeat === false) {
this.action.movingX = -1;
}
});
document.addEventListener("keyup", (event: KeyboardEvent) => {
this.captureEvent(event);
if (event.code === key) {
this.action.movingX = 0;
}
});
});
// 向左、向上、向下也是類似的方式
注意,按鍵的映射關系作為數組存儲在config.controls中,每個玩家都有自己的控制鍵。
我們現在可以忽略captureEvent。這只用來防止頁面在按下光標鍵時滾動。還記得如何為每一幀發送一個tick事件嗎?我們將在這里偵聽這個事件。對于每一幀,我都會在重新繪制角色之前更新位置。
private move(): void {
const { position, velocity, action } = this;
const newX = position.x + action.movingX * this.speed + velocity.x * this.speed;
const newY = position.y + action.movingY * this.speed + velocity.y * this.speed;
position.x = newX;
position.y = newY;
if (position.x < 0) {
position.x = 0;
} else if (newX > this.ctx.canvas.width - this.size) {
position.x = this.ctx.canvas.width - this.size;
}
if (position.y < 0) {
position.y = 0;
} else if (newY > this.ctx.canvas.height - this.size) {
position.y = this.ctx.canvas.height - this.size;
}
this.velocity.x = clamp(
(action.movingX ? this.velocity.x + action.movingX : this.velocity.x * 0.8) * this.speed,
this.maxVelocity * -1,
this.maxVelocity
);
this.velocity.y = clamp(
(action.movingY ? this.velocity.y + action.movingY : this.velocity.y * 0.8) * this.speed,
this.maxVelocity * -1,
this.maxVelocity
);
}
這里出現了速度(velocity)。速度是一個值,隨著玩家持續按住移動鍵,它會不斷增加,最高可達maxVelocity。當玩家松開移動鍵時,角色不會突然停止而是減速直到停止。速度又平緩地回到0。
角色不僅可以四處移動,還可以轉身。玩家應該專注于攻擊,而不是總是需要轉身對著對手。
private turn(): void {
const otherPlayer = this.player === 0 ? 1 : 0;
const orientationTarget: coordinates = this.players[otherPlayer]?.position || { x: 0, y: 0 };
const angle = Math.atan2(orientationTarget.y - this.position.y, orientationTarget.x - this.position.x);
this.orientation = angle;
}
小格斗游戲現在是這樣的!好像在跳舞。
角色需要能夠互相攻擊。同時為了增加游戲的深度,還應該有防守。兩者都被定義為角色動作,并且都有冷卻時間來防止濫發。
class Character {
range: number;
attackDuration: number;
blockDuration: number;
cooldownDuration: number;
action: {
attacking: boolean;
blocking: boolean;
cooldown: boolean;
};
// ...
}
觸發這些動作的工作方式與移動相同——通過偵聽鍵盤事件,然后將動作值設置為true……
// attack
config.controls[this.player].attack.forEach((key: string) => {
document.addEventListener("keydown", (event: KeyboardEvent) => {
if (
this.active &&
event.code === key &&
event.repeat === false &&
!this.action.cooldown
) {
this.action.attacking = true;
}
});
});
// block
config.controls[this.player].block.forEach((key: string) => {
document.addEventListener("keydown", (event: KeyboardEvent) => {
if (
this.active &&
event.code === key &&
event.repeat === false &&
!this.action.cooldown
) {
this.action.blocking = true;
}
});
});
最后在游戲循環中執行動作。
private attack(): void {
if (!this.active || !this.action.attacking || this.action.cooldown) {
return;
}
this.action.cooldown = true;
// strike duration
window.setTimeout(() => {
this.action.attacking = false;
}, this.attackDuration);
// cooldown to next attack/block
window.setTimeout(() => {
this.action.cooldown = false;
}, this.cooldownDuration);
this.strike();
}
攻擊只實現了一半工作。另一半是確保對手被擊中——這意味著對方不能阻擋攻擊并且武器在射程內。我們在trike()方法中處理。
private strike(): void {
const otherPlayerId = this.player === 0 ? 1 : 0;
const otherPlayer: rectangle = this.players[otherPlayerId].obstacle?.getObject();
const blocked = this.players[otherPlayerId].action.blocking;
if (blocked) {
// opponent blocked the attack
return;
}
// attack hits
const otherPlayerPolygon = new Polygon(new Vector(0, 0), [
new Vector(otherPlayer.a.x, otherPlayer.a.y),
new Vector(otherPlayer.b.x, otherPlayer.b.y),
new Vector(otherPlayer.c.x, otherPlayer.c.y),
new Vector(otherPlayer.d.x, otherPlayer.d.y),
]);
const weaponPosition = this.getWeaponPosition();
const weaponPolygon = new Polygon(new Vector(0, 0), [
new Vector(weaponPosition.a.x, weaponPosition.a.y),
new Vector(weaponPosition.b.x, weaponPosition.b.y),
new Vector(weaponPosition.c.x, weaponPosition.c.y),
new Vector(weaponPosition.d.x, weaponPosition.d.y),
]);
const hit = this.collider.testPolygonPolygon(weaponPolygon, otherPlayerPolygon) as boolean;
if (hit) {
// finish this round
this.finish();
}
}
這會在玩家周圍同時向對手的方向延申150%創建一個碰撞框。如果武器碰撞框與對手的碰撞箱發生撞擊,則攻擊落地且玩家贏得該回合。
那么碰撞框是怎么回事?
碰撞檢測并不像我想象的那么簡單。假設canvas上有兩個矩形,可以直接比較它們的x和y坐標。但是,一旦旋轉矩形,比較x和y坐標就沒什么用了。于是我嘗試著從矩形的邊界線創建線性函數并檢查交叉點。但仍然會產生一些邊緣情況,也非常低效。
然后我轉而上網搜索解決方案。并在StackOverflow上找到了:
?這個解決方案聰明、優雅、高效,而且——最重要的是——遠高于我的幾何技能水平。就是它了!
yarn add collider2d
我在每個相關對象周圍添加了碰撞多邊形作為碰撞框,相關對象包括玩家角色、畫布邊界和競技場中可能存在的障礙物。這些多邊形由描述周長的向量組成。角色多邊形存儲在角色類的一個屬性中,并在move()、turn()和stroke()方法中更新。
// inside character.strike()
const otherPlayerPolygon = new Polygon(new Vector(0, 0), [
new Vector(otherPlayer.a.x, otherPlayer.a.y),
new Vector(otherPlayer.b.x, otherPlayer.b.y),
new Vector(otherPlayer.c.x, otherPlayer.c.y),
new Vector(otherPlayer.d.x, otherPlayer.d.y),
]);
const weaponPosition = this.getWeaponPosition();
const weaponPolygon = new Polygon(new Vector(0, 0), [
new Vector(weaponPosition.a.x, weaponPosition.a.y),
new Vector(weaponPosition.b.x, weaponPosition.b.y),
new Vector(weaponPosition.c.x, weaponPosition.c.y),
new Vector(weaponPosition.d.x, weaponPosition.d.y),
]);
const hit = this.collider.testPolygonPolygon(
weaponPolygon,
otherPlayerPolygon
) as boolean;
現在我們來看看實際的游戲玩法!
角色可以你來我往地相互碰撞。Collider2D可以返回一些關于碰撞的信息,比如向量和位置。這與我之前確定的速度解決方案配合得很好。我可以直接將現有速度指向碰撞的方向:
private collide(): void {
const obstacles = this.obstacles.filter((obstacle) => obstacle.getId() !== this.obstacle.getId());
obstacles.forEach((obstacle) => {
const collision = this.obstacle.collidesWith(obstacle);
const friction = 0.8;
if (!collision) {
return;
}
this.velocity.x = (this.velocity.x + collision.overlapV.x * -1) * friction;
this.velocity.y = (this.velocity.y + collision.overlapV.y * -1) * friction;
});
}
現在,可以在游戲循環中調用collide()與move()、turn(),每一幀都有一個用于碰撞檢測的輪詢。
這樣的跳舞方塊可能很實用,但并不漂亮。我想要制作成復古的像素藝術風格,因而選擇了灰綠色屏幕(后來我設置為灰藍色)和放大像素上的陰影效果。
角色尺寸為16x16px。武器射程為150%,也就是能達到40x16px。所有的sprites設置為角色居中,其Photoshop畫布是64x64px。導出圖像時,放大到100x100px字符大小,因為全高清屏幕上的16px字符太小了。按方向在分組層中對sprites進行排序,每個sprite都需要八種變化——每個羅盤方向一個。然后將其乘以動畫sprites的幀數。
我需要控制每個像素,而鋸齒是我最大的敵人,因為它會根據定義影響相鄰像素。當我需要變換、縮放或旋轉某些東西時,我就使用鋼筆工具而不是畫筆,并使用像素重復模式。
導出圖像有點麻煩。我需要導出8位png。它們有一個alpha通道,并且比gifs甚至是webp的字節更小。由于某種原因,Photoshop的批量導出不支持8bit png。而且也不能自動裁剪單層。所以我只能手動導出。
目前,我只有一組sprites。在某些時候,我想每輪加載不同的集合。這意味著每個集合都需要遵守一套特定的規則。因此我需要定義主題。
一堆JavaScript和一堆png,需要相互匹配,同時還要達到一些次要目標:
在畫布中動畫sprites并不像加載gif那樣簡單。drawImage()只會繪制第一幀。有些技術可以在畫布內實現gif查看器,但對于這里的用例來說過于復雜。因此我選擇直接使用帶有單個幀的數組。
declare type Sprite = {
name: string;
images: string[];
animationSpeed: number; // use next image every N frames, max 60
offset: coordinates;
};
然后為drawImage()編寫包裝器,它將使用合并的sprites并根據幀數切換動畫步驟:
public drawSprite(ctx: CanvasRenderingContext2D, name: string, pos: coordinates, frameCount = 0) {
const sprite = this.sprites.find((x) => x.name === name);
if (!sprite) {
return;
}
const spriteFrame = Math.floor((frameCount / sprite.animationSpeed) % sprite.images.length);
const img = this.images.find((x) => x.src.endsWith(`${sprite.images[spriteFrame].replace("./", "")}`));
if (!img) {
return;
}
ctx.drawImage(img, pos.x + sprite.offset.x, pos.y + sprite.offset.y);
}
很好,我們現在可以制作動畫了!
?互換性需要一致。下面創建主題配置,定義使用哪些sprites以及如何使用。
declare type SpriteSet = {
n: Sprite; // sprite facing north
ne: Sprite; // sprite facing north-east
e: Sprite; // etc
se: Sprite;
s: Sprite;
sw: Sprite;
w: Sprite;
nw: Sprite;
};
declare type themeConfig = {
name: string; // has to match folder name
scene: Sprite; // scene image, 1920x1080
obstacles: rectangle[]; // outline obsacles within the scene
turnSprites?: boolean; // whether to turn sprites with characters
players: {
default: SpriteSet; // player when standing still, 100x100
move: SpriteSet; // player when moving, 100x100
attack: SpriteSet; // player when attacking, 250x100
block: SpriteSet; // player when blocking, 100x100
}[]; // provide sprites for each player, else player 1 sprites will be re-used
};
這個配置表示我們處理的主題并從中選擇資源。例如,character類現在可以像這樣繪制主題資源:
this.theme.drawSprite(
this.ctx,
"p1_move_s",
{ x: this.size / -2, y: this.size / -2 },
frameCount
);
還記得我在移動角色中添加了轉向部分嗎?這對于轉動的主題可能很有用——例如小行星。但在我的這個例子中,轉動sprite看起來有點傻。
我需要一種分配sprite方向值的方法。我必須將8個羅盤方向映射到一整圈方向值。一段圓弧表示一個方向。由于起點和終點正好在同一個方向的中間,所以這個重疊的方向需要分配兩次——第一次和最后一次。
private getSprite(): Sprite {
const directions = ["w", "nw", "n", "ne", "e", "se", "s", "sw", "w"];
const zones = directions.map((z, i) => ({
zone: z,
start: (Math.PI * -1) - (Math.PI / 8) + (i * Math.PI) / 4,
end: (Math.PI * -1) - (Math.PI / 8) + ((i + 1) * Math.PI) / 4,
}));
const direction = zones.find((zone) => this.orientation >= zone.start && this.orientation < zone.end);
// action refers to moving, attacking, blocking...
return this.theme.config.players[this.player][action][direction.zone];
}
最后,我在character類中使用this.theme.config.turnSprites以便在基于轉向和方向的主題之間切換。
視覺效果只是主題的一個方面。另一方面是聲音。我想要用特定的聲音來表示攻擊、阻擋、撞到東西,以及還有背景音樂。
我采用了一個簡單直接的方式,使用<audio>元素。每當需要聲音時,創建一個元素,自動播放,然后刪除。
const audio = new Audio("./sound.mp3");
audio.play();
這很好用,至少在Chrome和Firefox中是這樣。但是Safari在播放聲音之前總是有延遲。
我為聲音設置了AudioContext:一個由游戲的所有其他部分共享的上下文。
Web Audio API的構建就像一個真正的模塊化合成器。我們需要將一個設備連接到下一個設備。在這種情況下,我們使用音頻文件作為輸入源,緩沖,連接到Gain Node設置音量,最后播放出來。
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
async function play(sound: string): Promise<void> {
if (this.sounds[this.getAudioUrl(sound)].playing) {
return;
}
this.sounds[this.getAudioUrl(sound)].playing = true;
const arrayBuffer = await this.getSoundFile(this.getAudioUrl(sound));
const source = this.ctx.createBufferSource();
this.ctx.decodeAudioData(arrayBuffer, (audioBuffer) => {
source.buffer = audioBuffer;
source.connect(this.vol);
source.loop = false;
source.onended = () => {
this.terminateSound(source);
this.sounds[this.getAudioUrl(sound)].playing = false;
};
source.start();
});
}
以那樣的方式我可以注冊聲音:
// theme config
{
// ...
bgAudio: "./assets/bgm.mp3",
attackAudio: "./assets/attack.mp3",
blockAudio: "./assets/block.mp3",
collideAudio: "./assets/bump.mp3",
winAudio: "./assets/win.mp3",
}
并調用它們:
this.audio.play(this.theme.config.collideAudio);
現在,即使是Safari也會在我需要的時候播放聲音了。
這里我使用Gamepad API,它與多達四個連接的Gamepad接口。
不過,Gamepad API使用起來有點笨拙。與鍵盤和鼠標等更常見的輸入方法不同,Gamepads不發送事件。相反,一旦站點檢測到Gamepad交互,就會填充Gamepad對象。
interface Gamepad {
readonly axes: ReadonlyArray<number>;
readonly buttons: ReadonlyArray<GamepadButton>;
readonly connected: boolean;
readonly hapticActuators: ReadonlyArray<GamepadHapticActuator>;
readonly id: string;
readonly index: number;
readonly mapping: GamepadMappingType;
readonly timestamp: DOMHighResTimeStamp;
}
interface GamepadButton {
readonly pressed: boolean;
readonly touched: boolean;
readonly value: number;
}
每次交互都會改變對象。由于沒有發送瀏覽器原生事件,因此我需要監聽gamead對象的變化。
if (
this.gamepads[gamepadIndex]?.buttons &&
gamepadButton.button.value !==
this.gamepads[gamepadIndex]?.buttons[gamepadButton.index]?.value &&
gamepadButton.button.pressed
) {
// send press event
this.pressButton(gamepadIndex, b.index, gamepadButton.button);
} else if (
this.gamepads[gamepadIndex]?.buttons &&
gamepadButton.button.value !==
this.gamepads[gamepadIndex]?.buttons[gamepadButton.index]?.value &&
!gamepadButton.button.pressed
) {
// send release event
this.releaseButton(gamepadIndex, b.index, gamepadButton.button);
}
pressButton和releaseButton發送自定義事件,我可以在character類中使用這些事件并擴展我輸入法以識別游戲手柄。
?因為我只有Xbox 360控制器,所以我就用這個來構建和測試了。據我所知,鍵盤映射對于游戲機控制器的工作方式相同。Xbox的A B X Y按鈕的映射方式與Playstation的幾何形狀相同。
我無法讓GamepadHapticActuator(也稱為Rumble或Vibration)與我的360控制器一起工作。也不確定Chrome和Firefox是否可以配合這個控制器或根本不支持它。我可能還需要使用更新的控制器來進行測試。但現在,就先這樣吧。
現在我們雖然可以做一些攻擊命中和移動的動作,但到目前為止還做不了其他任何事情,游戲顯得非常無聊。因此我在想,要是誰贏了就給出反饋,然后重新開始就好了。由于這是一款節奏非常快的游戲,回合數很短,所以最好還能顯示分數。
character.strike()方法確定一輪的獲勝者。誰調用該方法并注冊一個實際命中,誰就贏。我打算發送一個包含這部分信息的事件并觸發以下調用:
declare interface FinishEvent extends Event {
readonly detail?: {
winner: number;
};
}
this.ctx.canvas.addEventListener("countdown", ((e: FinishEvent) => {
if (typeof e.detail?.winner === "number") {
this.gui.incrementScore(e.detail.winner);
}
this.startCountdown(e.detail?.winner);
this.togglePlayers(false);
}) as EventListener);
this.ctx.canvas.addEventListener("play", () => {
this.togglePlayers(true);
});
此時的事件機制還不夠復雜,不足以讓我厭煩重構。如果用圖表表示就是這樣:
啟動游戲并開始第一輪時,聲音和圖形不但加載滯后,而且會在登陸瀏覽器緩存時不斷彈出。因此我需要一個加載策略。
我通過創建新的Image原型并為其提供src來加載圖像。瀏覽器將開始自動獲取圖像。
private loadImage(src: string): Promise<HTMLImageElement> {
const url = `./themes/${this.config.name}/${src}`;
return fetch(url).then(() => {
const img = new Image();
img.src = url;
if (!this.images.includes(img)) {
this.images.push(img);
}
return img;
});
}
現在我可以遍歷主題配置中找到的每個圖像并加載所有內容。圖像存儲在一個數組中。
this.config.players.forEach((player) => {
const spriteSets = ["default", "move", "attack", "block"];
spriteSets.forEach((spriteSet) => {
Object.keys(player[spriteSet]).forEach((key: string) => {
player[spriteSet][key].images.forEach(async (image: string) => {
const imageResp = await this.loadImage(image);
if (toLoad.includes(imageResp)) {
return;
}
imageResp.onload = () => {
this.onAssetLoaded(toLoad);
};
toLoad.push(imageResp);
});
this.sprites.push(player[spriteSet][key]);
});
});
});
每次加載圖像,我都會檢查數組中的所有promise是否都已解決。如果是,則所有圖像都已加載,發送一個事件告知已經加載了多少游戲資源。
private onAssetLoaded(assetList: HTMLImageElement[]) {
const loadComplete = assetList.every((x) => x.complete);
const progress = Math.floor(
((assetList.length - assetList.filter((x) => !x.complete).length) / assetList.length) * 100
);
const loadingEvent: LoadingEvent = new CustomEvent("loadingEvent", { detail: { progress } });
this.ctx.canvas.dispatchEvent(loadingEvent);
if (loadComplete) {
this.assetsLoaded = true;
}
}
映射進度信息到<progress>元素。每當它達到100%時,則淡入<canvas>并開始游戲。
嚴格來說,游戲已經結束了。但它仍然是一個網站,因此應該盡力保持其快速、兼容和可訪問性。
我還沒有添加描述<meta>標簽。我將畫布tabindex設置為1,而它應該為0(只是為了讓它可聚焦)。我還有一個不受Safari支持的SVG favicon,因此當我使用它時,添加了一個apple-touch-icon。并且<input>也缺少一個<label>。
遺漏了一個Lighthouse類別:PWA。向這個項目添加PWA功能是有意義的。游戲應該允許安裝和離線。
第一步是清單。這不需要做太多,只需要包含必要的圖標、顏色和標題字符串,以便在安裝時格式化主屏幕圖標、啟動屏幕和瀏覽器 UI。指定PWA在全屏模式下運行,從而隱藏所有瀏覽器UI元素。
{
"theme_color": "#1e212e",
"background_color": "#1e212e",
"display": "fullscreen",
"scope": "/",
"start_url": "/",
"name": "Attacke!",
"short_name": "Attacke!",
"icons": [
{
"src": "assets/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
...
]
}
我希望游戲PWA只是游戲本身。只要在全屏視圖中打開,任何其他鏈接,例如版權頁和指向源代碼的鏈接都應該在新的瀏覽器窗口中打開。當app在常規瀏覽器窗口中打開時,我非常喜歡讓用戶控制鏈接的行為方式。
下面的代碼段詢問瀏覽器是否處于全屏模式,如果處于全屏模式,則在新選項卡中打開所有標有data-link='external'的鏈接:
if (window.matchMedia("(display-mode: fullscreen)").matches) {
document.querySelectorAll("[data-link='external']").forEach((el) => {
el.setAttribute("target", "_blank");
el.setAttribute("rel", "noopener noreferrer");
});
}
下一步是Service Worker。對于有效的PWA,它只需要注冊并為離線請求提供answer即可。我想創建包含所有游戲資源的離線緩存。如此一來在安裝時會產生相當多的網絡流量。
緩存進來的離線請求相對容易,響應也是如此。但是由于需要下載網絡上的大量資源,因此我只想在用戶安裝app時才緩存這些資源。否則,在需要時流式傳輸資源才是更好的選擇。由于我所有的主題都遵循相同的模式,因此我可以遍歷資源,然后返回一個資源列表:
export const getGameAssets = (): string[] => {
const assets = [];
Object.keys(themes).forEach((theme) => {
const themeConfig = themes[theme] as themeConfig;
// add player sprites
["p1", "p2"].forEach((player, pi) => {
["default", "move", "attack", "block"].forEach((action) => {
const spriteSet = themeConfig.players[pi][action] as SpriteSet;
["n", "ne", "e", "se", "s", "sw", "w", "nw"].forEach(
(direction) => {
const images = spriteSet[direction].images as string[];
const paths = images.map(
(image) => `/themes/${theme}/${image}`
);
assets.push(...paths);
}
);
});
});
// add background sprite
themeConfig.scene.images.forEach((image) => {
assets.push(`/themes/${theme}/${image}`);
});
// add sounds
[
"bgAudio",
"attackAudio",
"blockAudio",
"collideAudio",
"winAudio",
].forEach((audio) => {
assets.push(`/themes/${theme}/${themeConfig[audio]}`);
});
});
// return uniques only
return [...new Set(assets)];
};
這個函數在Service Worker中被調用,并緩存運行全功能游戲所需的一切。
const cacheAssets = () => {
const assets = [
"/index.html",
"/styles.css",
"/main.js",
"/assets/PressStart2P.woff2",
...getGameAssets(),
];
caches.open(cacheName).then(function (cache) {
cache.addAll(assets);
});
};
channel.addEventListener("message", (event) => {
switch (event.data.message) {
case "cache-assets":
cacheAssets();
break;
}
});
這是什么?cache-assets消息嗎?來自哪里?為什么不是安裝事件監聽器呢?
哈,因為我不喜歡PWA安裝提示的當前狀態。
Chrome on Android會展示一個又大又丑的安裝橫幅。Chrome on Desktop也是如此,會彈出窗口。Firefox on Android將安裝按鈕隱藏在瀏覽器菜單中,好吧,至少明確標記“安裝”了。最差勁的是Safari,為什么要在共享菜單中隱藏安裝按鈕??
Chrome提供了實現自己安裝UX的方法(請注意,這部分的內容不符合規范。出于道德原因,你可能會嗤之以鼻)。安裝提示由事件監聽器觸發,可以連接。我可以完全隱藏提示并將其事件綁定到自定義按鈕。單擊此按鈕,將安裝PWA以及附帶的所有資源。
window.addEventListener("appinstalled", () => {
button.setAttribute("hidden", "hidden");
deferredPrompt = null;
channel.postMessage({ message: "cache-assets" });
});
沒有未經請求的安裝提示,也不會在沒有警告的情況下向用戶的設備發送大量下載請求,只有一個老式的安裝按鈕。非常好。
現在我們完成了一款游戲,完全用typescript編寫并在<canvas>中渲染,甚至可以在所有主流瀏覽器上流暢運行,并打包在PWA中。
構建游戲邏輯和繪制圖形讓我獲得了很多樂趣。掌握Photoshop對我幫助很大。找出問題讓我頗費一番功夫(說的就是你,Safari),但最后的成果也是最好的回報。
擊查看乒乓球特效
這個乒乓球項目是基于html5/canvas畫布,雖然看起來布局是非常簡單的,但是里面的邏輯關系有點兒復雜,就比如右邊的電腦怎么接球,在哪個位置接球,接球后,球的運動軌跡,這都是較為復雜的邏輯判斷,計算了,當然這部分內容我們肯定是用javascript去完成,在這個案例里用的是原生javascript,復雜才用原生javascript,這樣對邏輯的理解以及對于javascript的運用才能更加熟練!想要更加深入一點的,可以把原生javascript封裝成插件,再調用使用。
如果有需要學習前端的,需要更多HTML5/javascript特效,項目可以關注頭條號,在往期文章!
代碼過長需要文檔版源碼來我的前端群570946165,源碼已經上傳了!
源:九九互娛
近日,九九互娛作為CAPCOM(喀普康)官方認證的CAPCOM Pro Tour(以下簡稱“CPT” https://capcomprotour.com)2022國內賽事授權方,聯合ALIENWARE外星人和超威半導體產品(中國)有限公司(以下簡稱“AMD”),共同舉辦CPT中國2022系列賽事。ALIENWARE外星人與AMD將以CPT中國賽區首席合作伙伴的身份參與到賽事之中。
CPT中國2022系列賽事,是圍繞《街霸Ⅴ》舉辦的專業電競格斗賽事,也是中國(大陸)賽區直通CAPCOM CUP IX的唯一渠道。賽事含括經典的CPT中國2022白金賽與全新設立的CPT 中國 2022 冠軍賽兩段賽道,分別為線上賽和線上+線下賽,兩段賽道的冠軍可以斬獲直通CAPCOM CUP IX資格。首先要舉行的是CPT中國2022冠軍賽,賽程涵蓋7月至11月,總計6個場次,賽制采用隨機抽簽的方式,5場線上賽按CPT賽制進行(CPT賽制:雙敗賽制,預選BO3,8強后BO5),決賽暫定于中國西部國際博覽會線下舉行,最終角逐出冠軍。CPT中國2022冠軍賽不但為冠軍選手準備了直通CAPCOM CUP IX資格,還有加磅賽事獎金。此次比賽在給予頂級玩家與世界級選手同臺競技的機會的同時,還可以讓更多游戲愛好者了解到格斗電競不俗的觀賞性。
此次賽事合作,ALIENWARE外星人與AMD旨在推動電競文化與電競格斗賽事在中國的發展。ALIENWARE外星人作為一線游戲PC品牌,將發揮其在玩家圈層的影響,為CPT中國2022賽事助力。ALIENWARE外星人一直以來堅持追求更強的性能,不斷刷新視覺、聽覺和觸覺等多維度的感官享受,為玩家帶來全新游戲體驗。旗下無論是筆記本、主機還是外設產品都得到了玩家們的廣泛喜愛,其中M系列產品一直以硬核產品力詮釋何謂“生來強悍”。至高搭載AMD 銳龍9 6900HX標壓處理器ALIENWARE m17 R5游戲本與至高搭載AMD 銳龍9 5950X處理器的ALIENWARE AURORA R14游戲主機,更是不少硬核玩家夢想的電競裝備。同樣的,AMD長期以來致力為消費者提供性能出色的核心硬件產品和解決方案,不僅滿足了爆炸式增長的數據中心計算需求,還為更多消費者打造了更輕、更薄、更驚人的游戲平臺。其核心產品還有著優秀的游戲性能和超長的電池續航能力。“選銳龍,像我一樣戰斗”不止體現在技術之上,AMD還曾多次贊助電子競技賽事,讓更多消費者透過賽事看到其背后生機勃勃的青春活力,推動電競行業的發展。
CPT中國2022冠軍賽參賽選手火熱招募中,ALIENWARE外星人與AMD共同邀請硬核玩家參與這場中國頂級格斗電競比賽。
?CAPCOM CO., LTD. 2016, 2020 ALL RIGHTS RESERVED.
*關注微信服務號:外星人ALIENWARE(微信號:ALIENWARE-1996)或AMD中國(微信號:amdchina),點擊底部菜單欄,即可進入賽事報名頁面:
https://www.matchon.net/match/info/17155.html。
加入這場電競格斗賽事,一起無憾搏戰,成就爭霸傳奇。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。