运动与游戏开发

530 阅读7分钟

运动与游戏开发

对于前端来说的运动,就是JS运动,本质来说,就是让web上的DOM元素动起来,而想要DOM动起来,就是改变其自身的属性,比如宽高,边距,透明度等等,动画的原理就是把不同的状态的物体,串联成连续的样子,不同状态的DOM,用定时器控制,就能得到动画效果,运动与动画的组合,就是最简单的运动与游戏开发。
游戏开发的最重要的元素是游戏“循环”, 从本质上讲,只要游戏一直持续,就是一个将不断重复的函数。 我们的游戏循环就像我们的动画循环,另附加一些关键事物的补充。

开发一款游戏都需要准备些什么呢?

  • canvas元素(画布):用于渲染游戏画面
  • audio元素(音频):用于添加音效和背景音乐
  • image元素(图像):用于加载游戏图像并在canvas中显示
  • 浏览器中的计时函数和循环函数:用于实现动画

一、Canvas

游戏中最重要的元素就是HTML5中的canvas元素。按照HTML5标准说法:“canvas元素为脚本提供了像素级的画布,可以实时渲染图形、游戏画面或其他虚拟图像”。
canvas元素允许我们绘制直线、圆、矩形等基本形状,以及图像和文字,而且它已经为快速绘图做过优化了。各大浏览器都支持GPU加速的2D canvas渲染,因此,使用canvas绘制出的游戏动画运行速度会很快。

绘制步骤

示例

const canvas = document.getElementById('canvas')
const context = canvas.getContext('2d')
canvas.width = 400
canvas.height = 400

context.beginPath()
context.arc(100, 100, 50, 0, Math.PI * 2, true)
context.closePath()
context.fillStyle = '#fff'
context.fill()

二、Canvas的API

1. 绘制路径

方法描述
fill()填充路径
stroke()描边
arc()创建圆弧
rect()创建矩形
fillRect()绘制矩形路径区域
strokeRect()绘制矩形路径描边
clearRect()在给定的矩形内清除指定的像素
arcTo()创建两切线之间的弧/曲线
beginPath()起始一条路径,或重置当前路径
moveTo()把路径移动到画布中的指定点,不创建线条
lineTo()添加一个新点,然后在画布中创建从该点到最后指定点的线条
closePath()创建从当前点回到起始点的路径
clip()从原始画布剪切任意形状和尺寸的区域
quadraticCurveTo()创建二次方贝塞尔曲线
bezierCurveTo()创建三次方贝塞尔曲线
isPointInPath()如果指定的点位于当前路径中,则返回 true,否则返回 false

绘制弧/曲线

arc() 方法创建弧/曲线(用于创建圆或部分圆)

context.arc(x,y,r,sAngle,eAngle,counterclockwise);
  • x:圆心的 x 坐标
  • y:圆心的 y 坐标
  • r:圆的半径
  • sAngle:起始角,以弧度计(弧的圆形的三点钟位置是 0 度)
  • eAngle:结束角,以弧度计
  • counterclockwise:可选。规定应该逆时针还是顺时针绘图。false 为顺时针,true 为逆时针



2. 改变样式

属性描述
fillStyle设置或返回用于填充绘画的颜色、渐变或模式
strokeStyle设置或返回用于笔触的颜色、渐变或模式
shadowColor设置或返回用于阴影的颜色
shadowBlur设置或返回用于阴影的模糊级别
shadowOffsetX设置或返回阴影距形状的水平距离
shadowOffsetY设置或返回阴影距形状的垂直距离

3. 直线添加样式

样式描述
lineCap设置或返回线条的结束端点样式
lineJoin设置或返回两条线相交时,所创建的拐角类型
lineWidth设置或返回当前的线条宽度
miterLimit设置或返回最大斜接长度

4. 设置渐变

方法描述
createLinearGradient()创建线性渐变(用在画布内容上)
createPattern()在指定的方向上重复指定的元素
createRadialGradient()创建放射状/环形的渐变(用在画布内容上)
addColorStop()规定渐变对象中的颜色和停止位置

5. 图形转换

方法描述
scale()缩放当前绘图至更大或更小
rotate()旋转当前绘图
translate()重新映射画布上的 (0,0) 位置
transform()替换绘图的当前转换矩阵
setTransform()将当前转换重置为单位矩阵,然后运行 transform()

7. 图像绘制

方法描述
drawImage()向画布上绘制图像、画布或视频

context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height);

  • img:规定要使用的图像、画布或视频
  • sx:可选。开始剪切的 x 坐标位置
  • sy:可选。开始剪切的 y 坐标位置
  • swidth:可选。被剪切图像的宽度
  • sheight:可选。被剪切图像的高度
  • x:在画布上放置图像的 x 坐标位置
  • y:在画布上放置图像的 y 坐标位置
  • width:可选。要使用的图像的宽度(伸展或缩小图像)
  • height:可选。要使用的图像的高度(伸展或缩小图像)

三、实例

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>太空射手</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="dialogue">
        <div class="dialogue--content">
          <h1>太空射手</h1>
          <ul>
            <li>使用<span class="key">&#8592;</span><span class="key">&#8594;</span> 键盘移动</li>
            <li>使用 <span class="key">空格键</span> 发射激光</li>
          </ul>
          <button>开始</button>
        </div>
    </div>
    <div class="score score--hidden">分数:<span>0</span></div>
    <script src="script.js"></script>
  </body>
</html>
* {
  margin: 0;
  padding: 0;
}
body {
  font-family: 'Press Start 2P', sans-serif;
  overflow: hidden;
  color: #fff;

  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
button,
.key {
  display: inline-block;
  padding: 10px;
  border: 1px solid currentColor;
  border-radius: 5px;
}
canvas {
  display: block;
}
h1 {
  font-size: 48px;
}
ul {
  margin: 20px 0;
  list-style: none;
}
li {
  font-size: 14px;
  margin-bottom: 10px;
  color: #ff9d00;
}
button:focus,
button:hover {
  transition-duration: .07s;
  color: #ff9d00;
  background-color: black;
}
.dialogue {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transform: translateX(0);
  text-align: center;
  opacity: 1;
  background-color: rgba(0, 0, 0, .85);
}
.dialogue--hidden {
  transition: opacity .3s linear 0s, transform 0s linear .3s;
  transform: translateX(-100vw);
  opacity: 0;
}
.dialogue--content {
  padding: 18px;
  text-align: center;
}
.key {
  margin: 0 10px;
  color: #fff;
  border-color: #fff;
}
.score {
  font-size: 14px;
  position: fixed;
  top: 0;
  left: 0;
  display: flex;
  justify-content: flex-start;
  box-sizing: border-box;
  width: 100%;
  margin: 0;
  padding: 10px;
  transition: opacity .07s linear .5s;
  opacity: 1;
  color: #ff9d00;
}
.score--hidden {
  opacity: 0;
}
const DEV_MODE = false

const stage = document.createElement('canvas')
const ctx = stage.getContext('2d')
const dialogue = document.querySelector('.dialogue')
const startBtn = dialogue.querySelector('button')
const hud = document.querySelector('.score')
const scoreNode = hud.querySelector('.score span')

let ship
let lasers = []
let enemies = []
let playing = false
let gameStarted = false
let speedMultiplier
let enemySeedFrameInterval
let score = 0

// 创建随机数
const randomBetween = function (min, max) {
	return Math.floor(Math.random() * (max - min + 1)) + min
}

// 计算分数
const calcScore = function (x) {
	return Math.floor((1 / x) * 500)
}

// 创建飞船
const Ship = function (options) {
	this.radius = 10
	this.x = options.x || stage.width * 0.5 - this.radius - 0.5
	this.y = options.y || stage.height - this.radius - 30
	this.width = this.radius * 2
	this.height = this.width
	this.color = options.color || 'red'
	this.left = false
	this.right = false
	this.speed = 10
	this.active = true

	// 监控键盘事件
	document.addEventListener('keydown', this.onKeyDown.bind(this))
	document.addEventListener('keyup', this.onKeyUp.bind(this))
}

Ship.prototype.update = function (x) {
	this.x = x
	this.y = stage.height - this.radius - 30
}

Ship.prototype.draw = function () {
	ctx.save()

	if (DEV_MODE) {
		ctx.fillStyle = '#fc0'
		ctx.fillRect(this.x, this.y, this.width, this.width)
	}

	ctx.fillStyle = this.color
	ctx.fillRect(this.x + this.radius - 5, this.y, 10, this.radius)
	ctx.fillRect(this.x, this.y + this.radius, this.width, 10)
	ctx.fillRect(this.x, this.y + this.radius + 10, 10, 5)
	ctx.fillRect(this.x + this.width - 10, this.y + this.radius + 10, 10, 5)

	ctx.restore()
}

Ship.prototype.onKeyDown = function (e) {
	if (ship.active) {
		if (e.keyCode === 39) this.right = true
		else if (e.keyCode === 37) this.left = true

		if (e.keyCode == 32) {
			const settings = {
				x: this.x + this.radius - 3,
				color: '#fc0'
			}
			const laser = new Laser(settings)
			lasers.push(laser)
		}
	}
}

Ship.prototype.onKeyUp = function (e) {
	if (e.key === 'ArrowRight') this.right = false
	else if (e.key === 'ArrowLeft') this.left = false
}

// 激光
const Laser = function (options) {
	this.x = options.x - 0.5
	this.y = options.y || stage.height - 50
	this.width = 6
	this.height = 20
	this.speed = 15
	this.color = options.color || '#fff'
	this.active = true
}

Laser.prototype.update = function (y) {
	this.y = y
}

Laser.prototype.draw = function () {
	ctx.save()
	ctx.fillStyle = this.color
	ctx.beginPath()
	ctx.rect(this.x, this.y, this.width, this.height)
	ctx.closePath()
	ctx.fill()
	ctx.restore()
}

// 创建圆点
const Enemy = function (options) {
	this.radius = randomBetween(10, 40)
	this.width = this.radius * 2
	this.height = this.width
	this.x = randomBetween(0, stage.width - this.width)
	this.y = -this.radius * 2
	this.color = options != undefined && options.color ? options.color : '#fff'
	this.speed = 2
	this.active = true
}

Enemy.prototype.update = function (x, y) {
	this.x = x
	this.y = y
}

Enemy.prototype.draw = function () {
	if (DEV_MODE) {
		ctx.fillStyle = 'skyblue'
		ctx.fillRect(this.x, this.y, this.width, this.width)
	}

	ctx.save()
	ctx.fillStyle = this.color
	ctx.beginPath()
	ctx.arc(
		this.x + this.radius,
		this.y + this.radius,
		this.radius,
		0,
		Math.PI * 2
	)
	ctx.closePath()
	ctx.fill()
	ctx.restore()
}

// 判断击中
const hitTest = function (item1, item2) {
	let collision = true
	if (
		item1.x > item2.x + item2.width ||
		item1.y > item2.y + item2.height ||
		item2.x > item1.x + item1.width ||
		item2.y > item1.y + item1.height
	) {
		collision = false
	}
	return collision
}

// 击中处理
const handleLaserCollision = function () {
	for (let enemy of enemies) {
		for (let laser of lasers) {
			let collision = hitTest(laser, enemy)
			if (collision && laser.active) {
				console.log('你消灭了一个敌人')
				enemy.active = false
				laser.active = false

				// 提高速度
				speedMultiplier += 0.025
				if (enemySeedFrameInterval > 20) {
					enemySeedFrameInterval -= 2
				}

				// 计算分数
				score += calcScore(enemy.radius)
				scoreNode.textContent = score
			}
		}
	}
}

// 飞船处理
const handleShipCollision = function () {
	if (enemies.length) {
		for (let enemy of enemies) {
			let collision = hitTest(ship, enemy)
			if (collision) {
				console.log('飞船被摧毁')
				ship.active = false
				setTimeout(() => {
					ship.active = true
					speedMultiplier = 1
					enemySeedFrameInterval = 100
					score = 0
					scoreNode.textContent = score
				}, 2000)
			}
		}
	}
}

// 构建飞船
const drawShip = function (xPosition) {
	if (ship.active) {
		ship.update(xPosition)
		ship.draw()
	}
}

// 构建圆点
const drawEnemies = function () {
	if (enemies.length) {
		for (let enemy of enemies) {
			if (enemy.active) {
				enemy.update(enemy.x, (enemy.y += enemy.speed * speedMultiplier))
				enemy.draw()
			}
		}
	}
}

// 清除圆点
const enemyCleanup = function () {
	if (enemies.length) {
		enemies = enemies.filter((enemy) => {
			let visible = enemy.y < stage.height + enemy.width
			let active = enemy.active === true
			return visible && active
		})
	}
}

// 构建激光
const drawLasers = function () {
	if (lasers.length) {
		for (let laser of lasers) {
			if (laser.active) {
				laser.update((laser.y -= laser.speed))
				laser.draw()
			}
		}
	}
}
// 清除激光
const laserCleanup = function () {
	lasers = lasers.filter((laser) => {
		let visible = laser.y > -laser.height
		let active = laser.active === true
		return visible && active
	})
}

let tick = 0

// 构建页面
const render = function (delta) {
	if (playing) {
		let xPos = ship.x

		// 增加新的敌人
		if (tick % enemySeedFrameInterval === 0 && ship.active) {
			const enemy = new Enemy()
			enemies.push(enemy)
		}

		// 背景
		ctx.save()
		ctx.fillStyle = '#000'
		ctx.fillRect(0, 0, stage.width, stage.height)
		ctx.restore()

		// 飞船移动
		if (ship.left) xPos = ship.x -= ship.speed
		else if (ship.right) xPos = ship.x += ship.speed

		// 边界
		if (gameStarted) {
			if (xPos < 0) xPos = 0
			else if (xPos > stage.width - ship.width) xPos = stage.width - ship.width
		}

		drawShip(xPos)

		handleShipCollision()
		handleLaserCollision()

		drawLasers()
		drawEnemies()

		enemyCleanup()
		laserCleanup()

		tick++
	}
	requestAnimationFrame(render)
}

// 启动游戏
const startGame = function (e) {
	console.log('开始游戏')
	dialogue.classList.add('dialogue--hidden')
	hud.classList.remove('score--hidden')
	e.currentTarget.blur()

	// 设置
	speedMultiplier = 1
	enemySeedFrameInterval = 100
	ship.x = stage.width * 0.5 - ship.radius - 0.5
	ship.y = stage.height - ship.radius - 30
	enemies = []
	gameStarted = true
}

// 重置浏览器大小
const onResize = function () {
	stage.width = window.innerWidth
	stage.height = window.innerHeight
}

// 点击开始游戏
startBtn.addEventListener('click', startGame)
// 监控浏览器大小
window.addEventListener('resize', onResize)

// canvas添加到body
document.body.appendChild(stage)
onResize()

// 启动
ship = new Ship({ color: '#ff9d00', x: -100, y: -100 })
playing = true
render()

拓展

利用canvas画一个机器猫