├── .gitattributes ├── sound ├── pause.ogg ├── game_over.ogg ├── bullet_hit_1.ogg ├── bullet_hit_2.ogg ├── bullet_shot.ogg ├── explosion_1.ogg ├── explosion_2.ogg ├── powerup_pick.ogg ├── stage_start.ogg ├── statistics_1.ogg └── powerup_appear.ogg ├── .gitignore ├── docs ├── imgs │ ├── editor.jpg │ ├── other-sagas.png │ ├── custom-stages.jpg │ ├── gallery-fire.jpg │ └── saga-structure-overview.png ├── values │ ├── bullet-spwan-position.jpg │ └── readme.md ├── AI-design.md └── other.md ├── resources ├── favicon.ico ├── Miscellaneous.png └── General-Sprites.png ├── app ├── utils │ ├── history.ts │ ├── store.ts │ ├── Collision.ts │ ├── values.ts │ ├── Timing.ts │ ├── IndexHelper.ts │ └── constants.ts ├── polyfills.ts ├── ai │ ├── logger.ts │ ├── dodge-utils.ts │ ├── followPath.ts │ ├── fire-utils.ts │ ├── simpleFireLoop.ts │ ├── spot-utils.ts │ ├── getAllSpots.ts │ ├── shortest-path.ts │ ├── Bot.ts │ └── Spot.ts ├── sagas │ ├── common │ │ ├── index.ts │ │ ├── spawnTank.ts │ │ ├── animateTexts.ts │ │ ├── flickerSaga.ts │ │ ├── destroyBullets.ts │ │ └── destroyTanks.ts │ ├── index.ts │ ├── soundManager.ts │ ├── powerUpLifecycle.ts │ ├── syncLocalStorage.ts │ ├── animateStatistics.ts │ ├── fireController.ts │ ├── tickEmitter.ts │ ├── botMasterSaga.ts │ ├── BotSaga.ts │ ├── playerController.ts │ ├── directionController.ts │ ├── stageSaga.ts │ ├── playerTankSaga.ts │ ├── fireDemoSaga.ts │ └── gameSaga.ts ├── types │ ├── Popup.ts │ ├── ScoreRecord.ts │ ├── TextRecord.ts │ ├── FlickerRecord.ts │ ├── EagleRecord.ts │ ├── PowerUpRecord.ts │ ├── ExplosionRecord.ts │ ├── PlayerRecord.ts │ ├── BulletRecord.ts │ ├── MapRecord.ts │ ├── TankRecord.ts │ └── index.ts ├── main.tsx ├── components │ ├── TextLayer.tsx │ ├── icons.tsx │ ├── SteelWall.tsx │ ├── dev-only │ │ ├── RestrictedAreaLayer.tsx │ │ ├── reducer.ts │ │ ├── TankPath.tsx │ │ └── SpotGraph.tsx │ ├── StageEnterCurtain.tsx │ ├── AreaButton.tsx │ ├── CurtainsContainer.tsx │ ├── SnowLayer.tsx │ ├── ForestLayer.tsx │ ├── BrickLayer.tsx │ ├── SteelLayer.tsx │ ├── Bullet.tsx │ ├── Curtain.tsx │ ├── Forest.tsx │ ├── BrickWall.tsx │ ├── PauseIndicator.tsx │ ├── TextWithLineWrap.tsx │ ├── Screen.tsx │ ├── RiverLayer.tsx │ ├── River.tsx │ ├── Grid.tsx │ ├── BotCountIndicator.tsx │ ├── TankHelmet.tsx │ ├── Snow.tsx │ ├── TextButton.tsx │ ├── elements.tsx │ ├── Flicker.tsx │ ├── Eagle.tsx │ ├── TextInput.tsx │ ├── StagePreview.tsx │ ├── GameScene.tsx │ ├── HUD.tsx │ ├── Score.tsx │ ├── About.tsx │ ├── GameoverScene.tsx │ └── PopupProvider.tsx ├── reducers │ ├── scores.ts │ ├── flickers.ts │ ├── powerUps.ts │ ├── explosions.ts │ ├── stages.ts │ ├── bullets.ts │ ├── texts.ts │ ├── map.ts │ ├── players.ts │ ├── index.ts │ └── tanks.ts ├── stages │ ├── stage-1.json │ ├── stage-12.json │ ├── stage-13.json │ ├── stage-2.json │ ├── stage-3.json │ ├── stage-14.json │ ├── stage-15.json │ ├── stage-16.json │ ├── stage-23.json │ ├── stage-25.json │ ├── stage-29.json │ ├── stage-34.json │ ├── stage-35.json │ ├── stage-11.json │ ├── stage-33.json │ ├── stage-4.json │ ├── stage-5.json │ ├── stage-6.json │ ├── stage-7.json │ ├── stage-8.json │ ├── stage-9.json │ ├── stage-10.json │ ├── stage-17.json │ ├── stage-18.json │ ├── stage-19.json │ ├── stage-20.json │ ├── stage-21.json │ ├── stage-22.json │ ├── stage-24.json │ ├── stage-26.json │ ├── stage-27.json │ ├── stage-28.json │ ├── stage-30.json │ ├── stage-31.json │ ├── stage-32.json │ └── index.ts ├── hocs │ ├── saga.tsx │ ├── registerTick.tsx │ └── Image.tsx ├── battle-city.css ├── App.tsx └── index.html ├── .yarnrc ├── .editorconfig ├── tsconfig.json ├── custom-tyings.d.ts ├── devConfig.js ├── LICENSE ├── readme.md ├── package.json └── webpack.config.js /.gitattributes: -------------------------------------------------------------------------------- 1 | build/**/* linguist-generated 2 | yarn.lock linguist-generated 3 | -------------------------------------------------------------------------------- /sound/pause.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/pause.ogg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | .idea/ 4 | npm-debug.log* 5 | node_modules/ 6 | .vscode 7 | -------------------------------------------------------------------------------- /docs/imgs/editor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/docs/imgs/editor.jpg -------------------------------------------------------------------------------- /sound/game_over.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/game_over.ogg -------------------------------------------------------------------------------- /resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/resources/favicon.ico -------------------------------------------------------------------------------- /sound/bullet_hit_1.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/bullet_hit_1.ogg -------------------------------------------------------------------------------- /sound/bullet_hit_2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/bullet_hit_2.ogg -------------------------------------------------------------------------------- /sound/bullet_shot.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/bullet_shot.ogg -------------------------------------------------------------------------------- /sound/explosion_1.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/explosion_1.ogg -------------------------------------------------------------------------------- /sound/explosion_2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/explosion_2.ogg -------------------------------------------------------------------------------- /sound/powerup_pick.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/powerup_pick.ogg -------------------------------------------------------------------------------- /sound/stage_start.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/stage_start.ogg -------------------------------------------------------------------------------- /sound/statistics_1.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/statistics_1.ogg -------------------------------------------------------------------------------- /docs/imgs/other-sagas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/docs/imgs/other-sagas.png -------------------------------------------------------------------------------- /sound/powerup_appear.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/sound/powerup_appear.ogg -------------------------------------------------------------------------------- /app/utils/history.ts: -------------------------------------------------------------------------------- 1 | import { createHashHistory } from 'history' 2 | 3 | export default createHashHistory() 4 | -------------------------------------------------------------------------------- /docs/imgs/custom-stages.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/docs/imgs/custom-stages.jpg -------------------------------------------------------------------------------- /docs/imgs/gallery-fire.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/docs/imgs/gallery-fire.jpg -------------------------------------------------------------------------------- /resources/Miscellaneous.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/resources/Miscellaneous.png -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | sass_binary_site "https://npm.taobao.org/mirrors/node-sass" 2 | registry "https://registry.npm.taobao.org" 3 | -------------------------------------------------------------------------------- /resources/General-Sprites.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/resources/General-Sprites.png -------------------------------------------------------------------------------- /docs/imgs/saga-structure-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/docs/imgs/saga-structure-overview.png -------------------------------------------------------------------------------- /docs/values/bullet-spwan-position.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feichao93/battle-city/HEAD/docs/values/bullet-spwan-position.jpg -------------------------------------------------------------------------------- /app/polyfills.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/fn/array/includes' 2 | import 'core-js/fn/object/entries' 3 | import 'core-js/fn/string/pad-end' 4 | import 'core-js/fn/string/pad-start' 5 | -------------------------------------------------------------------------------- /app/ai/logger.ts: -------------------------------------------------------------------------------- 1 | export const logAI = (...args: any[]) => 2 | console.log('%c AILOG ', 'background: #666;color:white;font-weight: bold', ...args) 3 | 4 | export const logAhead = (...args: any[]) => 0 5 | -------------------------------------------------------------------------------- /app/sagas/common/index.ts: -------------------------------------------------------------------------------- 1 | export { default as spawnTank } from './spawnTank' 2 | export { default as destroyBullets } from './destroyBullets' 3 | export { default as flickerSaga } from './flickerSaga' 4 | -------------------------------------------------------------------------------- /app/types/Popup.ts: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable' 2 | 3 | export type PopupType = 'alert' | 'confirm' 4 | 5 | export default class Popup extends Record({ 6 | type: 'alert' as PopupType, 7 | message: '', 8 | }) {} 9 | -------------------------------------------------------------------------------- /app/types/ScoreRecord.ts: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable' 2 | 3 | const ScoreRecordBase = Record({ 4 | scoreId: 0, 5 | score: 100, 6 | x: 0, 7 | y: 0, 8 | }) 9 | 10 | export default class ScoreRecord extends ScoreRecordBase { 11 | static fromJS(object: any) { 12 | return new ScoreRecord(object) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/types/TextRecord.ts: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable' 2 | 3 | const TextRecordBase = Record({ 4 | textId: 0, 5 | content: '', 6 | fill: '#000000', 7 | x: 0, 8 | y: 0, 9 | }) 10 | 11 | export default class TextRecord extends TextRecordBase { 12 | static fromJS(object: any) { 13 | return new TextRecord(object) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/types/FlickerRecord.ts: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable' 2 | 3 | const FlickerRecordBase = Record({ 4 | flickerId: 0, 5 | x: 0, 6 | y: 0, 7 | shape: 0 as FlickerShape, 8 | }) 9 | 10 | export default class FlickerRecord extends FlickerRecordBase { 11 | static fromJS(object: any) { 12 | return new FlickerRecord(object) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain 2 | # consistent coding styles between different editors and IDEs. 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /app/main.tsx: -------------------------------------------------------------------------------- 1 | import 'normalize.css' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { Provider } from 'react-redux' 5 | import App from './App' 6 | import './battle-city.css' 7 | import store from './utils/store' 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById('container'), 14 | ) 15 | -------------------------------------------------------------------------------- /app/types/EagleRecord.ts: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable' 2 | import { BLOCK_SIZE } from '../utils/constants' 3 | 4 | const EagleRecordBase = Record({ 5 | x: 6 * BLOCK_SIZE, 6 | y: 12 * BLOCK_SIZE, 7 | broken: false, 8 | }) 9 | 10 | export default class EagleRecord extends EagleRecordBase { 11 | static fromJS(object: any) { 12 | return new EagleRecord(object) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/types/PowerUpRecord.ts: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable' 2 | 3 | const PowerUpRecordBase = Record({ 4 | powerUpId: 0 as PowerUpId, 5 | x: 0, 6 | y: 0, 7 | visible: true, 8 | powerUpName: 'tank' as PowerUpName, 9 | }) 10 | 11 | export default class PowerUpRecord extends PowerUpRecordBase { 12 | static fromJS(object: any) { 13 | return new PowerUpRecord(object) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/types/ExplosionRecord.ts: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable' 2 | 3 | const ExplosionRecordBase = Record({ 4 | explosionId: 0 as ExplosionId, 5 | shape: 's0' as ExplosionShape, 6 | // 爆炸中心的位置, 因为爆炸形状改变的时候, 爆炸中心的坐标保持不变, 所以使用cx/cy比较合理 7 | cx: 0, 8 | cy: 0, 9 | }) 10 | 11 | export default class ExplosionRecord extends ExplosionRecordBase { 12 | static fromJS(object: any) { 13 | return new ExplosionRecord(object) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "esModuleInterop": true, 5 | "outDir": "./build/", 6 | "sourceMap": true, 7 | "noImplicitAny": true, 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "noStrictGenericChecks": true, 11 | "skipLibCheck": true, 12 | "target": "es2017", 13 | "jsx": "react" 14 | }, 15 | "include": ["./app/**/*", "custom-tyings.d.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /app/utils/store.ts: -------------------------------------------------------------------------------- 1 | import { routerMiddleware } from 'react-router-redux' 2 | import { applyMiddleware, createStore } from 'redux' 3 | import createSgaMiddleware from 'redux-saga' 4 | import reducer from '../reducers/index' 5 | import rootSaga from '../sagas/index' 6 | import history from '../utils/history' 7 | 8 | const sagaMiddleware = createSgaMiddleware() 9 | 10 | export default createStore(reducer, applyMiddleware(routerMiddleware(history), sagaMiddleware)) 11 | 12 | sagaMiddleware.run(rootSaga) 13 | -------------------------------------------------------------------------------- /app/components/TextLayer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TextsMap } from '../types' 3 | import Text from './Text' 4 | 5 | export default class TextLayer extends React.PureComponent<{ texts: TextsMap }, {}> { 6 | render() { 7 | const { texts } = this.props 8 | 9 | return ( 10 | 11 | {texts 12 | .map(t => ) 13 | .toArray()} 14 | 15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/reducers/scores.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable' 2 | import { ScoreRecord } from '../types' 3 | import { A, Action } from '../utils/actions' 4 | 5 | export type ScoresMap = Map 6 | 7 | export default function scores(state = Map() as ScoresMap, action: Action) { 8 | if (action.type === A.AddScore) { 9 | return state.set(action.score.scoreId, new ScoreRecord(action.score)) 10 | } else if (action.type === A.RemoveScore) { 11 | return state.delete(action.scoreId) 12 | } else { 13 | return state 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/ai/dodge-utils.ts: -------------------------------------------------------------------------------- 1 | import { BulletRecord, TankRecord } from '../types' 2 | import values from '../utils/values' 3 | import { RelativePosition } from './env-utils' 4 | 5 | export function canMoveToDodge(tank: TankRecord, bullet: BulletRecord) { 6 | const relPos = new RelativePosition(bullet, tank) // TODO 需要考虑tank的小大以及坦克的方向 7 | const distance = relPos.getForwardInfo(relPos.getPrimaryDirection()).length 8 | const time = distance / bullet.speed 9 | const moveDistance = time * values.moveSpeed(tank) 10 | 11 | // TODO 12 | 13 | return false 14 | } 15 | -------------------------------------------------------------------------------- /app/reducers/flickers.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable' 2 | import { FlickerRecord } from '../types' 3 | import { A, Action } from '../utils/actions' 4 | 5 | export type FlickersMap = Map 6 | 7 | export default function flickers(state = Map() as FlickersMap, action: Action) { 8 | if (action.type === A.SetFlicker) { 9 | return state.set(action.flicker.flickerId, action.flicker) 10 | } else if (action.type === A.RemoveFlicker) { 11 | return state.delete(action.flickerId) 12 | } else { 13 | return state 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/reducers/powerUps.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable' 2 | import { PowerUpRecord } from '../types' 3 | import { A, Action } from '../utils/actions' 4 | 5 | export type PowerUpsMap = Map 6 | 7 | export default function powerUps(state = Map(), action: Action) { 8 | if (action.type === A.SetPowerUp) { 9 | return state.set(action.powerUp.powerUpId, action.powerUp) 10 | } else if (action.type === A.RemovePowerUp) { 11 | return state.delete(action.powerUpId) 12 | } else { 13 | return state 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/reducers/explosions.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable' 2 | import { ExplosionRecord } from '../types' 3 | import { A, Action } from '../utils/actions' 4 | 5 | export type ExplosionsMap = Map 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 | ![bullet-spwan-position](bullet-spwan-position.jpg) 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 | 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 |

7 |

请使用鼠标操作该页面。

8 |
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 | 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 | --------------------------------------------------------------------------------