6 |
7 | export default function explosions(state = Map() as ExplosionsMap, action: Action) {
8 | if (action.type === A.SetExplosion) {
9 | return state.set(action.explosion.explosionId, action.explosion)
10 | } else if (action.type === A.RemoveExplosion) {
11 | return state.delete(action.explosionId)
12 | } else {
13 | return state
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/app/components/icons.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | type P = {
4 | x: number
5 | y: number
6 | }
7 |
8 | export class PlayerTankThumbnail extends React.PureComponent {
9 | render() {
10 | const { x, y } = this.props
11 | return (
12 |
18 | )
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/types/PlayerRecord.ts:
--------------------------------------------------------------------------------
1 | import { Record } from 'immutable'
2 | import TankRecord from './TankRecord'
3 |
4 | const PlayerRecordBase = Record({
5 | playerName: null as PlayerName,
6 | side: 'player' as Side,
7 | activeTankId: -1,
8 | lives: 0,
9 | score: 0,
10 | reservedTank: null as TankRecord,
11 | isSpawningTank: false,
12 | })
13 |
14 | export default class PlayerRecord extends PlayerRecordBase {
15 | static fromJS(object: any) {
16 | return new PlayerRecord(object).update('reservedTank', TankRecord.fromJS)
17 | }
18 |
19 | isActive() {
20 | return this.activeTankId !== -1
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/custom-tyings.d.ts:
--------------------------------------------------------------------------------
1 | import { SVGAttributes } from 'react'
2 |
3 | declare global {
4 | const DEV: Readonly<{
5 | LOG_AI: boolean
6 | ASSERT: boolean
7 | SPOT_GRAPH: boolean
8 | TANK_PATH: boolean
9 | RESTRICTED_AREA: boolean
10 | FAST: boolean
11 | TEST_STAGE: boolean
12 | HIDE_ABOUT: boolean
13 | INSPECTOR: boolean
14 | LOG: boolean
15 | LOG_PERF: boolean
16 | SKIP_CHOOSE_STAGE: boolean
17 | }>
18 | const COMPILE_VERSION: string
19 | const COMPILE_DATE: string
20 | }
21 |
22 | declare module 'react' {
23 | interface SVGAttributes extends DOMAttributes {
24 | href?: string
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/reducers/stages.ts:
--------------------------------------------------------------------------------
1 | import defaultStages from '../stages'
2 | import { A, Action } from '../utils/actions'
3 |
4 | export default function stages(state = defaultStages, action: Action) {
5 | if (action.type === A.SetCustomStage) {
6 | // 更新或是新增 stage
7 | const index = state.findIndex(s => s.name === action.stage.name)
8 | if (index === -1) {
9 | return state.push(action.stage)
10 | } else {
11 | return state.set(index, action.stage)
12 | }
13 | } else if (action.type === A.RemoveCustomStage) {
14 | return state.filterNot(s => s.custom && s.name === action.stageName)
15 | } else {
16 | return state
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/components/SteelWall.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from '../hocs/Image'
3 |
4 | export default class SteelWall extends React.PureComponent {
5 | render() {
6 | const { x, y } = this.props
7 | return (
8 |
15 |
16 |
17 |
18 |
19 | )
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/sagas/index.ts:
--------------------------------------------------------------------------------
1 | import { fork, put, takeEvery, takeLatest } from 'redux-saga/effects'
2 | import * as actions from '../utils/actions'
3 | import { A } from '../utils/actions'
4 | import gameSaga from './gameSaga'
5 | import soundManager from './soundManager'
6 | import { syncFrom, syncTo } from './syncLocalStorage'
7 |
8 | export default function* rootSaga() {
9 | DEV.LOG && console.log('root saga started')
10 |
11 | yield syncFrom()
12 | yield fork(soundManager)
13 | yield takeEvery(A.SyncCustomStages, syncTo)
14 | yield takeLatest([A.StartGame, A.ResetGame], gameSaga)
15 |
16 | if (DEV.SKIP_CHOOSE_STAGE) {
17 | yield put(actions.startGame(0))
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/components/dev-only/RestrictedAreaLayer.tsx:
--------------------------------------------------------------------------------
1 | import { Map } from 'immutable'
2 | import React from 'react'
3 |
4 | interface P {
5 | areas: Map
6 | }
7 |
8 | let RestrictedAreaLayer: React.ComponentClass = (() => null as any) as any
9 |
10 | if (DEV.RESTRICTED_AREA) {
11 | RestrictedAreaLayer = class extends React.PureComponent
{
12 | render() {
13 | const { areas } = this.props
14 | return (
15 |
16 | {areas.map((area, areaId) => ).toArray()}
17 |
18 | )
19 | }
20 | }
21 | }
22 |
23 | export default RestrictedAreaLayer
24 |
--------------------------------------------------------------------------------
/app/components/StageEnterCurtain.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BLOCK_SIZE as B } from '../utils/constants'
3 | import Curtain from './Curtain'
4 | import Text from './Text'
5 |
6 | interface P {
7 | t: number
8 | content: string
9 | }
10 |
11 | export default class StageEnterCurtain extends React.PureComponent
{
12 | render() {
13 | const { t, content } = this.props
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | )
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/types/BulletRecord.ts:
--------------------------------------------------------------------------------
1 | import { Record } from 'immutable'
2 |
3 | const BulletRecordBase = Record({
4 | bulletId: 0 as BulletId,
5 | // 子弹的方向
6 | direction: 'up' as Direction,
7 | // 子弹的速度
8 | speed: 0,
9 | // 子弹的位置
10 | x: 0,
11 | y: 0,
12 | // 子弹上一次的位置
13 | lastX: 0,
14 | lastY: 0,
15 | /**
16 | * 子弹的强度 默认强度为1
17 | * 强度大于等于2的子弹一下子可以破坏两倍的brick-wall
18 | * 强度为3的子弹可以破坏steel-wall */
19 | power: 1,
20 | // 发射子弹的坦克id
21 | tankId: -1 as TankId,
22 | side: 'player' as Side,
23 | // 发射子弹的玩家
24 | playerName: null as PlayerName,
25 | })
26 |
27 | export default class BulletRecord extends BulletRecordBase {
28 | static fromJS(object: any) {
29 | return new BulletRecord(object)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/reducers/bullets.ts:
--------------------------------------------------------------------------------
1 | import { Map } from 'immutable'
2 | import BulletRecord from '../types/BulletRecord'
3 | import { A, Action } from '../utils/actions'
4 |
5 | export type BulletsMap = Map
6 |
7 | export default function bullets(state = Map() as BulletsMap, action: Action): BulletsMap {
8 | if (action.type === A.AddBullet) {
9 | return state.set(action.bullet.bulletId, action.bullet)
10 | } else if (action.type === A.RemoveBullet) {
11 | return state.delete(action.bulletId)
12 | } else if (action.type === A.UpdateBullets) {
13 | return state.merge(action.updatedBullets)
14 | } else if (action.type === A.ClearBullets) {
15 | return state.clear()
16 | } else {
17 | return state
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/sagas/common/spawnTank.ts:
--------------------------------------------------------------------------------
1 | import { put } from 'redux-saga/effects'
2 | import { TankRecord } from '../../types'
3 | import * as actions from '../../utils/actions'
4 | import { asRect, getNextId } from '../../utils/common'
5 | import { flickerSaga } from '../common'
6 |
7 | export default function* spawnTank(tank: TankRecord, spawnSpeed = 1) {
8 | yield put(actions.startSpawnTank(tank))
9 |
10 | const areaId = getNextId('area')
11 | yield put(actions.addRestrictedArea(areaId, asRect(tank)))
12 |
13 | try {
14 | if (!DEV.FAST) {
15 | yield flickerSaga(tank.x, tank.y, spawnSpeed)
16 | }
17 | yield put(actions.addTank(tank.merge({ rx: tank.x, ry: tank.y })))
18 | } finally {
19 | yield put(actions.removeRestrictedArea(areaId))
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/components/AreaButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | interface AreaButtonProps {
4 | x?: number
5 | y?: number
6 | width: number
7 | height: number
8 | onClick?: () => void
9 | strokeWidth?: number
10 | spreadX?: number
11 | spreadY?: number
12 | }
13 |
14 | export default ({
15 | x = 0,
16 | y = 0,
17 | width,
18 | height,
19 | onClick,
20 | strokeWidth = 1,
21 | spreadX = 2,
22 | spreadY = 1,
23 | }: AreaButtonProps) => {
24 | return (
25 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/app/components/dev-only/reducer.ts:
--------------------------------------------------------------------------------
1 | import { Map, Record } from 'immutable'
2 | import { A, Action } from '../../utils/actions'
3 |
4 | let reducer: any = () => 0
5 |
6 | if (DEV.TANK_PATH) {
7 | const DevStateRecord = Record({
8 | pathmap: Map(),
9 | })
10 | class DevState extends DevStateRecord {}
11 |
12 | reducer = function testOnly(state = new DevState(), action: Action) {
13 | if (action.type === A.SetAITankPath) {
14 | return state.update('pathmap', pathmap => pathmap.set(action.tankId, action.path))
15 | } else if (action.type === A.RemoveAITankPath) {
16 | return state.update('pathmap', pathmap => pathmap.remove(action.tankId))
17 | } else {
18 | return state
19 | }
20 | }
21 | }
22 |
23 | export default reducer
24 |
--------------------------------------------------------------------------------
/app/stages/stage-1.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "1",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X X X X X X X X X X X ",
6 | "X Bf X Bf X Bf X Bf X Bf X Bf X ",
7 | "X Bf X Bf X Bf X Bf X Bf X Bf X ",
8 | "X Bf X Bf X Bf Tf Bf X Bf X Bf X ",
9 | "X Bf X Bf X B3 X B3 X Bf X Bf X ",
10 | "X B3 X B3 X Bc X Bc X B3 X B3 X ",
11 | "Bc X Bc Bc X B3 X B3 X Bc Bc X Bc ",
12 | "T3 X B3 B3 X Bc X Bc X B3 B3 X T3 ",
13 | "X Bc X Bc X Bf Bf Bf X Bc X Bc X ",
14 | "X Bf X Bf X Bf X Bf X Bf X Bf X ",
15 | "X Bf X Bf X B3 X B3 X Bf X Bf X ",
16 | "X Bf X Bf X B8 Bc B4 X Bf X Bf X ",
17 | "X X X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["18*basic", "2*fast"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/components/CurtainsContainer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { connect } from 'react-redux'
3 | import { State } from '../types'
4 | import StageEnterCurtain from './StageEnterCurtain'
5 |
6 | interface P {
7 | stageEnterCurtainT: number
8 | comingStageName: string
9 | }
10 |
11 | class CurtainsContainer extends React.PureComponent {
12 | render() {
13 | const { stageEnterCurtainT: t, comingStageName } = this.props
14 | return
15 | }
16 | }
17 |
18 | function mapStateToProps(state: State) {
19 | return {
20 | stageEnterCurtainT: state.game.stageEnterCurtainT,
21 | comingStageName: state.game.comingStageName,
22 | }
23 | }
24 |
25 | export default connect(mapStateToProps)(CurtainsContainer)
26 |
--------------------------------------------------------------------------------
/app/stages/stage-12.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "12",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X X X X X Bf Bf Bf X X X ",
6 | "X Bf Bf Bf Bc X Bc X X Bf X X X ",
7 | "X X X X Bf X B3 X X X X Bf Bf ",
8 | "X R R R R R X Bf B5 X X Bf T3 ",
9 | "X X Tc Tc Tc R X Bf X Tf T5 Bf X ",
10 | "Bf X Bf Bf Bf R R R X R Bf Bf X ",
11 | "X X X X Tf R X X X R T3 X X ",
12 | "R R R X R R Bf Bf X R X X X ",
13 | "X X X X X Bf T3 T3 X R R R X ",
14 | "Bf Bf Bf X X X X X X X X X X ",
15 | "X X Bf X T3 T3 X X X Bf Bf X Ba ",
16 | "Bf X X X X B8 Bc B4 X Bf X X Bf ",
17 | "X X X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["8*power", "6*fast", "6*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-13.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "13",
3 | "difficulty": 3,
4 | "map": [
5 | "X X X X Bc X X X Bc X X X X ",
6 | "X Bf Bf Bf Bf X X X Bf Bf Bf Bf X ",
7 | "X Bf X X X X Bf X X X X Tf X ",
8 | "X Tf X Bf B3 X X X B3 Bf X Bf Bf ",
9 | "X Bf X B5 F Tc Tf Tc F Ba X Tf Bf ",
10 | "X B3 X X F F F F F X X T3 Bf ",
11 | "Bf Tc X X F F F F F X X Bc Bf ",
12 | "Bf Tf X B5 F T3 Tf T3 F Ba X Bf X ",
13 | "Bf Bf X Bf Bc X X X Bc Bf X Tf X ",
14 | "Bf Tf X X X X Bf X X X X Bf X ",
15 | "Bf Bf Bf Bf Bf X X X Bf Bf Bf Tf Tf ",
16 | "Bf Bf X X B3 B8 Bc B4 B3 X X Bf X ",
17 | "Bf Bf X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["8*power", "8*fast", "4*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-2.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "2",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X Tf X X X Tf X X X X X ",
6 | "X B3 X Tf X X X Bf X Bf X Bf X ",
7 | "X Bf X X X X Bf BF X Bf Tf BF X ",
8 | "X X X Bf X X X X X Tf X X X ",
9 | "F X X BF X X TF X X BF F BF TF ",
10 | "F F X X X BF X X TF X F X X ",
11 | "X BF BF BF F F F TF X X F BF X ",
12 | "X X X TF F BF X BF X BF X BF X ",
13 | "TF BF X TF X BF X BF X X X BF X ",
14 | "X BF X BF X BF BF BF X BF TF BF X ",
15 | "X BF X BF X BF BF BF X X X X X ",
16 | "X BF X X X B8 BC B4 X BF X BF X ",
17 | "X BF X BF X BA EE B5 X BF BF BF X "
18 | ],
19 | "bots": ["2*armor", "4*fast", "14*basic"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-3.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "3",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X X BF X X X BF X X X X ",
6 | "X F F F BF X X X X X TC TC TC ",
7 | "BF F F F X X X X X X X X X ",
8 | "F F F F X X X BF X BF BF BF B5 ",
9 | "F F F F BF BF BF B3 X BF X BA X ",
10 | "F F F F X X BF X X X X BA X ",
11 | "X F X X X X TF TF TF X X F X ",
12 | "X BC X BF X X X X X F F F F ",
13 | "BF B5 BA BF B5 BC B3 B3 B3 F F F F ",
14 | "X X X X X BF X BC BC F F F F ",
15 | "BF X X T5 X X X B3 B3 F F F X ",
16 | "BF BF X T5 X B8 BC B4 X F F F X ",
17 | "TF BF BF X X BA EE B5 X BF X X X "
18 | ],
19 | "bots": ["14*basic", "4*fast", "2*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-14.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "14",
3 | "difficulty": 3,
4 | "map": [
5 | "X X X X X X X X X X X X X ",
6 | "F F X X Bc Bf Bf Bf Bc X X F F ",
7 | "F X X Ba Bf Bf Bf Bf Bf B5 X X F ",
8 | "X X X Bf Bf F Bf F Bf Bf X X X ",
9 | "X X X Bf F F Bf F F Bf X X X ",
10 | "F X X Bf Bf Bf Bf Bf Bf Bf X X F ",
11 | "F F X X Bf F Bf F Bf X X F F ",
12 | "R R R X Bf Bf Bf Bf Bf X R R R ",
13 | "X X X X Ba Ba Ba Ba Ba X X X X ",
14 | "X X X X B5 B5 B5 B5 B5 X X X X ",
15 | "Ta Ta Ta X X X X X X X T5 T5 T5 ",
16 | "B5 B5 B5 X X B8 Bc B4 X X Ba Ba Ba ",
17 | "T5 T5 T5 Ta X Ba E B5 X T5 Ta Ta Ta "
18 | ],
19 | "bots": ["10*power", "4*fast", "6*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-15.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "15",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X X Bf Bf X X Bf X X X X ",
6 | "X F F Bf Bf X X X Bf X X X X ",
7 | "F F F F F F F F Bf Bf X X X ",
8 | "F T3 Bf F Bf Bf Bf F F F F Bf Tf ",
9 | "F F Bf F F F T3 F F Bf T5 Bf X ",
10 | "X F F Bf Tc F F F F Bf X Bf X ",
11 | "X Bf Bf Bf Bf Bf F F Bf Bf B5 F F ",
12 | "Ta T3 Bf Bf X X X Bf B3 X X X F ",
13 | "X Bf X Bf X Tc Bc B3 F F Bf B5 F ",
14 | "X Bf X X Ba Bf B3 F F Bf X X F ",
15 | "X Bf Bf B5 Ba B3 F F Bc F Bf F F ",
16 | "X X Bf X F B8 Bc B4 Bf F B3 F X ",
17 | "X X B3 X X Ba E B5 X F F F X "
18 | ],
19 | "bots": ["2*basic", "10*fast", "8*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-16.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "16",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X X X X X X X X X X X ",
6 | "X X Tf F Tf X X X X X X X X ",
7 | "X X X F X F Tc X X X X X X ",
8 | "X F X X X X F Bc X X X X X ",
9 | "X F F X X F X F Tc X X X X ",
10 | "X F X F X F X X F Bc X X X ",
11 | "X F X X F X X X F F Tc X X ",
12 | "X X F X X X X F F F F Bc X ",
13 | "X X X F X X F X F F F F X ",
14 | "Bf X X X X X F X X F F F Tf ",
15 | "Bf Bf X X X X X F X F F F F ",
16 | "Tf Bf Bf X X B8 Bc B4 F X F F F ",
17 | "Tf Tf Bf Bf X Ba E B5 F X X F F "
18 | ],
19 | "bots": ["16*basic", "2*fast", "2*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-23.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "23",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X X X X X X X X X X X ",
6 | "X X X X X Tf Tf X X X X X X ",
7 | "X X X X X X Tf X X X X X X ",
8 | "X Tf Tf F F Bf Tf Bf F F Tf Tf X ",
9 | "X X X Tf F F Tf F F Tf X X X ",
10 | "F X X X Tf F F F Tf X X X F ",
11 | "Tf F X X X F F F X X X F Tf ",
12 | "F X X X Tc T3 F T3 Tc X X X F ",
13 | "X X X X Tf X Tc X Tf X X X X ",
14 | "X X X Tf X X Tf X X Tf X X X ",
15 | "X X X X X X X X X X X X X ",
16 | "X X X X X B8 Bc B4 X X X X X ",
17 | "X X Tf X X Ba E B5 X X Tf X X "
18 | ],
19 | "bots": ["6*armor", "4*power", "10*fast"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-25.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "25",
3 | "difficulty": 3,
4 | "map": [
5 | "X X X Tf X Bf X Bf X Bf X Tf X ",
6 | "X Bf X Bf X X X X X Tf X X X ",
7 | "X Bf X Bf X X Tf X X Tf X Tf Tf ",
8 | "X Bf X X X Bf X Tf Bf X X X Tf ",
9 | "X X X X Bf Bf X Bf Bf X Tf X X ",
10 | "X X Tf X Bf X X Bf Bf X Bf Bf X ",
11 | "Tf X Tf X X Bf X Tf X X Tf Bf X ",
12 | "X X Bf Bf X Bf X X X Bf Tf X X ",
13 | "X Tf Bf Bf X Bf Bf X Bf Bf X X Bf ",
14 | "X Bf X X X Bf Tf X X X X Bf Bf ",
15 | "X X X Bf X Bf Bf Tf X Tf X X Bf ",
16 | "Bf X Bf Bf X B8 Bc B4 X Bf Tf X X ",
17 | "Bf X Bf X X Ba E B5 X Bf Bf Bf X "
18 | ],
19 | "bots": ["2*power", "8*fast", "10*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-29.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "29",
3 | "difficulty": 3,
4 | "map": [
5 | "X X X X X X X X X X Bf X X ",
6 | "X Bf R R X Tf X Bf X X X X X ",
7 | "X X R R Bf F F F R R X Tf X ",
8 | "X X X X X F F F R R Bf X X ",
9 | "X Tf X X R R X F X X X X X ",
10 | "F F Bf X R R Tf X X X X Bf X ",
11 | "F F F X X X X X X Tf X X Tf ",
12 | "X Bf R R X Bf X X X X X X X ",
13 | "Tf X R R F F R R F F X Bf X ",
14 | "X X X X F X R R F F R R X ",
15 | "X X X Tf F X X X F F R R X ",
16 | "X X Bf X Bf B8 Bc B4 X X X X X ",
17 | "Bf X X X X Ba E B5 X Bf Tf X X "
18 | ],
19 | "bots": ["10*power", "4*fast", "6*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-34.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "34",
3 | "difficulty": 4,
4 | "map": [
5 | "X X X X B5 Ba X X X X X X X ",
6 | "B5 B5 B5 Ba X B5 X X B5 B5 X X X ",
7 | "B5 B5 B5 Bf Bf X X X B5 Bf B5 X X ",
8 | "Ba Ba X Bf B5 X X Ba B5 Bf Bf X X ",
9 | "X B5 X Bf Ba B5 X B5 Bf Bf Bf X X ",
10 | "X B5 Ba X X Bf B5 Bf Ba Ba Bf X X ",
11 | "X B5 X X Ba Bf Bf B5 X B5 Bf X X ",
12 | "X Ba X X B5 Bf Bf B5 X B5 Bf X X ",
13 | "X Ba B3 B3 X Bf Bf Bf X B5 B5 B3 Bf ",
14 | "X Ba X X Ba B5 Bf Ba B5 B5 B5 Ba Ba ",
15 | "X X B5 X Bf Ba B5 Bf Bf X X Ba X ",
16 | "X X B5 Ba B5 B8 Bc B4 Bf B5 X B5 X ",
17 | "X X B5 Ba X Ba E B5 X Bf Bf X X "
18 | ],
19 | "bots": ["4*power", "10*fast", "6*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-35.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "35",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X X X X X X X X X X X ",
6 | "X X X X Bf X Bf X X X X X X ",
7 | "F X X F Bf F Bf F X X F X X ",
8 | "Bf F F Bf Bf Bf Bf Bf F F Bf F X ",
9 | "Bf Bf Bf Bf Tf Bf Tf Bf Bf Bf Bf F X ",
10 | "R R R Bf Bf Bf Bf Bf R R R F X ",
11 | "R Bf Bf Bf Bf Bf Bf Bf Bf Bf R R F ",
12 | "Bf Bf Bf R Bf Bf Bf R Bf Bf Bf F F ",
13 | "Bf Bf R R R Bf R R R Bf Bf R R ",
14 | "F R R F F F F F R R F R F ",
15 | "X F F X X X X X F F X F X ",
16 | "X X X X X B8 Bc B4 X X X X X ",
17 | "X X X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["4*power", "6*fast", "10*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-11.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "11",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X X X Tf X Bf X Bf Bf X X ",
6 | "X Ba Bf Bf Bf Bf X Bf X X X X X ",
7 | "X X X B5 X Bf X Bf Bf X F F F ",
8 | "X Ba X X X X X Tf X F F F F ",
9 | "X Ba X Bf Bf Bf Tf Bf Bf F F B3 Tf ",
10 | "X B3 B3 B3 Tf X X Bf X F F X Ba ",
11 | "Ba Bf Bf Bf X Tf F F F F F X X ",
12 | "X X X Tf X X F F F F F Bf X ",
13 | "Tf Bf X F F F F Tf F F F Bf X ",
14 | "Ba Bf F F F F F X X X X Bf B5 ",
15 | "X Bf F F X X X X T3 Bf Bf Bf X ",
16 | "X X F F X B8 Bc B4 X Bf X Ba X ",
17 | "X Bc F F X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["5*fast", "6*armor", "4*power", "5*fast"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-33.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "33",
3 | "difficulty": 3,
4 | "map": [
5 | "X X X X Tf X X X X Tf X X X ",
6 | "X Tf X X X Tf X X Tf F F X X ",
7 | "X X Tf X X X X Tf F Tc T5 X X ",
8 | "X X X Tf X F F F F F X Ta X ",
9 | "X T5 X X Tf F F Tf F X X Tf X ",
10 | "X T3 T5 F X Tf F F Tf X X Ta X ",
11 | "X X F F F F F X X Tf X X X ",
12 | "X Tc T5 F X Tf F X X X Tf X X ",
13 | "X F F F Tf X Tf X Tc X X Tf X ",
14 | "F F F Tf X X X X Ta X X X X ",
15 | "X X Tf X X X X X X X X Ta Tf ",
16 | "X X X X X B8 Bc B4 X Tc T5 X X ",
17 | "T5 X Tc X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["4*fast", "8*armor", "4*power", "4*fast"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-4.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "4",
3 | "difficulty": 1,
4 | "map": [
5 | "X F F X X X X X X X X F X ",
6 | "F F X X Bc Bf Bf Bc Bc X X X F ",
7 | "F X X Ba Bf Bf Bf Bf Bf Bf Bc X T3 ",
8 | "T3 X X Bf Bf Bf Bf Bf Bf Bf Bf B5 X ",
9 | "X X Ba B3 X X X B3 Bf Bf X B5 X ",
10 | "R X Ba X T5 X T5 X Bf B5 X X X ",
11 | "X X Bf X Bc Bc X X Bf B5 X R R ",
12 | "X X Bf Bf Bf Bf Bf Bf Bf Bf X X X ",
13 | "X Ba Bf Bf Bf Bf Bf Bf Bf Bf B5 X X ",
14 | "X B3 B3 Bf Bf Bf Bf Bf Bf B3 B3 X X ",
15 | "X Bf Bf Bc B3 Bf Bf B3 Bc Bf Bf X F ",
16 | "F X B3 B3 X B8 Bc B4 B3 B3 X F F ",
17 | "Tf F X X X Ba E B5 X X F F Tf "
18 | ],
19 | "bots": ["10*power", "5*fast", "2*basic", "3*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-5.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "5",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X X Bf Bf X X X X X X X ",
6 | "Tc X Bc X Bf X X X T3 T3 Tf X X ",
7 | "Tf X Bf X X X Bf X X X X X X ",
8 | "Bf X Bf Bf Bf X Bf Bf X R R X R ",
9 | "B3 X X X B3 X X X X R X X X ",
10 | "X X Bc X R R X R R R X Bf Bf ",
11 | "Bf Bf X X R Bf X Bf Bf X X X X ",
12 | "X X X X R X X X X X Ta T5 X ",
13 | "R R R X R X Tf X Bf X Ta X X ",
14 | "X X X Bc Bc X X X X X Ta Bf Bf ",
15 | "X X X X Bf B3 B3 B3 Bf Bc X X X ",
16 | "Bf Bf B3 X X B8 Bc B4 X B3 Bf X X ",
17 | "B3 X X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["5*power", "2*armor", "8*basic", "5*fast"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-6.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "6",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X X X Ba X B5 F F X X X ",
6 | "X B5 Ta X B5 X X X Ba F B5 Ba F ",
7 | "X B5 Ta X B5 X Bf X Ba F B5 Ba F ",
8 | "X Bf X X Bf X Tf X Bf F X Bf F ",
9 | "X X X Ba T3 X Bf X B3 T5 X F F ",
10 | "Bf Bf B5 X X F Bf F X X Ba Bf Bf ",
11 | "X X X X Ba F F F B5 X X X X ",
12 | "Tf Bf Bf X B3 F F F B3 Ba Bf Bf Tf ",
13 | "T3 T3 T3 X Bc X F X Bc X T3 T3 T3 ",
14 | "X Bf X X Bf X X X Bf X X X X ",
15 | "X Bf B5 X X B3 X B3 X X Ba Bf F ",
16 | "X X B3 X X B8 Bc B4 X X F F F ",
17 | "X X Bc X X Ba E B5 X X Bc F F "
18 | ],
19 | "bots": ["7*power", "2*fast", "9*basic", "2*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-7.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "7",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X X X X X T3 T3 X X X X ",
6 | "X X Tf T3 T3 T3 X X X X Tf X X ",
7 | "X X Tf X X X F X T3 Tf Tf X X ",
8 | "X Tf X X X F Tf X X X Tf X X ",
9 | "X X X X F Tf Tf X X X T3 Tf X ",
10 | "X Tf X F Tf Tf Tf X Tf X X X X ",
11 | "X Ta X Tf Tf X X X Tf Tf X X X ",
12 | "T5 X X X Tf X Tf Tf Tf X X Ta X ",
13 | "X Ta Tf X X X Tf Tf F X X Tf X ",
14 | "X Tf X X X X Tf F X X Tf Tf X ",
15 | "X T3 T3 Tf X X F X X Tf X X X ",
16 | "X X X X X B8 Bc B4 X T3 X Tc Tf ",
17 | "Tc Tc X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["3*basic", "4*fast", "6*power", "7*basic"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-8.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "8",
3 | "difficulty": 3,
4 | "map": [
5 | "X X Bf X X Bf X Bc X Bf X X X ",
6 | "F Bf Bf Bf X Bf X Tc X Bf B5 X X ",
7 | "F F F X X B3 X Bf X B3 X Ba B5 ",
8 | "F R R R R R R R R R R X R ",
9 | "X Bf X X X X Bc Bc X X X X X ",
10 | "X X Bf X X Ba Bf Bf B3 Bf B3 T3 T3 ",
11 | "Bf Bf X Bf X Ba Bf Bf F Bf Tc Tc Bf ",
12 | "X X X Tf X Tc X F F F F X X ",
13 | "R R X R R R R R X R R R R ",
14 | "F F X Ba X X Bc Bc X X X X X ",
15 | "F F Bf X B5 X X Ba X Tc Bc Bf X ",
16 | "F Tc Bf X B5 B8 Bc B4 X B3 X Bf X ",
17 | "X X X X X Ba E B5 X Bc X B3 X "
18 | ],
19 | "bots": ["7*power", "2*armor", "4*fast", "7*basic"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-9.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "9",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X Bf X X X X X Tc F X X ",
6 | "Bf X X X X X Tc F Ta Tf T5 X Bf ",
7 | "X X X Tc F Ta Tf T5 X T3 F X X ",
8 | "X X Ta Tf T5 X T3 F X X X X X ",
9 | "X X X T3 F X X X X X X X X ",
10 | "X X X F Tc F X F Tc F X X X ",
11 | "Tf Bf X Ta Tf T5 X Ta Tf T5 X Bf Tf ",
12 | "X X X F T3 F X F T3 F X X X ",
13 | "X X X X Tc X X X Tc X X X X ",
14 | "Bf X X Ta Tf T5 X Ta Tf T5 X X Bf ",
15 | "Bf X X F T3 F X F T3 F X X Bf ",
16 | "X X Bc X X B8 Bc B4 X X Bc X X ",
17 | "X X Bf Bf X Ba E B5 X Bf Bf X X "
18 | ],
19 | "bots": ["6*fast", "4*fast", "7*power", "3*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-10.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "10",
3 | "difficulty": 1,
4 | "map": [
5 | "X X X X X X X X X X X X X ",
6 | "X Ba B3 Bf X X X X X X Bf B3 B5 ",
7 | "Ba B3 X X Bf X F F X Bf X X Ba ",
8 | "Bf X X X Bf F F F F Bf X X Ba ",
9 | "Bf X X X Bf F Tf Tf F Bf X X Bf ",
10 | "Ba Bc Bc Bf R R R R R R Bf Bf Bf ",
11 | "X Bf Bf Bf Tf Tf Bf Tf Tf Bf Bf Bf B5 ",
12 | "X X Bf Bf Tf X Bf X Tf Bf Bf B5 X ",
13 | "X X Bf Bf Bf Bf Bf Bf Bf Bf Bf B5 X ",
14 | "Bf F B3 B3 B3 Tf Tf B3 B3 B3 B3 F Bf ",
15 | "Bf F F F F F F F F F F F Bf ",
16 | "X X F F F B8 Bc B4 F F F F X ",
17 | "X X X B5 X Ba E B5 X X B5 X X "
18 | ],
19 | "bots": ["12*basic", "2*fast", "4*power", "2*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-17.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "17",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X X Bc X X X X X Bc X X ",
6 | "X Bf X Bf Bf X X S S S Bf Bf X ",
7 | "X Bf X X Bf X Tf S S S S S X ",
8 | "S S S T5 Bf X X Bf S S S S X ",
9 | "S S S S S S Bf Bf Ba B5 X X X ",
10 | "X X Ta S S S S Bf Ba B5 X T3 T3 ",
11 | "Bf Bf Bf Bf S S S S S S S Bf Bf ",
12 | "X X X Bf Bf S S S S T5 X X X ",
13 | "X Bf Bf Bf X S S S Bf Bf X Bf X ",
14 | "S S S Bf S X X X X Bf X Bf X ",
15 | "S S S S S T3 X T3 X X Bc Bf X ",
16 | "Bf S S S S B8 Bc B4 X Bf X X X ",
17 | "Bf Bf T5 X X Ba E B5 X Bf X Bf X "
18 | ],
19 | "bots": ["2*armor", "2*fast", "8*armor", "8*basic"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-18.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "18",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X X X X X X Tf Tf Tf F X ",
6 | "X Bf X X X X X X Tf X X Tf X ",
7 | "Bf F Bf X X X Bf Bf Bf Bf X Tf X ",
8 | "X Bf F Bf X X Bf X F Bf Tf Tf X ",
9 | "X X Bf X F Tf Bf F X Bf X X X ",
10 | "X X X X Tf X Bf Tf Bf Bf X X X ",
11 | "X X Bf Bf Tf Bf X Tf X X X X X ",
12 | "X X Bf X F Bf Tf F X X X X X ",
13 | "Tf Tf Tf F X Bf X X Bf Bf X X X ",
14 | "Tf X Bf Bf Bf Bf X X Bf Tf Tf X X ",
15 | "Tf X X Tf X X X X X Tf Bf Bf X ",
16 | "F Tf Tf Tf X B8 Bc B4 X X Bf Tf Tf ",
17 | "X X X X X Ba E B5 X X X Tf Tf "
18 | ],
19 | "bots": ["4*armor", "2*basic", "6*power", "8*fast"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-19.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "19",
3 | "difficulty": 3,
4 | "map": [
5 | "X Bf X Bf X Bf X Bf X Bf X Bf X ",
6 | "X Bf X Bf X Bf X Bf X Bf X Bf X ",
7 | "X T3 X T3 X T3 X T3 X T3 X T3 X ",
8 | "Bc X Bc X Bf X X X Bf X Bc X Bc ",
9 | "Bf X Bf B3 Bf X Bf X Bf B3 Bf X Bf ",
10 | "T3 X T3 X Tf X T3 X Tf X T3 X T3 ",
11 | "F F X X Bf X F X Bf X X F F ",
12 | "F F F F Bf B3 F B3 Bf F F F F ",
13 | "F F F F F F F F F F F F F ",
14 | "Bc X Bc X Bf F F F Bf X Bc X Bc ",
15 | "X Bf X Bf X X F X X Bf X Bf X ",
16 | "X Bf X Bf X B8 Bc B4 X Bf X Bf X ",
17 | "X B3 X B3 X Ba E B5 X B3 X B3 X "
18 | ],
19 | "bots": ["4*fast", "8*armor", "4*basic", "4*power"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-20.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "20",
3 | "difficulty": 4,
4 | "map": [
5 | "X X X R X Bf X X Bf X Bf X X ",
6 | "X X X X X X X X Bf X Tf X X ",
7 | "X X X R X Bc Tf X Bf X Bf X X ",
8 | "T3 X Bf R X Tf X Bc B3 X Bf X X ",
9 | "X X Bf R X X X Bf X X X X X ",
10 | "Bf X Bf R R X R R R R X X Bf ",
11 | "X X X Bc X X X F X R X T3 T3 ",
12 | "Bf Bf Ba Bf X Tf F F F R X Bc Bc ",
13 | "B3 X Ba X X Bf F F F R X Bf X ",
14 | "X Tc X X X Bf X F X R X F X ",
15 | "X Bf X Tc X B3 B3 B3 X X F F F ",
16 | "X Bf X Bf X B8 Bc B4 X R F F F ",
17 | "X X X X X Ba E B5 X R X F X "
18 | ],
19 | "bots": ["8*fast", "2*basic", "2*power", "8*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-21.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "21",
3 | "difficulty": 3,
4 | "map": [
5 | "X X X Bc Bc Bc X X Bc X X X X ",
6 | "X Bc Bf Bf Bf Bf Bf Bf Bf Bf X X X ",
7 | "X F F F F F F F F Bf Bf X X ",
8 | "F F X X X X X X F F Bf Bf X ",
9 | "F X Tf X X Tf X X X F F F X ",
10 | "F X Tf X X Tf X X X F F F X ",
11 | "F X X F X X X X F F Bf Bf B5 ",
12 | "F F F F F F F F F Bf Bf Bf B5 ",
13 | "Bf F F Bf Bf F F F Bf Bf Bf Bf X ",
14 | "X Bf Bf Bf Bf Bf Bf Bf Bf Bf Bf X Tf ",
15 | "Tf X Bf Tf Bf Bf Bf Bf Bf Bf B5 X Tf ",
16 | "X Tf Bf B3 Tf B8 Bc B4 Bf Bf Tf Tf Tf ",
17 | "X X X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["8*power", "2*fast", "6*basic", "4*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-22.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "22",
3 | "difficulty": 3,
4 | "map": [
5 | "X X X X X F X X X X X X X ",
6 | "X X X X F Tf F X X X X X X ",
7 | "X X F X X F X X F F X X X ",
8 | "X F Bf F X X X F Bf Bf F X X ",
9 | "X X F Bf F X X X F F X X F ",
10 | "F X X F X X F X X X X F Tf ",
11 | "Bf F X X X F Tf F X X F X F ",
12 | "Tf Bf F X X X F X X F Tf F X ",
13 | "Bf F X X F X X X F X F X X ",
14 | "F X X F Bf F X F Bf F X X X ",
15 | "X X X F Bf F X X F X X F X ",
16 | "X F X X F B8 Bc B4 X X F Tf F ",
17 | "F Tf F X X Ba E B5 X F Bf F X "
18 | ],
19 | "bots": ["8*fast", "6*basic", "2*power", "4*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-24.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "24",
3 | "difficulty": 3,
4 | "map": [
5 | "X X Tf X Bf T3 X X X X Ba X X ",
6 | "X X Bf X Bf F X B3 Bf Bf Bf X X ",
7 | "X F F X Bf F Ba B5 X X X Tf Tf ",
8 | "F F F F F F Bf Bf Bf X Ba Bf X ",
9 | "X X F F Bc Bc T3 Bf X Ba Bf B3 Ba ",
10 | "Bf T3 X Bc B3 B3 X X X Bf B3 X Ba ",
11 | "Ba X Bc Bf S S S S S S S S S ",
12 | "Ba X B3 X S S S S S S S S S ",
13 | "X X Tf X S S S S S S S S S ",
14 | "Bf X Bf X S S S S S S S S S ",
15 | "Ba X Bf X S S S S S S S S S ",
16 | "Ba X Bf X X B8 Bc B4 S S S S S ",
17 | "X X B3 X X Ba E B5 X S S S S "
18 | ],
19 | "bots": ["4*power", "2*armor", "4*fast", "10*basic"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-26.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "26",
3 | "difficulty": 2,
4 | "map": [
5 | "X X R R X X X X X X X X X ",
6 | "Tc X X R F X B5 X X X X X X ",
7 | "F Tc X X X X T5 X B5 X R R X ",
8 | "F F X T3 Bc Ba X X T5 F R X X ",
9 | "F F F X X Tf Bc Ba X X X X Tc ",
10 | "F F T3 Tc X Ba X Tf Bc X X Tc F ",
11 | "F T3 X X B3 Tf X Ba X T3 X F F ",
12 | "X X X X X B5 B3 Tf X X F F F ",
13 | "X X R F Ta X X B5 B3 Tc T3 F F ",
14 | "X R R X Ba X Ta X X X X X F ",
15 | "X X X X X X Ba X F R X X T3 ",
16 | "Tf X X X X B8 Bc B4 X R R X X ",
17 | "Tf Tf X X X Ba E B5 X X X X Tf "
18 | ],
19 | "bots": ["6*fast", "6*armor", "4*basic", "4*power"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-27.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "27",
3 | "difficulty": 4,
4 | "map": [
5 | "X X X X Tf X X X X X X X X ",
6 | "Tf Tf X X Tf X X Tf Tf X X X X ",
7 | "X Tf X X Tf X X X Tf X Tf Tf F ",
8 | "X Tf X X Tf Tf Tf X F X Tf X X ",
9 | "X Bf X X X X Tf X Tf Tf Tf X X ",
10 | "F Tf Tf X Tf Bf Tf Bf Bf X X X X ",
11 | "X X Tf F Tf F X X Bf X X Tf Tf ",
12 | "X X Tf X X F X X Tf X X Tf X ",
13 | "X X Bf X X Tf X X Tf Tf Bf Tf X ",
14 | "F Tf Tf Tf F F Bf Tf Tf X Bf Tf X ",
15 | "X X X Bf X X X X F F X Bf X ",
16 | "X X X Tf X B8 Bc B4 X F X Bf X ",
17 | "X X X Tf X Ba E B5 X Tf X Bf X "
18 | ],
19 | "bots": ["2*power", "8*armor", "8*fast", "2*basic"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-28.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "28",
3 | "difficulty": 3,
4 | "map": [
5 | "X X X X X X X X X X Ta T5 X ",
6 | "X X X X X X Tc X X X Tf X X ",
7 | "X X X X X Bc F Bc X Bf B5 X X ",
8 | "X X X X Tc F F F Tc Bf B5 X X ",
9 | "X X X Bc F F S F F Bf B5 X X ",
10 | "X X Tc F F S S S F F B5 X X ",
11 | "X Bc F F S S S S S F F Bc X ",
12 | "Tc F F S S S S S S S F F Tc ",
13 | "F F S S S S S S S S S F F ",
14 | "X F S S S S S S S S S F X ",
15 | "X F S S S S S S S S S F X ",
16 | "X F S S S B8 Bc B4 S S S F X ",
17 | "X F S S X Ba E B5 X S S F X "
18 | ],
19 | "bots": ["2*fast", "1*armor", "15*basic", "2*power"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-30.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "30",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X X X X X X X X X X X ",
6 | "X X X X X Bc Bc X X X Tc X X ",
7 | "X Tc Tc X Bc F F Tc X Bc F Bc X ",
8 | "Bc F F Bc F F F F Bc F F F Bc ",
9 | "F F F F F F F F F F F F F ",
10 | "Tf F R F F F F F R F F F F ",
11 | "F F R R R F F F R R R F Tf ",
12 | "F F F F R F Tf F F F R F F ",
13 | "F F F F F F F F F F F F F ",
14 | "F F F F F B3 B3 F F F F F T3 ",
15 | "T3 F F F B3 X X B3 F F F T3 X ",
16 | "X T3 B3 B3 X B8 Bc B4 B3 B3 B3 X X ",
17 | "X X X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["4*basic", "8*fast", "4*power", "4*armor"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-31.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "31",
3 | "difficulty": 2,
4 | "map": [
5 | "X X X R X X X X R X X X X ",
6 | "R R X R X R R R R X R R R ",
7 | "F F Bf X X Bf X X R X R F R ",
8 | "F R R R R X Tf X X Bf F F F ",
9 | "F F X R X X R X R R R R F ",
10 | "R R X R X R R X X R X X X ",
11 | "X X Bf F Bf X Bf F X R X X R ",
12 | "X R R F R R R R X F Bf X R ",
13 | "Bf X X Bf X X R X F F R X R ",
14 | "R R X R R X R Bf R R R X X ",
15 | "X X Bf X F F X X F R X X R ",
16 | "X R R R F B8 Bc B4 X R X R R ",
17 | "X R X X X Ba E B5 X X X X X "
18 | ],
19 | "bots": ["3*power", "8*fast", "6*armor", "3*power"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/stages/stage-32.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "32",
3 | "difficulty": 3,
4 | "map": [
5 | "S S S S S S X S S S S S S ",
6 | "S S S S S S S S S S S S S ",
7 | "S S S Bf S S S S S Bf S S S ",
8 | "S Bf X Bf X Bf S Bf X Bf X Bf S ",
9 | "S B3 B3 Bf X X X X X Bf B3 B3 S ",
10 | "S S S Bf Bc Bf Tf Bf Bc Bf S S S ",
11 | "Tf S S S X T3 X T3 X S S S Tf ",
12 | "S S S S X Bc X Bc X S S S S ",
13 | "S S S S X Bf X Bf X S S S S ",
14 | "S S S Bf X X Bc X X Bf S S S ",
15 | "S Bf S Bf X T3 T3 T3 X Bf S Bf S ",
16 | "X Bf Bc Bf X B8 Bc B4 X Bf Bc Bf X ",
17 | "X B3 X X X Ba E B5 X X X B3 X "
18 | ],
19 | "bots": ["8*armor", "6*basic", "2*power", "4*fast"]
20 | }
21 |
--------------------------------------------------------------------------------
/app/components/SnowLayer.tsx:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import React from 'react'
3 | import { getRowCol } from '../utils/common'
4 | import { ITEM_SIZE_MAP, N_MAP } from '../utils/constants'
5 | import Snow from './Snow'
6 |
7 | type P = {
8 | snows: List
9 | }
10 |
11 | export default class SnowLayer extends React.PureComponent {
12 | render() {
13 | const { snows } = this.props
14 |
15 | return (
16 |
17 | {snows.map((set, t) => {
18 | if (set) {
19 | const [row, col] = getRowCol(t, N_MAP.SNOW)
20 | return
21 | } else {
22 | return null
23 | }
24 | })}
25 |
26 | )
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/devConfig.js:
--------------------------------------------------------------------------------
1 | // 该文件定义的常量将被 webpack.DefinePlugin 使用
2 | // 注意 custom-tyings.d.ts 文件的类型定义要与该文件一致
3 | // 参数 dev 表示是否为开发环境
4 |
5 | module.exports = dev => ({
6 | // 是否打印 AI 的日志
7 | 'DEV.LOG_AI': false,
8 | // 是否启用 console.assert
9 | 'DEV.ASSERT': dev,
10 | // 是否显示
11 | 'DEV.SPOT_GRAPH': false,
12 | // 是否显示
13 | 'DEV.TANK_PATH': false,
14 | // 是否显示 与「坦克的转弯保留位置指示器」
15 | 'DEV.RESTRICTED_AREA': dev,
16 | // 是否加快游戏过程
17 | 'DEV.FAST': false,
18 | // 是否使用测试关卡
19 | 'DEV.TEST_STAGE': false,
20 | // 是否显示 About 信息
21 | 'DEV.HIDE_ABOUT': dev,
22 | // 是否启用
23 | 'DEV.INSPECTOR': false,
24 | // 是否打印游戏日志
25 | 'DEV.LOG': dev,
26 | // 是否打印游戏性能相关日志
27 | 'DEV.LOG_PERF': false,
28 | // 是否跳过关卡选择
29 | 'DEV.SKIP_CHOOSE_STAGE': false,
30 | })
31 |
--------------------------------------------------------------------------------
/app/components/ForestLayer.tsx:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import React from 'react'
3 | import { getRowCol } from '../utils/common'
4 | import { ITEM_SIZE_MAP, N_MAP } from '../utils/constants'
5 | import Forest from './Forest'
6 |
7 | type P = {
8 | forests: List
9 | }
10 |
11 | export default class ForestLayer extends React.PureComponent {
12 | render() {
13 | const { forests } = this.props
14 | return (
15 |
16 | {forests.map((set, t) => {
17 | if (set) {
18 | const [row, col] = getRowCol(t, N_MAP.FOREST)
19 | return
20 | } else {
21 | return null
22 | }
23 | })}
24 |
25 | )
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/components/BrickLayer.tsx:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import React from 'react'
3 | import { getRowCol } from '../utils/common'
4 | import { ITEM_SIZE_MAP, N_MAP } from '../utils/constants'
5 | import BrickWall from './BrickWall'
6 |
7 | type P = {
8 | bricks: List
9 | }
10 |
11 | export default class BrickLayer extends React.PureComponent {
12 | render() {
13 | const { bricks } = this.props
14 |
15 | return (
16 |
17 | {bricks.map((set, t) => {
18 | if (set) {
19 | const [row, col] = getRowCol(t, N_MAP.BRICK)
20 | return
21 | } else {
22 | return null
23 | }
24 | })}
25 |
26 | )
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/components/SteelLayer.tsx:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import React from 'react'
3 | import { getRowCol } from '../utils/common'
4 | import { ITEM_SIZE_MAP, N_MAP } from '../utils/constants'
5 | import SteelWall from './SteelWall'
6 |
7 | type P = {
8 | steels: List
9 | }
10 |
11 | export default class SteelLayer extends React.PureComponent {
12 | render() {
13 | const { steels } = this.props
14 |
15 | return (
16 |
17 | {steels.map((set, t) => {
18 | if (set) {
19 | const [row, col] = getRowCol(t, N_MAP.STEEL)
20 | return
21 | } else {
22 | return null
23 | }
24 | })}
25 |
26 | )
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/components/Bullet.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BulletRecord } from '../types'
3 | import { Pixel } from './elements'
4 |
5 | const fill = '#ADADAD'
6 |
7 | const Bullet = ({ bullet }: { bullet: BulletRecord }) => {
8 | const { direction, x, y } = bullet
9 | let head: JSX.Element = null
10 | if (direction === 'up') {
11 | head =
12 | } else if (direction === 'down') {
13 | head =
14 | } else if (direction === 'left') {
15 | head =
16 | } else {
17 | // right
18 | head =
19 | }
20 | return (
21 |
22 |
23 | {head}
24 |
25 | )
26 | }
27 |
28 | export default Bullet
29 |
--------------------------------------------------------------------------------
/app/components/Curtain.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | interface P {
4 | name: string
5 | animationSchema?: 'default'
6 | t: number
7 | x?: number
8 | y?: number
9 | width: number
10 | height: number
11 | }
12 |
13 | export default class Curtain extends React.PureComponent
{
14 | render() {
15 | const { name, children, t, x = 0, y = 0, width, height } = this.props
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {children}
25 |
26 | )
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/sagas/soundManager.ts:
--------------------------------------------------------------------------------
1 | import { takeEvery } from 'redux-saga/effects'
2 | import * as actions from '../utils/actions'
3 | import { A } from '../utils/actions'
4 |
5 | const SOUND_NAMES: SoundName[] = [
6 | 'stage_start',
7 | 'game_over',
8 | 'bullet_shot',
9 | 'bullet_hit_1',
10 | 'bullet_hit_2',
11 | 'explosion_1',
12 | 'explosion_2',
13 | 'pause',
14 | 'powerup_appear',
15 | 'powerup_pick',
16 | 'statistics_1',
17 | ]
18 |
19 | export default function* soundManager() {
20 | const map = new Map(
21 | SOUND_NAMES.map(name => {
22 | const audio = new Audio(`sound/${name}.ogg`)
23 | audio.load()
24 | return [name, audio] as [SoundName, HTMLAudioElement]
25 | }),
26 | )
27 |
28 | yield takeEvery(A.PlaySound, function*({ soundName }: actions.PlaySound) {
29 | try {
30 | yield map.get(soundName).play()
31 | } catch (e) {}
32 | })
33 | }
34 |
--------------------------------------------------------------------------------
/app/components/Forest.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from '../hocs/Image'
3 | import { Bitmap } from './elements'
4 |
5 | const scheme = {
6 | a: '#8CD600',
7 | b: '#005208',
8 | c: '#084A00',
9 | d: 'none',
10 | }
11 |
12 | const d = [
13 | 'dbbbcbad',
14 | 'bbcacaca',
15 | 'bbbccaaa',
16 | 'cbbaabca',
17 | 'bbacaaac',
18 | 'bcbaaaaa',
19 | 'aaaaacaa',
20 | 'daacaaad',
21 | ]
22 |
23 | export default class Forest extends React.PureComponent {
24 | render() {
25 | const { x, y } = this.props
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/components/BrickWall.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from '../hocs/Image'
3 | import { ITEM_SIZE_MAP } from '../utils/constants'
4 |
5 | export default class BrickWall extends React.PureComponent {
6 | render() {
7 | const { x, y } = this.props
8 | const row = Math.floor(y / ITEM_SIZE_MAP.BRICK)
9 | const col = Math.floor(x / ITEM_SIZE_MAP.BRICK)
10 | const shape = (row + col) % 2 === 0
11 |
12 | return (
13 |
20 |
21 |
22 |
23 |
24 | )
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/components/PauseIndicator.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Text from './Text'
3 |
4 | export default class PauseIndicator extends React.PureComponent<
5 | Partial,
6 | { visible: boolean }
7 | > {
8 | private handle: any = null
9 |
10 | state = {
11 | visible: true,
12 | }
13 |
14 | componentDidMount() {
15 | if (!this.props.noflash) {
16 | this.handle = setInterval(() => this.setState({ visible: !this.state.visible }), 250)
17 | }
18 | }
19 |
20 | componentWillUnmount() {
21 | clearInterval(this.handle)
22 | }
23 |
24 | render() {
25 | const { x = 0, y = 0, content = 'pause' } = this.props
26 | return (
27 |
34 | )
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/components/TextWithLineWrap.tsx:
--------------------------------------------------------------------------------
1 | import { Range } from 'immutable'
2 | import React from 'react'
3 | import { BLOCK_SIZE as B } from '../utils/constants'
4 | import Text from './Text'
5 |
6 | export interface TextWithLineWrapProps {
7 | x: number
8 | y: number
9 | fill?: string
10 | maxLength: number
11 | content: string
12 | lineSpacing?: number
13 | }
14 |
15 | export default ({
16 | x,
17 | y,
18 | fill,
19 | maxLength,
20 | content,
21 | lineSpacing = 0.25 * B,
22 | }: TextWithLineWrapProps) => (
23 |
24 | {Range(0, Math.ceil(content.length / maxLength))
25 | .map(index => (
26 |
33 | ))
34 | .toArray()}
35 |
36 | )
37 |
--------------------------------------------------------------------------------
/app/reducers/texts.ts:
--------------------------------------------------------------------------------
1 | import { Map, Set } from 'immutable'
2 | import { TextRecord } from '../types'
3 | import { A, Action } from '../utils/actions'
4 | import { getDirectionInfo } from '../utils/common'
5 |
6 | export type TextsMap = Map
7 |
8 | export default function textsReducer(state = Map() as TextsMap, action: Action) {
9 | if (action.type === A.SetText) {
10 | return state.set(action.text.textId, action.text)
11 | } else if (action.type === A.MoveTexts) {
12 | const { textIds, direction, distance } = action
13 | const set = Set(textIds)
14 | return state.map((t, textId) => {
15 | if (set.has(textId)) {
16 | const { xy, updater } = getDirectionInfo(direction)
17 | return t.update(xy, updater(distance))
18 | } else {
19 | return t
20 | }
21 | })
22 | } else if (action.type === A.RemoveText) {
23 | return state.delete(action.textId)
24 | } else {
25 | return state
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/sagas/common/animateTexts.ts:
--------------------------------------------------------------------------------
1 | import { put, take } from 'redux-saga/effects'
2 | import * as actions from '../../utils/actions'
3 | import { A } from '../../utils/actions'
4 |
5 | export interface TextAnimation {
6 | direction: Direction
7 | distance: number
8 | duration: number
9 | }
10 |
11 | export default function* animateTexts(
12 | textIds: TextId[],
13 | { direction, distance: totalDistance, duration }: TextAnimation,
14 | ) {
15 | const speed = totalDistance / duration
16 | // 累计移动的距离
17 | let animatedDistance = 0
18 | while (true) {
19 | const { delta }: actions.Tick = yield take(A.Tick)
20 | // 本次TICK中可以移动的距离
21 | const len = delta * speed
22 | const distance = len + animatedDistance < totalDistance ? len : totalDistance - animatedDistance
23 | yield put(actions.moveTexts(textIds, direction, distance))
24 | animatedDistance += distance
25 | if (animatedDistance >= totalDistance) {
26 | return
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/types/MapRecord.ts:
--------------------------------------------------------------------------------
1 | import { List, Map as IMap, Record, Repeat } from 'immutable'
2 | import { N_MAP } from '../utils/constants'
3 | import EagleRecord from './EagleRecord'
4 |
5 | const MapRecordBase = Record({
6 | eagle: new EagleRecord(),
7 | bricks: Repeat(false, N_MAP.BRICK ** 2).toList(),
8 | steels: Repeat(false, N_MAP.STEEL ** 2).toList(),
9 | rivers: Repeat(false, N_MAP.RIVER ** 2).toList(),
10 | snows: Repeat(false, N_MAP.SNOW ** 2).toList(),
11 | forests: Repeat(false, N_MAP.FOREST ** 2).toList(),
12 | restrictedAreas: IMap(),
13 | })
14 |
15 | export default class MapRecord extends MapRecordBase {
16 | static fromJS(object: any) {
17 | return new MapRecord(object)
18 | .update('eagle', EagleRecord.fromJS)
19 | .update('bricks', List)
20 | .update('steels', List)
21 | .update('rivers', List)
22 | .update('snows', List)
23 | .update('forests', List)
24 | .update('restrictedAreas', IMap)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/docs/AI-design.md:
--------------------------------------------------------------------------------
1 | **AI 目前还不完善,需要后续开发**
2 |
3 | ### 战场情况:
4 |
5 | 1. 任意一个位置的位置情况 `Spot`
6 | * 该点位置下标 `t = row * 26 + col`
7 | * 该点是否能通过 `canPass`
8 | 2. 向一个位置(目标位置)开火的理想位置,以及开火估算(`FireEstimate`)
9 | * 开火点位置下标 `source`
10 | * 目标点位置下标 `target`
11 | * 距离 `distance`
12 | * 穿过的 brick 的数量 `brickCount`
13 | * 穿过的 steel 的数量 `steelCount`
14 | * TODO *穿过的物体 `penetration`*
15 |
16 |
17 | ### 坦克基本功能与算法
18 |
19 | **最短路径**:求出任意两个位置之间的一条最短路径
20 |
21 | **路径跟随**:沿着一条路径不断移动,直到到达路径终点
22 |
23 | **危险检测**:判断在在一段时间内是否会被 player 子弹击中
24 |
25 | **碰撞检测**:发现不能继续前进时选择另外一条路径前进
26 |
27 |
28 | ### Dodge Mode 躲避模式
29 |
30 | 每隔一段时间使用「危险检测」判断是否处于危险情况下,如果是的话,尝试以下几种应对方式:
31 |
32 | 1. 继续前进:继续前进就能躲避子弹,那就继续前进,这是比较理想的方式
33 | 2. 发射子弹以抵挡攻击
34 | 3. 找到一个附近的安全点,然后移动到那个位置
35 |
36 | 理想的实现效果:在「危险检测」触发频率较高的时候,坦克应该能躲避绝大部分的攻击。
37 |
38 | ### Wander Mode 游走模式
39 |
40 | 该模式下,移动和开火是两个独立的流程。
41 |
42 | 移动:随机挑选一个目标点,使用「最短路径」计算一条路径,然后使用对该路径使用「路径跟随」,到达目标点之后重新选取新的目标点。
43 |
44 | 开火:每隔一段时间判断是否向前开火,靠近 brick-wall 时开火概率提升;可以击中 player-tank / eagle 时开火概率大幅提升。
45 |
46 |
--------------------------------------------------------------------------------
/app/utils/Collision.ts:
--------------------------------------------------------------------------------
1 | import { EagleRecord, TankRecord } from '../types'
2 |
3 | type Collision =
4 | | CollisionWithBrick
5 | | CollisionWithSteel
6 | | CollisionWithBorder
7 | | CollisionWithTank
8 | | CollisionWithBullet
9 | | CollisionWithEagle
10 |
11 | export interface CollisionWithBrick {
12 | type: 'brick'
13 | t: number
14 | }
15 |
16 | export interface CollisionWithSteel {
17 | type: 'steel'
18 | t: number
19 | }
20 |
21 | export interface CollisionWithBorder {
22 | type: 'border'
23 | // 撞上了那个方向的墙
24 | which: Direction
25 | }
26 |
27 | export interface CollisionWithTank {
28 | type: 'tank'
29 | tank: TankRecord
30 | shouldExplode: boolean
31 | }
32 |
33 | export interface CollisionWithBullet {
34 | type: 'bullet'
35 | otherBulletId: BulletId
36 | // 发生碰撞时该子弹的位置
37 | x: number
38 | y: number
39 | // 发生碰撞时另一个子弹的位置
40 | otherX: number
41 | otherY: number
42 | }
43 |
44 | export interface CollisionWithEagle {
45 | type: 'eagle'
46 | eagle: EagleRecord
47 | }
48 |
49 | export default Collision
50 |
--------------------------------------------------------------------------------
/app/types/TankRecord.ts:
--------------------------------------------------------------------------------
1 | import { Record } from 'immutable'
2 |
3 | const TankRecordType = Record({
4 | /** 坦克是否存活在战场上,因为坦克被击毁之后,坦克的子弹可能还处于飞行状态
5 | * 故不能直接将坦克移除,而是使用该字段来表示坦克状态 */
6 | alive: true,
7 | tankId: 0,
8 | x: 0,
9 | y: 0,
10 | side: 'player' as Side,
11 | direction: 'up' as Direction,
12 | moving: false,
13 | level: 'basic' as TankLevel,
14 | color: 'auto' as TankColor,
15 | hp: 1,
16 | withPowerUp: false,
17 |
18 | // 坦克转弯预留位置的坐标
19 | rx: 0,
20 | ry: 0,
21 |
22 | // helmetDuration用来记录tank的helmet的剩余的持续时间
23 | helmetDuration: 0,
24 | // frozenTimeout小于等于0表示可以进行移动, 大于0表示还需要等待frozen毫秒才能进行移动, 坦克转向不受影响
25 | frozenTimeout: 0,
26 | // cooldown小于等于0表示可以进行开火, 大于0表示还需要等待cooldown毫秒才能进行开火
27 | cooldown: 0,
28 | // player tank被队友击中时无法移动,此时坦克会闪烁,该变量用来记录坦克是否可见
29 | visible: true,
30 | })
31 |
32 | export default class TankRecord extends TankRecordType {
33 | static fromJS(object: any) {
34 | return new TankRecord(object)
35 | }
36 |
37 | useReservedXY() {
38 | return this.merge({ x: this.rx, y: this.ry })
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/hocs/saga.tsx:
--------------------------------------------------------------------------------
1 | import identity from 'lodash/identity'
2 | import React from 'react'
3 | import { connect, Provider } from 'react-redux'
4 | import { applyMiddleware, createStore } from 'redux'
5 | import createSgaMiddleware, { Task } from 'redux-saga'
6 |
7 | export default function saga(sagaFn: any, reducerFn: any, preloadedState?: any): any {
8 | return function(Component: any) {
9 | const Connected = connect(identity)(Component)
10 | return class extends React.PureComponent {
11 | task: Task
12 | store: any
13 |
14 | constructor() {
15 | super()
16 | const sagaMiddleware = createSgaMiddleware()
17 | this.store = createStore(reducerFn, preloadedState, applyMiddleware(sagaMiddleware))
18 | this.task = sagaMiddleware.run(sagaFn)
19 | }
20 |
21 | componentWillUnmount() {
22 | this.task.cancel()
23 | }
24 |
25 | render() {
26 | return (
27 |
28 |
29 |
30 | )
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/sagas/common/flickerSaga.ts:
--------------------------------------------------------------------------------
1 | import { put } from 'redux-saga/effects'
2 | import { FlickerRecord } from '../../types'
3 | import * as actions from '../../utils/actions'
4 | import { frame as f, getNextId } from '../../utils/common'
5 | import Timing from '../../utils/Timing'
6 |
7 | const flickerShapeTiming = new Timing([
8 | { v: 3, t: f(3) },
9 | { v: 2, t: f(3) },
10 | { v: 1, t: f(3) },
11 | { v: 0, t: f(3) },
12 | { v: 1, t: f(3) },
13 | { v: 2, t: f(3) },
14 | { v: 3, t: f(3) },
15 | { v: 2, t: f(3) },
16 | { v: 1, t: f(3) },
17 | { v: 0, t: f(3) },
18 | { v: 1, t: f(3) },
19 | { v: 2, t: f(3) },
20 | { v: 3, t: f(1) },
21 | ])
22 |
23 | export default function* flickerSaga(x: number, y: number, spawnSpeed: number) {
24 | const flickerId = getNextId('flicker')
25 |
26 | try {
27 | yield* flickerShapeTiming.accelerate(spawnSpeed).iter(function*(shape) {
28 | yield put(actions.setFlicker(new FlickerRecord({ flickerId, x, y, shape })))
29 | })
30 | } finally {
31 | yield put(actions.removeFlicker(flickerId))
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/components/Screen.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { SCREEN_HEIGHT, SCREEN_WIDTH, ZOOM_LEVEL } from '../utils/constants'
3 |
4 | export interface ScreenProps {
5 | background?: string
6 | children?: React.ReactNode
7 | onMouseDown?: React.MouseEventHandler
8 | onMouseUp?: React.MouseEventHandler
9 | onMouseMove?: React.MouseEventHandler
10 | onMouseLeave?: React.MouseEventHandler
11 | refFn?: (svg: SVGSVGElement) => void
12 | }
13 |
14 | export default ({
15 | children,
16 | background = '#757575',
17 | onMouseDown,
18 | onMouseLeave,
19 | onMouseMove,
20 | onMouseUp,
21 | refFn,
22 | }: ScreenProps) => (
23 |
35 | {children}
36 |
37 | )
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 shinima
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/app/components/RiverLayer.tsx:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import React from 'react'
3 | import registerTick from '../hocs/registerTick'
4 | import { getRowCol } from '../utils/common'
5 | import { ITEM_SIZE_MAP, N_MAP } from '../utils/constants'
6 | import River from './River'
7 |
8 | interface RiverLayerProps {
9 | rivers: List
10 | tickIndex: number
11 | }
12 |
13 | class RiverLayer extends React.PureComponent {
14 | render() {
15 | const { rivers, tickIndex } = this.props
16 |
17 | return (
18 |
19 | {rivers.map((set, t) => {
20 | if (set) {
21 | const [row, col] = getRowCol(t, N_MAP.RIVER)
22 | return (
23 |
29 | )
30 | } else {
31 | return null
32 | }
33 | })}
34 |
35 | )
36 | }
37 | }
38 |
39 | export default registerTick(600, 600)(RiverLayer)
40 |
--------------------------------------------------------------------------------
/app/components/River.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from '../hocs/Image'
3 | import { Pixel } from './elements'
4 |
5 | const coordinates = [
6 | [[5, 0], [0, 2], [1, 3], [4, 3], [3, 4], [5, 4], [1, 6], [2, 7], [6, 7]],
7 | [[7, 0], [1, 1], [2, 2], [3, 3], [6, 3], [7, 4], [3, 5], [2, 6], [4, 6], [0, 7]],
8 | ]
9 |
10 | const riverPart = (shape: number, dx: number, dy: number) => (
11 |
12 |
13 | {coordinates[shape].map(([x, y], i) => (
14 |
15 | ))}
16 |
17 | )
18 |
19 | type RiverProps = {
20 | x: number
21 | y: number
22 | shape: 0 | 1
23 | }
24 |
25 | export default class River extends React.PureComponent {
26 | render() {
27 | const { x, y, shape } = this.props
28 | return (
29 |
30 | {riverPart(shape, 0, 0)}
31 | {riverPart(shape, 8, 0)}
32 | {riverPart(shape, 8, 8)}
33 | {riverPart(shape, 0, 8)}
34 |
35 | )
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/battle-city.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | .about {
6 | max-width: 200px;
7 | margin-left: 10px;
8 | padding: 0 10px;
9 | font-family: consolas, Microsoft Yahei, monospace;
10 | line-height: 1.4;
11 | border: 1px dashed #ccc;
12 | position: relative;
13 | }
14 |
15 | .about.hide {
16 | display: none;
17 | }
18 |
19 | .about .close {
20 | position: absolute;
21 | font-size: 12px;
22 | right: 4px;
23 | top: 4px;
24 | }
25 |
26 | .about .bold {
27 | font-weight: bold;
28 | }
29 |
30 | .screen {
31 | display: block;
32 | user-select: none;
33 | cursor: default;
34 | }
35 |
36 | .gallery .svg,
37 | .gallery .screen {
38 | background: #757575;
39 | }
40 |
41 | .area-button {
42 | fill: transparent;
43 | cursor: pointer;
44 | transition: stroke 250ms;
45 | }
46 |
47 | .area-button:hover {
48 | stroke: #e91e63;
49 | }
50 |
51 | .text-area {
52 | fill: transparent;
53 | cursor: pointer;
54 | }
55 |
56 | .text-area:hover {
57 | fill: #865b69;
58 | }
59 |
60 | .text-area.selected,
61 | .text-area:active {
62 | fill: #e91e63;
63 | }
64 |
65 | .text-area.disabled {
66 | fill: transparent;
67 | cursor: default;
68 | }
69 |
--------------------------------------------------------------------------------
/app/reducers/map.ts:
--------------------------------------------------------------------------------
1 | import { MapRecord } from '../types'
2 | import { A, Action } from '../utils/actions'
3 |
4 | const initState = new MapRecord({ eagle: null })
5 |
6 | export default function mapReducer(state = initState, action: Action) {
7 | if (action.type === A.LoadStageMap) {
8 | return action.stage.map
9 | } else if (action.type === A.DestroyEagle) {
10 | return state.setIn(['eagle', 'broken'], true)
11 | } else if (action.type === A.RemoveBricks) {
12 | return state.update('bricks', bricks =>
13 | bricks.map((set, t) => (action.ts.has(t) ? false : set)),
14 | )
15 | } else if (action.type === A.RemoveSteels) {
16 | return state.update('steels', steels =>
17 | steels.map((set, t) => (action.ts.has(t) ? false : set)),
18 | )
19 | } else if (action.type === A.UpdateMap) {
20 | return action.map
21 | } else if (action.type === A.AddRestrictedArea) {
22 | return state.update('restrictedAreas', areas => areas.set(action.areaId, action.area))
23 | } else if (action.type === A.RemoveRestrictedArea) {
24 | return state.update('restrictedAreas', areas => areas.delete(action.areaId))
25 | } else {
26 | return state
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/ai/followPath.ts:
--------------------------------------------------------------------------------
1 | import { put, select, take } from 'redux-saga/effects'
2 | import { TankRecord } from '../types'
3 | import * as actions from '../utils/actions'
4 | import * as selectors from '../utils/selectors'
5 | import Bot from './Bot'
6 | import { logAI } from './logger'
7 | import { getTankSpot } from './spot-utils'
8 |
9 | // TODO 可以考虑「截断过长的路径」
10 | export default function* followPath(ctx: Bot, path: number[]) {
11 | DEV.LOG_AI && logAI('start-follow-path')
12 | try {
13 | yield put(actions.setAITankPath(ctx.tankId, path))
14 | const tank: TankRecord = yield select(selectors.tank, ctx.tankId)
15 | DEV.ASSERT && console.assert(tank != null)
16 | const start = getTankSpot(tank)
17 | let index = path.indexOf(start)
18 | DEV.ASSERT && console.assert(index !== -1)
19 |
20 | while (index < path.length - 1) {
21 | const delta = path[index + 1] - path[index]
22 | let step = 1
23 | while (
24 | index + step + 1 < path.length &&
25 | path[index + step + 1] - path[index + step] === delta
26 | ) {
27 | step++
28 | }
29 | index += step
30 | yield* ctx.moveTo(path[index])
31 | yield take(ctx.noteChannel, 'reach')
32 | }
33 | } finally {
34 | yield put(actions.removeAITankPath(ctx.tankId))
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/ai/fire-utils.ts:
--------------------------------------------------------------------------------
1 | import { MapRecord } from '../types'
2 | import Spot from './Spot'
3 |
4 | /** 开火估算
5 | * 从source开火击中target的过程中需要穿过的steel和brick的数量
6 | */
7 | export interface FireEstimate {
8 | source: number
9 | target: number
10 | distance: number
11 | brickCount: number
12 | steelCount: number
13 | // 子弹需要穿过的物体的列表
14 | // penetration: {
15 | // type: ItemType
16 | // count: number
17 | // }[]
18 | }
19 |
20 | /** 根据 FireEstimate 计算 AI 坦克开火的次数 */
21 | export function getAIFireCount(est: FireEstimate) {
22 | if (est.brickCount <= 3) {
23 | return 1
24 | } else if (est.brickCount <= 5) {
25 | return 2
26 | } else {
27 | return 3
28 | }
29 | }
30 |
31 | export function getFireResist(est: FireEstimate) {
32 | return est.brickCount + est.steelCount * 100
33 | }
34 |
35 | export function mergeEstMap(a: Map, b: Map) {
36 | for (const [key, value] of b.entries()) {
37 | if (!a.has(key) || (a.has(key) && getFireResist(a.get(key)) < getFireResist(value))) {
38 | a.set(key, value)
39 | }
40 | }
41 | return a
42 | }
43 |
44 | export function calculateFireEstimateMap(weakSpots: number[], allSpots: Spot[], map: MapRecord) {
45 | return weakSpots.map(spot => allSpots[spot].getIdealFireEstMap(map)).reduce(mergeEstMap)
46 | }
47 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # 坦克大战复刻版(Battle City Remake)
2 |
3 | 游戏地址: https://shinima.github.io/battle-city
4 |
5 | 游戏详细介绍见知乎专栏文章: [https://zhuanlan.zhihu.com/p/35551654](https://zhuanlan.zhihu.com/p/35551654)
6 |
7 | 该 GitHub 仓库的版本是经典坦克大战的复刻版本,基于原版素材,使用 React 将各类素材封装为对应的组件。素材使用 SVG 进行渲染以展现游戏的像素风,可以先调整浏览器缩放再进行游戏,1080P 屏幕下使用 200% 缩放为最佳。此游戏使用网页前端技术进行开发,主要使用 React 进行页面展现,使用 Immutable.js 作为数据结构工具库,使用 redux 管理游戏状态,以及使用 redux-saga/little-saga 处理复杂的游戏逻辑。
8 |
9 | 如果游戏过程中发现任何 BUG 的话,欢迎提 [issue](https://github.com/shinima/battle-city/issues/new)。
10 |
11 | ### 开发进度:
12 |
13 |
14 | Milestone 0.2(已完成于 2018-04-16)
15 |
16 | - [x] 游戏的基本框架
17 | - [x] 单人模式
18 | - [x] 展览页面
19 | - [x] 关卡编辑器与自定义关卡管理
20 |
21 |
22 |
23 |
24 | Milestone 0.3(已完成于 2018-11-03)
25 |
26 | - [x] 性能优化
27 | - [x] 完整的游戏音效(有一些小瑕疵)
28 | - [x] 双人模式(已完成)
29 |
30 |
31 |
32 | **Milestone 1.0(看起来遥遥无期 /(ㄒ o ㄒ)/~~)**
33 |
34 | - [ ] 更合理的电脑玩家
35 | - [ ] 完整的设计、开发文档
36 | - [ ] 基于 websocket 的多人游戏模式
37 |
38 | ### 本地开发
39 |
40 | 1. 克隆该项目到本地
41 | 2. 运行 `yarn install` 来安装依赖 (或者使用 `npm install`)
42 | 3. 运行 `yarn start` 开启 webpack-dev-server,并在浏览器中打开 `localhost:8080`
43 | 4. 运行 `yarn build` 来打包生产版本,打包输出在 `dist/` 文件夹下
44 |
45 | `devConfig.js` 包含了一些开发用的配置项,注意修改该文件中的配置之后需要重启 webpack-dev-server
46 |
--------------------------------------------------------------------------------
/app/components/Grid.tsx:
--------------------------------------------------------------------------------
1 | import { Range } from 'immutable'
2 | import React from 'react'
3 | import {
4 | BLOCK_SIZE as B,
5 | FIELD_BLOCK_SIZE as FBZ,
6 | SCREEN_HEIGHT,
7 | SCREEN_WIDTH,
8 | } from '../utils/constants'
9 |
10 | export default class Grid extends React.PureComponent<{ t?: number }> {
11 | render() {
12 | const { t = -1 } = this.props
13 | const hrow = Math.floor(t / FBZ)
14 | const hcol = t % FBZ
15 |
16 | return (
17 |
18 | {Range(1, FBZ + 1)
19 | .map(col => (
20 |
28 | ))
29 | .toArray()}
30 | {Range(1, FBZ + 1)
31 | .map(row => (
32 |
40 | ))
41 | .toArray()}
42 |
43 | )
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/components/BotCountIndicator.tsx:
--------------------------------------------------------------------------------
1 | import range from 'lodash/range'
2 | import React from 'react'
3 | import Image from '../hocs/Image'
4 |
5 | // 的尺寸为 8 * 8
6 | const BotTankThumbnail = ({ x, y }: { x: number; y: number }) => (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 |
20 | export interface BotCountIndicatorProps {
21 | count: number
22 | x?: number
23 | y?: number
24 | }
25 |
26 | export default class BotCountIndicator extends React.PureComponent {
27 | render() {
28 | const { x = 0, y = 0, count } = this.props
29 | return (
30 |
31 | {range(count).map(t => (
32 |
33 | ))}
34 |
35 | )
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/sagas/powerUpLifecycle.ts:
--------------------------------------------------------------------------------
1 | import { put, race, select, take } from 'redux-saga/effects'
2 | import { PowerUpRecord, State } from '../types'
3 | import * as actions from '../utils/actions'
4 | import { A, Action } from '../utils/actions'
5 | import { frame as f } from '../utils/common'
6 | import Timing from '../utils/Timing'
7 |
8 | function* blink(powerUpId: PowerUpId) {
9 | while (true) {
10 | yield Timing.delay(f(8))
11 | const { powerUps }: State = yield select()
12 | const powerUp = powerUps.get(powerUpId)
13 | yield put(actions.setPowerUp(powerUp.update('visible', v => !v)))
14 | }
15 | }
16 |
17 | /** 一个power-up的生命周期 */
18 | export default function* powerUpLifecycle(powerUp: PowerUpRecord) {
19 | const pickThisPowerUp = (action: Action) =>
20 | action.type === A.PickPowerUp && action.powerUp.powerUpId === powerUp.powerUpId
21 |
22 | try {
23 | yield put(actions.playSound('powerup_appear'))
24 | yield put(actions.setPowerUp(powerUp))
25 | const result = yield race({
26 | cancel: take([A.EndStage, A.ClearAllPowerUps]),
27 | blink: blink(powerUp.powerUpId),
28 | picked: take(pickThisPowerUp),
29 | })
30 | if (result.picked) {
31 | yield put(actions.playSound('powerup_pick'))
32 | }
33 | } finally {
34 | yield put(actions.removePowerUp(powerUp.powerUpId))
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/components/TankHelmet.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from '../hocs/Image'
3 | import registerTick from '../hocs/registerTick'
4 | import { frame as f } from '../utils/common'
5 |
6 | interface TankHelmetProps {
7 | x: number
8 | y: number
9 | tickIndex: number
10 | }
11 |
12 | class TankHelmet extends React.PureComponent {
13 | render() {
14 | const { x, y, tickIndex } = this.props
15 |
16 | const ds = [
17 | 'M0,8 v-2 h1 v-1 h1 v-1 h2 v-2 h1 v-1 h1 v-1 h2 v1 h-2 v1 h-1 v2 h-1 v1 h-2 v1 h-1 v2 h-1',
18 | 'M0,2 h1 v-1 h1 v-1 h2 v1 h1 v1 h2 v1 h1 v1 h-1 v-1 h-2 v-1 h-1 v-1 h-2 v1 h-1 v2 h1 v1 h1 v2 h1 v1 h-1 v-1 h-1 v-2 h-1 v-1 h-1 v-2',
19 | ]
20 |
21 | return (
22 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | )
37 | }
38 | }
39 |
40 | export default registerTick(f(2), f(2))(TankHelmet)
41 |
--------------------------------------------------------------------------------
/app/components/Snow.tsx:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import React from 'react'
3 | import Image from '../hocs/Image'
4 | import { Pixel } from './elements'
5 |
6 | const a = '#ffffff'
7 | const b = '#adadad'
8 | const c = '#636363'
9 |
10 | const snowPart = (dx: number, dy: number) => (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | {_.range(8).map(t => (
19 |
20 | ))}
21 | {_.range(7).map(t => (
22 |
23 | ))}
24 | {_.range(4).map(t => (
25 |
26 | ))}
27 | {_.range(3).map(t => (
28 |
29 | ))}
30 |
31 | )
32 |
33 | export default class Snow extends React.PureComponent {
34 | render() {
35 | const { x, y } = this.props
36 | return (
37 |
38 | {snowPart(0, 0)}
39 | {snowPart(8, 0)}
40 | {snowPart(8, 8)}
41 | {snowPart(0, 8)}
42 |
43 | )
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/ai/simpleFireLoop.ts:
--------------------------------------------------------------------------------
1 | import { race, select, take } from 'redux-saga/effects'
2 | import { State } from '../reducers'
3 | import { TankFireInfo } from '../types'
4 | import TankRecord from '../types/TankRecord'
5 | import { SIMPLE_FIRE_LOOP_INTERVAL } from '../utils/constants'
6 | import * as selectors from '../utils/selectors'
7 | import Timing from '../utils/Timing'
8 | import values from '../utils/values'
9 | import Bot from './Bot'
10 | import { determineFire, getEnv } from './env-utils'
11 |
12 | export default function* simpleFireLoop(ctx: Bot) {
13 | let skipDelayAtFirstTime = true
14 | while (true) {
15 | if (skipDelayAtFirstTime) {
16 | skipDelayAtFirstTime = false
17 | } else {
18 | const tank: TankRecord = yield select(selectors.tank, ctx.tankId)
19 | yield race({
20 | timeout: Timing.delay(tank ? values.bulletInterval(tank) : SIMPLE_FIRE_LOOP_INTERVAL),
21 | bulletComplete: take(ctx.noteChannel, 'bullet-complete'),
22 | })
23 | }
24 |
25 | const tank: TankRecord = yield select(selectors.tank, ctx.tankId)
26 | if (tank == null) {
27 | continue
28 | }
29 | const fireInfo: TankFireInfo = yield select(selectors.fireInfo, ctx.tankId)
30 | if (fireInfo.canFire) {
31 | const { map, tanks }: State = yield select()
32 |
33 | const env = getEnv(map, tanks, tank)
34 | if (determineFire(tank, env)) {
35 | ctx.fire()
36 | }
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/app/stages/index.ts:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import StageConfig from '../types/StageConfig'
3 |
4 | const requireStage = (require as any).context('.', false, /\.json/)
5 | const filenames = List(requireStage.keys())
6 |
7 | let defaultStages = filenames
8 | .map(requireStage)
9 | .map(StageConfig.fromRawStageConfig)
10 | // 按照关卡数字顺序排序
11 | .sortBy(s => Number(s.name))
12 |
13 | if (DEV.TEST_STAGE) {
14 | defaultStages = defaultStages.unshift(
15 | StageConfig.fromRawStageConfig({
16 | name: 'test',
17 | custom: false,
18 | difficulty: 1,
19 | map: [
20 | 'X X X X X X X X X X X X X ',
21 | 'X X X X X X X X X X X X X ',
22 | 'X X X X X X X X X X X X X ',
23 | 'X X X X X X X X X X X X X ',
24 | 'X X X X X X X X X X X X X ',
25 | 'X X X X X X X X X X X X X ',
26 | 'X X X X X X X X X X X X X ',
27 | 'X X X X X X X X X X X X X ',
28 | 'X X X X X X X X X X X X X ',
29 | 'X X X X X X X X X X X X X ',
30 | 'X X X X X X X X X X X X X ',
31 | 'X X X X X Xf Tf Tf X X X X X ',
32 | 'X X X X X X E Tf X X X X X ',
33 | ],
34 | bots: ['1*basic'],
35 | }),
36 | )
37 | }
38 |
39 | export const firstStageName = defaultStages.first().name
40 |
41 | export default defaultStages
42 |
--------------------------------------------------------------------------------
/docs/other.md:
--------------------------------------------------------------------------------
1 | ## 坦克与子弹的位置表示
2 |
3 | 坦克的位置用坦克的左上角的坐标表示,坦克的大小为 16 \* 16
4 |
5 | 子弹的位置用子弹的左上角的坐标表示,子弹的大小为 3 \* 3 (子弹在渲染时有一个额外的像素来表示子弹的方向,但是我们在处理子弹的碰撞时忽略该像素)
6 |
7 | ## URL 设计
8 |
9 | URL 设计:
10 |
11 | * 游戏过程中,url 要能反映当前的游戏进度
12 | * 访问对应的 url 可以直接进行操作(直接选择关卡或直接开始游戏)
13 | * url 只能反映游戏的一部分状态,`GameRecord#status` 记录了游戏主状态,该字段可以为以下值之一:`idle | on | statistics | gameover`
14 |
15 | URL 列表:
16 |
17 | * 主页面
18 | * url `/`
19 | * 渲染组件 ` `
20 | * 直接打开该地址的行为:_重置游戏状态_,并渲染 GameTitleScene
21 | * 游戏结束
22 | * url `/gameover`
23 | * 按下 `R` 跳转到上次关卡的选择页面的,以便重新开始游戏
24 | * 直接打开该地址的行为:自动跳转到主页面
25 | * 选择关卡界面
26 | * `/choose/:stageName`
27 | * 地址自动跳转 `/choose --> /choose/1`
28 | * 渲染组件 ` `
29 | * 直接打开该地址的行为:_重置游戏状态_,并渲染 ChooseStageScene
30 | * 游戏进行中
31 | * `/game/:stageName`
32 | * `/game --> /game/1`
33 | * 如果正在进行关卡统计,则渲染 ` `,否则渲染组件 ` `
34 | * 直接打开该 url 的行为:_重置游戏状态_,直接开始对应的关卡
35 | * 正在游戏中跳转到该地址的行为:如果地址中的关卡和正在游戏中的关卡一致,则什么也不做;否则直接开始地址对应的关卡
36 | * Gallery 页面,地址 `/gallery`
37 | * Editor 页面,地址 `/editor`
38 |
39 | \* 上面的 _重置游戏状态_ 意味着 **重置所有相关状态并重启所有的 saga**。
40 |
41 | ## PowerUp 生成与消失的规则
42 |
43 | \* 注意:_斜体字部分_ 可能与原版游戏有出入
44 |
45 | * 当玩家**第一次击中**属性 `withPowerUp` 为 `true` 的坦克时,一个 PowerUp 就会随机掉落,_不同类型的 PowerUp 掉落概率相同_
46 | * *PowerUp 掉落位置需要满足的条件:将 PowerUp 等分为四份,其中的一至三份与地图其他元素发生碰撞*
47 | * 当一架 `withPowerUp` 为 `true` 的坦克**开始生成**时(Flicker 出现时),地图上所有的 PowerUp 都会消失;PowerUp 在地图上不会因为长时间不拾取而消失
48 | * 每一关第 4、11、18 架坦克的 `withPowerUp` 属性为 `true`
49 |
--------------------------------------------------------------------------------
/app/ai/spot-utils.ts:
--------------------------------------------------------------------------------
1 | import { BulletRecord } from '../types'
2 |
3 | const N = 26
4 |
5 | export const getRow = (t: number) => Math.floor(t / N)
6 |
7 | export const getCol = (t: number) => t % N
8 |
9 | export const left = (t: number) => {
10 | const row = getRow(t)
11 | const col = getCol(t)
12 | return col === 0 ? null : row * N + (col - 1)
13 | }
14 |
15 | export const right = (t: number) => {
16 | const row = getRow(t)
17 | const col = getCol(t)
18 | return col === N - 1 ? null : row * N + (col + 1)
19 | }
20 |
21 | export const up = (t: number) => {
22 | const row = getRow(t)
23 | const col = getCol(t)
24 | return row === 0 ? null : (row - 1) * N + col
25 | }
26 |
27 | export const down = (t: number) => {
28 | const row = getRow(t)
29 | const col = getCol(t)
30 | return row === N - 1 ? null : (row + 1) * N + col
31 | }
32 |
33 | export const dirs = [left, right, up, down]
34 |
35 | export function around(t: number) {
36 | return [
37 | up(t),
38 | up(left(t)),
39 | left(t),
40 | down(left(t)),
41 | down(t),
42 | down(right(t)),
43 | right(t),
44 | right(up(t)),
45 | ].filter(x => x != null)
46 | }
47 |
48 | export const getTankSpot = (point: Point) => {
49 | const col = Math.round((point.x + 8) / 8)
50 | const row = Math.round((point.y + 8) / 8)
51 | return row * N + col
52 | }
53 |
54 | export function getBulletSpot(bullet: BulletRecord) {
55 | const col = Math.floor((bullet.x + 1) / 8)
56 | const row = Math.round((bullet.y + 1) / 8)
57 | return row * N + col
58 | }
59 |
--------------------------------------------------------------------------------
/app/components/TextButton.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames'
2 | import React from 'react'
3 | import { BLOCK_SIZE as B } from '../utils/constants'
4 | import Text from './Text'
5 |
6 | type TextButtonProps = {
7 | x?: number
8 | y?: number
9 | content: string
10 | spreadX?: number
11 | spreadY?: number
12 | onClick?: () => void
13 | onMouseOver?: () => void
14 | selected?: boolean
15 | textFill?: string
16 | selectedTextFill?: string
17 | disabled?: boolean
18 | stroke?: string
19 | }
20 |
21 | const TextButton = ({
22 | x = 0,
23 | y = 0,
24 | content,
25 | spreadX = 0.25 * B,
26 | spreadY = 0.125 * B,
27 | onClick,
28 | onMouseOver,
29 | selected,
30 | textFill = '#ccc',
31 | selectedTextFill = '#333',
32 | disabled = false,
33 | stroke = 'none',
34 | }: TextButtonProps) => {
35 | return (
36 |
37 |
48 |
55 |
56 | )
57 | }
58 |
59 | export default TextButton
60 |
--------------------------------------------------------------------------------
/app/components/elements.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from '../hocs/Image'
3 |
4 | type PixelProps = {
5 | x: number
6 | y: number
7 | fill: string
8 | }
9 | export class Pixel extends React.PureComponent {
10 | static displayName = 'Pixel'
11 | render() {
12 | const { x, y, fill } = this.props
13 | return
14 | }
15 | }
16 |
17 | interface BitMapProps {
18 | useImage?: boolean
19 | x: number
20 | y: number
21 | d: string[]
22 | scheme: { [key: string]: string }
23 | style?: React.CSSProperties
24 | }
25 |
26 | let nextImageKey: number = 1
27 | const imageKeyMap = new Map()
28 | function resolveImageKey(d: string[]) {
29 | if (!imageKeyMap.has(d)) {
30 | imageKeyMap.set(d, nextImageKey++)
31 | }
32 | return imageKeyMap.get(d)
33 | }
34 |
35 | export class Bitmap extends React.PureComponent {
36 | render() {
37 | const { x, y, d, scheme, style = {}, useImage } = this.props
38 | const width = d[0].length
39 | const height = d.length
40 | const content = d.map((cs, dy) =>
41 | Array.from(cs).map((c, dx) => ),
42 | )
43 | return (
44 |
52 | {content}
53 |
54 | )
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/hocs/registerTick.tsx:
--------------------------------------------------------------------------------
1 | import getSum from 'lodash/sum'
2 | import React from 'react'
3 | import { connect } from 'react-redux'
4 | import { wrapDisplayName } from 'recompose'
5 | import { State } from '../types'
6 |
7 | // HOC. 用来向组件注入名为 'tickIndex' 的prop
8 | // tickIndex会随着时间变化
9 | // 提供不同的interval可以改变tickIndex变化速度和tickIndex的范围
10 | // 例如, intervals为 [100, 200, 300]
11 | // 则前100毫秒中tickIndex的值为0, 接下来的200毫秒中tickIndex的值为1,
12 | // 紧接着的300毫秒中tickIndex的值为2. 然后tickIndex又会变为0, 如此循环...
13 | // tickIndex的值为 i 的时间长度由intervals数组下标 i 对应的数字决定
14 | export default function registerTick(...intervals: number[]) {
15 | const sum = getSum(intervals)
16 | return function(BaseComponent: React.ComponentClass) {
17 | type Props = { time: number }
18 | class Component extends React.Component<{}, {}> {
19 | static displayName = wrapDisplayName(BaseComponent, 'registerTick')
20 |
21 | startTime: number
22 |
23 | constructor(props: any) {
24 | super(props)
25 | this.startTime = props.time
26 | }
27 |
28 | render() {
29 | const { time, ...otherProps } = this.props as any
30 | let t = (time - this.startTime) % sum
31 | let tickIndex = 0
32 | while (intervals[tickIndex] < t) {
33 | t -= intervals[tickIndex]
34 | tickIndex += 1
35 | }
36 |
37 | return
38 | }
39 | }
40 |
41 | const enhance: any = connect((state: State, ownProps) => ({
42 | ...ownProps,
43 | time: state.time,
44 | }))
45 |
46 | return enhance(Component)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/sagas/syncLocalStorage.ts:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import { put, select } from 'redux-saga/effects'
3 | import { State } from '../reducers'
4 | import { default as StageConfig, RawStageConfig, StageConfigConverter } from '../types/StageConfig'
5 | import * as actions from '../utils/actions'
6 |
7 | function getStageNameList(stageList: List) {
8 | if (stageList.isEmpty()) {
9 | return 'empty'
10 | } else {
11 | return stageList.map(s => s.name).join(',')
12 | }
13 | }
14 |
15 | const key = 'custom-stages'
16 |
17 | /** 将自定义关卡保存到 localStorage 中 */
18 | export function* syncTo() {
19 | DEV.LOG && console.log('Sync custom stages to localStorage')
20 | const { stages }: State = yield select()
21 | const customStages = stages.filter(s => s.custom)
22 | if (customStages.isEmpty()) {
23 | localStorage.removeItem(key)
24 | } else {
25 | const stageList = customStages.map(StageConfigConverter.s2r)
26 | DEV.LOG && console.log('Saved stages:', getStageNameList(stageList))
27 | const content = JSON.stringify(stageList)
28 | localStorage.setItem(key, content)
29 | }
30 | }
31 |
32 | /** 从 localStorage 中读取自定义关卡信息 */
33 | export function* syncFrom() {
34 | try {
35 | DEV.LOG && console.log('Sync custom stages from localStorage')
36 | const content = localStorage.getItem(key)
37 | const stageList = List(JSON.parse(content)).map(StageConfigConverter.r2s)
38 | DEV.LOG && console.log('Loaded stages:', getStageNameList(stageList))
39 | yield* stageList.map(stage => put(actions.setCustomStage(stage)))
40 | } catch (e) {
41 | console.error(e)
42 | localStorage.removeItem(key)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/reducers/players.ts:
--------------------------------------------------------------------------------
1 | import PlayerRecord from '../types/PlayerRecord'
2 | import { A, Action } from '../utils/actions'
3 | import { dec, inc } from '../utils/common'
4 |
5 | export function playerReducerFactory(playerName: PlayerName) {
6 | const initState = new PlayerRecord({ playerName, side: 'player' })
7 | return function players(state = initState, action: Action) {
8 | if (action.type === A.ActivatePlayer && action.playerName === playerName) {
9 | return state.set('activeTankId', action.tankId)
10 | } else if (action.type === A.SetPlayerTankSpawningStatus && action.playerName === playerName) {
11 | return state.set('isSpawningTank', action.isSpawning)
12 | } else if (action.type === A.StartGame) {
13 | return state.set('lives', 3)
14 | } else if (action.type === A.SetReservedTank && action.playerName === playerName) {
15 | return state.set('reservedTank', action.tank)
16 | } else if (action.type === A.SetTankToDead) {
17 | return state.activeTankId === action.tankId ? state.set('activeTankId', -1) : state
18 | } else if (action.type === A.DecrementPlayerLife && action.playerName === playerName) {
19 | return state.update('lives', dec(1))
20 | } else if (action.type === A.IncrementPlayerLife && action.playerName === playerName) {
21 | return state.update('lives', inc(action.count))
22 | } else if (action.type === A.IncPlayerScore && action.playerName === playerName) {
23 | return state.update('score', inc(action.count))
24 | } else {
25 | return state
26 | }
27 | }
28 | }
29 |
30 | export const player1 = playerReducerFactory('player-1')
31 | export const player2 = playerReducerFactory('player-2')
32 |
--------------------------------------------------------------------------------
/app/utils/values.ts:
--------------------------------------------------------------------------------
1 | import { TankRecord } from '../types'
2 |
3 | namespace values {
4 | export function bulletPower(tank: TankRecord) {
5 | if (tank.side === 'player' && tank.level === 'armor') {
6 | return 3
7 | } else if (tank.side === 'bot' && tank.level === 'power') {
8 | return 2
9 | } else {
10 | return 1
11 | }
12 | }
13 |
14 | export function moveSpeed(tank: TankRecord) {
15 | // todo 需要校准数值
16 | if (tank.side === 'player') {
17 | return DEV.FAST ? 0.06 : 0.045
18 | } else {
19 | if (tank.level === 'power') {
20 | return 0.045
21 | } else if (tank.level === 'fast') {
22 | return 0.06
23 | } else {
24 | // baisc or armor
25 | return 0.03
26 | }
27 | }
28 | }
29 |
30 | export function bulletInterval(tank: TankRecord) {
31 | // todo 需要校准数值
32 | if (tank.level === 'basic') {
33 | return 300
34 | } else {
35 | return 200
36 | }
37 | }
38 |
39 | export function bulletLimit(tank: TankRecord) {
40 | if (tank.side === 'bot' || tank.level === 'basic' || tank.level === 'fast') {
41 | return 1
42 | } else {
43 | return 2
44 | }
45 | }
46 |
47 | export function bulletSpeed(tank: TankRecord) {
48 | // todo 需要校准数值
49 | if (tank.side === 'player') {
50 | if (DEV.FAST) {
51 | return 0.3
52 | }
53 | if (tank.level === 'basic') {
54 | return 0.12
55 | } else {
56 | return 0.18
57 | }
58 | } else {
59 | if (tank.level === 'basic') {
60 | return 0.12
61 | } else if (tank.level === 'power') {
62 | return 0.24
63 | } else {
64 | return 0.18
65 | }
66 | }
67 | }
68 | }
69 |
70 | export default values
71 |
--------------------------------------------------------------------------------
/docs/values/readme.md:
--------------------------------------------------------------------------------
1 | # battle-city 相关数值
2 |
3 | 默认时间单位为 毫秒 ms 其他单位: 1f = frame = 16.67ms 1s = 1000ms
4 |
5 | 默认距离的单位为 像素 px 其他单位: 1B = 1block = 16px
6 |
7 | #### 坦克颜色[DONE]
8 |
9 | 包含掉落物品的坦克颜色 [red/8f other/8f]
10 |
11 | AI armor tank HP 4 颜色 [green/1f silver/3f green/1f silver/1f]
12 |
13 | AI armor tank HP 3 颜色 [yellow/1f silver/3f yellow/1f silver/1f]
14 |
15 | AI armor tank HP 2 颜色 [green/3f yellow/1f green/1f yellow/1f]
16 |
17 | AI armor tank HP 1 颜色 silver
18 |
19 | player-1 的坦克颜色为 yellow
20 |
21 | player-2 的坦克颜色为 green
22 |
23 | **说明:**
24 |
25 | *[red/8f other/8f]*表示*red 持续 8 帧, 然后 other 持续 3 帧, 然后回到开头, red 持续 8 帧, other 持续 8 帧...*, 如此循环往复
26 |
27 | #### 坦克移动速度[DONE]
28 |
29 | slow: 0.03px/ms
30 |
31 | middle: 0.045px/ms
32 |
33 | fast: 0.6px/ms
34 |
35 | 玩家坦克的移动速度为 middle; AI basic 移动速度为 slow; AI fast 移动速度为 fast; AI power 与 AI armor 移动速度为 middle
36 |
37 | #### 子弹飞行速度[TODO]
38 |
39 | todo 下面几个数值似乎有问题
40 |
41 | Bot basic, Bot fast, Player basic 的子弹速度为 0.12px/ms; 其他坦克的子弹速度为 0.24px/ms
42 |
43 | #### 子弹上限[DONE]
44 |
45 | Player power 和 Player armor 的子弹上限为 2; 其余坦克的子弹上限 1
46 |
47 | 子弹上限表示一架坦克在场上的子弹数量最大值
48 |
49 | #### 道具相关时间[DONE]
50 |
51 | 道具掉落 ICON 的闪烁时间: [消失/8f 出现/8f]
52 |
53 | helmet 闪烁时间: 每个形状持续 2 帧左右
54 |
55 | 关卡开始时玩家自动获得的 helmet 持续时间: 135f
56 |
57 | 玩家坦克重生时自动获得的 helmet 持续时间: 180f
58 |
59 | 道具 helmet 的持续时间: 630f
60 |
61 | 道具 shovel, 总共的持续时间: 1268f. steel/1076f + (B/16f + T/16f) \* 6 次
62 |
63 | #### 其他数值[DONE]
64 |
65 | 得分提示的出现时间 48f
66 |
67 | #### 爆炸效果相关数值[TODO]
68 |
69 | #### 其他 TODO
70 |
71 | 暂无
72 |
73 | #### 子弹发射间隔[TODO]
74 |
75 | 玩家的子弹发射间隔为 ??? , 但受到子弹上限的影响, 所以子弹发射频率有上限
76 |
77 | ~~短距离射击时, 玩家坦克的子弹间隔取决于玩家手速~~
78 |
79 | AI 坦克的子弹发射间隔还没进行测量
80 |
81 | #### 子弹生成位置[DONE]
82 |
83 | 参考下图,与原版不一定一致
84 |
85 | 
86 |
--------------------------------------------------------------------------------
/app/sagas/common/destroyBullets.ts:
--------------------------------------------------------------------------------
1 | import { all, put } from 'redux-saga/effects'
2 | import { BulletRecord, BulletsMap, ExplosionRecord } from '../../types'
3 | import * as actions from '../../utils/actions'
4 | import { frame as f, getNextId } from '../../utils/common'
5 | import Timing from '../../utils/Timing'
6 |
7 | function* explosionFromBullet(bullet: BulletRecord) {
8 | const bulletExplosionShapeTiming: [ExplosionShape, number][] = [
9 | ['s0', f(4)],
10 | ['s1', f(3)],
11 | ['s2', f(2)],
12 | ]
13 |
14 | const explosionId = getNextId('explosion')
15 | try {
16 | for (const [shape, time] of bulletExplosionShapeTiming) {
17 | yield put(
18 | actions.setExplosion(
19 | new ExplosionRecord({
20 | cx: bullet.x + 2,
21 | cy: bullet.y + 2,
22 | shape,
23 | explosionId,
24 | }),
25 | ),
26 | )
27 | yield Timing.delay(time)
28 | }
29 | } finally {
30 | yield put(actions.removeExplosion(explosionId))
31 | }
32 | }
33 |
34 | /** 移除单个子弹, 调用explosionFromBullet来生成子弹爆炸(并在之后移除子弹爆炸效果) */
35 | function* destroyBullet(bullet: BulletRecord, useExplosion: boolean) {
36 | // if (bullet.side === 'player') {
37 | // // TODO soundManager.explosion_2()
38 | // }
39 | yield put(actions.beforeRemoveBullet(bullet.bulletId))
40 | yield put(actions.removeBullet(bullet.bulletId))
41 | if (useExplosion) {
42 | yield explosionFromBullet(bullet)
43 | }
44 | }
45 |
46 | /** 调用destroyBullet并使用ALL effects, 来同时移除若干个子弹 */
47 | export default function* destroyBullets(bullets: BulletsMap, useExplosion: boolean) {
48 | if (!bullets.isEmpty()) {
49 | yield all(
50 | bullets
51 | .toIndexedSeq()
52 | .toArray()
53 | .map(bullet => destroyBullet(bullet, useExplosion)),
54 | )
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/components/dev-only/TankPath.tsx:
--------------------------------------------------------------------------------
1 | import { Map as IMap } from 'immutable'
2 | import React from 'react'
3 | import { connect } from 'react-redux'
4 | import { State } from '../../types'
5 |
6 | let connectedTankPath: any = () => null as any
7 |
8 | if (DEV.TANK_PATH) {
9 | const colorList = ['aqua', 'red', 'yellow', 'coral', 'white']
10 | let nextColorIndex = 0
11 | const colorMap = new Map()
12 | function getColor(name: string) {
13 | if (!colorMap.has(name)) {
14 | colorMap.set(name, colorList[nextColorIndex++ % colorList.length])
15 | }
16 | return colorMap.get(name)
17 | }
18 |
19 | class TankPath extends React.PureComponent {
20 | render() {
21 | const { pathmap } = this.props
22 | const pointsMap: IMap = pathmap.map((path: number[]) => {
23 | return path
24 | .map(t => {
25 | const row = Math.floor(t / 26)
26 | const col = t % 26
27 | return `${col * 8},${row * 8}`
28 | })
29 | .join(' ')
30 | })
31 | return (
32 |
33 | {pointsMap
34 | .map((points, playerName) => (
35 |
45 | ))
46 | .toArray()}
47 |
48 | )
49 | }
50 | }
51 |
52 | function mapStateToProps(state: State) {
53 | return state.devOnly.toObject()
54 | }
55 |
56 | connectedTankPath = connect(mapStateToProps)(TankPath)
57 | }
58 |
59 | export default connectedTankPath
60 |
--------------------------------------------------------------------------------
/app/sagas/common/destroyTanks.ts:
--------------------------------------------------------------------------------
1 | import { put } from 'redux-saga/effects'
2 | import { ExplosionRecord, ScoreRecord, TankRecord } from '../../types'
3 | import * as actions from '../../utils/actions'
4 | import { frame as f, getNextId } from '../../utils/common'
5 | import { TANK_KILL_SCORE_MAP } from '../../utils/constants'
6 | import Timing from '../../utils/Timing'
7 |
8 | export function* scoreFromKillTank(tank: TankRecord) {
9 | const scoreId: ScoreId = getNextId('score')
10 | try {
11 | const score = new ScoreRecord({
12 | score: TANK_KILL_SCORE_MAP[tank.level],
13 | scoreId,
14 | x: tank.x,
15 | y: tank.y,
16 | })
17 | yield put(actions.addScore(score))
18 | yield Timing.delay(f(48))
19 | } finally {
20 | yield put(actions.removeScore(scoreId))
21 | }
22 | }
23 |
24 | const tankExplosionShapeTiming = new Timing([
25 | { v: 's0', t: f(7) },
26 | { v: 's1', t: f(5) },
27 | { v: 's2', t: f(7) },
28 | { v: 'b0', t: f(5) },
29 | { v: 'b1', t: f(7) },
30 | { v: 's2', t: f(5) },
31 | ])
32 | export function* explosionFromTank(tank: TankRecord) {
33 | const explosionId = getNextId('explosion')
34 | try {
35 | yield put(actions.playSound('explosion_1'))
36 | yield* tankExplosionShapeTiming.iter(function*(shape) {
37 | const explosion = new ExplosionRecord({
38 | cx: tank.x + 8,
39 | cy: tank.y + 8,
40 | shape,
41 | explosionId,
42 | })
43 | yield put(actions.setExplosion(explosion))
44 | })
45 | } finally {
46 | yield put(actions.removeExplosion(explosionId))
47 | }
48 | }
49 |
50 | export function* destroyTank(tank: TankRecord) {
51 | yield put(actions.setTankToDead(tank.tankId))
52 |
53 | yield explosionFromTank(tank)
54 | if (tank.side === 'bot') {
55 | yield scoreFromKillTank(tank)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/utils/Timing.ts:
--------------------------------------------------------------------------------
1 | import { clamp } from 'lodash'
2 | import { Effect, take } from 'redux-saga/effects'
3 | import * as actions from '../utils/actions'
4 |
5 | const add = (x: number, y: number) => x + y
6 |
7 | export default class Timing {
8 | /** 用于生成等待一段时间的effect.
9 | * 该函数作用和delay类似, 不过该函数会考虑游戏暂停的情况 */
10 | static *delay(ms: number) {
11 | let acc = 0
12 | while (true) {
13 | const { delta }: actions.Tick = yield take(actions.A.Tick)
14 | acc += delta
15 | if (acc >= ms) {
16 | break
17 | }
18 | }
19 | }
20 |
21 | static *tween(duration: number, effectFactory: (t: number) => Effect) {
22 | let accumulation = 0
23 | while (accumulation < duration) {
24 | const { delta }: actions.Tick = yield take(actions.A.Tick)
25 | accumulation += delta
26 | yield effectFactory(clamp(accumulation / duration, 0, 1))
27 | }
28 | }
29 |
30 | readonly sum: number
31 | constructor(readonly array: ReadonlyArray<{ t: number; v: V }>) {
32 | this.sum = array.map(item => item.t).reduce(add)
33 | }
34 |
35 | find(time: number) {
36 | let rem = time % this.sum
37 | let index = 0
38 | while (this.array[index].t < rem) {
39 | rem -= this.array[index].t
40 | index += 1
41 | }
42 | return this.array[index].v
43 | }
44 |
45 | accelerate(speed: number) {
46 | return new Timing(this.array.map(({ t, v }) => ({ t: t / speed, v })))
47 | }
48 |
49 | *iter(handler: (v: V) => Iterable) {
50 | let acc = 0
51 | let target = 0
52 | for (const { t, v } of this.array) {
53 | yield* handler(v)
54 |
55 | target += t
56 | while (true) {
57 | const { delta }: actions.Tick = yield take(actions.A.Tick)
58 | acc += delta
59 | if (acc >= target) {
60 | break
61 | }
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/components/Flicker.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { FlickerRecord } from '../types'
3 |
4 | interface P {
5 | flicker: FlickerRecord
6 | }
7 |
8 | export default class Flicker extends React.PureComponent {
9 | render() {
10 | const {
11 | flicker: { x, y, shape },
12 | } = this.props
13 | const transform = `translate(${x},${y})`
14 | if (shape === 0) {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | )
22 | } else if (shape === 1) {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 | )
31 | } else if (shape === 2) {
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 | )
40 | } else if (shape === 3) {
41 | return (
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | )
50 | } else {
51 | throw new Error(`Invalid tickIndex: ${shape}`)
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/ai/getAllSpots.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import MapRecord from '../types/MapRecord'
3 | import { testCollide } from '../utils/common'
4 | import IndexHelper from '../utils/IndexHelper'
5 | import Spot from './Spot'
6 |
7 | const threshold = -0.01
8 |
9 | export default function getAllSpots(map: MapRecord): Spot[] {
10 | const result: Spot[] = []
11 | for (const row of _.range(0, 26)) {
12 | next: for (const col of _.range(0, 26)) {
13 | const x = col * 8
14 | const y = row * 8
15 | const spotIndex = row * 26 + col
16 | const rect: Rect = { x: x - 8, y: y - 8, width: 16, height: 16 }
17 | if (row === 0 || col === 0) {
18 | // 第一行和第一列总是和边界相撞
19 | result.push(new Spot(spotIndex, false))
20 | continue next
21 | }
22 | for (const t of IndexHelper.iter('brick', rect)) {
23 | if (map.bricks.get(t)) {
24 | const subject = IndexHelper.getRect('brick', t)
25 | if (testCollide(subject, rect, threshold)) {
26 | result.push(new Spot(spotIndex, false))
27 | continue next
28 | }
29 | }
30 | }
31 | for (const t of IndexHelper.iter('steel', rect)) {
32 | if (map.steels.get(t)) {
33 | const subject = IndexHelper.getRect('steel', t)
34 | if (testCollide(subject, rect, threshold)) {
35 | result.push(new Spot(spotIndex, false))
36 | continue next
37 | }
38 | }
39 | }
40 | for (const t of IndexHelper.iter('river', rect)) {
41 | if (map.rivers.get(t)) {
42 | const subject = IndexHelper.getRect('river', t)
43 | if (testCollide(subject, rect, threshold)) {
44 | result.push(new Spot(spotIndex, false))
45 | continue next
46 | }
47 | }
48 | }
49 | result.push(new Spot(spotIndex, true))
50 | }
51 | }
52 | return result
53 | }
54 |
--------------------------------------------------------------------------------
/app/components/Eagle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from '../hocs/Image'
3 | import { Pixel } from './elements'
4 |
5 | const points = [[8, 3], [3, 6], [4, 7], [6, 8], [9, 8], [11, 7], [12, 6]]
6 |
7 | interface EagleProps {
8 | x: number
9 | y: number
10 | broken: boolean
11 | }
12 |
13 | export default class Eagle extends React.PureComponent {
14 | render() {
15 | const { x, y, broken } = this.props
16 |
17 | if (broken) {
18 | return (
19 |
26 |
30 |
34 |
35 | )
36 | } else {
37 | return (
38 |
45 |
49 | {points.map(([dx, dy], index) => (
50 |
51 | ))}
52 |
53 | )
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/components/TextInput.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BLOCK_SIZE as B } from '../utils/constants'
3 | import Text from './Text'
4 |
5 | type TextInputProps = {
6 | x: number
7 | y: number
8 | maxLength: number
9 | value: string
10 | onChange: (newValue: string) => void
11 | }
12 |
13 | export default class TextInput extends React.Component {
14 | constructor(props: TextInputProps) {
15 | super(props)
16 | this.state = {
17 | focused: false,
18 | }
19 | }
20 |
21 | onFocus = () => {
22 | this.setState({ focused: true })
23 | }
24 |
25 | onBlur = () => {
26 | this.setState({ focused: false })
27 | }
28 |
29 | onKeyDown = (event: React.KeyboardEvent) => {
30 | const { value, onChange, maxLength } = this.props
31 | if (event.key === 'Backspace') {
32 | onChange(value.slice(0, value.length - 1))
33 | } else if (Text.support(event.key)) {
34 | onChange((value + event.key).slice(0, maxLength))
35 | }
36 | }
37 |
38 | render() {
39 | const { x, y, maxLength, value } = this.props
40 | const { focused } = this.state
41 | return (
42 |
49 |
59 |
60 |
67 |
68 | )
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/app/ai/shortest-path.ts:
--------------------------------------------------------------------------------
1 | import Spot from './Spot'
2 | import { dirs } from './spot-utils'
3 |
4 | // TODO 使用A*算法
5 | // TODO 寻找路径还需要考虑经过的位置安全与否 (是否容易被 player 玩家轻易地击中)
6 | export function findPath(
7 | allSpots: Spot[],
8 | start: number,
9 | stopConditionOrTarget: number | ((spot: Spot) => boolean),
10 | calculateScore: (step: number, spot: Spot) => number = step => step,
11 | ) {
12 | let stopCondition: (spot: Spot) => boolean
13 | if (typeof stopConditionOrTarget === 'number') {
14 | stopCondition = (spot: Spot) => spot.t === stopConditionOrTarget
15 | } else {
16 | stopCondition = stopConditionOrTarget
17 | }
18 |
19 | function getPath(end: number) {
20 | const path: number[] = []
21 | while (true) {
22 | path.unshift(end)
23 | if (end === start) {
24 | break
25 | }
26 | end = pre[end]
27 | }
28 | return path
29 | }
30 |
31 | const pre = new Array(allSpots.length)
32 | const distance = new Array(allSpots.length)
33 | pre.fill(-1)
34 | distance.fill(Infinity)
35 |
36 | let end = -1
37 | let minScore = Infinity
38 | let step = 0
39 | let cnt = new Set()
40 | cnt.add(start)
41 | while (cnt.size > 0) {
42 | step++
43 | const next = new Set()
44 | for (const u of cnt) {
45 | const spot = allSpots[u]
46 | if (!spot.canPass) {
47 | continue
48 | }
49 | distance[u] = step
50 | if (stopCondition(spot)) {
51 | const score = calculateScore(step, spot)
52 | if (score < minScore) {
53 | minScore = score
54 | end = u
55 | }
56 | }
57 | for (const dir of dirs) {
58 | const v = dir(u)
59 | if (v != null && distance[v] === Infinity) {
60 | next.add(v)
61 | pre[v] = u
62 | }
63 | }
64 | }
65 | cnt = next
66 | }
67 |
68 | if (end !== -1) {
69 | return getPath(end)
70 | } else {
71 | return null
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/app/reducers/index.ts:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import { routerReducer } from 'react-router-redux'
3 | import { combineReducers } from 'redux'
4 | import devOnly from '../components/dev-only/reducer'
5 | import MapRecord from '../types/MapRecord'
6 | import PlayerRecord from '../types/PlayerRecord'
7 | import StageConfig from '../types/StageConfig'
8 | import { A, Action } from '../utils/actions'
9 | import bullets, { BulletsMap } from './bullets'
10 | import explosions, { ExplosionsMap } from './explosions'
11 | import flickers, { FlickersMap } from './flickers'
12 | import game, { GameRecord } from './game'
13 | import map from './map'
14 | import { player1, player2 } from './players'
15 | import powerUps, { PowerUpsMap } from './powerUps'
16 | import scores, { ScoresMap } from './scores'
17 | import stages from './stages'
18 | import tanks, { TanksMap } from './tanks'
19 | import texts, { TextsMap } from './texts'
20 |
21 | export interface State {
22 | router: any
23 | game: GameRecord
24 | player1: PlayerRecord
25 | player2: PlayerRecord
26 | bullets: BulletsMap
27 | explosions: ExplosionsMap
28 | map: MapRecord
29 | time: number
30 | tanks: TanksMap
31 | flickers: FlickersMap
32 | texts: TextsMap
33 | powerUps: PowerUpsMap
34 | scores: ScoresMap
35 | stages: List
36 | editorContent: StageConfig
37 | devOnly: any
38 | }
39 |
40 | export function time(state = 0, action: Action) {
41 | if (action.type === A.Tick) {
42 | return state + action.delta
43 | } else {
44 | return state
45 | }
46 | }
47 |
48 | export function editorContent(state = new StageConfig(), action: Action) {
49 | if (action.type === A.SetEditorContent) {
50 | return action.stage
51 | } else {
52 | return state
53 | }
54 | }
55 |
56 | export default combineReducers({
57 | router: routerReducer,
58 | game,
59 | player1,
60 | player2,
61 | bullets,
62 | map,
63 | time,
64 | explosions,
65 | flickers,
66 | tanks,
67 | texts,
68 | powerUps,
69 | scores,
70 | stages,
71 | devOnly,
72 | editorContent,
73 | })
74 |
--------------------------------------------------------------------------------
/app/components/dev-only/SpotGraph.tsx:
--------------------------------------------------------------------------------
1 | import identity from 'lodash/identity'
2 | import React from 'react'
3 | import { connect } from 'react-redux'
4 | import { FireEstimate, getFireResist, mergeEstMap } from '../../ai/fire-utils'
5 | import getAllSpots from '../../ai/getAllSpots'
6 | import { around, getTankSpot } from '../../ai/spot-utils'
7 | import { State } from '../../reducers'
8 |
9 | let connectedSpotGraph: any = () => null as any
10 |
11 | if (DEV.SPOT_GRAPH) {
12 | const colors = {
13 | red: '#ff0000b3',
14 | green: '#4caf50aa',
15 | orange: 'orange',
16 | }
17 |
18 | class SpotGraph extends React.PureComponent {
19 | render() {
20 | const { map } = this.props
21 | const allSpots = getAllSpots(map)
22 | let estMap = new Map()
23 | if (map.eagle) {
24 | estMap = around(getTankSpot(map.eagle))
25 | .map(t => allSpots[t].getIdealFireEstMap(map))
26 | .reduce(mergeEstMap)
27 | }
28 | return (
29 |
30 | {allSpots.map((spot, t) => {
31 | const row = Math.floor(t / 26)
32 | const col = t % 26
33 | if (row === 0 || col === 0) {
34 | return null
35 | }
36 | const est = estMap.get(t)
37 | const fireResist = est ? getFireResist(est) : ''
38 | return (
39 |
40 |
47 |
48 | {fireResist}
49 |
50 |
51 | )
52 | })}
53 |
54 | )
55 | }
56 | }
57 |
58 | connectedSpotGraph = connect(identity)(SpotGraph)
59 | }
60 |
61 | export default connectedSpotGraph
62 |
--------------------------------------------------------------------------------
/app/components/StagePreview.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from '../hocs/Image'
3 | import StageConfig from '../types/StageConfig'
4 | import { BLOCK_SIZE as B } from '../utils/constants'
5 | import BrickLayer from './BrickLayer'
6 | import Eagle from './Eagle'
7 | import ForestLayer from './ForestLayer'
8 | import RiverLayer from './RiverLayer'
9 | import SnowLayer from './SnowLayer'
10 | import SteelLayer from './SteelLayer'
11 | import Text from './Text'
12 |
13 | interface StagePreviewProps {
14 | stage: StageConfig
15 | disableImageCache?: boolean
16 | x?: number
17 | y?: number
18 | scale?: number
19 | }
20 |
21 | export default class StagePreview extends React.PureComponent {
22 | render() {
23 | const { stage, x = 0, y = 0, scale = 1, disableImageCache } = this.props
24 | const name = stage != null ? stage.name : 'empty'
25 | if (stage == null) {
26 | return (
27 |
28 |
29 |
30 | )
31 | }
32 | const { rivers, steels, bricks, snows, eagle, forests } = stage.map
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
47 |
48 |
49 |
50 | {eagle ? : null}
51 |
52 |
53 |
54 | )
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "battle-city",
3 | "version": "0.4.0-SNAPSHOT",
4 | "description": "Battle city remake built with react.",
5 | "author": "Shi Feichao <842351815@qq.com> (https://shinima.github.io)",
6 | "scripts": {
7 | "start": "webpack-dev-server --mode=development",
8 | "build": "webpack --mode=production && copyfiles sound/* dist"
9 | },
10 | "dependencies": {
11 | "@types/history": "^4.6.2",
12 | "@types/lodash": "^4.14.105",
13 | "@types/node": "^10.9.4",
14 | "@types/prop-types": "^15.5.2",
15 | "@types/react": "^15.0.21",
16 | "@types/react-dom": "^15.5.1",
17 | "@types/react-hot-loader": "^4.1.0",
18 | "@types/react-redux": "^6.0.8",
19 | "@types/react-router-dom": "^4.2.5",
20 | "@types/react-router-redux": "^5.0.12",
21 | "@types/recompose": "^0.26.5",
22 | "classnames": "^2.2.5",
23 | "copyfiles": "^2.1.0",
24 | "core-js": "^2.5.5",
25 | "file-saver": "^1.3.3",
26 | "history": "^4.7.2",
27 | "immutable": "4.0.0-rc.9",
28 | "lodash": "^4.17.5",
29 | "normalize.css": "^8.0.0",
30 | "prop-types": "^15.6.1",
31 | "react": "^15.3.2",
32 | "react-dom": "^15.3.2",
33 | "react-hot-loader": "^4.0.0",
34 | "react-redux": "^5.0.7",
35 | "react-router-dom": "^4.2.2",
36 | "react-router-redux": "5.0.0-alpha.9",
37 | "recompose": "^0.30.0",
38 | "redux": "^4.0.0",
39 | "redux-saga": "1.0.2"
40 | },
41 | "devDependencies": {
42 | "@types/classnames": "^2.2.0",
43 | "@types/file-saver": "^1.3.0",
44 | "babel-core": "^6.26.0",
45 | "babel-loader": "7",
46 | "css-loader": "^1.0.0",
47 | "html-webpack-plugin": "^3.0.6",
48 | "json-loader": "^0.5.7",
49 | "moment": "^2.21.0",
50 | "prettier": "^1.11.1",
51 | "source-map-loader": "^0.2.1",
52 | "style-loader": "^0.23.0",
53 | "ts-loader": "^5.1.0",
54 | "typescript": "^3.4.1",
55 | "webpack": "^4.1.1",
56 | "webpack-cli": "^3.1.0",
57 | "webpack-dev-server": "^3.1.1"
58 | },
59 | "resolutions": {
60 | "@types/react": "15.6.7"
61 | },
62 | "prettier": {
63 | "printWidth": 100,
64 | "semi": false,
65 | "singleQuote": true,
66 | "trailingComma": "all"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app/sagas/animateStatistics.ts:
--------------------------------------------------------------------------------
1 | import { Map } from 'immutable'
2 | import { put, select } from 'redux-saga/effects'
3 | import { State } from '../types'
4 | import * as actions from '../utils/actions'
5 | import { TANK_LEVELS } from '../utils/constants'
6 | import * as selectors from '../utils/selectors'
7 | import Timing from '../utils/Timing'
8 |
9 | export default function* animateStatistics() {
10 | yield put(actions.showStatistics())
11 |
12 | const state: State = yield select()
13 |
14 | // 这里总是执行双人模式下的逻辑,但在单人摸下,渲染那边只会显示 player-1 的击杀信息
15 | const player1KillInfo = state.game.killInfo.get('player-1', Map())
16 | const player2KillInfo = state.game.killInfo.get('player-2', Map())
17 |
18 | yield Timing.delay(DEV.FAST ? 200 : 500)
19 |
20 | for (const tankLevel of TANK_LEVELS) {
21 | const tki = yield select((s: State) => s.game.transientKillInfo)
22 |
23 | yield Timing.delay(DEV.FAST ? 100 : 250)
24 | const count1 = player1KillInfo.get(tankLevel, 0)
25 | const count2 = player2KillInfo.get(tankLevel, 0)
26 | const killCount = Math.max(count1, count2)
27 |
28 | if (killCount === 0) {
29 | // 如果击杀数是 0 的话,则直接在界面中显示 0
30 | yield put(actions.playSound('statistics_1'))
31 | yield put(
32 | actions.updateTransientKillInfo(
33 | tki.setIn(['player-1', tankLevel], 0).setIn(['player-2', tankLevel], 0),
34 | ),
35 | )
36 | } else {
37 | // 如果击杀数大于 0,则显示从 1 开始增加到击杀数的动画
38 | for (let n = 1; n <= killCount; n += 1) {
39 | yield put(actions.playSound('statistics_1'))
40 | yield put(
41 | actions.updateTransientKillInfo(
42 | tki
43 | .setIn(['player-1', tankLevel], Math.min(n, count1))
44 | .setIn(['player-2', tankLevel], Math.min(n, count2)),
45 | ),
46 | )
47 | yield Timing.delay(DEV.FAST ? 64 : 160)
48 | }
49 | }
50 | yield Timing.delay(DEV.FAST ? 80 : 200)
51 | }
52 | yield Timing.delay(DEV.FAST ? 80 : 200)
53 | yield put(actions.playSound('statistics_1'))
54 | yield put(actions.showTotalKillCount())
55 | yield Timing.delay(DEV.FAST ? 400 : 1000)
56 |
57 | yield put(actions.hideStatistics())
58 | }
59 |
--------------------------------------------------------------------------------
/app/components/GameScene.tsx:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable'
2 | import React from 'react'
3 | import { connect } from 'react-redux'
4 | import { match } from 'react-router'
5 | import { Dispatch } from 'redux'
6 | import { GameRecord } from '../reducers/game'
7 | import { State } from '../types'
8 | import StageConfig from '../types/StageConfig'
9 | import * as actions from '../utils/actions'
10 | import BattleFieldScene from './BattleFieldScene'
11 | import StatisticsScene from './StatisticsScene'
12 |
13 | export interface GameSceneProps {
14 | game: GameRecord
15 | stages: List
16 | dispatch: Dispatch
17 | match: match
18 | }
19 |
20 | class GameScene extends React.PureComponent {
21 | componentDidMount() {
22 | this.didMountOrUpdate()
23 | }
24 |
25 | componentDidUpdate() {
26 | this.didMountOrUpdate()
27 | }
28 |
29 | didMountOrUpdate() {
30 | const { game, dispatch, match, stages } = this.props
31 | if (game.status === 'idle' || game.status === 'gameover') {
32 | // 如果游戏还没开始或已经结束 则开始游戏
33 | const stageName = match.params.stageName
34 | const stageIndex = stages.findIndex(s => s.name === stageName)
35 | dispatch(actions.startGame(stageIndex === -1 ? 0 : stageIndex))
36 | } else {
37 | // status is 'on' or 'statistics'
38 | // 用户在地址栏中手动输入了新的关卡名称
39 | const stageName = match.params.stageName
40 | if (
41 | game.currentStageName != null &&
42 | stages.some(s => s.name === stageName) &&
43 | stageName !== game.currentStageName
44 | ) {
45 | DEV.LOG && console.log('`stageName` in url changed. Restart game...')
46 | dispatch(actions.startGame(stages.findIndex(s => s.name === stageName)))
47 | }
48 | }
49 | }
50 |
51 | componentWillUnmount() {
52 | this.props.dispatch(actions.leaveGameScene())
53 | }
54 |
55 | render() {
56 | const { game } = this.props
57 | if (game.status === 'stat') {
58 | return
59 | } else {
60 | return
61 | }
62 | }
63 | }
64 |
65 | function mapStateToProps(state: State) {
66 | return { game: state.game, stages: state.stages }
67 | }
68 |
69 | export default connect(mapStateToProps)(GameScene) as any
70 |
--------------------------------------------------------------------------------
/app/sagas/fireController.ts:
--------------------------------------------------------------------------------
1 | import { put, select, take } from 'redux-saga/effects'
2 | import { BulletRecord, State, TankRecord } from '../types'
3 | import * as actions from '../utils/actions'
4 | import { A } from '../utils/actions'
5 | import { calculateBulletStartPosition, getNextId } from '../utils/common'
6 | import * as selectors from '../utils/selectors'
7 | import values from '../utils/values'
8 |
9 | export default function* fireController(tankId: TankId, shouldFire: () => boolean) {
10 | // tank.cooldown用来记录player距离下一次可以发射子弹的时间
11 | // tank.cooldown大于0的时候玩家不能发射子弹
12 | // 每个TICK时, cooldown都会相应减少. 坦克发射子弹的时候, cooldown重置为坦克的发射间隔
13 | // tank.cooldown和bulletLimit共同影响坦克能否发射子弹
14 | while (true) {
15 | const { delta }: actions.Tick = yield take(A.Tick)
16 | const { bullets: allBullets }: State = yield select()
17 | const tank: TankRecord = yield select((s: State) => s.tanks.get(tankId))
18 | const { game }: State = yield select()
19 | if (tank == null || !tank.alive || (tank.side === 'bot' && game.botFrozenTimeout > 0)) {
20 | continue
21 | }
22 | let nextCooldown = tank.cooldown <= 0 ? 0 : tank.cooldown - delta
23 |
24 | if (tank.cooldown <= 0 && shouldFire()) {
25 | const bullets = allBullets.filter(bullet => bullet.tankId === tank.tankId)
26 | if (bullets.count() < values.bulletLimit(tank)) {
27 | const { x, y } = calculateBulletStartPosition(tank)
28 | if (tank.side === 'player') {
29 | yield put(actions.playSound('bullet_shot'))
30 | }
31 | const bullet = new BulletRecord({
32 | bulletId: getNextId('bullet'),
33 | direction: tank.direction,
34 | x,
35 | y,
36 | lastX: x,
37 | lastY: y,
38 | power: values.bulletPower(tank),
39 | speed: values.bulletSpeed(tank),
40 | tankId: tank.tankId,
41 | side: tank.side,
42 | playerName: yield select(selectors.playerName, tankId),
43 | })
44 | yield put(actions.addBullet(bullet))
45 | // 一旦发射子弹, 则重置cooldown计数器
46 | nextCooldown = values.bulletInterval(tank)
47 | } // else 如果坦克发射的子弹已经到达上限, 则坦克不能继续发射子弹
48 | }
49 |
50 | if (tank.cooldown !== nextCooldown) {
51 | yield put(actions.setCooldown(tank.tankId, nextCooldown))
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { hot } from 'react-hot-loader'
3 | import { connect } from 'react-redux'
4 | import { Redirect, Route, Switch } from 'react-router-dom'
5 | import { ConnectedRouter } from 'react-router-redux'
6 | import About from './components/About'
7 | import ChooseStageScene from './components/ChooseStageScene'
8 | import Inspector from './components/dev-only/Inspector'
9 | import Editor from './components/Editor'
10 | import Gallery from './components/Gallery'
11 | import GameoverScene from './components/GameoverScene'
12 | import GameScene from './components/GameScene'
13 | import GameTitleScene from './components/GameTitleScene'
14 | import StageListPageWrapper from './components/StageList'
15 | import { GameRecord } from './reducers/game'
16 | import { firstStageName as fsn } from './stages'
17 | import { State } from './types'
18 | import history from './utils/history'
19 |
20 | class App extends React.PureComponent<{ game: GameRecord }> {
21 | render() {
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | }
34 | />
35 |
36 | }
40 | />
41 |
42 |
43 |
44 | {DEV.HIDE_ABOUT ? null :
}
45 | {DEV.INSPECTOR &&
}
46 |
47 |
48 | )
49 | }
50 | }
51 |
52 | function mapStateToProps(state: State) {
53 | return { game: state.game }
54 | }
55 |
56 | export default hot(module)(connect(mapStateToProps)(App))
57 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Battle City
10 |
34 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
49 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/app/sagas/tickEmitter.ts:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom'
2 | import { eventChannel, EventChannel } from 'redux-saga'
3 | import { put, select, take, takeEvery } from 'redux-saga/effects'
4 | import { State } from '../types'
5 | import * as actions from '../utils/actions'
6 |
7 | export interface TickEmitterOptions {
8 | maxFPS?: number
9 | bindESC?: boolean
10 | slow?: number
11 | }
12 |
13 | export default function* tickEmitter(options: TickEmitterOptions = {}) {
14 | const { bindESC = false, slow = 1, maxFPS = Infinity } = options
15 | let escChannel: EventChannel<'Escape'>
16 | const tickChannel = eventChannel(emit => {
17 | let lastTime = performance.now()
18 | let requestId = requestAnimationFrame(emitTick)
19 |
20 | function emitTick() {
21 | const now = performance.now()
22 | ReactDOM.unstable_batchedUpdates(emit, actions.tick(now - lastTime))
23 | lastTime = now
24 | requestId = requestAnimationFrame(emitTick)
25 | }
26 |
27 | return () => cancelAnimationFrame(requestId)
28 | })
29 |
30 | if (bindESC) {
31 | escChannel = eventChannel(emitter => {
32 | const onKeyDown = (event: KeyboardEvent) => {
33 | if (event.key === 'Escape') {
34 | emitter('Escape')
35 | }
36 | }
37 | document.addEventListener('keydown', onKeyDown)
38 | return () => {
39 | document.removeEventListener('keydown', onKeyDown)
40 | }
41 | })
42 | yield takeEvery(escChannel, function* handleESC() {
43 | const {
44 | game: { paused },
45 | }: State = yield select()
46 | yield put(actions.playSound('pause'))
47 | if (!paused) {
48 | yield put(actions.gamePause())
49 | } else {
50 | yield put(actions.gameResume())
51 | }
52 | })
53 | }
54 |
55 | try {
56 | let accumulation = 0
57 | while (true) {
58 | const { delta }: actions.Tick = yield take(tickChannel)
59 | const {
60 | game: { paused },
61 | }: State = yield select()
62 | if (!paused) {
63 | accumulation += delta
64 | if (accumulation > 1000 / maxFPS) {
65 | yield put(actions.tick(accumulation / slow))
66 | yield put(actions.afterTick(accumulation / slow))
67 | accumulation = 0
68 | }
69 | }
70 | }
71 | } finally {
72 | tickChannel.close()
73 | if (escChannel) {
74 | escChannel.close()
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/app/components/HUD.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { connect } from 'react-redux'
3 | import { State } from '../types'
4 | import PlayerRecord from '../types/PlayerRecord'
5 | import { BLOCK_SIZE as B, FIELD_SIZE } from '../utils/constants'
6 | import * as selectors from '../utils/selectors'
7 | import BotCountIndicator from './BotCountIndicator'
8 | import { PlayerTankThumbnail } from './icons'
9 | import Text from './Text'
10 |
11 | interface HUDContentProps {
12 | x?: number
13 | y?: number
14 | remainingBotCount: number
15 | player1: PlayerRecord
16 | player2: PlayerRecord
17 | show: boolean
18 | inMultiPlayersMode: boolean
19 | }
20 |
21 | export class HUDContent extends React.PureComponent {
22 | renderPlayer1Info() {
23 | const { player1 } = this.props
24 | return (
25 |
26 |
27 |
28 |
29 |
30 | )
31 | }
32 |
33 | renderPlayer2Info() {
34 | const { player2 } = this.props
35 | const transform = `translate(0, ${B})`
36 | return (
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | render() {
46 | const { remainingBotCount, show, x = 0, y = 0, inMultiPlayersMode } = this.props
47 |
48 | return (
49 |
50 |
51 |
52 | {this.renderPlayer1Info()}
53 | {inMultiPlayersMode && this.renderPlayer2Info()}
54 |
55 |
56 | )
57 | }
58 | }
59 |
60 | function mapStateToProps(state: State) {
61 | return {
62 | remainingBotCount: state.game.remainingBots.size,
63 | player1: state.player1,
64 | player2: state.player2,
65 | show: state.game.showHUD,
66 | inMultiPlayersMode: selectors.isInMultiPlayersMode(state),
67 | }
68 | }
69 |
70 | export default connect(mapStateToProps)((props: HUDContentProps) => (
71 |
72 | ))
73 |
--------------------------------------------------------------------------------
/app/sagas/botMasterSaga.ts:
--------------------------------------------------------------------------------
1 | import { actionChannel, fork, put, select, take } from 'redux-saga/effects'
2 | import { State } from '../reducers'
3 | import { TankRecord } from '../types'
4 | import * as actions from '../utils/actions'
5 | import { A } from '../utils/actions'
6 | import { getNextId } from '../utils/common'
7 | import { AI_SPAWN_SPEED_MAP, TANK_INDEX_THAT_WITH_POWER_UP } from '../utils/constants'
8 | import * as selectors from '../utils/selectors'
9 | import Timing from '../utils/Timing'
10 | import botSaga from './BotSaga'
11 | import { spawnTank } from './common'
12 |
13 | function* addBotHelper() {
14 | const reqChannel = yield actionChannel(A.ReqAddBot)
15 |
16 | try {
17 | while (true) {
18 | yield take(reqChannel)
19 | const { game, stages }: State = yield select()
20 | if (!game.remainingBots.isEmpty()) {
21 | let spawnPos: Point = yield select(selectors.availableSpawnPosition)
22 | while (spawnPos == null) {
23 | yield Timing.delay(200)
24 | spawnPos = yield select(selectors.availableSpawnPosition)
25 | }
26 | yield put(actions.removeFirstRemainingBot())
27 | const level = game.remainingBots.first()
28 | const hp = level === 'armor' ? 4 : 1
29 | const tank = new TankRecord({
30 | tankId: getNextId('tank'),
31 | x: spawnPos.x,
32 | y: spawnPos.y,
33 | side: 'bot',
34 | level,
35 | hp,
36 | withPowerUp: TANK_INDEX_THAT_WITH_POWER_UP.includes(20 - game.remainingBots.count()),
37 | frozenTimeout: game.botFrozenTimeout,
38 | })
39 | const difficulty = stages.find(s => s.name === game.currentStageName).difficulty
40 | const spawnSpeed = AI_SPAWN_SPEED_MAP[difficulty]
41 | yield put(actions.setIsSpawningBotTank(true))
42 | yield spawnTank(tank, spawnSpeed)
43 | yield put(actions.setIsSpawningBotTank(false))
44 | yield fork(botSaga, tank.tankId)
45 | }
46 | }
47 | } finally {
48 | yield put(actions.setIsSpawningBotTank(false))
49 | reqChannel.close()
50 | }
51 | }
52 |
53 | export default function* botMasterSaga() {
54 | const inMultiPlayersMode = yield select(selectors.isInMultiPlayersMode)
55 | const maxBotCount = inMultiPlayersMode ? 4 : 2
56 |
57 | yield fork(addBotHelper)
58 |
59 | while (true) {
60 | yield take(A.StartStage)
61 | for (let i = 0; i < maxBotCount; i++) {
62 | yield put(actions.reqAddBot())
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/hocs/Image.tsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import React from 'react'
3 | import { renderToStaticMarkup } from 'react-dom/server'
4 |
5 | const withContext = require('recompose/withContext').default
6 |
7 | const cache = new Map()
8 |
9 | class SimpleWrapper extends React.Component {
10 | render() {
11 | return {this.props.children}
12 | }
13 | }
14 |
15 | export interface ImageProps {
16 | disabled?: boolean
17 | imageKey: string
18 | transform?: string
19 | width: string | number
20 | height: string | number
21 | children?: React.ReactNode
22 | className?: string
23 | style?: any
24 | }
25 |
26 | export default class Image extends React.PureComponent {
27 | static contextTypes = {
28 | store: PropTypes.any,
29 | underImageComponent: PropTypes.bool,
30 | }
31 |
32 | render() {
33 | const { store, underImageComponent } = this.context
34 | const { disabled = false, imageKey, width, height, transform, children, ...other } = this.props
35 |
36 | if (disabled || underImageComponent) {
37 | // underImageComponent 不能嵌套,如果已经在一个 ImageComponent 下的话,那么只能使用原始的render方法
38 | return (
39 |
40 | {children}
41 |
42 | )
43 | } else {
44 | if (!cache.has(imageKey)) {
45 | DEV.LOG_PERF && console.time(`Image: loading content of ${imageKey}`)
46 | const open = ``
47 | const enhancer = withContext(
48 | { underImageComponent: PropTypes.bool, store: PropTypes.any },
49 | () => ({
50 | underImageComponent: true,
51 | store,
52 | }),
53 | )
54 | const element = React.createElement(enhancer(SimpleWrapper), null, children)
55 | const string = renderToStaticMarkup(element)
56 | const close = ' '
57 | const markup = open + string + close
58 | const blob = new Blob([markup], { type: 'image/svg+xml' })
59 | const url = URL.createObjectURL(blob)
60 | cache.set(imageKey, url)
61 | DEV.LOG_PERF && console.timeEnd(`Image: loading content of ${imageKey}`)
62 | }
63 | return (
64 |
72 | )
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/components/Score.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | interface P {
4 | score: number
5 | x?: number
6 | y?: number
7 | }
8 |
9 | const Zero = ({ x, y }: Point) => (
10 |
11 |
12 |
13 |
14 |
15 |
16 | )
17 |
18 | const One = ({ x, y }: Point) => (
19 |
20 |
21 |
22 |
23 |
24 | )
25 |
26 | const Two = ({ x, y }: Point) => (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | )
37 |
38 | const Three = ({ x, y }: Point) => (
39 |
44 | )
45 |
46 | const Four = ({ x, y }: Point) => (
47 |
51 | )
52 |
53 | const Five = ({ x, y }: Point) => (
54 |
58 | )
59 |
60 | export default class Score extends React.PureComponent {
61 | render() {
62 | const { score, x = 0, y = 0 } = this.props
63 | let Num: typeof One
64 | if (score === 100) {
65 | Num = One
66 | } else if (score === 200) {
67 | Num = Two
68 | } else if (score === 300) {
69 | Num = Three
70 | } else if (score === 400) {
71 | Num = Four
72 | } else if (score === 500) {
73 | Num = Five
74 | } else {
75 | throw new Error(`Invalid score: ${score}`)
76 | }
77 | return (
78 |
79 |
80 |
81 |
82 |
83 | )
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/app/reducers/tanks.ts:
--------------------------------------------------------------------------------
1 | import { Map } from 'immutable'
2 | import { TankRecord } from '../types'
3 | import { A, Action } from '../utils/actions'
4 | import { incTankLevel } from '../utils/common'
5 |
6 | export type TanksMap = Map
7 |
8 | export default function tanks(state = Map() as TanksMap, action: Action) {
9 | if (action.type === A.AddTank) {
10 | return state.set(action.tank.tankId, new TankRecord(action.tank))
11 | } else if (action.type === A.Hurt) {
12 | const tankId = action.targetTank.tankId
13 | return state.update(tankId, t => t.update('hp', hp => hp - 1))
14 | } else if (action.type === A.StartStage) {
15 | return state.clear()
16 | } else if (action.type === A.Move) {
17 | return state.update(action.tankId, t => t.merge(action))
18 | } else if (action.type === A.SetTankVisibility) {
19 | return state.update(action.tankId, t => t.set('visible', action.visible))
20 | } else if (action.type === A.StartMove) {
21 | return state.setIn([action.tankId, 'moving'], true)
22 | } else if (action.type === A.StopMove) {
23 | return state.setIn([action.tankId, 'moving'], false)
24 | } else if (action.type === A.UpgardeTank) {
25 | // todo 当tank.level已经是armor 该怎么办?
26 | return state.update(action.tankId, incTankLevel)
27 | } else if (action.type === A.RemovePowerUpProperty) {
28 | return state.update(action.tankId, tank => tank.set('withPowerUp', false))
29 | } else if (action.type === A.SetTankToDead) {
30 | // 不能在关卡进行过程中移除坦克, 因为坦克的子弹可能正在飞行
31 | // 防御式编程: 坦克设置为 dead 的时候重置一些状态
32 | return state.update(action.tankId, tank =>
33 | tank.merge({
34 | alive: false,
35 | cooldown: 0,
36 | frozenTimeout: 0,
37 | helmetDuration: 0,
38 | moving: false,
39 | withPowerUp: false,
40 | }),
41 | )
42 | } else if (action.type === A.SetCooldown) {
43 | return state.update(action.tankId, tank => tank.set('cooldown', action.cooldown))
44 | } else if (action.type === A.SetBotFrozenTimeout) {
45 | return state.map(
46 | tank =>
47 | tank.side === 'bot' ? tank.set('moving', false).set('frozenTimeout', action.timeout) : tank,
48 | )
49 | } else if (action.type === A.SetFrozenTimeout) {
50 | return state.update(action.tankId, tank =>
51 | tank
52 | .set('frozenTimeout', action.frozenTimeout)
53 | // 如果tank从'自由'变为'冰冻', 那么将moving设置为false, 否则保持原样
54 | .set('moving', tank.frozenTimeout <= 0 && action.frozenTimeout > 0 && tank.moving),
55 | )
56 | } else if (action.type === A.SetHelmetDuration) {
57 | return state.update(action.tankId, tank =>
58 | tank.set('helmetDuration', Math.max(0, action.duration)),
59 | )
60 | } else {
61 | return state
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/app/sagas/BotSaga.ts:
--------------------------------------------------------------------------------
1 | import { all, put, race, select, take, takeEvery } from 'redux-saga/effects'
2 | import AIWorkerSaga from '../ai/AIWorkerSaga'
3 | import Bot from '../ai/Bot'
4 | import { State } from '../reducers'
5 | import { TankRecord } from '../types'
6 | import * as actions from '../utils/actions'
7 | import { A } from '../utils/actions'
8 | import * as selectors from '../utils/selectors'
9 | import { explosionFromTank, scoreFromKillTank } from './common/destroyTanks'
10 | import directionController from './directionController'
11 | import fireController from './fireController'
12 |
13 | export default function* botSaga(tankId: TankId) {
14 | const ctx = new Bot(tankId)
15 | try {
16 | yield takeEvery(hitPredicate, hitHandler)
17 | const result = yield race({
18 | service: all([
19 | generateBulletCompleteNote(),
20 | directionController(tankId, ctx.directionControllerCallback),
21 | fireController(tankId, ctx.fireControllerCallback),
22 | AIWorkerSaga(ctx),
23 | ]),
24 | killed: take(killedPredicate),
25 | endGame: take(A.EndGame),
26 | })
27 | const tank: TankRecord = yield select(selectors.tank, tankId)
28 | yield put(actions.setTankToDead(tankId))
29 | if (result.killed) {
30 | yield explosionFromTank(tank)
31 | if (result.killed.method === 'bullet') {
32 | yield scoreFromKillTank(tank)
33 | }
34 | }
35 | yield put(actions.reqAddBot())
36 | } finally {
37 | const tank: TankRecord = yield select(selectors.tank, tankId)
38 | if (tank && tank.alive) {
39 | yield put(actions.setTankToDead(tankId))
40 | }
41 | }
42 |
43 | function hitPredicate(action: actions.Action) {
44 | return action.type === actions.A.Hit && action.targetTank.tankId === tankId
45 | }
46 |
47 | function* hitHandler(action: actions.Hit) {
48 | const tank: TankRecord = yield select(selectors.tank, tankId)
49 | DEV.ASSERT && console.assert(tank != null)
50 | if (tank.hp > 1) {
51 | yield put(actions.hurt(tank))
52 | } else {
53 | const { sourceTank, targetTank } = action
54 | yield put(actions.kill(targetTank, sourceTank, 'bullet'))
55 | }
56 | }
57 |
58 | function killedPredicate(action: actions.Action) {
59 | return action.type === actions.A.Kill && action.targetTank.tankId === tankId
60 | }
61 |
62 | function* generateBulletCompleteNote() {
63 | while (true) {
64 | const { bulletId }: actions.BeforeRemoveBullet = yield take(actions.A.BeforeRemoveBullet)
65 | const { bullets }: State = yield select()
66 | const bullet = bullets.get(bulletId)
67 | if (bullet.tankId === tankId) {
68 | ctx.noteChannel.put({ type: 'bullet-complete', bullet })
69 | }
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/app/ai/Bot.ts:
--------------------------------------------------------------------------------
1 | import { multicastChannel } from 'redux-saga'
2 | import { select } from 'redux-saga/effects'
3 | import { Input, TankRecord } from '../types'
4 | import { getDirectionInfo } from '../utils/common'
5 | import * as selectors from '../utils/selectors'
6 | import { RelativePosition } from './env-utils'
7 | import { logAI } from './logger'
8 | import { getCol, getRow } from './spot-utils'
9 |
10 | export default class Bot {
11 | private _fire = false
12 | private nextDirection: Direction = null
13 | private forwardLength = 0
14 | private startPos = 0
15 | readonly noteChannel = multicastChannel()
16 |
17 | constructor(readonly tankId: TankId) {}
18 |
19 | turn(direction: Direction) {
20 | DEV.LOG_AI && logAI('turn', direction)
21 | this.nextDirection = direction
22 | }
23 |
24 | fire() {
25 | DEV.LOG_AI && logAI('fire')
26 | this._fire = true
27 | }
28 |
29 | *forward(forwardLength: number) {
30 | DEV.LOG_AI && logAI('forward', forwardLength)
31 | const tank = yield select(selectors.tank, this.tankId)
32 | DEV.ASSERT && console.assert(tank != null)
33 | const { xy } = getDirectionInfo(this.nextDirection || tank.direction)
34 | this.startPos = tank.get(xy)
35 | this.forwardLength = forwardLength
36 | }
37 |
38 | *moveTo(t: number) {
39 | const tank = yield select(selectors.tank, this.tankId)
40 | DEV.ASSERT && console.assert(tank != null)
41 | const target = {
42 | x: getCol(t) * 8 - 8,
43 | y: getRow(t) * 8 - 8,
44 | }
45 | const relativePosition = new RelativePosition(tank, target)
46 | const direction = relativePosition.getPrimaryDirection()
47 |
48 | this.turn(direction)
49 | yield* this.forward(relativePosition.getForwardInfo(direction).length)
50 | }
51 |
52 | readonly directionControllerCallback = (tank: TankRecord): Input => {
53 | if (this.nextDirection && tank.direction !== this.nextDirection) {
54 | const direction = this.nextDirection
55 | return { type: 'turn', direction }
56 | } else if (this.forwardLength > 0) {
57 | const { xy } = getDirectionInfo(tank.direction)
58 | const movedLength = Math.abs(tank[xy] - this.startPos)
59 | const maxDistance = this.forwardLength - movedLength
60 | if (movedLength === this.forwardLength) {
61 | this.forwardLength = 0
62 | DEV.LOG_AI && logAI('note reach')
63 | this.noteChannel.put({ type: 'reach' })
64 | return null
65 | } else {
66 | return {
67 | type: 'forward',
68 | maxDistance,
69 | }
70 | }
71 | }
72 | return null
73 | }
74 |
75 | readonly fireControllerCallback = () => {
76 | if (this._fire) {
77 | this._fire = false
78 | return true
79 | } else {
80 | return false
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const webpack = require('webpack')
3 | const HtmlWebpackPlugin = require('html-webpack-plugin')
4 | const packageInfo = require('./package.json')
5 | const getDevConfig = require('./devConfig')
6 |
7 | function getNow() {
8 | const d = new Date()
9 | const YYYY = d.getFullYear()
10 | const MM = String(d.getMonth() + 1).padStart(2, '0')
11 | const DD = String(d.getDate()).padStart(2, '0')
12 | const HH = String(d.getHours()).padStart(2, '0')
13 | const mm = String(d.getMinutes()).padStart(2, '0')
14 | const ss = String(d.getSeconds()).padStart(2, '0')
15 | return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss}`
16 | }
17 |
18 | function processDevConfig(config) {
19 | const result = {}
20 | for (const [key, value] of Object.entries(config)) {
21 | result[key] = JSON.stringify(value)
22 | }
23 | return result
24 | }
25 |
26 | module.exports = function(env = {}, argv) {
27 | const prod = argv.mode === 'production'
28 |
29 | const plugins = [
30 | new webpack.DefinePlugin({
31 | COMPILE_VERSION: JSON.stringify(packageInfo.version),
32 | COMPILE_DATE: JSON.stringify(getNow()),
33 | // 将 devConfig.js 中的配置数据加入到 DefinePlugin 中
34 | ...processDevConfig(getDevConfig(!prod)),
35 | }),
36 | new HtmlWebpackPlugin({
37 | title: 'battle-city',
38 | filename: 'index.html',
39 | template: path.resolve(__dirname, `app/index.html`),
40 | }),
41 | !prod && new webpack.HotModuleReplacementPlugin(),
42 | ].filter(Boolean)
43 |
44 | return {
45 | context: __dirname,
46 | target: 'web',
47 |
48 | entry: [path.resolve(__dirname, 'app/main.tsx'), path.resolve(__dirname, 'app/polyfills.ts')],
49 |
50 | output: {
51 | path: path.resolve(__dirname, 'dist'),
52 | filename: prod ? '[name]-[chunkhash:6].js' : '[name].js',
53 | },
54 |
55 | resolve: {
56 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
57 | },
58 |
59 | module: {
60 | rules: [
61 | { test: /\.json$/, type: 'javascript/auto', loader: 'json-loader' },
62 | {
63 | test: /\.tsx?$/,
64 | use: [
65 | {
66 | loader: 'babel-loader',
67 | options: {
68 | plugins: ['react-hot-loader/babel'],
69 | },
70 | },
71 | {
72 | loader: 'ts-loader',
73 | options: {
74 | transpileOnly: true,
75 | },
76 | },
77 | ],
78 | exclude: /node_modules/,
79 | },
80 | {
81 | test: /\.css$/,
82 | use: ['style-loader', 'css-loader'],
83 | },
84 | ],
85 | },
86 |
87 | plugins,
88 |
89 | devServer: {
90 | contentBase: __dirname,
91 | hot: true,
92 | },
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/app/components/About.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames'
2 | import React from 'react'
3 | import { Route, Switch } from 'react-router-dom'
4 |
5 | const AboutGallery = () => (
6 |
9 | )
10 |
11 | const AboutList = () => (
12 |
13 |
请使用鼠标操作该页面。切换分页时会有卡顿现象,请耐心等待。
14 |
自定义关卡数据会保存在浏览器缓存中。
15 |
16 | )
17 |
18 | const AboutEditor = () => (
19 |
20 |
请使用鼠标操作该页面。
21 |
在 config tab 中配置关卡的名称和敌人,注意关卡名称不能和游戏自带关卡的名称相同。
22 |
23 | 在 map tab
24 | 中配置关卡地图,选定一种画笔之后,在地图中按下鼠标并拖拽,来完成地图配置。brick-wall 和
25 | steel-wall 的形状可以进行调整。
26 |
27 |
28 | )
29 |
30 | const AboutGame = () => (
31 |
32 |
33 | ESC
34 | :暂停游戏
35 |
36 | 后退
37 | :返回到关卡选择页面
38 |
39 |
40 | 玩家一
41 |
42 | WASD
43 | :控制方向
44 |
45 | J
46 | :控制开火
47 |
48 |
49 | 玩家二
50 |
51 | 方向键
52 | :控制方向
53 |
54 | /
55 | :控制开火
56 |
57 |
58 | )
59 |
60 | const AboutChoose = () => (
61 |
62 |
A 上一个关卡
63 |
D 下一个关卡
64 |
J 开始游戏
65 |
该页面也支持鼠标控制
66 |
67 | )
68 |
69 | const AboutTitle = () => (
70 |
71 |
72 | 请使用最新的 chrome 浏览器,并适当调整浏览器的缩放比例(1080P 下设置为 200%
73 | 缩放),以获得最好的游戏体验。
74 |
75 |
W 上一个选项
76 |
S 下一个选项
77 |
J 确定
78 |
该页面也支持鼠标控制
79 |
80 | )
81 |
82 | export default class About extends React.Component {
83 | state = { hide: false }
84 |
85 | onHide = () => {
86 | this.setState({ hide: true })
87 | }
88 |
89 | render() {
90 | const { hide } = this.state
91 | return (
92 |
93 |
94 | 隐藏
95 |
96 |
97 | 当前版本
98 | {COMPILE_VERSION}
99 |
100 |
101 | 编译时间
102 | {COMPILE_DATE}
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | )
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/app/sagas/playerController.ts:
--------------------------------------------------------------------------------
1 | import last from 'lodash/last'
2 | import pull from 'lodash/pull'
3 | import { all, take } from 'redux-saga/effects'
4 | import { Input, PlayerConfig, TankRecord } from '../types'
5 | import { A } from '../utils/actions'
6 | import directionController from './directionController'
7 | import fireController from './fireController'
8 |
9 | // 一个 playerController 实例对应一个人类玩家(用户)的控制器.
10 | // 参数playerName用来指定人类玩家的玩家名称, config为该玩家的操作配置.
11 | // playerController 将启动 fireController 与 directionController, 从而控制人类玩家的坦克
12 | export default function* playerController(tankId: TankId, config: PlayerConfig) {
13 | let firePressing = false // 用来记录当前玩家是否按下了fire键
14 | let firePressed = false // 用来记录上一个tick内 玩家是否按下过fire键
15 | const pressed: Direction[] = [] // 用来记录上一个tick内, 玩家按下过的方向键
16 |
17 | try {
18 | document.addEventListener('keydown', onKeyDown)
19 | document.addEventListener('keyup', onKeyUp)
20 | yield all([
21 | directionController(tankId, getPlayerInput),
22 | fireController(tankId, () => firePressed || firePressing),
23 | resetFirePressedEveryTick(),
24 | ])
25 | } finally {
26 | document.removeEventListener('keydown', onKeyDown)
27 | document.removeEventListener('keyup', onKeyUp)
28 | }
29 |
30 | // region function-definitions
31 | function tryPush(direciton: Direction) {
32 | if (!pressed.includes(direciton)) {
33 | pressed.push(direciton)
34 | }
35 | }
36 |
37 | function onKeyDown(event: KeyboardEvent) {
38 | const code = event.code
39 | if (code === config.control.fire) {
40 | firePressing = true
41 | firePressed = true
42 | } else if (code == config.control.left) {
43 | tryPush('left')
44 | } else if (code === config.control.right) {
45 | tryPush('right')
46 | } else if (code === config.control.up) {
47 | tryPush('up')
48 | } else if (code === config.control.down) {
49 | tryPush('down')
50 | }
51 | }
52 |
53 | function onKeyUp(event: KeyboardEvent) {
54 | const code = event.code
55 | if (code === config.control.fire) {
56 | firePressing = false
57 | } else if (code === config.control.left) {
58 | pull(pressed, 'left')
59 | } else if (code === config.control.right) {
60 | pull(pressed, 'right')
61 | } else if (code === config.control.up) {
62 | pull(pressed, 'up')
63 | } else if (code === config.control.down) {
64 | pull(pressed, 'down')
65 | }
66 | }
67 |
68 | // 调用该函数来获取当前用户的移动操作(坦克级别)
69 | function getPlayerInput(tank: TankRecord): Input {
70 | const direction = pressed.length > 0 ? last(pressed) : null
71 | if (direction != null) {
72 | if (direction !== tank.direction) {
73 | return { type: 'turn', direction } as Input
74 | } else {
75 | return { type: 'forward' }
76 | }
77 | }
78 | }
79 |
80 | function* resetFirePressedEveryTick() {
81 | // 每次tick时, 都将firePressed重置
82 | while (true) {
83 | yield take(A.Tick)
84 | firePressed = false
85 | }
86 | }
87 | // endregion
88 | }
89 |
--------------------------------------------------------------------------------
/app/ai/Spot.ts:
--------------------------------------------------------------------------------
1 | import MapRecord from '../types/MapRecord'
2 | import IndexHelper from '../utils/IndexHelper'
3 | import { FireEstimate } from './fire-utils'
4 | import { dirs, getCol, getRow, left, right, up } from './spot-utils'
5 |
6 | const e = 0.1
7 |
8 | export default class Spot {
9 | constructor(readonly t: number, readonly canPass: boolean) {}
10 |
11 | getIdealFireEstMap(map: MapRecord): Map {
12 | const startEst: FireEstimate = {
13 | target: this.t,
14 | source: this.t,
15 | distance: 0,
16 | brickCount: 0,
17 | steelCount: 0,
18 | }
19 | const estMap = new Map()
20 | estMap.set(startEst.source, startEst)
21 | for (const dir of dirs) {
22 | let lastPos = this.t
23 | let cntPos = dir(lastPos)
24 | let brickCount = 0
25 | let steelCount = 0
26 | let distance = 8
27 | while (cntPos != null) {
28 | const start = { x: getCol(lastPos) * 8, y: getRow(lastPos) * 8 }
29 | const end = { x: getCol(cntPos) * 8, y: getRow(cntPos) * 8 }
30 |
31 | let r1: Rect
32 | let r2: Rect
33 | if (dir === left) {
34 | r1 = { x: end.x + 4 + e, y: end.y - e, width: 4 - 2 * e, height: 2 * e }
35 | r2 = { x: end.x + e, y: end.y - e, width: 4 - 2 * e, height: 2 * e }
36 | } else if (dir === right) {
37 | r1 = { x: start.x + e, y: start.y - e, width: 4 - 2 * e, height: 2 * e }
38 | r2 = { x: start.x + e + 4, y: start.y - e, width: 4 - 2 * e, height: 2 * e }
39 | } else if (dir === up) {
40 | r1 = { x: end.x - e, y: end.y + e + 4, width: 2 * e, height: 4 - 2 * e }
41 | r2 = { x: end.x - e, y: end.y + e, width: 2 * e, height: 4 - 2 * e }
42 | } else {
43 | r1 = { x: start.x - e, y: start.y + e, width: 2 * e, height: 4 - 2 * e }
44 | r2 = { x: start.x - e, y: start.y + e + 4, width: 2 * e, height: 4 - 2 * e }
45 | }
46 |
47 | const collidedWithSteel =
48 | Array.from(IndexHelper.iter('steel', r1)).some(steelT => map.steels.get(steelT)) ||
49 | Array.from(IndexHelper.iter('steel', r2)).some(steelT => map.steels.get(steelT))
50 | if (collidedWithSteel) {
51 | steelCount++
52 | }
53 |
54 | if (!collidedWithSteel) {
55 | // 只有在不碰到steel的情况下 才开始考虑brick
56 | const r1CollidedWithBrick = Array.from(IndexHelper.iter('brick', r1)).some(brickT =>
57 | map.bricks.get(brickT),
58 | )
59 | const r2CollidedWithBrick = Array.from(IndexHelper.iter('brick', r2)).some(brickT =>
60 | map.bricks.get(brickT),
61 | )
62 | if (r1CollidedWithBrick) {
63 | brickCount++
64 | }
65 | if (r2CollidedWithBrick) {
66 | brickCount++
67 | }
68 | }
69 |
70 | const est = { source: cntPos, distance, target: this.t, brickCount, steelCount }
71 | estMap.set(est.source, est)
72 | lastPos = cntPos
73 | cntPos = dir(cntPos)
74 | distance += 8
75 | }
76 | }
77 | return estMap
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/app/sagas/directionController.ts:
--------------------------------------------------------------------------------
1 | import { put, select, take } from 'redux-saga/effects'
2 | import { Input, State, TankRecord } from '../types'
3 | import * as actions from '../utils/actions'
4 | import { A } from '../utils/actions'
5 | import canTankMove from '../utils/canTankMove'
6 | import { ceil8, floor8, getDirectionInfo, isPerpendicular, round8 } from '../utils/common'
7 | import values from '../utils/values'
8 |
9 | // 坦克进行转向时, 需要对坐标进行处理
10 | // 如果转向前的方向为 left / right, 则将 x 坐标转换到最近的 8 的倍数
11 | // 如果转向前的方向为 up / down, 则将 y 坐标设置为最近的 8 的倍数
12 | // 这样做是为了使坦克转向之后更容易的向前行驶, 因为障碍物(brick/steel/river)的坐标也总是4或8的倍数
13 | // 但是有的时候简单的使用 round8 来转换坐标, 可能使得坦克卡在障碍物中
14 | // 所以这里转向的时候, 需要同时尝试 floor8 和 ceil8 来转换坐标
15 | function* getReservedTank(tank: TankRecord) {
16 | const { xy } = getDirectionInfo(tank.direction)
17 | const coordinate = tank[xy]
18 | const useFloor = tank.set(xy, floor8(coordinate))
19 | const useCeil = tank.set(xy, ceil8(coordinate))
20 | const canMoveWhenUseFloor = yield select(canTankMove, useFloor)
21 | const canMoveWhenUseCeil = yield select(canTankMove, useCeil)
22 |
23 | if (!canMoveWhenUseFloor) {
24 | return useCeil
25 | } else if (!canMoveWhenUseCeil) {
26 | return useFloor
27 | } else {
28 | return tank.set(xy, round8(coordinate))
29 | }
30 | }
31 |
32 | export default function* directionController(
33 | tankId: TankId,
34 | getPlayerInput: (tank: TankRecord, delta: number) => Input,
35 | ) {
36 | while (true) {
37 | const { delta }: actions.Tick = yield take(A.Tick)
38 | const tank = yield select((s: State) => s.tanks.get(tankId))
39 |
40 | const input: Input = getPlayerInput(tank, delta)
41 |
42 | if (input == null) {
43 | if (tank.moving) {
44 | yield put(actions.stopMove(tank.tankId))
45 | }
46 | } else if (input.type === 'turn') {
47 | if (isPerpendicular(input.direction, tank.direction)) {
48 | yield put(actions.move(tank.useReservedXY().set('direction', input.direction)))
49 | } else {
50 | yield put(actions.move(tank.set('direction', input.direction)))
51 | }
52 | } else if (input.type === 'forward') {
53 | if (tank.frozenTimeout === 0) {
54 | const speed = values.moveSpeed(tank)
55 | const distance = Math.min(delta * speed, input.maxDistance || Infinity)
56 |
57 | const { xy, updater } = getDirectionInfo(tank.direction)
58 | const movedTank = tank.update(xy, updater(distance))
59 | if (yield select(canTankMove, movedTank)) {
60 | const reservedTank: TankRecord = yield getReservedTank(movedTank)
61 | yield put(actions.move(movedTank.merge({ rx: reservedTank.x, ry: reservedTank.y })))
62 | if (!tank.moving) {
63 | yield put(actions.startMove(tank.tankId))
64 | }
65 | }
66 | }
67 | } else {
68 | throw new Error(`Invalid input: ${input}`)
69 | }
70 |
71 | const nextFrozenTimeout = tank.frozenTimeout <= 0 ? 0 : tank.frozenTimeout - delta
72 | if (tank.frozenTimeout !== nextFrozenTimeout) {
73 | yield put(actions.setFrozenTimeout(tank.tankId, nextFrozenTimeout))
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/app/sagas/stageSaga.ts:
--------------------------------------------------------------------------------
1 | import { replace } from 'react-router-redux'
2 | import { cancelled, put, select, take } from 'redux-saga/effects'
3 | import { State } from '../reducers'
4 | import { TankRecord } from '../types'
5 | import StageConfig from '../types/StageConfig'
6 | import * as actions from '../utils/actions'
7 | import { A } from '../utils/actions'
8 | import { frame as f } from '../utils/common'
9 | import * as selectors from '../utils/selectors'
10 | import Timing from '../utils/Timing'
11 | import animateStatistics from './animateStatistics'
12 |
13 | function* animateCurtainAndLoadMap(stage: StageConfig) {
14 | try {
15 | yield put(actions.updateComingStageName(stage.name))
16 | yield put(actions.updateCurtain('stage-enter-curtain', 0))
17 |
18 | yield* Timing.tween(f(30), t => put(actions.updateCurtain('stage-enter-curtain', t)))
19 |
20 | // 在幕布完全将舞台遮起来的时候载入地图
21 | yield Timing.delay(f(20))
22 | yield put(actions.playSound('stage_start'))
23 | yield put(actions.loadStageMap(stage))
24 | yield Timing.delay(f(20))
25 |
26 | yield* Timing.tween(f(30), t => put(actions.updateCurtain('stage-enter-curtain', 1 - t)))
27 | // todo 游戏开始的时候有一个 反色效果
28 | } finally {
29 | if (yield cancelled()) {
30 | // 将幕布隐藏起来
31 | yield put(actions.updateCurtain('stage-enter-curtain', 0))
32 | }
33 | }
34 | }
35 |
36 | export interface StageResult {
37 | pass: boolean
38 | reason?: 'eagle-destroyed' | 'dead'
39 | }
40 |
41 | /**
42 | * stage-saga的一个实例对应一个关卡
43 | * 在关卡开始时, 一个stage-saga实例将会启动, 负责关卡地图生成
44 | * 在关卡过程中, 该saga负责统计该关卡中的战斗信息
45 | * 当玩家清空关卡时stage-saga退出, 并向game-saga返回该关卡结果
46 | */
47 | export default function* stageSaga(stage: StageConfig) {
48 | const { router }: State = yield select()
49 | yield put(replace(`/stage/${stage.name}${router.location.search}`))
50 |
51 | try {
52 | yield animateCurtainAndLoadMap(stage)
53 | yield put(actions.beforeStartStage(stage))
54 | yield put(actions.showHud())
55 | yield put(actions.startStage(stage))
56 |
57 | while (true) {
58 | const action: actions.Action = yield take([A.SetTankToDead, A.DestroyEagle])
59 |
60 | if (action.type === A.SetTankToDead) {
61 | const tank: TankRecord = yield select(selectors.tank, action.tankId)
62 | if (tank.side === 'bot') {
63 | if (yield select(selectors.isAllBotDead)) {
64 | yield Timing.delay(DEV.FAST ? 1000 : 4000)
65 | yield animateStatistics()
66 | yield put(actions.beforeEndStage())
67 | yield put(actions.endStage())
68 | return { pass: true } as StageResult
69 | }
70 | } else {
71 | if (yield select(selectors.isAllPlayerDead)) {
72 | yield Timing.delay(DEV.FAST ? 1000 : 3000)
73 | yield animateStatistics()
74 | // 因为 gameSaga 会 put END_GAME 所以这里不需要 put END_STAGE
75 | return { pass: false, reason: 'dead' } as StageResult
76 | }
77 | }
78 | } else if (action.type === A.DestroyEagle) {
79 | // 因为 gameSaga 会 put END_GAME 所以这里不需要 put END_STAGE
80 | return { pass: false, reason: 'eagle-destroyed' } as StageResult
81 | }
82 | }
83 | } finally {
84 | yield put(actions.hideHud())
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/app/types/index.ts:
--------------------------------------------------------------------------------
1 | import BulletRecord from './BulletRecord'
2 |
3 | export { default as TankRecord } from './TankRecord'
4 | export { default as PowerUpRecord } from './PowerUpRecord'
5 | export { default as ScoreRecord } from './ScoreRecord'
6 | export { default as ExplosionRecord } from './ExplosionRecord'
7 | export { default as FlickerRecord } from './FlickerRecord'
8 | export { default as TextRecord } from './TextRecord'
9 | export { default as BulletRecord } from './BulletRecord'
10 | export { default as PlayerRecord } from './PlayerRecord'
11 | export { default as MapRecord } from './MapRecord'
12 | export { default as EagleRecord } from './EagleRecord'
13 | export { default as StageConfig, RawStageConfig, StageDifficulty } from './StageConfig'
14 | export { State } from '../reducers/index'
15 | export { BulletsMap } from '../reducers/bullets'
16 | export { TextsMap } from '../reducers/texts'
17 | export { TanksMap } from '../reducers/tanks'
18 | export { ScoresMap } from '../reducers/scores'
19 | export { ExplosionsMap } from '../reducers/explosions'
20 |
21 | /** 记录一架坦克的开火信息 */
22 | export interface TankFireInfo {
23 | bulletCount: number
24 | canFire: boolean
25 | cooldown: number
26 | }
27 |
28 | export interface PlayerConfig {
29 | color: TankColor
30 | control: {
31 | fire: string
32 | up: string
33 | down: string
34 | left: string
35 | right: string
36 | }
37 | spawnPos: Point
38 | }
39 |
40 | export type Input =
41 | | { type: 'turn'; direction: Direction }
42 | | { type: 'forward'; maxDistance?: number }
43 |
44 | declare global {
45 | interface Rect {
46 | x: number
47 | y: number
48 | width: number
49 | height: number
50 | }
51 |
52 | interface Point {
53 | x: number
54 | y: number
55 | }
56 |
57 | type PowerUpName = 'tank' | 'star' | 'grenade' | 'timer' | 'helmet' | 'shovel'
58 |
59 | type TankLevel = 'basic' | 'fast' | 'power' | 'armor'
60 | type TankColor = 'green' | 'yellow' | 'silver' | 'red' | 'auto'
61 |
62 | type Direction = 'up' | 'down' | 'left' | 'right'
63 |
64 | type TankId = number
65 | type BulletId = number
66 | type PowerUpId = number
67 | type ScoreId = number
68 | type AreaId = number
69 |
70 | type PlayerName = 'player-1' | 'player-2'
71 | type BotName = string
72 | type TextId = number
73 | type FlickerId = number
74 | type ExplosionId = number
75 |
76 | type ExplosionShape = 's0' | 's1' | 's2' | 'b0' | 'b1'
77 | type FlickerShape = 0 | 1 | 2 | 3
78 |
79 | type SteelIndex = number
80 | type BrickIndex = number
81 | type RiverIndex = number
82 |
83 | type Side = 'player' | 'bot'
84 |
85 | /** Note 包含了一些游戏逻辑向AI逻辑发送的消息/通知 */
86 | type Note = Note.Note
87 |
88 | namespace Note {
89 | type Note = BulletComplete | Reach
90 |
91 | interface BulletComplete {
92 | type: 'bullet-complete'
93 | bullet: BulletRecord
94 | }
95 |
96 | interface Reach {
97 | type: 'reach'
98 | }
99 | }
100 |
101 | type SoundName =
102 | | 'stage_start'
103 | | 'game_over'
104 | | 'bullet_shot'
105 | | 'bullet_hit_1'
106 | | 'bullet_hit_2'
107 | | 'explosion_1'
108 | | 'explosion_2'
109 | | 'pause'
110 | | 'powerup_appear'
111 | | 'powerup_pick'
112 | | 'statistics_1'
113 | }
114 |
--------------------------------------------------------------------------------
/app/sagas/playerTankSaga.ts:
--------------------------------------------------------------------------------
1 | import { put, race, select, take, takeEvery, takeLatest } from 'redux-saga/effects'
2 | import { State, TankRecord } from '../types'
3 | import * as actions from '../utils/actions'
4 | import { A } from '../utils/actions'
5 | import { asRect, testCollide } from '../utils/common'
6 | import { TANK_KILL_SCORE_MAP } from '../utils/constants'
7 | import * as selectors from '../utils/selectors'
8 | import Timing from '../utils/Timing'
9 | import { explosionFromTank } from './common/destroyTanks'
10 |
11 | function* handleTankPickPowerUps(tankId: TankId) {
12 | const { tanks, powerUps }: State = yield select()
13 | const tank = tanks.get(tankId)
14 | const powerUp = powerUps.find(p => testCollide(asRect(p, -0.5), asRect(tank)))
15 |
16 | if (powerUp) {
17 | yield put(actions.pickPowerUp(yield select(selectors.playerName, tankId), tank, powerUp))
18 | }
19 | }
20 |
21 | export default function* playerTankSaga(playerName: PlayerName, tankId: TankId) {
22 | const { hit: hitAction }: { hit: actions.Hit } = yield race({
23 | service: service(),
24 | hit: take(hitByBotPredicate),
25 | })
26 |
27 | const tank: TankRecord = yield select(selectors.tank, tankId)
28 | DEV.ASSERT && console.assert(tank != null && tank.hp === 1)
29 | DEV.ASSERT && console.assert(hitAction.sourceTank.side === 'bot')
30 | // 玩家的坦克 HP 始终为 1. 一旦被 bot 击中就需要派发 KILL
31 | const { sourceTank, targetTank } = hitAction
32 | yield put(actions.kill(targetTank, sourceTank, 'bullet'))
33 | yield put(actions.setTankToDead(tank.tankId))
34 | yield explosionFromTank(tank)
35 | return true
36 |
37 | function hitByTeammatePredicate(action: actions.Action) {
38 | return (
39 | action.type === A.Hit &&
40 | action.targetTank.tankId === tankId &&
41 | action.sourceTank.side === 'player'
42 | )
43 | }
44 |
45 | function* service() {
46 | yield takeEvery(A.AfterTick, handleTankPickPowerUps, tankId)
47 | yield takeLatest(hitByTeammatePredicate, hitByTeammateHandler)
48 | yield takeEvery(killBot, killBotHandler)
49 | }
50 |
51 | function* hitByTeammateHandler(action: actions.Hit) {
52 | DEV.ASSERT && console.assert(action.targetTank.side === 'player')
53 | yield put(actions.setFrozenTimeout(tankId, 1000))
54 | try {
55 | while (true) {
56 | yield Timing.delay(150)
57 | const tank: TankRecord = yield select(selectors.tank, tankId)
58 | yield put(actions.setTankVisibility(tank.tankId, !tank.visible))
59 | if (tank.frozenTimeout === 0) {
60 | break
61 | }
62 | }
63 | } finally {
64 | yield put(actions.setTankVisibility(tankId, true))
65 | }
66 | }
67 |
68 | function hitByBotPredicate(action: actions.Action) {
69 | return (
70 | action.type === A.Hit &&
71 | action.targetTank.tankId === tankId &&
72 | action.sourceTank.side === 'bot'
73 | )
74 | }
75 |
76 | function killBot(action: actions.Action) {
77 | return action.type === A.Kill && action.sourceTank.tankId === tankId
78 | }
79 |
80 | function* killBotHandler({ method, sourceTank, targetTank }: actions.Kill) {
81 | DEV.ASSERT && console.assert(sourceTank.tankId === tankId)
82 | yield put(actions.incKillCount(playerName, targetTank.level))
83 | if (method === 'bullet') {
84 | yield put(actions.incPlayerScore(playerName, TANK_KILL_SCORE_MAP[targetTank.level]))
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/app/sagas/fireDemoSaga.ts:
--------------------------------------------------------------------------------
1 | import { all, fork, put, take, delay } from 'redux-saga/effects'
2 | import { TankRecord } from '../types'
3 | import { StageConfigConverter } from '../types/StageConfig'
4 | import * as actions from '../utils/actions'
5 | import { A, Action } from '../utils/actions'
6 | import { getNextId } from '../utils/common'
7 | import { BLOCK_SIZE as B } from '../utils/constants'
8 | import bulletsSaga from './bulletsSaga'
9 | import { spawnTank } from './common'
10 | import { explosionFromTank } from './common/destroyTanks'
11 | import directionController from './directionController'
12 | import fireController from './fireController'
13 | import tickEmitter from './tickEmitter'
14 |
15 | const always = (v: any) => () => v
16 |
17 | function* demoPlayerContronller(tankId: TankId) {
18 | let fire = false
19 |
20 | yield all([
21 | directionController(tankId, always(null)),
22 | fireController(tankId, shouldFire),
23 | setFireToTrueEvery3Seconds(),
24 | ])
25 |
26 | function shouldFire() {
27 | if (fire) {
28 | fire = false
29 | return true
30 | } else {
31 | return false
32 | }
33 | }
34 | function* setFireToTrueEvery3Seconds() {
35 | while (true) {
36 | fire = true
37 | yield delay(3000)
38 | }
39 | }
40 | }
41 |
42 | export function* demoPlayerSaga(tankPrototype: TankRecord) {
43 | const tankId = getNextId('tank')
44 | yield spawnTank(tankPrototype.set('tankId', tankId), 2)
45 | yield fork(demoPlayerContronller, tankId)
46 | }
47 |
48 | export const demoStage = StageConfigConverter.r2s({
49 | name: 'demo',
50 | custom: false,
51 | difficulty: 1,
52 | map: [
53 | 'X X X X X X Ta X X X X X X ',
54 | 'X X X X X X Ta X X X X X X ',
55 | 'X X X X X X Ta X X X X X X ',
56 | 'X R F S Bf Bf Ta X X X X X X ',
57 | 'X R F S Bf Bf Ta X X X X X X ',
58 | 'X X X X X X Ta X X X X X X ',
59 | 'X X X X X X X X X X X X X ',
60 | 'X X X X X X X X X X X X X ',
61 | 'X X X X X X X X X X X X X ',
62 | 'X X X X X X X X X X X X X ',
63 | 'X X X X X X X X X X X X X ',
64 | 'X X X X X X X X X X X X X ',
65 | 'X X X X X X X X X X X X E ',
66 | ],
67 | bots: [],
68 | })
69 |
70 | export function* demoAIMasterSaga() {
71 | while (true) {
72 | const tankId = getNextId('tank')
73 | const tank = new TankRecord({
74 | tankId,
75 | x: 5.5 * B,
76 | y: 0.5 * B,
77 | side: 'bot',
78 | level: 'basic',
79 | hp: 1,
80 | direction: 'left',
81 | })
82 | yield spawnTank(tank, 1.5)
83 | yield take((action: Action) => action.type === A.Hit && action.targetTank.tankId === tankId)
84 | yield put(actions.setTankToDead(tankId))
85 | yield explosionFromTank(tank)
86 | yield delay(7e3)
87 | }
88 | }
89 |
90 | export default function* fireDemoSaga() {
91 | yield fork(tickEmitter, { slow: 5, bindESC: true })
92 | yield fork(bulletsSaga)
93 | yield put(actions.loadStageMap(demoStage))
94 | yield fork(demoAIMasterSaga)
95 | const baseTank = new TankRecord({ direction: 'right' })
96 | const yelloTank = baseTank.merge({ y: 0.5 * B, color: 'yellow' })
97 | const greenTank = baseTank.merge({ y: 3.5 * B, color: 'green', level: 'fast' })
98 | yield fork(demoPlayerSaga, yelloTank)
99 | yield fork(demoPlayerSaga, greenTank)
100 | }
101 |
--------------------------------------------------------------------------------
/app/components/GameoverScene.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { connect } from 'react-redux'
3 | import { replace } from 'react-router-redux'
4 | import { Dispatch } from 'redux'
5 | import { State } from '../reducers'
6 | import { GameRecord } from '../reducers/game'
7 | import { BLOCK_SIZE as B, ITEM_SIZE_MAP } from '../utils/constants'
8 | import BrickWall from './BrickWall'
9 | import Screen from './Screen'
10 | import Text from './Text'
11 | import TextButton from './TextButton'
12 |
13 | export class GameoverSceneContent extends React.PureComponent<{ onRestart?: () => void }> {
14 | render() {
15 | const size = ITEM_SIZE_MAP.BRICK
16 | const scale = 4
17 | return (
18 |
19 |
20 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
42 |
48 |
49 |
50 |
57 |
58 |
59 | )
60 | }
61 | }
62 |
63 | interface GameoverSceneProps {
64 | dispatch: Dispatch
65 | game: GameRecord
66 | router: any
67 | }
68 |
69 | class GameoverScene extends React.PureComponent {
70 | componentDidMount() {
71 | document.addEventListener('keydown', this.onKeyDown)
72 | const { game, dispatch } = this.props
73 | if (game.status === 'idle') {
74 | dispatch(replace('/'))
75 | }
76 | // 这里不考虑这种情况:玩家在游戏过程中手动在地址栏中输入了 /gameover
77 | }
78 |
79 | componentWillUnmount() {
80 | document.removeEventListener('keydown', this.onKeyDown)
81 | }
82 |
83 | onKeyDown = (event: KeyboardEvent) => {
84 | if (event.code === 'KeyR') {
85 | this.onRestart()
86 | }
87 | }
88 |
89 | onRestart = () => {
90 | const { game, dispatch, router } = this.props
91 | const search = router.location.search
92 | if (game.lastStageName) {
93 | dispatch(replace(`/choose/${game.lastStageName}${search}`))
94 | } else {
95 | dispatch(replace(`/choose${search}`))
96 | }
97 | }
98 |
99 | render() {
100 | return (
101 |
102 |
103 |
104 | )
105 | }
106 | }
107 |
108 | const mapStateToProps = (state: State) => ({ game: state.game, router: state.router })
109 |
110 | export default connect(mapStateToProps)(GameoverScene)
111 |
--------------------------------------------------------------------------------
/app/utils/IndexHelper.ts:
--------------------------------------------------------------------------------
1 | import range from 'lodash/range'
2 | import { ITEM_SIZE_MAP, N_MAP } from './constants'
3 |
4 | export type ItemType = 'brick' | 'steel' | 'river' | 'snow' | 'forest'
5 |
6 | export default class IndexHelper {
7 | static resolveN(type: ItemType) {
8 | let N: number
9 | if (type === 'brick') {
10 | N = N_MAP.BRICK
11 | } else if (type === 'steel') {
12 | N = N_MAP.STEEL
13 | } else if (type === 'river') {
14 | N = N_MAP.RIVER
15 | } else if (type === 'snow') {
16 | N = N_MAP.SNOW
17 | } else {
18 | N = N_MAP.FOREST
19 | }
20 | return N
21 | }
22 |
23 | static resolveItemSize(type: ItemType) {
24 | if (type === 'brick') {
25 | return ITEM_SIZE_MAP.BRICK
26 | } else if (type === 'steel') {
27 | return ITEM_SIZE_MAP.STEEL
28 | } else if (type === 'river') {
29 | return ITEM_SIZE_MAP.RIVER
30 | } else if (type === 'snow') {
31 | return ITEM_SIZE_MAP.SNOW
32 | } else {
33 | return ITEM_SIZE_MAP.FOREST
34 | }
35 | }
36 |
37 | static getT(type: ItemType, row: number, col: number) {
38 | const N = IndexHelper.resolveN(type)
39 | return row * N + col
40 | }
41 |
42 | static getRowCol(type: ItemType, t: number) {
43 | const N = IndexHelper.resolveN(type)
44 | return [Math.floor(t / N), t % N]
45 | }
46 |
47 | static getPos(type: ItemType, t: number): Point {
48 | const itemSize = IndexHelper.resolveItemSize(type)
49 | const [row, col] = IndexHelper.getRowCol(type, t)
50 | return { x: col * itemSize, y: row * itemSize }
51 | }
52 |
53 | static getRect(type: ItemType, t: number): Rect {
54 | const itemSize = IndexHelper.resolveItemSize(type)
55 | const [row, col] = IndexHelper.getRowCol(type, t)
56 | return {
57 | x: col * itemSize,
58 | y: row * itemSize,
59 | width: itemSize,
60 | height: itemSize,
61 | }
62 | }
63 |
64 | /** 输入itemtType和rect. 返回[row, col]的迭代器.
65 | * [row, col]代表的元素将会与rect发生碰撞
66 | * 参数direction可以改变迭代的方向
67 | */
68 | static *iterRowCol(type: ItemType, rect: Rect, direction: Direction = 'down') {
69 | const N = IndexHelper.resolveN(type)
70 | const itemSize = IndexHelper.resolveItemSize(type)
71 | const col1 = Math.max(0, Math.floor(rect.x / itemSize))
72 | const col2 = Math.min(N - 1, Math.floor((rect.x + rect.width) / itemSize))
73 | const row1 = Math.max(0, Math.floor(rect.y / itemSize))
74 | const row2 = Math.min(N - 1, Math.floor((rect.y + rect.height) / itemSize))
75 | if (direction === 'down') {
76 | for (const row of range(row1, row2 + 1)) {
77 | for (const col of range(col1, col2 + 1)) {
78 | yield [row, col]
79 | }
80 | }
81 | } else if (direction === 'up') {
82 | for (const row of range(row2, row1 - 1, -1)) {
83 | for (const col of range(col1, col2 + 1)) {
84 | yield [row, col]
85 | }
86 | }
87 | } else if (direction === 'right') {
88 | for (const col of range(col1, col2 + 1)) {
89 | for (const row of range(row1, row2 + 1)) {
90 | yield [row, col]
91 | }
92 | }
93 | } else {
94 | // direction === 'left'
95 | for (const col of range(col2, col1 - 1, -1)) {
96 | for (const row of range(row1, row2 + 1)) {
97 | yield [row, col]
98 | }
99 | }
100 | }
101 | }
102 |
103 | static *iter(type: ItemType, rect: Rect, direction: Direction = 'down') {
104 | const N = IndexHelper.resolveN(type)
105 | for (const [row, col] of IndexHelper.iterRowCol(type, rect, direction)) {
106 | yield row * N + col
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/app/utils/constants.ts:
--------------------------------------------------------------------------------
1 | import { PlayerConfig } from '../types'
2 |
3 | /** 一块的大小对应16个像素 */
4 | export const BLOCK_SIZE = 16
5 | /** 坦克的大小 */
6 | export const TANK_SIZE = BLOCK_SIZE
7 | /** 战场的大小 (13block * 13block) */
8 | export const FIELD_BLOCK_SIZE = 13
9 | /** 战场的大小 (208pixel * 208pixel) */
10 | export const FIELD_SIZE = BLOCK_SIZE * FIELD_BLOCK_SIZE
11 | /** 子弹的大小 */
12 | export const BULLET_SIZE = 3
13 | /** 摧毁steel的最低子弹power值 */
14 | export const STEEL_POWER = 3
15 |
16 | export const ZOOM_LEVEL = 2
17 | export const SCREEN_WIDTH = 16 * BLOCK_SIZE
18 | export const SCREEN_HEIGHT = 15 * BLOCK_SIZE
19 |
20 | export const MULTI_PLAYERS_SEARCH_KEY = 'multi-players'
21 |
22 | /**
23 | * 坦克的配色方案
24 | * 共有4种配色方案: 黄色方案, 绿色方案, 银色方案, 红色方案
25 | * 每种配色方案包括三个具体的颜色值, a对应浅色, b对应一般颜色, c对应深色
26 | */
27 | type Schema = { [color: string]: { a: string; b: string; c: string } }
28 | export const TANK_COLOR_SCHEMES: Schema = {
29 | yellow: {
30 | a: '#E7E794',
31 | b: '#E79C21',
32 | c: '#6B6B00',
33 | },
34 | green: {
35 | a: '#B5F7CE',
36 | b: '#008C31',
37 | c: '#005200',
38 | },
39 | silver: {
40 | a: '#FFFFFF',
41 | b: '#ADADAD',
42 | c: '#00424A',
43 | },
44 | red: {
45 | a: '#FFFFFF',
46 | b: '#B53121',
47 | c: '#5A007B',
48 | },
49 | }
50 |
51 | /** 击杀坦克的得分列表 */
52 | export const TANK_KILL_SCORE_MAP = {
53 | basic: 100,
54 | fast: 200,
55 | power: 300,
56 | armor: 400,
57 | }
58 |
59 | /** 物体的大小(边长) */
60 | export const ITEM_SIZE_MAP = {
61 | BRICK: 4,
62 | STEEL: 8,
63 | RIVER: BLOCK_SIZE,
64 | SNOW: BLOCK_SIZE,
65 | FOREST: BLOCK_SIZE,
66 | }
67 |
68 | /** 物体铺满地图一整行所需要的数量 */
69 | export const N_MAP = {
70 | BRICK: FIELD_SIZE / ITEM_SIZE_MAP.BRICK,
71 | STEEL: FIELD_SIZE / ITEM_SIZE_MAP.STEEL,
72 | RIVER: FIELD_SIZE / ITEM_SIZE_MAP.RIVER,
73 | SNOW: FIELD_SIZE / ITEM_SIZE_MAP.SNOW,
74 | FOREST: FIELD_SIZE / ITEM_SIZE_MAP.FOREST,
75 | }
76 |
77 | export const PLAYER_CONFIGS: { [key: string]: PlayerConfig } = {
78 | player1: {
79 | color: 'yellow',
80 | control: {
81 | up: 'KeyW',
82 | left: 'KeyA',
83 | down: 'KeyS',
84 | right: 'KeyD',
85 | fire: 'KeyJ',
86 | },
87 | spawnPos: {
88 | x: 4 * BLOCK_SIZE,
89 | y: 12 * BLOCK_SIZE,
90 | },
91 | },
92 | player2: {
93 | color: 'green',
94 | control: {
95 | up: 'ArrowUp',
96 | left: 'ArrowLeft',
97 | down: 'ArrowDown',
98 | right: 'ArrowRight',
99 | fire: 'Slash',
100 | },
101 | spawnPos: {
102 | x: 8 * BLOCK_SIZE,
103 | y: 12 * BLOCK_SIZE,
104 | },
105 | },
106 | }
107 |
108 | export const TANK_LEVELS: TankLevel[] = ['basic', 'fast', 'power', 'armor']
109 |
110 | export const POWER_UP_NAMES: PowerUpName[] = [
111 | 'tank',
112 | 'star',
113 | 'grenade',
114 | 'timer',
115 | 'helmet',
116 | 'shovel',
117 | ]
118 |
119 | /** 游戏原版:每一关中包含powerUp的tank的下标(从0开始计数) */
120 | // export const TANK_INDEX_THAT_WITH_POWER_UP = [3, 10, 17]
121 | /** 复刻版:每一关中包含powerUp的tank的下标(从0开始计数) */
122 | export const TANK_INDEX_THAT_WITH_POWER_UP = [3, 7, 12, 17]
123 |
124 | /** AI 坦克检测自己是否无法移动的超时时间 */
125 | export const BLOCK_TIMEOUT = 200
126 | /** AI 坦克检测自己是否无法移动的距离阈值 */
127 | export const BLOCK_DISTANCE_THRESHOLD = 0.01
128 |
129 | // TODO 该项需要重新测量
130 | /** 不同难度关卡下的 AI 坦克生成速度 */
131 | export const AI_SPAWN_SPEED_MAP = {
132 | 1: 0.7,
133 | 2: 0.85,
134 | 3: 1,
135 | 4: 1.15,
136 | }
137 |
138 | export const SIMPLE_FIRE_LOOP_INTERVAL = 300
139 |
140 | // 每次拾取一个 power-up 就能获得 500 分
141 | export const POWER_UP_SCORE = 500
142 |
143 | // 坦克升级到最高级之后,每次拾取一个 star 获得 5000 分
144 | export const STAR_PICKED_BY_ARMOR_TANK_SCORE = 5000
145 |
146 | // 每获得 10000 分就能够增加生命
147 | export const LIFE_BONUS_SCORE = 10000
148 |
--------------------------------------------------------------------------------
/app/sagas/gameSaga.ts:
--------------------------------------------------------------------------------
1 | import { replace } from 'react-router-redux'
2 | import { all, put, race, select, take } from 'redux-saga/effects'
3 | import { delay } from 'redux-saga/effects'
4 | import { State } from '../reducers'
5 | import TextRecord from '../types/TextRecord'
6 | import * as actions from '../utils/actions'
7 | import { A } from '../utils/actions'
8 | import { getNextId } from '../utils/common'
9 | import { BLOCK_SIZE, PLAYER_CONFIGS } from '../utils/constants'
10 | import * as selectors from '../utils/selectors'
11 | import Timing from '../utils/Timing'
12 | import botMasterSaga from './botMasterSaga'
13 | import bulletsSaga from './bulletsSaga'
14 | import animateTexts from './common/animateTexts'
15 | import playerSaga from './playerSaga'
16 | import powerUpManager from './powerUpManager'
17 | import stageSaga, { StageResult } from './stageSaga'
18 | import tickEmitter from './tickEmitter'
19 |
20 | // 播放游戏结束的动画
21 | function* animateGameover() {
22 | const textId1 = getNextId('text')
23 | const textId2 = getNextId('text')
24 | try {
25 | const text1 = new TextRecord({
26 | textId: textId1,
27 | content: 'game',
28 | fill: 'red',
29 | x: BLOCK_SIZE * 6.5,
30 | y: BLOCK_SIZE * 13,
31 | })
32 | yield put(actions.setText(text1))
33 | const text2 = new TextRecord({
34 | textId: textId2,
35 | content: 'over',
36 | fill: 'red',
37 | x: BLOCK_SIZE * 6.5,
38 | y: BLOCK_SIZE * 13.5,
39 | })
40 | yield put(actions.setText(text2))
41 | yield put(actions.playSound('game_over'))
42 | yield animateTexts([textId1, textId2], {
43 | direction: 'up',
44 | distance: BLOCK_SIZE * 6,
45 | duration: 2000,
46 | })
47 | yield Timing.delay(500)
48 | } finally {
49 | yield put(actions.removeText(textId1))
50 | yield put(actions.removeText(textId2))
51 | }
52 | }
53 |
54 | function* stageFlow(startStageIndex: number) {
55 | const { stages }: State = yield select()
56 | for (const stage of stages.slice(startStageIndex)) {
57 | const stageResult: StageResult = yield stageSaga(stage)
58 | DEV.LOG && console.log('stageResult:', stageResult)
59 | if (!stageResult.pass) {
60 | break
61 | }
62 | }
63 | yield animateGameover()
64 | return true
65 | }
66 |
67 | /**
68 | * game-saga负责管理整体游戏进度
69 | * 负责管理游戏开始界面, 游戏结束界面
70 | * game-stage调用stage-saga来运行不同的关卡
71 | * 并根据stage-saga返回的结果选择继续下一个关卡, 或是选择游戏结束
72 | */
73 | export default function* gameSaga(action: actions.StartGame | actions.ResetGame) {
74 | if (action.type === A.ResetGame) {
75 | DEV.LOG && console.log('GAME RESET')
76 | return
77 | }
78 |
79 | // 这里的 delay(0) 是为了「异步执行」后续的代码
80 | // 以保证后续代码执行前已有的cancel逻辑执行完毕
81 | yield delay(0)
82 | DEV.LOG && console.log('GAME STARTED')
83 |
84 | const players = [playerSaga('player-1', PLAYER_CONFIGS.player1)]
85 | if (yield select(selectors.isInMultiPlayersMode)) {
86 | players.push(playerSaga('player-2', PLAYER_CONFIGS.player2))
87 | }
88 |
89 | const result = yield race({
90 | tick: tickEmitter({ bindESC: true }),
91 | players: all(players),
92 | ai: botMasterSaga(),
93 | powerUp: powerUpManager(),
94 | bullets: bulletsSaga(),
95 | // 上面几个 saga 在一个 gameSaga 的生命周期内被认为是后台服务
96 | // 当 stage-flow 退出(或者是用户直接离开了game-scene)的时候,自动取消上面几个后台服务
97 | flow: stageFlow(action.stageIndex),
98 | leave: take(A.LeaveGameScene),
99 | })
100 |
101 | if (DEV.LOG) {
102 | if (result.leave) {
103 | console.log('LEAVE GAME SCENE')
104 | }
105 | }
106 |
107 | if (result.flow) {
108 | DEV.LOG && console.log('GAME ENDED')
109 | const { router }: State = yield select()
110 | yield put(replace(`/gameover${router.location.search}`))
111 | }
112 | yield put(actions.beforeEndGame())
113 | yield put(actions.endGame())
114 | }
115 |
--------------------------------------------------------------------------------
/app/components/PopupProvider.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Popup from '../types/Popup'
3 | import { BLOCK_SIZE as B, SCREEN_HEIGHT, SCREEN_WIDTH } from '../utils/constants'
4 | import TextButton from './TextButton'
5 | import TextWithLineWrap from './TextWithLineWrap'
6 |
7 | export interface PopupHandle {
8 | showAlertPopup(message: string): Promise
9 | showConfirmPopup(message: string): Promise
10 | popup: React.ReactNode
11 | }
12 |
13 | export default class PopupProvider extends React.PureComponent<
14 | { children: (props: PopupHandle) => JSX.Element | null | false },
15 | { popup: Popup }
16 | > {
17 | state = {
18 | popup: null as Popup,
19 | }
20 |
21 | private resolveConfirm: (ok: boolean) => void = null
22 | private resolveAlert: () => void = null
23 |
24 | showAlertPopup = (message: string) => {
25 | this.setState({
26 | popup: new Popup({ type: 'alert', message }),
27 | })
28 | return new Promise(resolve => {
29 | this.resolveAlert = resolve
30 | })
31 | }
32 |
33 | showConfirmPopup = (message: string) => {
34 | this.setState({
35 | popup: new Popup({ type: 'confirm', message }),
36 | })
37 | return new Promise(resolve => {
38 | this.resolveConfirm = resolve
39 | })
40 | }
41 |
42 | onConfirm = () => {
43 | this.resolveConfirm(true)
44 | this.resolveConfirm = null
45 | this.setState({ popup: null })
46 | }
47 |
48 | onCancel = () => {
49 | this.resolveConfirm(false)
50 | this.resolveConfirm = null
51 | this.setState({ popup: null })
52 | }
53 |
54 | onClickOkOfAlert = () => {
55 | this.resolveAlert()
56 | this.resolveAlert = null
57 | this.setState({ popup: null })
58 | }
59 |
60 | renderAlertPopup() {
61 | const { popup } = this.state
62 | return (
63 |
64 |
65 |
66 |
67 |
68 |
75 |
76 |
77 | )
78 | }
79 |
80 | renderConfirmPopup() {
81 | const { popup } = this.state
82 | return (
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | )
93 | }
94 |
95 | renderPopup() {
96 | const { popup } = this.state
97 | if (popup == null) {
98 | return null
99 | }
100 | if (popup.type === 'alert') {
101 | return this.renderAlertPopup()
102 | } else if (popup.type === 'confirm') {
103 | return this.renderConfirmPopup()
104 | } else {
105 | throw new Error(`Invalid popup type ${popup.type}`)
106 | }
107 | }
108 |
109 | render() {
110 | return this.props.children({
111 | showAlertPopup: this.showAlertPopup,
112 | showConfirmPopup: this.showConfirmPopup,
113 | popup: this.renderPopup(),
114 | })
115 | }
116 | }
117 |
--------------------------------------------------------------------------------