├── original └── .gitignore ├── .gitignore ├── docs ├── screenshot.png ├── speeds.txt └── ccfonts.txt ├── .github └── FUNDING.yml ├── src ├── engine │ ├── README.md │ ├── index.ts │ ├── physics.ts │ ├── LICENSE │ ├── core.ts │ ├── image.ts │ ├── scene.ts │ ├── io.ts │ ├── animation.ts │ ├── archive.ts │ ├── entity.ts │ ├── keyboard.ts │ ├── utils.ts │ ├── playlist.ts │ ├── sprite.ts │ └── mouse.ts ├── game │ ├── entities │ │ ├── overlay.ts │ │ ├── smudge.ts │ │ ├── tiberium.ts │ │ ├── terrain.ts │ │ ├── health.ts │ │ ├── bib.ts │ │ ├── selection.ts │ │ ├── effect.ts │ │ ├── storage.ts │ │ ├── mask.ts │ │ ├── infantry.ts │ │ └── unit.ts │ ├── scenes │ │ ├── loading.ts │ │ ├── team.ts │ │ ├── menu.ts │ │ ├── movie.ts │ │ ├── map.ts │ │ ├── score.ts │ │ └── theatre.ts │ ├── ui │ │ ├── map.ts │ │ ├── cursor.ts │ │ └── construction.ts │ ├── fow.ts │ ├── physics.ts │ ├── player.ts │ └── weapons.ts ├── index.ts └── index.scss ├── data └── GAME.DAT │ ├── strdims.ini │ ├── theaters.ini │ ├── land.ini │ ├── houses.ini │ ├── warheads.ini │ ├── aircraft.ini │ ├── colors.ini │ ├── stranims.ini │ ├── weapons.ini │ ├── rules.ini │ ├── grids.ini │ ├── themes.ini │ ├── overlay.ini │ └── sounds.ini ├── .eslintrc ├── LICENSE ├── package.json ├── README.md ├── webpack.config.js ├── TODO.md └── tsconfig.json /original/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /node_modules 3 | /data 4 | /dist 5 | *.swp 6 | *.swo 7 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andersevenrud/cncjs/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | patreon: andersevenrud 3 | custom: https://paypal.me/andersevenrud 4 | -------------------------------------------------------------------------------- /docs/speeds.txt: -------------------------------------------------------------------------------- 1 | Game Speed 6 <= 59 FPS 2 | 3 | Game speed 5 <= 49 FPS 4 | 5 | Game Speed 4 <= 39 FPS 6 | 7 | Game Speed 3 <= 29 FPS 8 | 9 | Game Speed 2 <= 19 FPS 10 | 11 | Game Speed 1 <= 9 FPS 12 | -------------------------------------------------------------------------------- /src/engine/README.md: -------------------------------------------------------------------------------- 1 | # tesen 2 | 3 | Simple TypeScript 2D Canvas Game Engine 4 | 5 | > "tesen" comes from "ts engine" 6 | 7 | ## Usage 8 | 9 | Browse the documentation for more information. 10 | 11 | See `examples/` for examples. 12 | 13 | ## License 14 | 15 | MIT 16 | -------------------------------------------------------------------------------- /data/GAME.DAT/strdims.ini: -------------------------------------------------------------------------------- 1 | [StructureDimensions] 2 | 0=1x1 3 | 1=2x1 4 | 2=1x2 5 | 3=2x2 6 | 4=2x3 7 | 5=3x2 8 | 6=3x3 9 | 7=4x2 10 | 8=5x5 11 | 12 | [1x1] 13 | X=1 14 | Y=1 15 | 16 | [2x1] 17 | X=2 18 | Y=1 19 | 20 | [1x2] 21 | X=1 22 | Y=2 23 | 24 | [2x2] 25 | X=2 26 | Y=2 27 | 28 | [2x3] 29 | X=2 30 | Y=3 31 | 32 | [3x2] 33 | X=3 34 | Y=2 35 | 36 | [3x3] 37 | X=3 38 | Y=3 39 | 40 | [4x2] 41 | X=4 42 | Y=2 43 | 44 | [5x5] 45 | X=5 46 | Y=5 47 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended" 5 | ], 6 | "parserOptions": { 7 | "ecmaVersion": 2018, 8 | "sourceType": "module", 9 | "project": "./tsconfig.json" 10 | }, 11 | "rules": { 12 | "semi": "off", 13 | "@typescript-eslint/semi": ["error"], 14 | "@typescript-eslint/indent": ["error", 2], 15 | "@typescript-eslint/no-non-null-assertion": 1, 16 | "@typescript-eslint/no-empty-interface": 1, 17 | "@typescript-eslint/no-unused-vars": ["error", { 18 | "args": "none" 19 | }] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/engine/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | 7 | export * from './animation'; 8 | export * from './archive'; 9 | export * from './core'; 10 | export * from './engine'; 11 | export * from './entity'; 12 | export * from './image'; 13 | export * from './io'; 14 | export * from './keyboard'; 15 | export * from './mouse'; 16 | export * from './physics'; 17 | export * from './playlist'; 18 | export * from './scene'; 19 | export * from './sound'; 20 | export * from './sprite'; 21 | export * from './ui'; 22 | export * from './utils'; 23 | export { Vector } from 'vector2d'; 24 | -------------------------------------------------------------------------------- /data/GAME.DAT/theaters.ini: -------------------------------------------------------------------------------- 1 | ; Note: All theaters probably need to have a unique starting letter, since that 2 | ; letter is used to complete some filenames inside the mixfile. It's possible 3 | ; the game only loads one theater mixfile at the time though, which would 4 | ; remove this problem. Stil, better play safe. 5 | 6 | [Theaters] 7 | 0=DESERT 8 | 1=JUNGLE 9 | 2=TEMPERATE 10 | 3=WINTER 11 | 4=SNOW 12 | 13 | [DESERT] 14 | MixName=DESERT 15 | Extension=DES 16 | 17 | [JUNGLE] 18 | MixName=JUNGLE 19 | Extension=JUN 20 | 21 | [TEMPERATE] 22 | MixName=TEMPERAT 23 | Extension=TEM 24 | 25 | [WINTER] 26 | MixName=WINTER 27 | Extension=WIN 28 | 29 | [SNOW] 30 | MixName=SNOW 31 | Extension=SNO 32 | 33 | -------------------------------------------------------------------------------- /src/game/entities/overlay.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { GameMapEntity } from './mapentity'; 7 | import { MIXGrid } from '../mix'; 8 | 9 | /** 10 | * Overlay Entity 11 | */ 12 | export class OverlayEntity extends GameMapEntity { 13 | protected zIndex: number = 4; 14 | protected occupy: MIXGrid = { name: '', grid: [['x']] }; 15 | 16 | public toJson(): any { 17 | return { 18 | ...super.toJson(), 19 | type: 'overlay' 20 | }; 21 | } 22 | 23 | public onRender(deltaTime: number): void { 24 | const context = this.map.objects.getContext(); 25 | this.renderSprite(deltaTime, context); 26 | super.onRender(deltaTime); 27 | } 28 | 29 | public getColor(): string { 30 | return '#ffffff'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /data/GAME.DAT/land.ini: -------------------------------------------------------------------------------- 1 | ; clear grassy terrain 2 | [Clear] 3 | Foot=90% 4 | Track=80% 5 | Wheel=60% 6 | Float=0% 7 | Buildable=yes 8 | 9 | ; rocky terrain 10 | [Rough] 11 | Foot=80% 12 | Track=70% 13 | Wheel=40% 14 | Float=0% 15 | Buildable=no 16 | 17 | ; roads 18 | [Road] 19 | Foot=100% 20 | Track=100% 21 | Wheel=100% 22 | Float=0% 23 | Buildable=yes 24 | 25 | ; open water 26 | [Water] 27 | Foot=0% 28 | Track=0% 29 | Wheel=0% 30 | Float=100% 31 | Buildable=no 32 | 33 | ; cliffs 34 | [Rock] 35 | Foot=0% 36 | Track=0% 37 | Wheel=0% 38 | Float=0% 39 | Buildable=no 40 | 41 | ; walls and other man made obstacles 42 | [Wall] 43 | Foot=0% 44 | Track=0% 45 | Wheel=0% 46 | Float=0% 47 | Buildable=no 48 | 49 | ; Tiberium 50 | [Tiberium] 51 | Foot=90% 52 | Track=70% 53 | Wheel=50% 54 | Float=0% 55 | Buildable=no 56 | 57 | ; sandy beach 58 | [Beach] 59 | Foot=80% 60 | Track=70% 61 | Wheel=40% 62 | Float=0% 63 | Buildable=no 64 | 65 | ; craggy riverbed 66 | [River] 67 | Foot=0% 68 | Track=0% 69 | Wheel=0% 70 | Float=0% 71 | Buildable=no 72 | -------------------------------------------------------------------------------- /src/game/entities/smudge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { GameMapEntity } from './mapentity'; 7 | 8 | /** 9 | * Smudge Entity 10 | */ 11 | export class SmudgeEntity extends GameMapEntity { 12 | protected zIndex: number = -1; 13 | 14 | public toJson(): any { 15 | return { 16 | ...super.toJson(), 17 | type: 'smudge' 18 | }; 19 | } 20 | 21 | public async init(): Promise { 22 | await super.init(); 23 | 24 | if (this.sprite) { 25 | this.setDimension(this.sprite.size); 26 | } 27 | } 28 | 29 | public onRender(deltaTime: number): void { 30 | const context = this.map.terrain.getContext(); 31 | this.renderSprite(deltaTime, context); 32 | } 33 | 34 | public getSpriteName(): string { 35 | return `${this.map.getTheatre().toUpperCase()}.MIX/${this.data.name.toLowerCase()}.png`; 36 | } 37 | 38 | public getColor(): string { 39 | return '#002200'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/game/entities/tiberium.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { OverlayEntity } from './overlay'; 7 | import { MIXGrid } from '../mix'; 8 | 9 | /** 10 | * Overlay Entity 11 | */ 12 | export class TiberiumEntity extends OverlayEntity { 13 | protected tiberiumLeft = 11; 14 | protected zIndex: number = 1; 15 | protected occupy: MIXGrid = { name: '', grid: [] }; 16 | 17 | public toJson(): any { 18 | return { 19 | ...super.toJson(), 20 | type: 'tiberium' 21 | }; 22 | } 23 | 24 | public onUpdate(deltaTime: number): void { 25 | super.onUpdate(deltaTime); 26 | this.frameOffset.setY(this.tiberiumLeft); 27 | } 28 | 29 | public getColor(): string { 30 | return '#004400'; 31 | } 32 | 33 | public subtractTiberium(): void { 34 | this.tiberiumLeft = Math.max(0, this.tiberiumLeft - 1); 35 | } 36 | 37 | public hasTiberium(): boolean { 38 | return this.tiberiumLeft > 0; 39 | } 40 | 41 | public isTiberium(): boolean { 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/engine/physics.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Vector } from 'vector2d'; 7 | 8 | /** 9 | * Box interface 10 | */ 11 | export interface Box { 12 | x1: number; 13 | x2: number; 14 | y1: number; 15 | y2: number; 16 | } 17 | 18 | /** 19 | * Checks if boxes intersects 20 | */ 21 | export const collideAABB = (a: Box, b: Box): boolean => 22 | (a.x1 < b.x2) && 23 | (a.x2 > b.x1) && 24 | (a.y1 < b.y2) && 25 | (a.y2 > b.y1); 26 | 27 | /** 28 | * Checks if vector intersects with box 29 | */ 30 | export const collidePoint = (point: Vector, box: Box): boolean => 31 | (point.x >= box.x1) && 32 | (point.x <= box.x2) && 33 | (point.y >= box.y1) && 34 | (point.y <= box.y2); 35 | 36 | /** 37 | * Gets a random integer min/max-ed 38 | */ 39 | export const randomBetweenInteger = (min: number, max: number): number => 40 | Math.floor(Math.random() * max) + min; 41 | 42 | /** 43 | * Gets the number with the nearest N 44 | */ 45 | export const roundToNearest = (num: number, near: number = 1.0): number => 46 | Math.round(num / near) * near; 47 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import './index.scss'; 7 | import { GameEngine } from './game/game'; 8 | import { throttle } from './engine'; 9 | 10 | const oninit = async (): Promise => { 11 | const canvas = document.createElement('canvas'); 12 | const body = document.querySelector('body') as HTMLElement; 13 | body.appendChild(canvas); 14 | 15 | const game: GameEngine = new GameEngine(canvas, { 16 | debugMode: process.env.DEBUG_MODE === 'true', 17 | updateRate: typeof process.env.TICK_RATE === 'undefined' 18 | ? undefined 19 | : parseFloat(process.env.TICK_RATE), 20 | 21 | sound: { 22 | muted: process.env.SOUND_MUTED === 'true' 23 | } 24 | }); 25 | 26 | const onresize = (): void => game.resize(); 27 | const onleave = (): void => game.pause(); 28 | const onenter = (): void => game.resume(); 29 | 30 | window.addEventListener('resize', throttle(onresize, 100)); 31 | document.addEventListener('mouseleave', onleave); 32 | document.addEventListener('mouseenter', onenter); 33 | 34 | game.run(); 35 | 36 | await game.init(); 37 | }; 38 | 39 | window.addEventListener('load', oninit); 40 | -------------------------------------------------------------------------------- /src/game/entities/terrain.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { GameMapEntity } from './mapentity'; 7 | import { MIXGrid, MIXTerrain } from '../mix'; 8 | import { Vector } from 'vector2d'; 9 | 10 | /** 11 | * Terrain Entity 12 | */ 13 | export class TerrainEntity extends GameMapEntity { 14 | public readonly properties: MIXTerrain = this.engine.mix.terrain.get(this.data.name) as MIXTerrain; 15 | protected zIndex: number = this.isTiberiumTree() ? 5 : 2; 16 | protected dimension: Vector = new Vector(16, 16); 17 | protected occupy?: MIXGrid = { 18 | name: '', 19 | grid: [['x']] 20 | }; 21 | 22 | public toJson(): any { 23 | return { 24 | ...super.toJson(), 25 | type: 'terrain' 26 | }; 27 | } 28 | 29 | public async init(): Promise { 30 | await super.init(); 31 | 32 | if (this.sprite) { 33 | this.setDimension(this.sprite.size); 34 | } 35 | } 36 | 37 | public onRender(deltaTime: number): void { 38 | const context = this.map.objects.getContext(); 39 | this.renderSprite(deltaTime, context); 40 | super.onRender(deltaTime); 41 | } 42 | 43 | public getColor(): string { 44 | return '#002200'; 45 | } 46 | 47 | private isTiberiumTree(): boolean { 48 | return this.data.name.substr(0, 5) === 'SPLIT'; 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | cncjs 2 | 3 | Copyright 2017 Anders Evenrud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | --- 12 | 13 | Command & Conquer (Tiberium Dawn) 14 | 15 | Copyright (c) 1995-1997 Westwood Studios 16 | 17 | > Tiberian Dawn, Red Alert, and Tiberian Sun are officially freeware. EA Games announced them as freeware in 2007, 2008, and 2010. 18 | 19 | --- 20 | 21 | C&C Logo 22 | 23 | https://commons.wikimedia.org/wiki/File:C%26C-TD.png 24 | -------------------------------------------------------------------------------- /src/engine/LICENSE: -------------------------------------------------------------------------------- 1 | cncjs 2 | 3 | Copyright 2017 Anders Evenrud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | --- 12 | 13 | Command & Conquer (Tiberium Dawn) 14 | 15 | Copyright (c) 1995-1997 Westwood Studios 16 | 17 | > Tiberian Dawn, Red Alert, and Tiberian Sun are officially freeware. EA Games announced them as freeware in 2007, 2008, and 2010. 18 | 19 | --- 20 | 21 | C&C Logo 22 | 23 | https://commons.wikimedia.org/wiki/File:C%26C-TD.png 24 | -------------------------------------------------------------------------------- /src/engine/core.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { SoundOutput } from './sound'; 7 | import { KeyboardInput } from './keyboard'; 8 | import { MouseInput } from './mouse'; 9 | import { Vector } from 'vector2d'; 10 | 11 | export interface Core { 12 | readonly keyboard: KeyboardInput; 13 | readonly mouse: MouseInput; 14 | readonly sound: SoundOutput; 15 | readonly configuration: CoreConfiguration; 16 | time: number; 17 | ticks: number; 18 | frames: number; 19 | onUpdate(deltaTime: number): void; 20 | onRender(deltaTime: number): void; 21 | getDimension(): Vector; 22 | getContext(): CanvasRenderingContext2D; 23 | setScale(scale: number): void; 24 | setDebug(debug: boolean): void; 25 | setCanvasFilter(enable: boolean): void; 26 | getCanvasFilter(): boolean; 27 | getDebug(): boolean; 28 | getScale(): number; 29 | getScaledDimension(): Vector; 30 | getPause(): boolean; 31 | getRoot(): HTMLElement; 32 | getCanvas(): HTMLCanvasElement; 33 | } 34 | 35 | export interface CoreSoundConfiguration { 36 | muted: boolean; 37 | mainVolume: number; 38 | musicVolume: number; 39 | sfxVolume: number; 40 | guiVolume: number; 41 | } 42 | 43 | export interface CoreConfiguration { 44 | debugMode: boolean; 45 | maxScale: number; 46 | minScale: number; 47 | scale: number; 48 | updateRate: number; 49 | sound: CoreSoundConfiguration; 50 | cursorLock: boolean; 51 | } 52 | -------------------------------------------------------------------------------- /src/engine/image.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { DataArchive } from './archive'; 7 | import { CachedLoader } from './io'; 8 | import { fetchImage } from './utils'; 9 | 10 | /** 11 | * Image Loader class interface 12 | */ 13 | export interface ImageLoader { 14 | fetch(source: string): Promise; 15 | } 16 | 17 | /** 18 | * Image Loader: Http 19 | */ 20 | export class HttpImageLoader extends CachedLoader implements ImageLoader { 21 | /** 22 | * Fetches image 23 | */ 24 | public async fetch(source: string): Promise { 25 | return super.fetchResource(source, (): Promise => fetchImage(source)); 26 | } 27 | } 28 | 29 | /** 30 | * Image Loader: Archive 31 | */ 32 | export class DataArchiveImageLoader extends CachedLoader implements ImageLoader { 33 | protected archive: DataArchive; 34 | 35 | public constructor(archive: DataArchive) { 36 | super(); 37 | this.archive = archive; 38 | } 39 | 40 | /** 41 | * Fetches image 42 | */ 43 | public async fetch(source: string): Promise { 44 | return super.fetchResource(source, async (): Promise => { 45 | const blob: Blob = await this.archive.extract(source, 'blob') as Blob; 46 | const url = URL.createObjectURL(blob); 47 | const image = await fetchImage(url); 48 | URL.revokeObjectURL(url); 49 | return image; 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cncts", 3 | "version": "0.4.0", 4 | "description": "Command & Conquer - JavaScript remake", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "webpack", 9 | "build:watch": "webpack --watch", 10 | "convert": "node scripts/deploy.js", 11 | "build:production": "NODE_ENV=production webpack", 12 | "start:dev": "webpack-dev-server", 13 | "eslint": "eslint 'src/**/*.ts'" 14 | }, 15 | "author": "Anders Evenrud ", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@types/eventemitter3": "^2.0.2", 19 | "@types/ini": "^1.3.30", 20 | "@types/jszip": "^3.1.6", 21 | "@types/lodash-es": "^4.17.3", 22 | "@types/pathfinding": "0.0.2", 23 | "@typescript-eslint/eslint-plugin": "^1.13.0", 24 | "@typescript-eslint/parser": "^1.13.0", 25 | "css-loader": "^3.1.0", 26 | "dotenv-webpack": "^1.7.0", 27 | "eslint": "^6.1.0", 28 | "eventemitter3": "^4.0.0", 29 | "html-webpack-plugin": "^3.2.0", 30 | "mini-css-extract-plugin": "^0.8.0", 31 | "node-sass": "^4.12.0", 32 | "sass-loader": "^7.1.0", 33 | "terser-webpack-plugin": "^1.3.0", 34 | "ts-loader": "^6.0.4", 35 | "typescript": "^3.5.3", 36 | "webpack": "^4.36.1", 37 | "webpack-cli": "^3.3.6", 38 | "webpack-dev-server": "^3.7.2" 39 | }, 40 | "dependencies": { 41 | "fs-extra": "^8.1.0", 42 | "hyperapp": "^1.2.10", 43 | "ini": "^1.3.5", 44 | "jszip": "^3.2.2", 45 | "lodash-es": "^4.17.15", 46 | "pathfinding": "^0.4.18", 47 | "vector2d": "^3.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/engine/scene.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import EventEmitter from 'eventemitter3'; 7 | import { Core } from './core'; 8 | 9 | /** 10 | * Base Scene 11 | */ 12 | export abstract class Scene extends EventEmitter { 13 | public engine: Core; 14 | protected destroyed: boolean = false; 15 | 16 | public constructor(engine: Core) { 17 | super(); 18 | 19 | this.engine = engine; 20 | this.onCreate(); 21 | } 22 | 23 | /** 24 | * Initialize scene 25 | */ 26 | public async init(): Promise { 27 | } 28 | 29 | /** 30 | * Destroys instance 31 | */ 32 | public destroy(): void { 33 | if (this.destroyed) { 34 | return; 35 | } 36 | 37 | this.destroyed = true; 38 | super.removeAllListeners(); 39 | this.onDestroy(); 40 | } 41 | 42 | /** 43 | * Convert to string (for debugging) 44 | */ 45 | public toString(): string { 46 | return ''; 47 | } 48 | 49 | /** 50 | * Destruction action 51 | */ 52 | public onDestroy(): void { 53 | } 54 | 55 | /** 56 | * Creation action 57 | */ 58 | public onCreate(): void { 59 | } 60 | 61 | /** 62 | * Resize action 63 | */ 64 | public onResize(): void { 65 | } 66 | 67 | /** 68 | * Pause action 69 | */ 70 | public onPause(state: boolean): void { 71 | } 72 | 73 | /** 74 | * Update action 75 | */ 76 | public onUpdate(deltaTime: number): void { 77 | } 78 | 79 | /** 80 | * Render action 81 | */ 82 | public onRender(deltaTime: number): void { 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/game/entities/health.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Entity } from '../../engine'; 7 | import { Vector } from 'vector2d'; 8 | import { healthBarColors } from '../mix'; 9 | import { GameMapEntity } from './mapentity'; 10 | 11 | const HEALT_BAR_HEIGHT = 6; 12 | 13 | export class HealthBarEntity extends Entity { 14 | private parent: GameMapEntity; 15 | protected readonly context: CanvasRenderingContext2D = this.canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D; 16 | private lastPercentage = -1; 17 | 18 | public constructor(parent: GameMapEntity) { 19 | super(); 20 | this.parent = parent; 21 | } 22 | 23 | public render(deltaTime: number, context: CanvasRenderingContext2D) { 24 | this.setDimension(new Vector( 25 | this.parent.getDimension().x, 26 | HEALT_BAR_HEIGHT 27 | )); 28 | 29 | const percentage = this.parent.getHealth() / this.parent.getHitPoints(); 30 | const position = this.parent.getPosition(); 31 | const x = Math.trunc(position.x); 32 | const y = Math.trunc(position.y); 33 | 34 | if (percentage !== this.lastPercentage) { 35 | const color = healthBarColors[this.parent.getDamageState()]; 36 | const w = Math.round(this.dimension.x * percentage) - 2; 37 | 38 | this.context.fillStyle = '#000000'; 39 | this.context.fillRect(0, 0, this.dimension.x, HEALT_BAR_HEIGHT); 40 | this.context.fillStyle = color; 41 | this.context.fillRect(1, 1, w - 2, HEALT_BAR_HEIGHT - 2); 42 | } 43 | 44 | context.drawImage(this.canvas, x, y - HEALT_BAR_HEIGHT - 2); 45 | this.lastPercentage = percentage; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/game/scenes/loading.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { h, app } from 'hyperapp'; 7 | import { Scene } from '../../engine'; 8 | import { GameEngine } from '../game'; 9 | 10 | /** 11 | * Loading Scene 12 | */ 13 | export class LoadingScene extends Scene { 14 | public engine: GameEngine; 15 | private root: HTMLElement = document.createElement('div'); 16 | private app?: any; 17 | 18 | public constructor(engine: GameEngine) { 19 | super(engine); 20 | this.engine = engine; 21 | } 22 | 23 | public toString(): string { 24 | return `Loading`; 25 | } 26 | 27 | public async init(): Promise { 28 | this.root.classList.add('loading'); 29 | this.engine.getRoot().appendChild(this.root); 30 | 31 | const view = (state: any) => h('div', { 32 | class: 'progressbar' 33 | }, [ 34 | h('div', { 35 | class: 'progress', 36 | style: { 37 | width: `${Math.ceil(state.progress * 100)}%` 38 | } 39 | }), 40 | h('div', { 41 | class: 'label' 42 | }, (state.progress * 100).toFixed(2)) 43 | ]); 44 | 45 | this.app = app({ 46 | progress: 0.0 47 | }, { 48 | setProgress: (progress: number): any => ({ progress }) 49 | }, view, this.root); 50 | } 51 | 52 | public onDestroy(): void { 53 | this.root.remove(); 54 | } 55 | 56 | public onCreate(): void { 57 | this.on('progress', (progress: number, total: number): void => { 58 | this.app.setProgress(progress / total); 59 | }); 60 | } 61 | 62 | public onUpdate(deltaTime: number): void { 63 | } 64 | 65 | public onRender(deltaTime: number): void { 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | html, body { 7 | width: 100%; 8 | height: 100%; 9 | margin: 0; 10 | padding: 0; 11 | } 12 | 13 | body { 14 | font-family: sans-serif; 15 | font-size: 16px; 16 | background: #000; 17 | color: #fff; 18 | overflow: hidden; 19 | } 20 | 21 | canvas { 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | right: 0; 26 | bottom: 0; 27 | width: 100%; 28 | height: 100%; 29 | 30 | &.sharpen { 31 | image-rendering: optimizeSpeed; 32 | image-rendering: -moz-crisp-edges; 33 | image-rendering: -webkit-optimize-contrast; 34 | image-rendering: -o-crisp-edges; 35 | image-rendering: pixelated; 36 | -ms-interpolation-mode: nearest-neighbor; 37 | } 38 | } 39 | 40 | .loading { 41 | font-family: monospace; 42 | font-size: 16px; 43 | background: url('htitle.png') no-repeat 50% 50%; 44 | background-size: contain; 45 | position: relative; 46 | width: 100%; 47 | height: 100%; 48 | 49 | .progressbar { 50 | position: absolute; 51 | top: 50%; 52 | left: 50%; 53 | width: 80%; 54 | height: 48px; 55 | transform: translate(-50%, -50%); 56 | border: 1px solid #fff; 57 | background: #000; 58 | color: #fff; 59 | 60 | .progress { 61 | position: absolute; 62 | top: 0; 63 | left: 0; 64 | bottom: 0; 65 | width: 0; 66 | background-color: #00ff00; 67 | z-index: 1; 68 | } 69 | 70 | .label { 71 | position: absolute; 72 | top: 50%; 73 | right: 0; 74 | left: 0; 75 | text-align: center; 76 | transform: translateY(-50%); 77 | z-index: 2; 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/engine/io.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Core } from './core'; 7 | 8 | /** 9 | * Cached Loader 10 | */ 11 | export abstract class CachedLoader { 12 | protected readonly cache: Map> = new Map(); 13 | 14 | /** 15 | * Clears cache 16 | */ 17 | public clearCache(): void { 18 | this.cache.clear(); 19 | } 20 | 21 | /** 22 | * Gets cached promise or false 23 | */ 24 | protected cached(source: string): any { 25 | if (this.cache.has(source)) { 26 | return this.cache.get(source); 27 | } 28 | 29 | return false; 30 | } 31 | 32 | /** 33 | * Fetch a resource (wrapper) 34 | */ 35 | protected async fetchResource(source: string, callback: Function): Promise { 36 | const cached = this.cached(source); 37 | if (cached) { 38 | return cached; 39 | } 40 | 41 | const item = await callback(); 42 | this.cache.set(source, item); 43 | return item; 44 | } 45 | } 46 | 47 | /** 48 | * IO Device Base Class 49 | */ 50 | export abstract class IODevice { 51 | protected readonly engine: Core; 52 | 53 | public constructor(engine: Core) { 54 | this.engine = engine; 55 | } 56 | 57 | /** 58 | * Destroys instance 59 | */ 60 | public destroy(): void { 61 | } 62 | 63 | /** 64 | * Initializes IO 65 | */ 66 | public async init(): Promise { 67 | } 68 | 69 | /** 70 | * Restores IO from a paused state 71 | */ 72 | public restore(): void { 73 | } 74 | 75 | /** 76 | * Flushes out stuff between scenes etc. 77 | */ 78 | public clear(): void { 79 | } 80 | 81 | /** 82 | * On every game tick 83 | */ 84 | public onUpdate(): void { 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /data/GAME.DAT/houses.ini: -------------------------------------------------------------------------------- 1 | [Houses] 2 | 0=GoodGuy 3 | 1=BadGuy 4 | 2=Neutral 5 | 3=Special 6 | 4=Multi1 7 | 5=Multi2 8 | 6=Multi3 9 | 7=Multi4 10 | 8=Multi5 11 | 9=Multi6 12 | 13 | ; DefaultColor = default to reset the colour to after a MP game. 14 | ; Currently, I haven't figured out exactly which system uses this. 15 | ; The existing 1.06 colour systems override the bare house type's 16 | ; colors rather than using the existing multiplayer color system. 17 | [GoodGuy] 18 | SideLetter=G 19 | ColorScheme=GDI 20 | DefaultColor=0 21 | Unknown4=0 22 | NameID=33 23 | ShortName=GDI 24 | 25 | [BadGuy] 26 | SideLetter=B 27 | ColorScheme=Nod 28 | DefaultColor=5 29 | Unknown4=0 30 | NameID=34 31 | ShortName=NOD 32 | 33 | [Neutral] 34 | SideLetter=C 35 | ColorScheme=Neutral 36 | DefaultColor=0 37 | Unknown4=0 38 | NameID=35 39 | ShortName=CIV 40 | 41 | [Special] 42 | SideLetter=J 43 | ColorScheme=Neutral 44 | DefaultColor=0 45 | Unknown4=0 46 | NameID=36 47 | ShortName=JP 48 | 49 | [Multi1] 50 | SideLetter=M 51 | ColorScheme=Teal 52 | DefaultColor=2 53 | Unknown4=0 54 | NameID=35 55 | ShortName=MP1 56 | 57 | [Multi2] 58 | SideLetter=M 59 | ColorScheme=Orange 60 | DefaultColor=3 61 | Unknown4=0 62 | NameID=35 63 | ShortName=MP2 64 | 65 | [Multi3] 66 | SideLetter=M 67 | ColorScheme=Green 68 | DefaultColor=4 69 | Unknown4=0 70 | NameID=35 71 | ShortName=MP3 72 | 73 | [Multi4] 74 | SideLetter=M 75 | ColorScheme=Gray 76 | DefaultColor=5 77 | Unknown4=0 78 | NameID=35 79 | ShortName=MP4 80 | 81 | [Multi5] 82 | SideLetter=M 83 | ColorScheme=Yellow 84 | DefaultColor=0 85 | Unknown4=0 86 | NameID=35 87 | ShortName=MP5 88 | 89 | [Multi6] 90 | SideLetter=M 91 | ColorScheme=Red 92 | DefaultColor=1 93 | Unknown4=0 94 | NameID=35 95 | ShortName=MP6 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cncjs 2 | 3 | A javascript (typescript) remake of the classic Real-time strategy game "Command & Conquer". 4 | 5 | Written on a custom engine using canvas 2d renderer. 6 | 7 | ![Screenshot](https://raw.githubusercontent.com/andersevenrud/cncjs/development/docs/screenshot.png) 8 | 9 | ## Demo 10 | 11 | *Coming soon*. Meanwhile you can look at a [video preview](https://www.youtube.com/watch?v=KmGqKZGeN9Y&feature=youtu.be). 12 | 13 | ## Requirements 14 | 15 | You need a copy of the orignal game (Gold edition) v1.06c or a later. 16 | 17 | Should run on any modern browser or device that supports ES6. 18 | 19 | ## Installation 20 | 21 | **The required utilities for converting game assets is currently not included. You can ask me for a zip file of everything you need** 22 | 23 | Place all original game files in `original/`. 24 | 25 | ``` 26 | npm install 27 | npm run build 28 | npm run convert 29 | npm run deploy 30 | ``` 31 | 32 | ## In-game controls 33 | 34 | Standard game controls, with the additional: 35 | 36 | * `+` / `?` - Zoom in/out 37 | * `0` / `=` - Main volume up/down 38 | * `.` / `:` - Switch music track 39 | * `m` - Mute main audio 40 | 41 | ## Development 42 | 43 | When in development mode: 44 | 45 | * `F1 - F6` - Switch scenes 46 | * `F10` - Toggle FOW 47 | * `F11` - Toggle canvas filtering 48 | * `F12` - Toggle debugging 49 | * `Delete` - Destroys selected entities 50 | * `PageUp` / `PageDown` - Modifies selected entities health 51 | * `End` - Moves entities to current mouse position 52 | 53 | You can also pass `scene=` to the URL to select a scene: 54 | 55 | * `team` 56 | * `movie` requires parameter `movie=` 57 | * `theatre` requires parameter `map=` 58 | 59 | Set `debug=false` to disable debug overlay and `zoom=` for a initial zoom level. 60 | 61 | ## Shoutout 62 | 63 | Without http://nyerguds.arsaneus-design.com/cnc95upd/inirules/ this would not be possible. 64 | 65 | ## License 66 | 67 | This codebase is [MIT Licenses](LICENSE). 68 | -------------------------------------------------------------------------------- /src/game/ui/map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { UIScene } from '../../engine'; 7 | import { UIBox, UIButton } from './elements'; 8 | import { MapSelectionScene } from '../scenes/map'; 9 | import { MIXTeamName, gdiMaps, nodMaps } from '../mix'; 10 | import { Vector } from 'vector2d'; 11 | 12 | export class MapSelectionUI extends UIScene { 13 | public readonly scene: MapSelectionScene; 14 | private readonly list: string[]; 15 | 16 | public constructor(teamName: MIXTeamName, scene: MapSelectionScene) { 17 | super(scene.engine); 18 | this.scene = scene; 19 | this.list = teamName === 'nod' ? nodMaps : gdiMaps; 20 | } 21 | 22 | public async init(): Promise { 23 | const menu = new UIBox('menu', new Vector(300, 270), new Vector(0.5, 0.5), this); 24 | 25 | const margin = 10; 26 | const count = 2; 27 | const width = (300 - (margin * (count + 1))) / count; 28 | 29 | this.list.forEach((name: string, index: number) => { 30 | const row = Math.floor(index / count); 31 | const col = index % count; 32 | 33 | const dim = new Vector(width, 16); 34 | const pos = new Vector(margin + (width + margin) * col, 30 + (row * 18)); 35 | const btn = new UIButton(`map-select-${index}`, name, dim, pos, this); 36 | btn.on('click', () => this.scene.handleMapSelection(name)); 37 | menu.addChild(btn); 38 | }); 39 | menu.setDecorations(0); 40 | menu.setVisible(true); 41 | 42 | this.elements.push(menu); 43 | 44 | await super.init(); 45 | } 46 | 47 | public onResize(): void { 48 | super.onResize(); 49 | } 50 | 51 | public onUpdate(deltaTime: number): void { 52 | super.onUpdate(deltaTime); 53 | } 54 | 55 | public onRender(deltaTime: number, ctx: CanvasRenderingContext2D): void { 56 | this.context.clearRect(0, 0, this.dimension.x, this.dimension.y); 57 | super.onRender(deltaTime, ctx); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const packagejson = require('./package.json'); 2 | const path = require('path'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const Dotenv = require('dotenv-webpack'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const TerserPlugin = require('terser-webpack-plugin'); 7 | 8 | const mode = process.env.NODE_ENV || 'development'; 9 | const minimize = mode === 'production'; 10 | 11 | module.exports = { 12 | mode, 13 | entry: './src/index.ts', 14 | devtool: 'source-map', 15 | devServer: { 16 | host: '0.0.0.0', 17 | contentBase: path.join(__dirname, 'dist') 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.tsx?$/, 23 | use: 'ts-loader', 24 | exclude: /node_modules/ 25 | }, 26 | { 27 | test: /\.s?css$/i, 28 | exclude: /node_modules/, 29 | use: [ 30 | MiniCssExtractPlugin.loader, 31 | { 32 | loader: 'css-loader', 33 | options: { sourceMap: true, url: false } 34 | }, 35 | { 36 | loader: 'sass-loader', 37 | options: { sourceMap: true, url: false } 38 | } 39 | ] 40 | } 41 | ] 42 | }, 43 | resolve: { 44 | extensions: ['.tsx', '.ts', '.js'] 45 | }, 46 | output: { 47 | path: path.resolve(__dirname, 'dist') 48 | }, 49 | plugins: [ 50 | new Dotenv(), 51 | new HtmlWebpackPlugin({ 52 | title: packagejson.name 53 | }), 54 | new MiniCssExtractPlugin({ 55 | filename: '[name].css' 56 | }) 57 | ], 58 | optimization: { 59 | minimize, 60 | splitChunks: { 61 | chunks: 'all' 62 | }, 63 | minimizer: [ 64 | new TerserPlugin({ 65 | sourceMap: true, 66 | terserOptions: { 67 | compress: { 68 | pure_funcs: ['console.log', 'console.debug', 'console.group', 'console.groupEnd', 'console.time', 'console.timeEnd'] 69 | } 70 | } 71 | }) 72 | ] 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/game/entities/bib.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Sprite, Entity } from '../../engine'; 7 | import { CELL_SIZE } from '../physics'; 8 | import { spriteFromName } from '../sprites'; 9 | import { GameEngine } from '../game'; 10 | import { Vector } from 'vector2d'; 11 | 12 | /** 13 | * Bib underlay entity 14 | */ 15 | export class BibEntity extends Entity { 16 | protected static cache: Map = new Map(); 17 | private size: Vector; 18 | private offset: number; 19 | private sprite: Sprite; 20 | private engine: GameEngine; 21 | 22 | public constructor(size: Vector, theatre: string, engine: GameEngine) { 23 | super(); 24 | 25 | const id = size.x > 3 ? 1 : (size.x > 2 ? 2 : 3); 26 | const name = `${theatre.toUpperCase()}.MIX/bib${id}.png`; 27 | const sprite = spriteFromName(name); 28 | const sizeX = sprite.frames / 2; 29 | const sizeY = 2; 30 | 31 | this.engine = engine; 32 | this.sprite = sprite; 33 | this.offset = (size.y - 1) * CELL_SIZE; 34 | this.size = new Vector(sizeX, sizeY); 35 | this.setDimension(new Vector( 36 | sizeX * CELL_SIZE, 37 | sizeY * CELL_SIZE 38 | )); 39 | } 40 | 41 | public async init(): Promise { 42 | try { 43 | await this.engine.loadArchiveSprite(this.sprite); 44 | 45 | let i = 0; 46 | for ( let y = 0; y < this.size.y; y++ ) { 47 | for ( let x = 0; x < this.size.x; x++ ) { 48 | this.sprite.render(new Vector(0, i), new Vector(x * CELL_SIZE, y * CELL_SIZE), this.context); 49 | i++; 50 | } 51 | } 52 | 53 | } catch (e) { 54 | console.error('BibEntity::init()', e); 55 | } 56 | } 57 | 58 | public static async createOrCache(engine: GameEngine, size: Vector, theatre: string): Promise { 59 | const key = size.toString() + theatre; 60 | if (!this.cache.has(key)) { 61 | const bib = new BibEntity(size, theatre, engine); 62 | await bib.init(); 63 | 64 | this.cache.set(key, bib); 65 | } 66 | 67 | return this.cache.get(key) as BibEntity; 68 | } 69 | 70 | public getOffset(): number { 71 | return this.offset; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/engine/animation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import EventEmitter from 'eventemitter3'; 7 | import { Vector } from 'vector2d'; 8 | 9 | /** 10 | * Animation 11 | */ 12 | export class Animation extends EventEmitter { 13 | protected readonly name: string; 14 | protected readonly offset: Vector = new Vector(0, 0); 15 | protected readonly count: number = 0; 16 | protected readonly speed: number = 0.1; 17 | protected readonly loop: boolean = true; 18 | protected current: number = 0.0; 19 | protected stopped: boolean = false; 20 | protected reversed: boolean = false; 21 | 22 | /** 23 | * Creates a new instance 24 | */ 25 | public constructor(name: string, offset: Vector, count: number, speed: number, loop: boolean = true, reversed: boolean = false) { 26 | super(); 27 | 28 | this.name = name; 29 | this.offset = offset; 30 | this.count = count; 31 | this.speed = speed; 32 | this.loop = loop; 33 | this.reversed = reversed; 34 | } 35 | 36 | /** 37 | * Fixed update 38 | */ 39 | public onUpdate(): void { 40 | const value = this.current + this.speed; 41 | if (value >= this.count) { 42 | this.emit('done'); 43 | this.stopped = !this.loop; 44 | } 45 | 46 | if (!this.stopped) { 47 | this.current = value % this.count; 48 | } 49 | } 50 | 51 | /** 52 | * Reset animation frame 53 | */ 54 | public reset(): void { 55 | this.current = 0; 56 | } 57 | 58 | /** 59 | * Set if playing in reverse 60 | */ 61 | public setReversed(reversed: boolean): void { 62 | this.reversed = reversed; 63 | } 64 | 65 | /** 66 | * Get current frame index vector 67 | */ 68 | public getFrameIndex(offset: Vector = new Vector(0, 0)): Vector { 69 | let frame = Math.floor(this.current); 70 | if (this.reversed) { 71 | frame = this.count - frame; 72 | } 73 | 74 | return this.offset 75 | .clone() 76 | .add(offset) 77 | .add(new Vector(0, frame)) as Vector; 78 | } 79 | 80 | /** 81 | * Get animation frame count 82 | */ 83 | public getCount(): number { 84 | return this.count; 85 | } 86 | 87 | public getOffset(): Vector { 88 | return this.offset.clone() as Vector; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/game/entities/selection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | 7 | import { Entity, Sprite } from '../../engine'; 8 | import { GameMap } from '../map'; 9 | import { GameEntity } from '../entity'; 10 | import { spriteFromName } from '../sprites'; 11 | import { Vector } from 'vector2d'; 12 | 13 | /** 14 | * Mep Entity Selection 15 | */ 16 | export class GameMapEntitySelection extends Entity { 17 | public readonly map: GameMap; 18 | private readonly sprite: Sprite = spriteFromName('CONQUER.MIX/select.png'); 19 | 20 | public constructor(map: GameMap) { 21 | super(); 22 | this.map = map; 23 | } 24 | 25 | public async init(): Promise { 26 | await this.map.engine.loadArchiveSprite(this.sprite); 27 | } 28 | 29 | public render(target: GameEntity, ctx: CanvasRenderingContext2D): void { 30 | // TODO: This can be cached based on dimensions 31 | const { canvas } = this.sprite; 32 | const position = target.getPosition(); 33 | const dimension = target.getDimension(); 34 | const isInfantry = target.isInfantry(); 35 | const f = isInfantry ? 0 : 1; 36 | const l = isInfantry ? 3 : 5; 37 | const o = isInfantry ? new Vector(10, 3) : new Vector(7, 2); 38 | const size = isInfantry ? new Vector(11, 12) : new Vector(16, 16); 39 | 40 | // top-left 41 | ctx.drawImage( 42 | canvas, 43 | o.x, 44 | o.y + (f * this.sprite.size.y), 45 | l, 46 | l, 47 | position.x, 48 | position.y, 49 | l, 50 | l 51 | ); 52 | 53 | ctx.drawImage( // top-right 54 | canvas, 55 | o.x + size.x - l, 56 | o.y + (f * this.sprite.size.y), 57 | l, 58 | l, 59 | position.x + dimension.x - l, 60 | position.y, 61 | l, 62 | l 63 | ); 64 | 65 | ctx.drawImage( // bottom-left 66 | canvas, 67 | o.x, 68 | o.y + (f * this.sprite.size.y) + (size.y - l), 69 | l, 70 | l, 71 | position.x, 72 | position.y + dimension.y - l, 73 | l, 74 | l 75 | ); 76 | 77 | ctx.drawImage( // bottom-right 78 | canvas, 79 | o.x + size.x - l, 80 | o.y + (f * this.sprite.size.y) + (size.y - l), 81 | l, 82 | l, 83 | position.x + dimension.x - l, 84 | position.y + dimension.y - l, 85 | l, 86 | l 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/engine/archive.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import JSZip, { OutputType } from 'jszip'; 7 | import EventEmitter from 'eventemitter3'; 8 | import { fetchArrayBufferXHR } from './utils'; 9 | 10 | /** 11 | * Data archive output type 12 | */ 13 | export type DataArchiveType = ArrayBuffer | Blob | string | number[]; 14 | 15 | 16 | /** 17 | * Data archive 18 | */ 19 | export class DataArchive extends EventEmitter { 20 | private readonly source: string; 21 | private readonly type: string; 22 | private archive?: JSZip; 23 | private readonly cache: Map = new Map(); 24 | private readonly busy: Map> = new Map(); 25 | 26 | public constructor(source: string, type: string = 'zip') { 27 | super(); 28 | this.source = source; 29 | this.type = type; 30 | } 31 | 32 | /** 33 | * Clears cache 34 | */ 35 | public clearCache(): void { 36 | this.cache.clear(); 37 | } 38 | 39 | /** 40 | * Fetches the data archive 41 | */ 42 | public async fetch(): Promise { 43 | const zip = new JSZip(); 44 | const arrayBuffer = await fetchArrayBufferXHR(this.source, this); 45 | const blob = new Blob([arrayBuffer], { type: 'application/zip' }); 46 | 47 | this.archive = await zip.loadAsync(blob); 48 | } 49 | 50 | /** 51 | * Extracts file from archive 52 | */ 53 | private async extractFile(filename: string, type: OutputType): Promise { 54 | const result = await this.archive! 55 | .file(filename) 56 | .async(type); 57 | 58 | this.cache.set(filename, result); 59 | 60 | return result; 61 | } 62 | 63 | /** 64 | * Extract from archive 65 | */ 66 | public async extract(filename: string, type: OutputType): Promise { 67 | if (this.cache.has(filename)) { 68 | return this.cache.get(filename); 69 | } 70 | 71 | if (this.busy.has(filename)) { 72 | return this.busy.get(filename); 73 | } 74 | 75 | const promise = this.extractFile(filename, type); 76 | this.busy.set(filename, promise); 77 | 78 | console.debug('DataArchive::extract()', filename); 79 | 80 | let result; 81 | try { 82 | result = await promise; 83 | } catch (e) { 84 | console.error(e); 85 | } finally { 86 | this.busy.delete(filename); 87 | } 88 | 89 | return result as DataArchiveType; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/engine/entity.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Vector } from 'vector2d'; 7 | import { Box } from './physics'; 8 | 9 | /** 10 | * Entity 11 | */ 12 | export class Entity { 13 | protected position: Vector = new Vector(0, 0); 14 | protected dimension: Vector = new Vector(0, 0); 15 | protected readonly canvas: HTMLCanvasElement = document.createElement('canvas'); 16 | protected readonly context: CanvasRenderingContext2D = this.canvas.getContext('2d') as CanvasRenderingContext2D; 17 | protected destroyed: boolean = false; 18 | 19 | /** 20 | * Convert to string (for debugging) 21 | */ 22 | public toString(): string { 23 | return `${this.position.toString()} ${this.dimension.toString()}`; 24 | } 25 | 26 | /** 27 | * Destroy entity 28 | */ 29 | public destroy(): void { 30 | this.destroyed = true; 31 | } 32 | 33 | /** 34 | * Sets position 35 | */ 36 | public setPosition(position: Vector): void { 37 | this.position = position; 38 | } 39 | 40 | /** 41 | * Sets dimension 42 | */ 43 | public setDimension(dimension: Vector): void { 44 | if (this.dimension.equals(dimension)) { 45 | return; 46 | } 47 | 48 | this.dimension = dimension; 49 | this.canvas.width = dimension.x; 50 | this.canvas.height = dimension.y; 51 | } 52 | 53 | /** 54 | * Gets current position 55 | */ 56 | public getPosition(): Vector { 57 | return this.position.clone() as Vector; 58 | } 59 | 60 | /** 61 | * Gets current dimension 62 | */ 63 | public getDimension(): Vector { 64 | return this.dimension.clone() as Vector; 65 | } 66 | 67 | /** 68 | * Gets the entity box 69 | */ 70 | public getBox(): Box { 71 | return { 72 | x1: this.position.x, 73 | x2: this.position.x + this.dimension.x, 74 | y1: this.position.y, 75 | y2: this.position.y + this.dimension.y 76 | }; 77 | } 78 | 79 | /** 80 | * Gets the entity canvas 81 | */ 82 | public getCanvas(): HTMLCanvasElement { 83 | return this.canvas; 84 | } 85 | 86 | /** 87 | * Gets the entity canvas context 88 | */ 89 | public getContext(): CanvasRenderingContext2D { 90 | return this.context; 91 | } 92 | 93 | /** 94 | * Gets destroyed state 95 | */ 96 | public isDestroyed(): boolean { 97 | return this.destroyed; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/game/ui/cursor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Sprite, Entity, Animation } from '../../engine'; 7 | import { spriteFromName } from '../sprites'; 8 | import { GameEngine } from '../game'; 9 | import { cursorMap, MIXCursor, MIXCursorType } from '../mix'; 10 | import { Vector } from 'vector2d'; 11 | 12 | const animations: any = Object.keys(cursorMap) 13 | .map((name): any => { 14 | const cursor = cursorMap[name as MIXCursorType]; 15 | return [ 16 | name, 17 | new Animation(name, new Vector(0, cursor.index), cursor.count, 0.25) 18 | ]; 19 | }); 20 | 21 | export class Cursor extends Entity { 22 | protected readonly engine: GameEngine; 23 | protected animations: Map = new Map(animations); 24 | protected animation: string = 'default'; 25 | protected cursor: MIXCursor = cursorMap[this.animation as MIXCursorType]; 26 | public readonly cursorSprite: Sprite = spriteFromName('CCLOCAL.MIX/mouse.png') as Sprite; 27 | 28 | public constructor(engine: GameEngine) { 29 | super(); 30 | this.engine = engine; 31 | } 32 | 33 | public async init(): Promise { 34 | try { 35 | await this.engine.loadArchiveSprite(this.cursorSprite); 36 | this.engine.setCursor(false); 37 | } catch (e) { 38 | console.error('Cursor::init()', e); 39 | } 40 | } 41 | 42 | public onUpdate(deltaTime: number): void { 43 | this.position = this.engine.mouse.getVector(); 44 | 45 | const animation = this.animations.get(this.animation) as Animation; 46 | animation.onUpdate(); 47 | } 48 | 49 | public onRender(deltaTime: number, context: CanvasRenderingContext2D): void { 50 | const animation = this.animations.get(this.animation) as Animation; 51 | const offset = new Vector( 52 | this.cursorSprite.size.x * this.cursor.x, 53 | this.cursorSprite.size.y * this.cursor.y 54 | ); 55 | 56 | const position = this.position.clone().subtract(offset) as Vector; 57 | this.cursorSprite.render(animation.getFrameIndex(), position, context); 58 | } 59 | 60 | public setCursor(name: string = 'default'): void { 61 | if (name === this.animation) { 62 | return; 63 | } 64 | 65 | if (this.animations.has(name)) { 66 | const animation = this.animations.get(this.animation) as Animation; 67 | animation.reset(); 68 | 69 | this.animation = name; 70 | this.cursor = cursorMap[this.animation as MIXCursorType]; 71 | } 72 | } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /data/GAME.DAT/warheads.ini: -------------------------------------------------------------------------------- 1 | [Warheads] 2 | 0=SmallArms 3 | 1=HiExplosive 4 | 2=ArmorPiercing 5 | 3=Fire 6 | 4=SUPER 7 | 5=IonCannon 8 | 6=Kick 9 | 7=Punch 10 | 8=HollowPoint 11 | 9=Organic 12 | 10=DinoBite1 13 | 11=DinoBite2 14 | 12=Nuke 15 | 16 | ; Infantry death animations: 0=normal, 1=frag, 2=explode, 3=fire, 4=kicked, 17 | ; 5=punched, 6=eaten (used for carnivorous dinosaur healing). 18 | ; Verses values are stored as parts of 256, for all armor degrees (0-5). 19 | ; Armor '5' is newly added, but unused at the moment. With the current values, 20 | ; it makes a unit invincible. 21 | [SmallArms] 22 | Spread=2 23 | TargetWalls=0 24 | TargetWood=0 25 | Unknown4=0 26 | InfantryDeath=0 27 | verses=256,128,144,64,64,0 28 | 29 | [HiExplosive] 30 | Spread=6 31 | TargetWalls=1 32 | TargetWood=1 33 | Unknown4=1 34 | InfantryDeath=1 35 | Verses=224,192,144,64,256,0 36 | 37 | [ArmorPiercing] 38 | Spread=6 39 | TargetWalls=1 40 | TargetWood=1 41 | Unknown4=0 42 | InfantryDeath=2 43 | verses=64,192,192,256,128,0 44 | 45 | [Fire] 46 | Spread=8 47 | TargetWalls=0 48 | TargetWood=1 49 | Unknown4=1 50 | InfantryDeath=3 51 | verses=224,256,176,64,128,0 52 | 53 | ; used for Laser 54 | [SUPER] 55 | Spread=4 56 | TargetWalls=0 57 | TargetWood=0 58 | Unknown4=0 59 | InfantryDeath=3 60 | verses=256,256,256,256,256,0 61 | 62 | [IonCannon] 63 | Spread=7 64 | TargetWalls=1 65 | TargetWood=1 66 | Unknown4=1 67 | InfantryDeath=3 68 | verses=256,256,192,192,192,0 69 | 70 | [Kick] 71 | Spread=4 72 | TargetWalls=0 73 | TargetWood=0 74 | Unknown4=0 75 | InfantryDeath=4 76 | verses=256,32,32,16,16,0 77 | 78 | [Punch] 79 | Spread=4 80 | TargetWalls=0 81 | TargetWood=0 82 | Unknown4=0 83 | InfantryDeath=5 84 | verses=256,32,32,16,16,0 85 | 86 | ; used for Sniper 87 | [HollowPoint] 88 | Spread=4 89 | TargetWalls=0 90 | TargetWood=0 91 | Unknown4=0 92 | InfantryDeath=0 93 | verses=256,8,8,8,8,0 94 | 95 | ; used for the blossom tree spray damage 96 | [Organic] 97 | Spread=-1 98 | TargetWalls=0 99 | TargetWood=0 100 | Unknown4=0 101 | InfantryDeath=0 102 | verses=0,256,1,1,1,1,0 103 | 104 | ; carnivore bite 105 | [DinoBite1] 106 | Spread=1 107 | TargetWalls=1 108 | TargetWood=1 109 | Unknown4=0 110 | InfantryDeath=6 111 | verses=256,192,128,32,8,0 112 | 113 | ; herbivore bite 114 | [DinoBite2] 115 | Spread=1 116 | TargetWalls=1 117 | TargetWood=1 118 | Unknown4=0 119 | InfantryDeath=0 120 | verses=256,192,128,32,8,0 121 | 122 | ; Fire clone; unused for now because I can't reproduce the fire effects hardcoded to warhead "3". 123 | [Nuke] 124 | Spread=8 125 | TargetWalls=0 126 | TargetWood=1 127 | Unknown4=1 128 | InfantryDeath=3 129 | verses=224,256,176,64,128,0 130 | -------------------------------------------------------------------------------- /src/game/entities/effect.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Entity } from '../../engine'; 7 | import { GameMapEntity, GameMapEntityAnimation } from './mapentity'; 8 | import { MIXAnimation } from '../mix'; 9 | import { CELL_SIZE } from '../physics'; 10 | import { Vector } from 'vector2d'; 11 | 12 | export class EffectEntity extends GameMapEntity { 13 | protected zIndex: number = 10; 14 | protected centerEntity?: Entity; 15 | 16 | public toJson(): any { 17 | return { 18 | ...super.toJson(), 19 | type: 'effect' 20 | }; 21 | } 22 | 23 | public async init(): Promise { 24 | await super.init(); 25 | 26 | const name = this.data.name; 27 | const manim = this.engine.mix.animations.get(name) as MIXAnimation; 28 | if (manim.Report) { 29 | this.playSfx(manim.Report.toLowerCase()); 30 | } 31 | 32 | if (this.sprite) { 33 | const start = manim.FirstFrame; 34 | const length = manim.Frames === -1 ? this.sprite.frames : manim.Frames; 35 | const anim = new GameMapEntityAnimation(name, new Vector(0, start), length, 0.1); 36 | anim.on('done', () => this.destroy()); 37 | this.animation = name; 38 | 39 | this.dimension = this.sprite.size.clone() as Vector; 40 | if (['IONSFX'].indexOf(this.data.name) !== -1) { 41 | this.offset = new Vector(this.sprite.size.x / 2, this.sprite.size.y); 42 | } else { 43 | this.offset = new Vector(this.sprite.size.x / 2, this.sprite.size.y / 2); 44 | } 45 | 46 | if (['IONSFX', 'ATOMSFX'].indexOf(this.data.name) !== -1) { 47 | this.offset.subtract(new Vector( 48 | CELL_SIZE / 2, 49 | CELL_SIZE / 2 50 | )); 51 | } 52 | 53 | if (this.centerEntity) { 54 | const dimension = this.centerEntity.getDimension().divS(2); 55 | this.offset.subtract(dimension); 56 | } 57 | 58 | this.animations.set(name, anim); 59 | } 60 | } 61 | 62 | public onUpdate(deltaTime: number): void { 63 | const anim = this.animations.get(this.animation); 64 | if (anim) { 65 | anim.onUpdate(); 66 | this.frame = anim.getFrameIndex(); 67 | } 68 | super.onUpdate(deltaTime); 69 | } 70 | 71 | public onRender(deltaTime: number): void { 72 | const context = this.map.overlay.getContext(); 73 | this.renderSprite(deltaTime, context); 74 | super.onRender(deltaTime); 75 | } 76 | 77 | public setCenterEntity(entity?: Entity) { 78 | this.centerEntity = entity; 79 | } 80 | 81 | public getSpriteName(): string { 82 | const manim = this.engine.mix.animations.get(this.data.name) as MIXAnimation; 83 | const name = manim.Graphic || this.data.name; 84 | return `CONQUER.MIX/${name.toLowerCase()}.png`; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/game/scenes/team.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Animation, Scene, Sprite } from '../../engine'; 7 | import { spriteFromName } from '../sprites'; 8 | import { GameEngine } from '../game'; 9 | import { getScaledDimensions } from '../physics'; 10 | import { Vector } from 'vector2d'; 11 | 12 | /** 13 | * Team Scene 14 | */ 15 | export class TeamScene extends Scene { 16 | public engine: GameEngine; 17 | private background: Sprite = spriteFromName('TRANSIT.MIX/choose.png'); 18 | private animation: Animation = new Animation('choose', new Vector(0, 0), this.background.frames, 0.5); 19 | private sounds: Map = new Map(); 20 | private chosen: boolean = false; 21 | 22 | public constructor(engine: GameEngine) { 23 | super(engine); 24 | this.engine = engine; 25 | } 26 | 27 | public toString(): string { 28 | return `TEAM`; 29 | } 30 | 31 | public async init(): Promise { 32 | const playlist = this.engine.sound.getPlaylist(); 33 | playlist.setList([{ 34 | source: 'TRANSIT.MIX/struggle.wav', 35 | title: 'struggle' 36 | }]); 37 | 38 | playlist.play(); 39 | 40 | await this.engine.loadArchiveSprite(this.background); 41 | 42 | this.sounds.set('nod', await this.engine.sfxLoader.fetch('TRANSIT.MIX/nod_slct.wav')); 43 | this.sounds.set('gdi', await this.engine.sfxLoader.fetch('TRANSIT.MIX/gdi_slct.wav')); 44 | } 45 | 46 | private async clicked(position: Vector) { 47 | if (this.chosen) { 48 | return; 49 | } 50 | this.chosen = true; 51 | 52 | const playlist = this.engine.sound.getPlaylist(); 53 | const viewport = this.engine.getScaledDimension(); 54 | const clickedRight = (position.x) > (viewport.x / 2); 55 | const selected = clickedRight ? 'nod' : 'gdi'; 56 | const done = () => this.engine.onTeamSelected(selected); 57 | const context = this.sounds.get(selected) as AudioBuffer; 58 | 59 | playlist.pause(); 60 | this.engine.sound.playSfx({ context, done }, 'gui'); 61 | } 62 | 63 | public onUpdate(deltaTime: number): void { 64 | const mouse = this.engine.mouse; 65 | if (mouse.wasClicked('left')) { 66 | const position = mouse.getVector(); 67 | this.clicked(position); 68 | } 69 | 70 | this.animation.onUpdate(); 71 | } 72 | 73 | public onRender(deltaTime: number): void { 74 | const context = this.engine.getContext(); 75 | const frame = this.animation.getFrameIndex(); 76 | const dimension = this.engine.getScaledDimension(); 77 | const { sw, sh, dx, dy, dw, dh } = getScaledDimensions( 78 | this.background.size, 79 | dimension 80 | ); 81 | 82 | context.drawImage(this.background.canvas, frame.x * sw, frame.y * sh, sw, sh, dx, dy, dw, dh); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/game/fow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Entity } from '../engine'; 7 | import { GameMap } from './map'; 8 | import { CELL_SIZE } from './physics'; 9 | import { Vector } from 'vector2d'; 10 | 11 | export class FOW extends Entity { 12 | private map: GameMap; 13 | private grid: any[] = []; 14 | private updated: boolean = false; 15 | 16 | public constructor(map: GameMap) { 17 | super(); 18 | this.map = map; 19 | } 20 | 21 | public async init(): Promise { 22 | const dimension = this.map.getMapDimension(); 23 | this.grid = Array(...Array(dimension.x)).map(() => Array(dimension.y)); 24 | 25 | this.context.fillStyle = 'rgba(0, 0, 0, 1)'; 26 | this.context.fillRect(0, 0, this.dimension.x, this.dimension.y); 27 | } 28 | 29 | public onUpdate(deltaTime: number): void { 30 | const dimension = this.map.getMapDimension(); 31 | const entities = this.map.getEntities() 32 | .filter(entity => entity.canReveal()); 33 | 34 | entities.forEach(entity => { 35 | const box = entity.getCellBox(); 36 | const sight = entity.getSight(); 37 | 38 | for (let y: number = box.y1 - sight; y <= box.y2 + sight; y++) { 39 | for (let x: number = box.x1 - sight; x <= box.x2 + sight; x++) { 40 | if (x < 0 || y < 0 || x >= dimension.x || y >= dimension.y) { 41 | continue; 42 | } 43 | 44 | if (this.grid[y] && !this.grid[y][x]) { 45 | this.updated = true; 46 | this.grid[y][x] = 1; 47 | } 48 | } 49 | } 50 | }); 51 | } 52 | 53 | public onRender(deltaTime: number): void { 54 | if (!this.updated) { 55 | return; 56 | } 57 | 58 | if (this.map.engine.frames % 10 !== 0) { 59 | return; 60 | } 61 | 62 | this.updated = false; 63 | 64 | this.context.globalCompositeOperation = 'destination-out'; 65 | 66 | const dimension = this.map.getMapDimension(); 67 | for (let y = 0; y < dimension.y; y++) { 68 | for (let x = 0; x < dimension.x; x++) { 69 | if (this.grid[y] && this.grid[y][x] === 1) { 70 | const px = x * CELL_SIZE; 71 | const py = y * CELL_SIZE; 72 | 73 | this.context.fillStyle = 'rgba(0, 255, 0, 0.5)'; 74 | this.context.beginPath(); 75 | this.context.arc(px + (CELL_SIZE / 2), py + (CELL_SIZE / 2), CELL_SIZE + 10, 0, 2 * Math.PI, false); 76 | this.context.fill(); 77 | 78 | this.context.fillStyle = 'rgba(0, 255, 0, 1)'; 79 | this.context.beginPath(); 80 | this.context.arc(px + (CELL_SIZE / 2), py + (CELL_SIZE / 2), CELL_SIZE + 2, 0, 2 * Math.PI, false); 81 | this.context.fill(); 82 | 83 | this.grid[y][x] = 2; 84 | } 85 | } 86 | } 87 | 88 | this.context.globalCompositeOperation = 'source-over'; 89 | } 90 | 91 | public isRevealedAt(cell: Vector) { 92 | return this.grid[cell.y] && this.grid[cell.y][cell.x] > 0; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/game/entities/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Entity, Sprite } from '../../engine'; 7 | import { spriteFromName } from '../sprites'; 8 | import { GameEntity } from '../entity'; 9 | import { GameEngine } from '../game'; 10 | import { Vector } from 'vector2d'; 11 | 12 | const SLOT_OFFSET_X = 13; 13 | const SLOT_OFFSET_Y = 2; 14 | const SLOT_WIDTH = 4; 15 | const SLOT_HEIGHT = 4; 16 | 17 | export class StorageBarEntity extends Entity { 18 | private parent: GameEntity; 19 | private sprite: Sprite = spriteFromName('UPDATEC.MIX/hpips.png'); 20 | private lastSlots: number = -1; 21 | private readonly engine: GameEngine; 22 | protected readonly context: CanvasRenderingContext2D = this.canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D; 23 | protected background: Entity = new Entity(); 24 | protected foreground: Entity = new Entity(); 25 | 26 | public constructor(parent: GameEntity, engine: GameEngine) { 27 | super(); 28 | this.parent = parent; 29 | this.engine = engine; 30 | } 31 | 32 | public async init(): Promise { 33 | try { 34 | await this.engine.loadArchiveSprite(this.sprite); 35 | } catch (e) { 36 | console.error('StorageBarEntity::init()', e); 37 | } 38 | 39 | 40 | const maxSlots = this.parent.getStorageSlots(); 41 | const dimension = new Vector(maxSlots * SLOT_WIDTH, this.sprite.size.y); 42 | 43 | this.foreground.setDimension(dimension); 44 | this.background.setDimension(dimension); 45 | this.setDimension(dimension); 46 | 47 | const emptyCanvas = this.sprite.render(new Vector(0, 0)); 48 | const takenCanvas = this.sprite.render(new Vector(0, 1)); 49 | const backgroundContext = this.background.getContext(); 50 | const foregroundContext = this.foreground.getContext(); 51 | 52 | for (let x = 0; x < maxSlots; x++) { 53 | const py = 0; 54 | const px = x * SLOT_WIDTH; 55 | backgroundContext.drawImage(emptyCanvas, SLOT_OFFSET_X, SLOT_OFFSET_Y, SLOT_WIDTH, SLOT_HEIGHT, px, py, SLOT_WIDTH, SLOT_HEIGHT); 56 | foregroundContext.drawImage(takenCanvas, SLOT_OFFSET_X, SLOT_OFFSET_Y, SLOT_WIDTH, SLOT_HEIGHT, px, py, SLOT_WIDTH, SLOT_HEIGHT); 57 | } 58 | } 59 | 60 | public render(deltaTime: number, context: CanvasRenderingContext2D) { 61 | const position = this.parent.getPosition(); 62 | const dimension = this.parent.getDimension(); 63 | const x = Math.trunc(position.x); 64 | const y = Math.trunc(position.y) + Math.trunc(dimension.y); 65 | const value = this.parent.getStorageValue(); 66 | const xoff = (this.dimension.x / 2) - (dimension.x / 2); 67 | 68 | if (value !== this.lastSlots) { 69 | this.context.drawImage(this.background.getCanvas(), 0, 0); 70 | this.context.drawImage(this.foreground.getCanvas(), 0, 0, value * SLOT_WIDTH, SLOT_HEIGHT, 0, 0, value * SLOT_WIDTH, SLOT_HEIGHT); 71 | } 72 | 73 | context.drawImage(this.canvas, x - xoff, y); 74 | this.lastSlots = value; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/engine/keyboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { IODevice } from './io'; 7 | 8 | /** 9 | * Keyboard key type 10 | */ 11 | export type KeyboardKey = string; 12 | 13 | /** 14 | * Normalizes keyboard input names 15 | */ 16 | const keyName = (key: string): string => { 17 | key = key.toLowerCase(); 18 | return key === ' ' ? 'space' : key; 19 | }; 20 | 21 | /** 22 | * Keyboard Device 23 | */ 24 | export class KeyboardInput extends IODevice { 25 | private activeKeys: Set = new Set(); 26 | private activePresses: Set = new Set(); 27 | 28 | /** 29 | * Initializes keyboard input 30 | */ 31 | public async init(): Promise { 32 | console.debug('KeyboardInput::init()'); 33 | 34 | const onKeyDown = this.onKeyDown.bind(this); 35 | const onKeyUp = this.onKeyUp.bind(this); 36 | const onKeyPress = this.onKeyPress.bind(this); 37 | 38 | document.addEventListener('keydown', onKeyDown); 39 | document.addEventListener('keyup', onKeyUp); 40 | document.addEventListener('keypress', onKeyPress); 41 | } 42 | 43 | /** 44 | * On every update 45 | */ 46 | public onUpdate(): void { 47 | this.activePresses.clear(); 48 | } 49 | 50 | /** 51 | * Convert to string (for debugging) 52 | */ 53 | public toString(): string { 54 | return `[${Array.from(this.activeKeys.values()).join(',')}]`; 55 | } 56 | 57 | /** 58 | * Restores IO from a paused state 59 | */ 60 | public restore(): void { 61 | this.activeKeys.clear(); 62 | } 63 | 64 | /** 65 | * Key down 66 | */ 67 | private onKeyDown(ev: KeyboardEvent): void { 68 | if (!ev.ctrlKey) { 69 | ev.preventDefault(); 70 | } 71 | 72 | this.activeKeys.add(keyName(ev.key)); 73 | } 74 | 75 | /** 76 | * Key up 77 | */ 78 | private onKeyUp(ev: KeyboardEvent): void { 79 | ev.preventDefault(); 80 | 81 | const key = keyName(ev.key); 82 | this.activeKeys.delete(key); 83 | this.activePresses.add(key); 84 | } 85 | 86 | /** 87 | * Key press 88 | */ 89 | private onKeyPress(ev: KeyboardEvent): void { 90 | ev.preventDefault(); 91 | } 92 | 93 | /** 94 | * Check if key is pressed 95 | */ 96 | public isPressed(key?: KeyboardKey | KeyboardKey[]): boolean { 97 | if (key instanceof Array) { 98 | return key.some((b: KeyboardKey): boolean => this.activeKeys.has(keyName(b))); 99 | } 100 | 101 | return typeof key === 'undefined' 102 | ? this.activeKeys.size > 0 103 | : this.activeKeys.has(keyName(key)); 104 | } 105 | 106 | /** 107 | * Check is key was clicked 108 | */ 109 | public wasClicked(key?: KeyboardKey | KeyboardKey[]): boolean { 110 | if (key instanceof Array) { 111 | return key.some((b: KeyboardKey): boolean => this.activePresses.has(keyName(b))); 112 | } 113 | 114 | return typeof key === 'undefined' 115 | ? this.activePresses.size > 0 116 | : this.activePresses.has(keyName(key)); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/game/entities/mask.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Entity, Sprite } from '../../engine'; 7 | import { cellFromPoint, pointFromCell, CELL_SIZE } from '../physics'; 8 | import { GameMap } from '../map'; 9 | import { spriteFromName } from '../sprites'; 10 | import { Vector } from 'vector2d'; 11 | 12 | // FIXME: Don't remove this when building stops ? Hide ? 13 | export class StructureMaskEntity extends Entity { 14 | public readonly name: string; 15 | public readonly map: GameMap; 16 | public readonly dimension: Vector = new Vector(CELL_SIZE, CELL_SIZE); 17 | private readonly sprite: Sprite = spriteFromName('CONQUER.MIX/trans.png'); 18 | private cell: Vector = new Vector(0, 0); 19 | private white?: CanvasPattern; 20 | private yellow?: CanvasPattern; 21 | private red?: CanvasPattern; 22 | private blocked: boolean = true; 23 | 24 | public constructor(name: string, map: GameMap) { 25 | super(); 26 | this.name = name; 27 | this.map = map; 28 | 29 | const properties = this.map.engine.mix.structures.get(name); 30 | if (properties) { 31 | const size = properties.Dimensions.clone() as Vector; 32 | if (properties.HasBib) { 33 | size.add(new Vector(0, 1)); 34 | } 35 | 36 | size.mulS(CELL_SIZE); 37 | this.setDimension(size); 38 | } 39 | } 40 | 41 | public async init(): Promise { 42 | await this.map.engine.loadArchiveSprite(this.sprite); 43 | 44 | this.white = this.sprite.createPattern(new Vector(0, 0)) as CanvasPattern; 45 | this.yellow = this.sprite.createPattern(new Vector(0, 1)) as CanvasPattern; 46 | this.red = this.sprite.createPattern(new Vector(0, 2)) as CanvasPattern; 47 | } 48 | 49 | public onUpdate(deltaTime: number) { 50 | const mouse = this.map.engine.mouse; 51 | const point = mouse.getVector(); 52 | const pos = this.map.getRealMousePosition(point); 53 | const cell = cellFromPoint(pos); 54 | const position = pointFromCell(cell); 55 | 56 | this.cell = cell; 57 | this.setPosition(position); 58 | } 59 | 60 | public onRender(deltaTime: number, ctx: CanvasRenderingContext2D) { 61 | const w = this.dimension.x / CELL_SIZE; 62 | const h = this.dimension.y / CELL_SIZE; 63 | 64 | this.context.clearRect(0, 0, this.dimension.x, this.dimension.y); 65 | this.blocked = false; 66 | 67 | for (let y = 0; y < h; y++) { 68 | for (let x = 0; x < w; x++) { 69 | const cx = this.cell.x + x; 70 | const cy = this.cell.y + y; 71 | let dx = x * CELL_SIZE; 72 | let dy = y * CELL_SIZE; 73 | 74 | // FIXME: Units and infantry 75 | const occupied = !this.map.grid.isWalkableAt(cx, cy); 76 | if (occupied) { 77 | this.context.fillStyle = this.red || '#ff0000'; 78 | this.blocked = true; 79 | } else if (h > 1 && y > h - 2) { 80 | this.context.fillStyle = this.yellow || '#ffff00'; 81 | } else { 82 | this.context.fillStyle = this.white || '#ffffff'; 83 | } 84 | 85 | this.context.fillRect(dx, dy, CELL_SIZE, CELL_SIZE); 86 | } 87 | } 88 | 89 | ctx.drawImage(this.canvas, this.position.x, this.position.y); 90 | } 91 | 92 | public isBlocked(): boolean { 93 | return this.blocked; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/game/scenes/menu.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Scene, Sprite, Entity } from '../../engine'; 7 | import { spriteFromName } from '../sprites'; 8 | import { GameEngine } from '../game'; 9 | import { MainMenuUI } from '../ui/mainmenu'; 10 | import { getScaledDimensions } from '../physics'; 11 | import { Vector } from 'vector2d'; 12 | 13 | class MenuScreen extends Entity { 14 | public readonly engine: GameEngine; 15 | 16 | protected readonly sprites: Map = new Map([ 17 | ['background', spriteFromName('UPDATE.MIX/htitle.png')] 18 | ]); 19 | 20 | public constructor(engine: GameEngine) { 21 | super(); 22 | this.engine = engine; 23 | } 24 | 25 | public async init(): Promise { 26 | for (const sprite of this.sprites.values()) { 27 | await this.engine.loadArchiveSprite(sprite); 28 | } 29 | 30 | const bkg = this.sprites.get('background') as Sprite; 31 | this.setDimension(bkg.size); 32 | } 33 | 34 | public onUpdate(deltaTime: number): void { 35 | } 36 | 37 | public onRender(deltaTime: number): void { 38 | const background = this.sprites.get('background') as Sprite; 39 | background.render(new Vector(0, 0), new Vector(0, 0), this.context); 40 | } 41 | } 42 | 43 | /** 44 | * Menu Scene 45 | */ 46 | export class MenuScene extends Scene { 47 | public engine: GameEngine; 48 | protected readonly screen: MenuScreen; 49 | protected readonly ui: MainMenuUI = new MainMenuUI(this); 50 | 51 | public constructor(engine: GameEngine) { 52 | super(engine); 53 | this.engine = engine; 54 | this.screen = new MenuScreen(engine); 55 | } 56 | 57 | public toString(): string { 58 | return `Menu`; 59 | } 60 | 61 | public async init(): Promise { 62 | await this.screen.init(); 63 | 64 | const playlist = this.engine.sound.getPlaylist(); 65 | playlist.setList([{ 66 | source: 'TRANSIT.MIX/map1.wav', 67 | title: 'title' 68 | }]); 69 | 70 | playlist.play(); 71 | 72 | this.ui.setDimension(this.screen.getDimension()); 73 | await this.ui.init(); 74 | } 75 | 76 | public onNewGame(): void { 77 | this.engine.pushTeamScene(); 78 | } 79 | 80 | public onDestroy(): void { 81 | } 82 | 83 | public onResize(): void { 84 | super.onResize(); 85 | this.ui.onResize(); 86 | } 87 | 88 | public onUpdate(deltaTime: number): void { 89 | const dimension = this.engine.getScaledDimension(); 90 | const { dx, dy, bR } = getScaledDimensions( 91 | this.screen.getDimension(), 92 | dimension 93 | ); 94 | 95 | this.ui.setScale({ 96 | offset: new Vector(dx, dy), 97 | scale: bR 98 | }); 99 | 100 | this.ui.onUpdate(deltaTime); 101 | } 102 | 103 | public onRender(deltaTime: number): void { 104 | const { sx, sy, sw, sh, dx, dy, dw, dh } = getScaledDimensions( 105 | this.screen.getDimension(), 106 | this.engine.getScaledDimension() 107 | ); 108 | 109 | this.screen.onRender(deltaTime); 110 | this.ui.onRender(deltaTime, this.screen.getContext()); 111 | 112 | const context = this.engine.getContext(); 113 | context.drawImage(this.screen.getCanvas(), sx, sy, sw, sh, dx, dy, dw, dh); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/game/physics.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Vector } from 'vector2d'; 7 | import { Box } from '../engine'; 8 | 9 | export const CELL_SIZE = 24; 10 | 11 | /** 12 | * Gets vector from map cell 13 | */ 14 | export const pointFromCell = (cell: Vector) => new Vector( 15 | cell.x * CELL_SIZE, 16 | cell.y * CELL_SIZE 17 | ); 18 | 19 | /** 20 | * Gets map cell from vector 21 | */ 22 | export const cellFromPoint = (point: Vector): Vector => new Vector( 23 | Math.floor(point.x / CELL_SIZE), 24 | Math.floor(point.y / CELL_SIZE) 25 | ); 26 | 27 | /** 28 | * Gets direction from target and source vector based on slices 29 | */ 30 | export const getDirection = (target: Vector, source: Vector, base = 32): number => { 31 | let dx = target.x - source.x; 32 | let dy = target.y - source.y; 33 | let angle = base / 2 + Math.round(Math.atan2(dx, dy) * base / (2 * Math.PI)); 34 | 35 | if ( angle < 0 ) { 36 | angle += base; 37 | } 38 | 39 | if ( angle >= base ) { 40 | angle -= base; 41 | } 42 | 43 | return angle; 44 | }; 45 | 46 | export const getNewDirection = (current: number, target: number, speed: number, dirs: number): number => { 47 | if ( target > current && target - current < dirs / 2 || target < current && current - target > dirs / 2 ) { 48 | current = current + speed / 10; 49 | } else { 50 | current = current - speed / 10; 51 | } 52 | 53 | if ( current > dirs - 1 ) { 54 | current -= dirs - 1; 55 | } else if ( current < 0 ) { 56 | current += dirs - 1; 57 | } 58 | 59 | return current; 60 | }; 61 | 62 | export const getScaledDimensions = (source: Vector, target: Vector): any => { // FIXME 63 | const sx = 0; 64 | const sy = 0; 65 | const sw = source.x; 66 | const sh = source.y; 67 | 68 | const wR = target.x / sw; 69 | const hR = target.y / sh; 70 | const bR = Math.min(wR, hR); 71 | 72 | const dw = Math.trunc(sw * bR); 73 | const dh = Math.trunc(sh * bR); 74 | const dx = Math.trunc((target.x - dw) / 2); 75 | const dy = Math.trunc((target.y - dh) / 2); 76 | 77 | return { sx, sy, sw, sh, dx, dy, dw, dh, bR }; 78 | }; 79 | 80 | export const isRectangleVisible = (box: Box): boolean => 81 | (box.x2 - box.x1) > 12 && (box.y2 - box.y1) > 12; 82 | 83 | export const getSubCellOffset = (subcell: number, dimension: Vector): Vector => { 84 | const offset = new Vector(0, 0); 85 | 86 | if (subcell >= 0) { 87 | const center = dimension.clone() as Vector; 88 | center.subtract(new Vector(CELL_SIZE / 2, CELL_SIZE / 2)); 89 | offset.add(center); 90 | 91 | if (subcell !== 0) { 92 | const row = Math.floor((subcell - 1) / 2); 93 | const col = (subcell - 1) % 2; 94 | 95 | const dx = dimension.x / 2; 96 | const dy = dimension.y / 2; 97 | 98 | const v = new Vector( 99 | ((col + 1) % 2) === 1 ? -dx : dx, 100 | (row + 1) < 2 ? -dy : dy 101 | ); 102 | 103 | offset.add(v); 104 | } 105 | } 106 | 107 | return offset; 108 | }; 109 | 110 | export const findClosestPosition = (source: Vector, positions: Vector[]) => { 111 | let closest = -1; 112 | 113 | for (let i = 0; i < positions.length; i++) { 114 | let distance = source.distance(positions[i]); 115 | if (closest === -1 || distance < closest) { 116 | closest = i; 117 | } 118 | } 119 | 120 | return closest; 121 | }; 122 | -------------------------------------------------------------------------------- /src/game/scenes/movie.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Core, Scene } from '../../engine'; 7 | import { getScaledDimensions } from '../physics'; 8 | import { Vector } from 'vector2d'; 9 | 10 | /** 11 | * Movie Scene 12 | */ 13 | export class MovieScene extends Scene { 14 | private name: string; 15 | private movieElement: HTMLVideoElement = document.createElement('video'); 16 | private ended: boolean = false; 17 | private loaded: boolean = false; 18 | private failed: boolean = false; 19 | 20 | public constructor(name: string, engine: Core) { 21 | super(engine); 22 | 23 | this.engine = engine; 24 | this.name = name; 25 | } 26 | 27 | public destroy(): void { 28 | if (this.destroyed) { 29 | return; 30 | } 31 | 32 | this.ended = true; 33 | this.loaded = false; 34 | this.movieElement.pause(); 35 | this.emit('done'); 36 | 37 | super.destroy(); 38 | } 39 | 40 | public toString(): string { 41 | const time = `${this.movieElement.currentTime.toFixed(1)}/${this.movieElement.duration.toFixed(1)}`; 42 | const dimension = `${this.movieElement.videoWidth}x${this.movieElement.videoHeight}`; 43 | return `Movie | ${dimension}@${time}`; 44 | } 45 | 46 | public async init(): Promise { 47 | this.movieElement.addEventListener('loadedmetadata', (): void => { 48 | this.engine.sound.createMediaGainNode(this.movieElement); 49 | }); 50 | 51 | this.movieElement.addEventListener('loadeddata', (): void => { 52 | this.loaded = true; 53 | this.play(); 54 | }); 55 | 56 | this.movieElement.addEventListener('ended', (): void => { 57 | this.ended = true; 58 | this.destroy(); 59 | }); 60 | 61 | this.movieElement.addEventListener('error', (ev): void => { 62 | console.error(ev); 63 | this.failed = true; 64 | }); 65 | 66 | this.movieElement.src = `MOVIES.MIX/${this.name.toLowerCase()}.webm`; 67 | } 68 | 69 | public onUpdate(deltaTime: number): void { 70 | if (this.engine.keyboard.wasClicked(['Enter', 'Escape'])) { 71 | this.destroy(); 72 | } 73 | } 74 | 75 | public onRender(deltaTime: number): void { 76 | const context = this.engine.getContext(); 77 | const dimension = this.engine.getScaledDimension(); 78 | 79 | if (this.loaded) { 80 | const { sx, sy, sw, sh, dx, dy, dw, dh } = getScaledDimensions( 81 | new Vector( 82 | this.movieElement.videoWidth, 83 | this.movieElement.videoHeight 84 | ), 85 | dimension 86 | ); 87 | 88 | context.drawImage(this.movieElement, sx, sy, sw, sh, dx, dy, dw, dh); 89 | } 90 | 91 | if (this.ended || this.failed) { 92 | const str = this.ended ? 'Loading...' : 'Press Enter to continue'; 93 | context.font = 'monospace 20px'; 94 | context.fillStyle = '#ff0000'; 95 | context.textAlign = 'center'; 96 | context.textBaseline = 'middle'; 97 | context.fillText(str, dimension.x / 2, dimension.y / 2); 98 | } 99 | } 100 | 101 | public onPause(state: boolean): void { 102 | if (state) { 103 | this.movieElement.pause(); 104 | } else if (!this.ended) { 105 | this.play(); 106 | } 107 | } 108 | 109 | private play() { 110 | this.movieElement.play() 111 | .catch(ev => { 112 | console.error(ev); 113 | this.failed = true; 114 | }); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /data/GAME.DAT/aircraft.ini: -------------------------------------------------------------------------------- 1 | [Aircraft] 2 | 0=TRAN 3 | 1=A10 4 | 2=HELI 5 | 3=C17 6 | 4=ORCA 7 | 8 | [A10] 9 | Unknown1=13 10 | TurningSpeed=5 11 | Speed=40 12 | Armor=2 13 | SecondaryWeapon=None 14 | PrimaryWeapon=Napalm 15 | Owner=GoodGuy,BadGuy,Special,Multi1,Multi2,Multi3,Multi4,Multi5,Multi6 16 | Unknown8=1 17 | Unknown9=10 18 | BuildLevel=0 19 | Cost=800 20 | Sight=0 21 | HitPoints=60 22 | Ammo=3 23 | Unknown15=1 24 | Buildable=No 25 | Unknown17=0 26 | Unknown18=0 27 | Invulnerable=No 28 | Unknown20=0 29 | ValidTarget=Yes 30 | Selectable=No 31 | Unknown23=1 32 | Unknown24=0 33 | Unknown25=0 34 | Unknown26=0 35 | HasRotorBlades=No 36 | Airplane=Yes 37 | IsTransport=No 38 | Unknown30=0 39 | Unknown31=0 40 | Prerequisites=None 41 | NameID=96 42 | TechLevel=99 43 | 44 | [TRAN] 45 | Unknown1=13 46 | TurningSpeed=5 47 | Speed=30 48 | Armor=2 49 | SecondaryWeapon=None 50 | PrimaryWeapon=None 51 | Owner=GoodGuy,BadGuy,Special,Multi1,Multi2,Multi3,Multi4,Multi5,Multi6 52 | Unknown8=80 53 | Unknown9=10 54 | BuildLevel=98 55 | Cost=1500 56 | Sight=0 57 | HitPoints=90 58 | Ammo=0 59 | Unknown15=1 60 | Buildable=Yes 61 | Unknown17=0 62 | Unknown18=0 63 | Invulnerable=No 64 | Unknown20=0 65 | ValidTarget=Yes 66 | Selectable=Yes 67 | Unknown23=1 68 | Unknown24=0 69 | Unknown25=1 70 | Unknown26=1 71 | HasRotorBlades=Yes 72 | Airplane=No 73 | IsTransport=Yes 74 | Unknown30=0 75 | Unknown31=0 76 | Prerequisites=HPAD 77 | NameID=95 78 | TechLevel=6 79 | 80 | [HELI] 81 | Unknown1=13 82 | TurningSpeed=4 83 | Speed=40 84 | Armor=3 85 | SecondaryWeapon=None 86 | PrimaryWeapon=Chaingun 87 | Owner=BadGuy,Special,Multi1,Multi2,Multi3,Multi4,Multi5,Multi6 88 | Unknown8=80 89 | Unknown9=10 90 | BuildLevel=10 91 | Cost=1200 92 | Sight=0 93 | HitPoints=125 94 | Ammo=15 95 | Unknown15=1 96 | Buildable=Yes 97 | Unknown17=0 98 | Unknown18=0 99 | Invulnerable=No 100 | Unknown20=0 101 | ValidTarget=Yes 102 | Selectable=Yes 103 | Unknown23=1 104 | Unknown24=0 105 | Unknown25=0 106 | Unknown26=0 107 | HasRotorBlades=Yes 108 | Airplane=No 109 | IsTransport=No 110 | Unknown30=1 111 | Unknown31=1 112 | Prerequisites=HPAD 113 | NameID=108 114 | TechLevel=6 115 | 116 | [ORCA] 117 | Unknown1=13 118 | TurningSpeed=4 119 | Speed=40 120 | Armor=3 121 | SecondaryWeapon=None 122 | PrimaryWeapon=Rocket 123 | Owner=GoodGuy,Special,Multi1,Multi2,Multi3,Multi4,Multi5,Multi6 124 | Unknown8=80 125 | Unknown9=10 126 | BuildLevel=10 127 | Cost=1200 128 | Sight=0 129 | HitPoints=125 130 | Ammo=6 131 | Unknown15=1 132 | Buildable=Yes 133 | Unknown17=0 134 | Unknown18=0 135 | Invulnerable=No 136 | Unknown20=0 137 | ValidTarget=Yes 138 | Selectable=Yes 139 | Unknown23=1 140 | Unknown24=0 141 | Unknown25=0 142 | Unknown26=0 143 | HasRotorBlades=No 144 | Airplane=No 145 | IsTransport=No 146 | Unknown30=1 147 | Unknown31=1 148 | Prerequisites=HPAD 149 | NameID=109 150 | TechLevel=6 151 | 152 | [C17] 153 | Unknown1=13 154 | TurningSpeed=5 155 | Speed=40 156 | Armor=2 157 | SecondaryWeapon=None 158 | PrimaryWeapon=None 159 | Owner=GoodGuy,BadGuy,Special,Multi1,Multi2,Multi3,Multi4,Multi5,Multi6 160 | Unknown8=1 161 | Unknown9=10 162 | BuildLevel=0 163 | Cost=800 164 | Sight=0 165 | HitPoints=25 166 | Ammo=0 167 | Unknown15=1 168 | Buildable=No 169 | Unknown17=0 170 | Unknown18=0 171 | Invulnerable=No 172 | Unknown20=0 173 | ValidTarget=No 174 | Selectable=No 175 | Unknown23=1 176 | Unknown24=0 177 | Unknown25=0 178 | Unknown26=0 179 | HasRotorBlades=No 180 | Airplane=Yes 181 | IsTransport=Yes 182 | Unknown30=0 183 | Unknown31=0 184 | Prerequisites=None 185 | NameID=97 186 | TechLevel=99 187 | -------------------------------------------------------------------------------- /src/game/player.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | 7 | import { defaultTeamMap, MIXMapPlayer, MIXPlayerName, MIXTeamName } from './mix'; 8 | import { GameEntity } from './entity'; 9 | import EventEmitter from 'eventemitter3'; 10 | 11 | export class Player extends EventEmitter { 12 | protected id: number = 0; 13 | protected credits: number = 0; 14 | protected power: [number, number] = [0, 0]; // Avail / Used 15 | protected name: MIXPlayerName = 'GoodGuy'; 16 | protected team: MIXTeamName = 'gdi'; 17 | protected structures: Set = new Set(); 18 | protected sessionPlayer: boolean = false; 19 | 20 | public constructor(id: number, name: MIXPlayerName, team?: MIXTeamName) { 21 | super(); 22 | 23 | this.id = id; 24 | this.name = name; 25 | 26 | if (team) { 27 | this.team = team; 28 | } else if (defaultTeamMap[id]) { 29 | this.team = defaultTeamMap[id]; 30 | } 31 | } 32 | 33 | public toString(): string { 34 | return `${this.id} ${this.name}/${this.team} C:${this.credits} P:${this.power.join('/')}`; 35 | } 36 | 37 | public update(entities: GameEntity[]): void { 38 | this.structures.clear(); 39 | 40 | for (let i = 0; i < entities.length; i++) { 41 | if (entities[i].isStructure()) { 42 | let name = entities[i].getName(); 43 | this.structures.add(name); 44 | } 45 | } 46 | 47 | this.emit('entities-updated'); 48 | } 49 | 50 | public load(data: MIXMapPlayer): void { 51 | this.credits = data.Credits * 100; 52 | } 53 | 54 | public addCredits(credits: number): void { 55 | this.credits += credits; 56 | } 57 | 58 | public subScredits(credits: number): void { 59 | this.credits -= credits; 60 | } 61 | 62 | public enoughCredits(what: number): boolean { 63 | return this.credits >= what; 64 | } 65 | 66 | public setCredits(credits: number): void { 67 | this.credits = credits; 68 | } 69 | 70 | public setPower(power: [number, number]) { 71 | this.power = power; 72 | } 73 | 74 | public setSessionPlayer(sess: boolean) { 75 | this.sessionPlayer = sess; 76 | } 77 | 78 | public getId(): number { 79 | return this.id; 80 | } 81 | 82 | public getName(): MIXPlayerName { 83 | return this.name; 84 | } 85 | 86 | public getTeam(): MIXTeamName { 87 | return this.team; 88 | } 89 | 90 | public getCredits(): number { 91 | return this.credits; 92 | } 93 | 94 | public getStructures(): string[] { 95 | return Array.from(this.structures.values()); 96 | } 97 | 98 | public isSessionPlayer(): boolean { 99 | return this.sessionPlayer; 100 | } 101 | 102 | public hasPrequisite(names: string[]): boolean { 103 | if (!this.structures.size) { 104 | return false; 105 | } 106 | 107 | const snames = this.getStructures(); 108 | return names 109 | .every((n: string) => snames.indexOf(n) !== -1); 110 | } 111 | 112 | public hasMinimap(): boolean { 113 | return ['HQ', 'EYE'].some(n => this.getStructures().indexOf(n) !== -1); 114 | } 115 | 116 | public canConstruct(): boolean { 117 | return this.canConstructStructure() || 118 | this.canConstructUnit() || 119 | this.canConstructInfantry(); 120 | } 121 | 122 | public canConstructStructure(): boolean { 123 | return this.getStructures().indexOf('FACT') !== -1; 124 | } 125 | 126 | public canConstructUnit(): boolean { 127 | return ['HAND', 'WEAP'].some(n => this.getStructures().indexOf(n) !== -1); 128 | } 129 | 130 | public canConstructInfantry(): boolean { 131 | return ['HAND', 'PYLE'].some(n => this.getStructures().indexOf(n) !== -1); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/engine/utils.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | 7 | import EventEmitter from 'eventemitter3'; 8 | 9 | /** 10 | * Throttles a function 11 | */ 12 | export const throttle = (fn: Function, time: number = 100): any => { 13 | let timeout: number; 14 | return (): void => { 15 | clearTimeout(timeout); 16 | timeout = setTimeout(fn, time); 17 | }; 18 | }; 19 | 20 | /** 21 | * Check if integer number 22 | */ 23 | export const isInt = (i: number): boolean => i % 1 === 0; 24 | 25 | /** 26 | * Check if float number 27 | */ 28 | export const isFloat = (i: number): boolean => !isInt(i); 29 | 30 | /** 31 | * Check if negative number 32 | */ 33 | export const isNegative = (i: number): boolean => Object.is(-0, i) || i < 0; 34 | 35 | /** 36 | * Capitalizes word 37 | */ 38 | export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); 39 | 40 | /** 41 | * Request browser to save a file 42 | */ 43 | export const requestSaveFile = (blob: Blob, filename: string) => { 44 | if (window.navigator && window.navigator.msSaveOrOpenBlob) { 45 | window.navigator.msSaveOrOpenBlob(blob, filename); 46 | return; 47 | } 48 | 49 | const file = URL.createObjectURL(blob); 50 | const a = document.createElement('a'); 51 | a.setAttribute('style', 'display: none'); 52 | a.setAttribute('href', file); 53 | a.setAttribute('download', filename); 54 | 55 | document.body.appendChild(a); 56 | a.click(); 57 | 58 | window.URL.revokeObjectURL(file); 59 | document.body.removeChild(a); 60 | }; 61 | 62 | /** 63 | * Request browser to open file 64 | */ 65 | export const requestLoadFile = (): Promise => new Promise((resolve, reject) => { 66 | const input = document.createElement('input'); 67 | input.type = 'file'; 68 | 69 | input.addEventListener('change', (ev: Event) => { 70 | if (input.files!.length > 0) { 71 | const reader = new FileReader(); 72 | reader.onload = (evt) => { 73 | if (reader.error) { 74 | reject(reader.error); 75 | } else if (reader.readyState === 2) { 76 | resolve(reader.result as string); 77 | } 78 | }; 79 | reader.onerror = evt => { 80 | reject(evt); 81 | }; 82 | 83 | reader.readAsText(input.files![0]); 84 | } 85 | }); 86 | 87 | input.addEventListener('error', (ev) => { 88 | reject(ev); 89 | }); 90 | 91 | input.click(); 92 | }); 93 | 94 | /** 95 | * Fetch arraybuffer via XHR API 96 | */ 97 | export const fetchArrayBufferXHR = (url: string, bus?: EventEmitter): Promise => 98 | new Promise((resolve, reject): void => { 99 | const xhr = new XMLHttpRequest(); 100 | xhr.onload = (): void => resolve(xhr.response); 101 | xhr.onerror = reject; 102 | xhr.onabort = reject; 103 | 104 | if (bus) { 105 | xhr.onprogress = (ev: any): void => { 106 | bus.emit('progress', ev.loaded, ev.total); 107 | }; 108 | } 109 | 110 | xhr.responseType = 'arraybuffer'; 111 | xhr.open('GET', url, true); 112 | xhr.send(); 113 | }); 114 | 115 | /** 116 | * Fetch arraybuffer via Fetch API 117 | */ 118 | export const fetchArrayBuffer = (url: string): Promise => fetch(url) 119 | .then((response: Response): Promise => response.arrayBuffer()); 120 | 121 | /** 122 | * Fetches an image via HTTP 123 | */ 124 | export const fetchImage = (url: string): Promise => 125 | new Promise((resolve, reject): void => { 126 | const image = new Image(); 127 | image.onload = (): void => resolve(image); 128 | image.onerror = (error: any): void => reject(error); 129 | image.src = url; 130 | }); 131 | 132 | -------------------------------------------------------------------------------- /src/game/scenes/map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Animation, Scene, Entity, Sprite } from '../../engine'; 7 | import { GameEngine } from '../game'; 8 | import { Player } from '../player'; 9 | import { spriteFromName } from '../sprites'; 10 | import { getScaledDimensions } from '../physics'; 11 | import { MapSelectionUI } from '../ui/map'; 12 | import { Vector } from 'vector2d'; 13 | 14 | class MapScene extends Entity { 15 | public readonly engine: GameEngine; 16 | private readonly animation: Animation; 17 | 18 | protected readonly sprites: Map = new Map([ 19 | ['background', spriteFromName('GENERAL.MIX/greyerth.png')] 20 | ]); 21 | 22 | public constructor(engine: GameEngine) { 23 | super(); 24 | this.engine = engine; 25 | 26 | const bkg = this.sprites.get('background') as Sprite; 27 | this.animation = new Animation('spinning', new Vector(0, 0), bkg.frames, 0.1); 28 | } 29 | 30 | public async init(): Promise { 31 | for (const sprite of this.sprites.values()) { 32 | await this.engine.loadArchiveSprite(sprite); 33 | } 34 | 35 | const bkg = this.sprites.get('background') as Sprite; 36 | this.setDimension(bkg.size); 37 | } 38 | 39 | public onUpdate(deltaTime: number): void { 40 | this.animation.onUpdate(); 41 | } 42 | 43 | public onRender(deltaTime: number): void { 44 | const background = this.sprites.get('background') as Sprite; 45 | const frame = this.animation.getFrameIndex(); 46 | background.render(frame, new Vector(0, 0), this.context); 47 | } 48 | } 49 | 50 | /** 51 | * Map Selection Scene 52 | */ 53 | export class MapSelectionScene extends Scene { 54 | public engine: GameEngine; 55 | private player: Player; 56 | protected readonly ui: MapSelectionUI; 57 | protected readonly screen: MapScene; 58 | 59 | public constructor(player: Player, engine: GameEngine) { 60 | super(engine); 61 | this.engine = engine; 62 | this.player = player; 63 | this.screen = new MapScene(engine); 64 | this.ui = new MapSelectionUI(player.getTeam(), this); 65 | } 66 | 67 | public toString(): string { 68 | return `Map Select`; 69 | } 70 | 71 | public async init(): Promise { 72 | this.ui.setDimension(new Vector(540, 400)); 73 | 74 | await this.screen.init(); 75 | await this.ui.init(); 76 | 77 | const playlist = this.engine.sound.getPlaylist(); 78 | playlist.setList([{ 79 | source: 'TRANSIT.MIX/loopie6m.wav', 80 | title: 'loopie' 81 | }]); 82 | 83 | playlist.play(); 84 | } 85 | 86 | public handleMapSelection(name: string): void { 87 | this.engine.onMapSelect(name, this.player); 88 | } 89 | 90 | public onResize(): void { 91 | super.onResize(); 92 | this.ui.onResize(); 93 | } 94 | 95 | public onUpdate(deltaTime: number): void { 96 | const dimension = this.engine.getScaledDimension(); 97 | const { dx, dy, bR } = getScaledDimensions( 98 | this.ui.getDimension(), 99 | dimension 100 | ); 101 | 102 | this.ui.setScale({ 103 | offset: new Vector(dx, dy), 104 | scale: bR 105 | }); 106 | 107 | this.screen.onUpdate(deltaTime); 108 | this.ui.onUpdate(deltaTime); 109 | } 110 | 111 | public onRender(deltaTime: number): void { 112 | const context = this.engine.getContext(); 113 | 114 | let { sx, sy, sw, sh, dx, dy, dw, dh } = getScaledDimensions( 115 | this.screen.getDimension(), 116 | this.engine.getScaledDimension() 117 | ); 118 | 119 | this.screen.onRender(deltaTime); 120 | context.drawImage(this.screen.getCanvas(), sx, sy, sw, sh, dx, dy, dw, dh); 121 | 122 | this.ui.onRender(deltaTime, context); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /data/GAME.DAT/colors.ini: -------------------------------------------------------------------------------- 1 | [ColorPalettes] 2 | 0=YellowPalette 3 | 1=RedPalette 4 | 2=TealPalette 5 | 3=OrangePalette 6 | 4=GreenPalette 7 | 5=GrayPalette 8 | 6=NeutralPalette 9 | 7=DarkGrayPalette 10 | 8=BrownPalette 11 | 9=FirePalette 12 | 10=WarmSilverPalette 13 | 14 | ; These values tell the game which color indexes to replace the C0-CF range 15 | ; (decimal: 176-191) with, for a specific remap palette. 16 | ; Note that these are not sorted from bright to dark as you would expect, 17 | ; but represent two independent ranges of colors going from bright to dark. 18 | ; To correctly sort a set of colors to the order C&C needs, order the 16 colors 19 | ; from bright to dark, then reorder them to this order: 20 | ; 01 02 04 06 08 12 15 16 03 05 07 09 10 11 13 14 21 | 22 | [YellowPalette] 23 | RemapIndexes=176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191 24 | 25 | [RedPalette] 26 | RemapIndexes=127,126,125,124,122,46,120,47,125,124,123,122,42,121,120,120 27 | 28 | [TealPalette] 29 | RemapIndexes=2,119,118,135,136,138,112,12,118,135,136,137,138,139,114,112 30 | 31 | [OrangePalette] 32 | RemapIndexes=24,25,26,27,29,31,46,47,26,27,28,29,30,31,43,47 33 | 34 | [GreenPalette] 35 | RemapIndexes=5,165,166,167,159,142,140,199,166,167,157,3,159,143,142,141 36 | 37 | [GrayPalette] 38 | RemapIndexes=161,200,201,202,204,205,206,12,201,202,203,204,205,115,198,114 39 | 40 | [NeutralPalette] 41 | RemapIndexes=176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191 42 | 43 | [DarkGrayPalette] 44 | RemapIndexes=14,195,196,13,169,198,199,112,14,195,196,13,169,198,199,112 45 | 46 | [BrownPalette] 47 | RemapIndexes=146,152,209,151,173,150,173,183,146,152,209,151,173,150,173,183 48 | 49 | [FirePalette] 50 | RemapIndexes=5,149,25,27,29,175,47,12,24,26,28,30,31,31,44,46 51 | 52 | [WarmSilverPalette] 53 | RemapIndexes=192,164,132,155,133,197,112,12,163,132,155,133,134,197,154,198 54 | 55 | 56 | [ColorSchemes] 57 | 0=GDI 58 | 1=Nod 59 | 2=Red 60 | 3=Teal 61 | 4=Orange 62 | 5=Green 63 | 6=Gray 64 | 7=Yellow 65 | 8=Neutral 66 | 9=Jurassic 67 | 10=DarkGray 68 | 11=Brown 69 | 12=Fire 70 | 13=WarmSilver 71 | 72 | [GDI] 73 | ColorPalette=YellowPalette 74 | UnitRadarColor=176 75 | BuildingRadarColor=180 76 | SecondaryScheme=None 77 | 78 | [Nod] 79 | ColorPalette=GrayPalette 80 | UnitRadarColor=127 81 | BuildingRadarColor=123 82 | SecondaryScheme=Red 83 | 84 | [Red] 85 | ColorPalette=RedPalette 86 | UnitRadarColor=127 87 | BuildingRadarColor=123 88 | SecondaryScheme=None 89 | 90 | [Teal] 91 | ColorPalette=TealPalette 92 | UnitRadarColor=2 93 | BuildingRadarColor=135 94 | SecondaryScheme=None 95 | 96 | [Orange] 97 | ColorPalette=OrangePalette 98 | UnitRadarColor=24 99 | BuildingRadarColor=26 100 | SecondaryScheme=None 101 | 102 | [Green] 103 | ColorPalette=GreenPalette 104 | UnitRadarColor=167 105 | BuildingRadarColor=159 106 | SecondaryScheme=None 107 | 108 | [Gray] 109 | ColorPalette=GrayPalette 110 | UnitRadarColor=201 111 | BuildingRadarColor=203 112 | SecondaryScheme=None 113 | 114 | [Yellow] 115 | ColorPalette=YellowPalette 116 | UnitRadarColor=5 117 | BuildingRadarColor=157 118 | SecondaryScheme=None 119 | 120 | [Neutral] 121 | ColorPalette=NeutralPalette 122 | UnitRadarColor=202 123 | BuildingRadarColor=205 124 | SecondaryScheme=None 125 | 126 | [Jurassic] 127 | ColorPalette=NeutralPalette 128 | UnitRadarColor=127 129 | BuildingRadarColor=123 130 | SecondaryScheme=None 131 | 132 | [DarkGray] 133 | ColorPalette=DarkGrayPalette 134 | UnitRadarColor=194 135 | BuildingRadarColor=197 136 | SecondaryScheme=None 137 | 138 | [Brown] 139 | ColorPalette=BrownPalette 140 | UnitRadarColor=146 141 | BuildingRadarColor=209 142 | SecondaryScheme=None 143 | 144 | [Fire] 145 | ColorPalette=FirePalette 146 | UnitRadarColor=148 147 | BuildingRadarColor=27 148 | SecondaryScheme=None 149 | 150 | [WarmSilver] 151 | ColorPalette=WarmSilverPalette 152 | UnitRadarColor=163 153 | BuildingRadarColor=132 154 | SecondaryScheme=None 155 | -------------------------------------------------------------------------------- /src/game/scenes/score.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Scene, Sprite, Entity, Animation } from '../../engine'; 7 | import { GameEngine } from '../game'; 8 | import { spriteFromName } from '../sprites'; 9 | import { Vector } from 'vector2d'; 10 | import { getScaledDimensions } from '../physics'; 11 | 12 | class ScoreScreen extends Entity { 13 | public readonly engine: GameEngine; 14 | protected backgroundAnimation: Animation; 15 | protected timeAnimation: Animation; 16 | 17 | protected readonly sprites: Map = new Map([ 18 | ['background-gdi', spriteFromName('GENERAL.MIX/s-gdiin2.png')], 19 | ['background-nod', spriteFromName('GENERAL.MIX/scrscn1.png')], 20 | ['time', spriteFromName('CONQUER.MIX/time.png')], 21 | ['bar-red', spriteFromName('CONQUER.MIX/bar3red.png')], 22 | ['bar-yellow', spriteFromName('CONQUER.MIX/bar3ylw.png')], 23 | ['coins', spriteFromName('CONQUER.MIX/creds.png')], 24 | ['corner', spriteFromName('CONQUER.MIX/hiscore1.png')], 25 | ['top', spriteFromName('CONQUER.MIX/hiscore2.png')], 26 | ['logos', spriteFromName('CONQUER.MIX/logos.png')], 27 | //['multiplayer', spriteFromName('CONQUER.MIX/mltiplyr.png')], 28 | ]); 29 | 30 | public constructor(engine: GameEngine) { 31 | super(); 32 | this.engine = engine; 33 | 34 | const bkg = this.sprites.get('background-gdi') as Sprite; 35 | this.backgroundAnimation = new Animation('background', new Vector(0, 0), bkg.frames, 1.0, false); 36 | 37 | const time = this.sprites.get('time') as Sprite; 38 | this.timeAnimation = new Animation('time', new Vector(0, 0), time.frames, 1.0); 39 | this.setDimension(bkg.size); 40 | } 41 | 42 | public async init(): Promise { 43 | for (const sprite of this.sprites.values()) { 44 | await this.engine.loadArchiveSprite(sprite); 45 | } 46 | } 47 | 48 | public onUpdate(deltaTime: number): void { 49 | this.backgroundAnimation.onUpdate(); 50 | this.timeAnimation.onUpdate(); 51 | } 52 | 53 | public onRender(deltaTime: number): void { 54 | const ctx = this.context; 55 | const background = this.sprites.get('background-gdi') as Sprite; 56 | const time = this.sprites.get('time') as Sprite; 57 | const x = background.size.x - time.size.x; 58 | 59 | ctx.clearRect(0, 0, this.dimension.x, this.dimension.y); 60 | 61 | background.render( 62 | this.backgroundAnimation.getFrameIndex(), 63 | new Vector(0, 0), 64 | ctx 65 | ); 66 | 67 | time.render( 68 | this.timeAnimation.getFrameIndex(), 69 | new Vector(x, 0), 70 | ctx 71 | ); 72 | } 73 | } 74 | 75 | /** 76 | * Score Scene 77 | */ 78 | export class ScoreScene extends Scene { 79 | public readonly engine: GameEngine; 80 | protected readonly screen: ScoreScreen; 81 | 82 | public constructor(engine: GameEngine) { 83 | super(engine); 84 | this.engine = engine; 85 | this.screen = new ScoreScreen(engine); 86 | } 87 | 88 | public toString(): string { 89 | return `Score`; 90 | } 91 | 92 | public async init(): Promise { 93 | await this.screen.init(); 94 | 95 | const playlist = this.engine.sound.getPlaylist(); 96 | playlist.setList([{ 97 | source: 'TRANSIT.MIX/win1.wav', 98 | title: 'win' 99 | }]); 100 | 101 | playlist.play(); 102 | } 103 | 104 | public onUpdate(deltaTime: number): void { 105 | this.screen.onUpdate(deltaTime); 106 | 107 | if (this.engine.keyboard.wasClicked('Enter')) { 108 | this.emit('done'); 109 | } 110 | } 111 | 112 | public onRender(deltaTime: number): void { 113 | this.screen.onRender(deltaTime); 114 | 115 | const canvas = this.screen.getCanvas(); 116 | const context = this.engine.getContext(); 117 | const dimension = this.engine.getScaledDimension(); 118 | const { sx, sy, sw, sh, dx, dy, dw, dh } = getScaledDimensions( 119 | this.screen.getDimension(), 120 | dimension 121 | ); 122 | 123 | context.drawImage(canvas, sx, sy, sw, sh, dx, dy, dw, dh); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/engine/playlist.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import EventEmitter from 'eventemitter3'; 7 | 8 | /** 9 | * Music track interface; 10 | */ 11 | export interface MusicTrack { 12 | source: string; 13 | title?: string; 14 | name?: string; 15 | length?: number; 16 | } 17 | 18 | /** 19 | * Music Playlist 20 | */ 21 | export class MusicPlaylist extends EventEmitter { 22 | private list: MusicTrack[] = []; 23 | private shuffle: boolean = false; 24 | private loop: boolean = true; 25 | private currentIndex: number = -1; 26 | private isPaused: boolean = false; 27 | 28 | public constructor(list: MusicTrack[] = []) { 29 | super(); 30 | this.setList(list); 31 | } 32 | 33 | /** 34 | * Convert to string (for debugging) 35 | */ 36 | public toString(): string { 37 | const status = this.isPaused ? 'paused' : 'playing'; 38 | const name = String(this.current ? this.current.name || this.current.source : undefined); 39 | const opts = [ 40 | this.shuffle ? 'shuffle' : '', 41 | this.loop ? 'loop' : '' 42 | ].filter((item: any): boolean => !!item).join(' '); 43 | return `${status} (${opts}) "${name}" (${this.currentIndex + 1}/${this.list.length})`; 44 | } 45 | 46 | /** 47 | * Gets current index 48 | */ 49 | public get index(): number { 50 | return this.currentIndex; 51 | } 52 | 53 | /** 54 | * Gets current size 55 | */ 56 | public get size(): number { 57 | return this.list.length; 58 | } 59 | 60 | /** 61 | * Gets current track 62 | */ 63 | public get current(): MusicTrack | undefined { 64 | return this.list[this.currentIndex]; 65 | } 66 | 67 | /** 68 | * Gets paused state 69 | */ 70 | public get paused(): boolean { 71 | return this.isPaused; 72 | } 73 | 74 | /** 75 | * Play current track 76 | */ 77 | public play(track?: string | number, skip?: boolean): void { 78 | console.debug('MusicPlaylist::play()'); 79 | 80 | this.isPaused = false; 81 | 82 | if (this.currentIndex !== -1 && !skip) { 83 | this.emit('pause', false); 84 | } else { 85 | const foundIndex = typeof track === 'number' 86 | ? (this.list[track] ? track : -1) 87 | : (track 88 | ? this.list.findIndex((item): boolean => item.name === track) 89 | : -1); 90 | 91 | if (foundIndex === -1) { 92 | this.next(); 93 | } else { 94 | this.setIndex(foundIndex); 95 | } 96 | } 97 | } 98 | 99 | /** 100 | * Set paused state 101 | */ 102 | public pause(state: boolean = true): void { 103 | console.debug('MusicPlaylist::pause()', state); 104 | 105 | this.isPaused = state; 106 | this.emit('pause', state); 107 | } 108 | 109 | /** 110 | * Play next track 111 | */ 112 | public next(): void { 113 | console.debug('MusicPlaylist::next()'); 114 | 115 | let newIndex = this.currentIndex + 1; 116 | if (newIndex > this.list.length - 1) { 117 | newIndex = this.loop ? 0 : -1; 118 | } 119 | 120 | this.setIndex(newIndex); 121 | } 122 | 123 | /** 124 | * Play previous track 125 | */ 126 | public prev(): void { 127 | console.debug('MusicPlaylist::prev()'); 128 | 129 | let newIndex = this.currentIndex - 1; 130 | if (newIndex < 0) { 131 | newIndex = this.loop ? this.list.length - 1 : -1; 132 | } 133 | 134 | this.setIndex(newIndex); 135 | } 136 | 137 | /** 138 | * Clears tracks 139 | */ 140 | public clear(): void { 141 | console.debug('MusicPlaylist::clear()'); 142 | 143 | this.currentIndex = -1; 144 | this.list = []; 145 | 146 | this.emit('stop'); 147 | } 148 | 149 | /** 150 | * Sets track index 151 | */ 152 | private setIndex(index: number): void { 153 | if (this.list[index]) { 154 | this.currentIndex = index; 155 | this.emit('play', this.currentIndex, this.list[index]); 156 | } else { 157 | this.currentIndex = -1; 158 | } 159 | } 160 | 161 | /** 162 | * Sets list 163 | */ 164 | public setList(list: MusicTrack[] = []): void { 165 | console.debug('MusicPlaylist::setList()', list); 166 | 167 | this.clear(); 168 | this.list = list; 169 | } 170 | 171 | /** 172 | * Gets list 173 | */ 174 | public getList(): MusicTrack[] { 175 | return this.list; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /data/GAME.DAT/stranims.ini: -------------------------------------------------------------------------------- 1 | ; These animations do NOT need to be linked to from any other ini, because 2 | ; the animation data itself determines which structure it is for. 3 | ; All animations added to this list will be processed by the animations adding system. 4 | [StructureAnimations] 5 | 1=OBLI_Charge 6 | 1=NUK2_Idle 7 | 2=AFLD_Idle 8 | 3=PYLE_Active 9 | 4=PYLE_Idle 10 | 5=FACT_Active 11 | 6=FACT_Idle 12 | 7=EYE_Idle 13 | 8=HPAD_Active 14 | 9=HPAD_Idle 15 | 10=HOSP_Idle 16 | 11=NUKE_Idle 17 | 12=V19_Idle 18 | 13=HQ_Idle 19 | 14=PROC_Docking 20 | 15=PROC_Unloading 21 | 16=PROC_Undocking 22 | 17=PROC_Idle 23 | 18=PROC_Waiting 24 | 19=FIX_Active 25 | 20=FIX_Idle 26 | 21=TMPL_Idle 27 | 22=V20_Idle 28 | 23=V21_Idle 29 | 24=V22_Idle 30 | 25=V23_Idle 31 | 26=WEAP_Active 32 | 27=WEAP_Idle 33 | 28=TMPL_Launch 34 | 35 | ; BuildingState: Each structure can store up to 5 states, from 1 to 5. 36 | ; Most just use 1 as Idle and 2 as Active, but the refinery uses 2 as Docking, 37 | ; 3 as Waiting For Harvester, 4 as Unloading and 5 as Undocking. 38 | ; Note: I'm not sure if the value identified as "Delay" is really a delay. 39 | [OBLI_Charge] 40 | Structure=OBLI 41 | BuildingState=2 42 | StartFrame=0 43 | Frames=4 44 | Delay=15 45 | 46 | [NUK2_Idle] 47 | Structure=NUK2 48 | BuildingState=1 49 | StartFrame=0 50 | Frames=4 51 | Delay=15 52 | 53 | [AFLD_Idle] 54 | Structure=AFLD 55 | BuildingState=1 56 | StartFrame=0 57 | Frames=16 58 | Delay=3 59 | 60 | [PYLE_Active] 61 | Structure=PYLE 62 | BuildingState=2 63 | StartFrame=0 64 | Frames=10 65 | Delay=3 66 | 67 | [PYLE_Idle] 68 | Structure=PYLE 69 | BuildingState=1 70 | StartFrame=0 71 | Frames=10 72 | Delay=3 73 | 74 | [FACT_Active] 75 | Structure=FACT 76 | BuildingState=2 77 | StartFrame=4 78 | Frames=20 79 | Delay=3 80 | 81 | [FACT_Idle] 82 | Structure=FACT 83 | BuildingState=1 84 | StartFrame=0 85 | Frames=4 86 | Delay=3 87 | 88 | [EYE_Idle] 89 | Structure=EYE 90 | BuildingState=1 91 | StartFrame=0 92 | Frames=16 93 | Delay=4 94 | 95 | [HPAD_Active] 96 | Structure=HPAD 97 | BuildingState=2 98 | StartFrame=0 99 | Frames=7 100 | Delay=4 101 | 102 | [HPAD_Idle] 103 | Structure=HPAD 104 | BuildingState=1 105 | StartFrame=0 106 | Frames=0 107 | Delay=0 108 | 109 | [HOSP_Idle] 110 | Structure=HOSP 111 | BuildingState=1 112 | StartFrame=0 113 | Frames=4 114 | Delay=3 115 | 116 | [NUKE_Idle] 117 | Structure=NUKE 118 | BuildingState=1 119 | StartFrame=0 120 | Frames=4 121 | Delay=15 122 | 123 | [V19_Idle] 124 | Structure=V19 125 | BuildingState=1 126 | StartFrame=0 127 | Frames=14 128 | Delay=4 129 | 130 | [HQ_Idle] 131 | Structure=HQ 132 | BuildingState=1 133 | StartFrame=0 134 | Frames=16 135 | Delay=4 136 | 137 | [PROC_Docking] 138 | Structure=PROC 139 | BuildingState=2 140 | StartFrame=12 141 | Frames=7 142 | Delay=4 143 | 144 | [PROC_Unloading] 145 | StructureID=PROC 146 | BuildingState=4 147 | StartFrame=19 148 | Frames=5 149 | Delay=4 150 | 151 | [PROC_Undocking] 152 | StructureID=PROC 153 | BuildingState=5 154 | StartFrame=24 155 | Frames=6 156 | Delay=4 157 | 158 | [PROC_Idle] 159 | Structure=PROC 160 | BuildingState=1 161 | StartFrame=0 162 | Frames=6 163 | Delay=4 164 | 165 | [PROC_Waiting] 166 | StructureID=PROC 167 | BuildingState=3 168 | StartFrame=6 169 | Frames=6 170 | Delay=4 171 | 172 | [FIX_Active] 173 | Structure=FIX 174 | BuildingState=2 175 | StartFrame=0 176 | Frames=7 177 | Delay=2 178 | 179 | [FIX_Idle] 180 | Structure=FIX 181 | BuildingState=1 182 | StartFrame=0 183 | Frames=1 184 | Delay=0 185 | 186 | [TMPL_Idle] 187 | Structure=TMPL 188 | BuildingState=1 189 | StartFrame=0 190 | Frames=1 191 | Delay=0 192 | 193 | [V20_Idle] 194 | Structure=V20 195 | BuildingState=1 196 | StartFrame=0 197 | Frames=3 198 | Delay=3 199 | 200 | [V21_Idle] 201 | Structure=V21 202 | BuildingState=1 203 | StartFrame=0 204 | Frames=3 205 | Delay=3 206 | 207 | [V22_Idle] 208 | Structure=V22 209 | BuildingState=1 210 | StartFrame=0 211 | Frames=3 212 | Delay=3 213 | 214 | [V23_Idle] 215 | Structure=V23 216 | BuildingState=1 217 | StartFrame=0 218 | Frames=3 219 | Delay=3 220 | 221 | [WEAP_Active] 222 | Structure=WEAP 223 | BuildingState=2 224 | StartFrame=0 225 | Frames=1 226 | Delay=0 227 | 228 | [WEAP_Idle] 229 | Structure=WEAP 230 | BuildingState=1 231 | StartFrame=0 232 | Frames=1 233 | Delay=0 234 | 235 | [TMPL_Launch] 236 | Structure=TMPL 237 | BuildingState=2 238 | StartFrame=0 239 | Frames=5 240 | Delay=1 241 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | This is an almost complete overview of the tasks done and to be done. 4 | 5 | ## Engine 6 | 7 | - [x] Base Engine 8 | - [x] Scene switching 9 | - [x] Fully async 10 | - [x] Buffered renderer 11 | - [x] Buffered Keyboard 12 | - [x] Buffered Mouse 13 | - [x] Pointer lock 14 | - [x] Touch support 15 | - [x] Contextual Audio 16 | - [x] Data Archive support 17 | - [x] Image loaders 18 | - [x] Sound loaders 19 | - [x] Sprites from fixed grid 20 | - [ ] Sprites from dynamic grid 21 | - [x] Animations 22 | - [x] General loaders 23 | - [x] Basic physics 24 | - [x] Debugging support 25 | - [x] User Interfaces 26 | - [x] Caching support 27 | - [ ] Add support for embedding in templates (non-fullscreen) 28 | 29 | ## Game 30 | 31 | - [x] Scenes 32 | - [x] Main menu 33 | - [x] Team selection 34 | - [x] Movies 35 | - [ ] Scores (in progress) 36 | - [ ] Level Selection (in progress) 37 | - [x] Theatre 38 | - [x] Data 39 | - [x] MIX Data interfaces 40 | - [x] Parse original data (ini) 41 | - [x] Render original maps (binary) 42 | - [x] Music playlist 43 | - [ ] Add custom coordinates for reinforcement locations 44 | - [x] User interface 45 | - [x] Selection 46 | - [x] Selection rectangle 47 | - [x] Elements 48 | - [x] Menus 49 | - [x] Construction 50 | - [x] Construction mask on map 51 | - [x] Button icon support 52 | - [x] Tooltips 53 | - [x] Render active unit selection 54 | - [x] Cursors 55 | - [x] Edge scrolling 56 | - [x] Rendering 57 | - [x] Map 58 | - [x] Entities 59 | - [x] Effects 60 | - [x] Projectiles 61 | - [x] Health bar 62 | - [x] Fog of war 63 | - [x] Smudge 64 | - [x] Rendering order 65 | - [x] Map 66 | - [ ] Win conditions (in progress) 67 | - [ ] Triggers 68 | - [ ] Reinforcements 69 | - [x] Clamp viewport position on movement 70 | - [x] Minimap 71 | - [x] Rendering passes 72 | - [x] Layers 73 | - [ ] Animate shorelines 74 | - [x] Structures, Units & Infantry 75 | - [x] Movement 76 | - [x] Rotation 77 | - [x] Sight 78 | - [x] Building 79 | - [x] Attacking 80 | - [ ] Patrol 81 | - [ ] Force shot target (CTRL key) 82 | - [ ] Special weapon availability (ION, NUKE, STRIKE) 83 | - [x] Death 84 | - [ ] Harvesting 85 | - [ ] Boarding and unboarding vehicles 86 | - [x] Damage indication 87 | - [x] Turrets 88 | - [x] Tower turrets 89 | - [x] Infantry random idle animations 90 | - [ ] Aircraft 91 | - [ ] Helicopters 92 | - [x] Boats 93 | - [x] Spawn units and infantry in the correct structures 94 | - [x] Weapons 95 | - [x] Warheads and Effects 96 | - [x] Tails and muzzle flashes 97 | - [x] Nukes 98 | - [x] ION Cannon 99 | - [ ] Airstrikes 100 | - [ ] Special effects (like for obelisk) 101 | - [ ] Area of effect 102 | - [ ] Inaccuracy 103 | - [x] Double shots 104 | - [ ] Arcing 105 | - [ ] Curving 106 | - [ ] Heatseeker fuel 107 | - [x] Mechanics 108 | - [x] Credits 109 | - [x] Sew together fences 110 | - [x] Player abstraction 111 | - [x] Tech tree 112 | - [ ] Apply correct timings to animations and actions 113 | - [x] Selling 114 | - [x] Repairing 115 | - [x] Capturing 116 | - [ ] C4-ing 117 | - [ ] Primary structure selection 118 | - [ ] Subcell infantry group damage division 119 | - [x] Minimap availability 120 | - [ ] Destruction of certain entities spawns infantry or civilians 121 | - [ ] Squashing of infantry when units drive over 122 | - [ ] Tiberium spread 123 | - [ ] Tiberium trees 124 | - [ ] Tiberium storage 125 | - [x] Sounds 126 | - [x] Unit reporting 127 | - [x] Effects 128 | - [x] Projectiles 129 | - [x] EVA 130 | - [x] Pathfinding 131 | - [ ] Group movement 132 | - [ ] Movement weights 133 | - [ ] Movement soft collisions 134 | - [ ] AI 135 | - [ ] Use triggers 136 | - [ ] Attack nearest 137 | - [ ] SAM launchers 138 | - [ ] Boats 139 | - [x] Savegames 140 | - [x] Basic support for load/save 141 | - [ ] Reset scene on load 142 | - [ ] Save FOW state 143 | - [ ] Save map position 144 | - [ ] Save player states 145 | - [ ] Improve save format 146 | 147 | ## Misc 148 | 149 | - [ ] Fix font matrixes 150 | 151 | ## Tools 152 | 153 | - [ ] Asset conversion script (in progress) 154 | 155 | ## Future 156 | 157 | - [ ] Multiplayer 158 | - [ ] Look into using 'createImageBitmap' 159 | - [ ] Look into using 'OffscreenCanvas' 160 | - [ ] Look into using 'WebWorker' for AI etc 161 | - [ ] Custom build for legacy or older browsers 162 | -------------------------------------------------------------------------------- /data/GAME.DAT/weapons.ini: -------------------------------------------------------------------------------- 1 | [Weapons] 2 | 0=Sniper 3 | 1=Chaingun 4 | 2=Pistol 5 | 3=Rifle 6 | 4=Rocket 7 | 5=InfantryFlamer 8 | 6=TankFlamer 9 | 7=ChemSpray 10 | 8=Grenade 11 | 9=LightCannon 12 | 10=MediumCannon 13 | 11=HeavyCannon 14 | 12=TurretCannon 15 | 13=MammothTusk 16 | 14=MLRSMissile 17 | 15=ArtilleryShell 18 | 16=Machinegun 19 | 17=BoatMissile 20 | 18=AGTMissile 21 | 19=Napalm 22 | 20=LASER 23 | 21=SAMMissile 24 | 22=HonestJohn 25 | 23=DinoChew 26 | 24=DinoGore 27 | 28 | ; Projectile: A bullet from the Projectiles list 29 | ; Damage: bare damage, before warhead correction 30 | ; ROF: Delay between shots 31 | ; Range: Range in cells. 32 | ; Report: The sounds to play, from the Sounds list 33 | ; MuzzleFlash: Animation to play, from the Animations list 34 | 35 | ; Commando's Sniper Rifle 36 | [Sniper] 37 | Projectile=InvisibleSniper 38 | Damage=125 39 | ROF=40 40 | Range=5.50 41 | Report=Ramgun2 42 | MuzzleFlash=None 43 | 44 | ; Guard Tower/Apache machinegun 45 | [Chaingun] 46 | Projectile=InvisibleHeavy 47 | Damage=25 48 | ROF=50 49 | Range=4.00 50 | Report=Gun8 51 | MuzzleFlash=MINIGUN-N 52 | 53 | ; Civilian pistol 54 | [Pistol] 55 | Projectile=Invisible 56 | Damage=1 57 | ROF=7 58 | Range=1.75 59 | Report=Gun18 60 | MuzzleFlash=None 61 | 62 | ; M-16 rifle 63 | [Rifle] 64 | Projectile=Invisible 65 | Damage=15 66 | ROF=20 67 | Range=2.00 68 | Report=Mgun2 69 | MuzzleFlash=None 70 | 71 | ; Bazooka/bike/orca rocket 72 | [Rocket] 73 | Projectile=HeatSeeker 74 | Damage=30 75 | ROF=60 76 | Range=4.00 77 | Report=Bazook1 78 | MuzzleFlash=None 79 | 80 | ; Infantry flamethrower 81 | [InfantryFlamer] 82 | Projectile=FlameBurst 83 | Damage=35 84 | ROF=50 85 | Range=2.00 86 | Report=Flamer2 87 | MuzzleFlash=FLAME-N 88 | 89 | ; Tank flamethrower 90 | [TankFlamer] 91 | Projectile=FlameBurst 92 | Damage=50 93 | ROF=50 94 | Range=2.00 95 | Report=Flamer2 96 | MuzzleFlash=FLAME-N 97 | 98 | ; chemwarrior/visceroid chemical spray 99 | [ChemSpray] 100 | Projectile=ChemBurst 101 | Damage=80 102 | ROF=70 103 | Range=2.00 104 | Report=Flamer2 105 | MuzzleFlash=CHEM-N 106 | 107 | ; Grenade 108 | [Grenade] 109 | Projectile=Bomb 110 | Damage=50 111 | ROF=60 112 | Range=3.25 113 | Report=Toss 114 | MuzzleFlash=None 115 | 116 | ; Light Tank gun 117 | [LightCannon] 118 | Projectile=Cannon 119 | Damage=25 120 | ROF=60 121 | Range=4.00 122 | Report=Tnkfire3 123 | MuzzleFlash=GUNFIRE 124 | 125 | ; Medium Tank gun 126 | [MediumCannon] 127 | Projectile=Cannon 128 | Damage=30 129 | ROF=50 130 | Range=4.75 131 | Report=Tnkfire4 132 | MuzzleFlash=GUNFIRE 133 | 134 | ; Mammoth tank gun 135 | [HeavyCannon] 136 | Projectile=Cannon 137 | Damage=40 138 | ROF=80 139 | Range=4.75 140 | Report=Tnkfire6 141 | MuzzleFlash=GUNFIRE 142 | 143 | ; Turret gun 144 | [TurretCannon] 145 | Projectile=Cannon 146 | Damage=40 147 | ROF=60 148 | Range=6.00 149 | Report=Tnkfire6 150 | MuzzleFlash=GUNFIRE 151 | 152 | ; Mammoth tank missiles 153 | [MammothTusk] 154 | Projectile=HeatSeeker2 155 | Damage=75 156 | ROF=80 157 | Range=5.00 158 | Report=Rocket1 159 | MuzzleFlash=None 160 | 161 | ; MLRS Missile 162 | [MLRSMissile] 163 | Projectile=HeatSeeker3 164 | Damage=75 165 | ROF=80 166 | Range=6.00 167 | Report=Rocket1 168 | MuzzleFlash=None 169 | 170 | ; Artillery weapon 171 | [ArtilleryShell] 172 | Projectile=Ballistic 173 | Damage=150 174 | ROF=65 175 | Range=6.00 176 | Report=Tnkfire2 177 | MuzzleFlash=GUNFIRE 178 | 179 | ; Humvee/buggy/APC gun 180 | [Machinegun] 181 | Projectile=Invisible 182 | Damage=15 183 | ROF=30 184 | Range=4.00 185 | Report=Mgun11 186 | MuzzleFlash=MINIGUN-N 187 | 188 | ; Gunboat missile 189 | [BoatMissile] 190 | Projectile=HeatSeeker2 191 | Damage=60 192 | ROF=35 193 | Range=7.50 194 | Report=Rocket2 195 | MuzzleFlash=None 196 | 197 | ; Advanced Guard Tower missile 198 | [AGTMissile] 199 | Projectile=HeatSeeker2 200 | Damage=60 201 | ROF=40 202 | Range=6.50 203 | Report=Rocket2 204 | MuzzleFlash=None 205 | 206 | ; A-10's Napalm drop 207 | [Napalm] 208 | Projectile=Bomblet 209 | Damage=100 210 | ROF=20 211 | Range=4.50 212 | Report=None 213 | MuzzleFlash=None 214 | 215 | ; Obelisk LASER 216 | [LASER] 217 | Projectile=LaserShot 218 | Damage=200 219 | ROF=90 220 | Range=7.50 221 | Report=Obelray1 222 | MuzzleFlash=None 223 | 224 | ; SAM Missile 225 | [SAMMissile] 226 | Projectile=BigHeatSeeker 227 | Damage=50 228 | ROF=50 229 | Range=7.50 230 | Report=Rocket2 231 | MuzzleFlash=None 232 | 233 | ; SSM Launcher's napalm missile 234 | [HonestJohn] 235 | Projectile=FlameMissile 236 | Damage=100 237 | ROF=200 238 | Range=10.00 239 | Report=Rocket1 240 | MuzzleFlash=None 241 | 242 | ; Herbivorous dinosaur weapon 243 | [DinoChew] 244 | Projectile=Chew 245 | Damage=100 246 | ROF=30 247 | Range=1.50 248 | Report=Dinoatk1 249 | MuzzleFlash=None 250 | 251 | ; Carnivorous dinosaur weapon 252 | [DinoGore] 253 | Projectile=Gore 254 | Damage=155 255 | ROF=30 256 | Range=1.50 257 | Report=Dinoatk1 258 | MuzzleFlash=None 259 | -------------------------------------------------------------------------------- /src/engine/sprite.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Vector } from 'vector2d'; 7 | 8 | /** 9 | * Sprite 10 | * TODO: Should really have been an Entity 11 | */ 12 | export class Sprite { 13 | public readonly canvas: HTMLCanvasElement = document.createElement('canvas'); 14 | public readonly context: CanvasRenderingContext2D = this.canvas.getContext('2d') as CanvasRenderingContext2D; 15 | public readonly source: string; 16 | public readonly clip: number[]; 17 | public readonly size: Vector; 18 | public readonly frames: number; 19 | private readonly frameCache: Map = new Map(); 20 | private static patterns: Map = new Map(); 21 | protected static cacheCount: number = 0; 22 | protected static readonly cache: Map = new Map(); 23 | protected loaded: boolean = false; 24 | 25 | public constructor(source: string, size: Vector, frames: number, clip: number[] = []) { 26 | this.source = source; 27 | this.size = size; 28 | this.frames = frames; 29 | this.clip = clip; 30 | } 31 | 32 | /** 33 | * Clears cache 34 | */ 35 | public static clearCache(): void { 36 | console.info('Sprite::clearCache()'); 37 | this.cache.clear(); 38 | this.patterns.clear(); 39 | this.cacheCount = 0; 40 | } 41 | 42 | /** 43 | * Factory sprite from cache 44 | */ 45 | public static createOrCache(name: string, cb: Function) { 46 | if (Sprite.cache.has(name)) { 47 | return Sprite.cache.get(name); 48 | } 49 | 50 | const instance = cb(name); 51 | Sprite.cache.set(name, instance); 52 | 53 | return instance; 54 | } 55 | 56 | /** 57 | * Convert to string (for debugging) 58 | */ 59 | public static toString(): string { 60 | return `${this.cache.size} images ${this.patterns.size} patterns ${this.cacheCount} frames`; 61 | } 62 | 63 | /** 64 | * Renders sprite 65 | */ 66 | public render(frame: Vector, position?: Vector, context?: CanvasRenderingContext2D): HTMLCanvasElement { 67 | const xoff = frame.x * this.size.x; 68 | const yoff = frame.y * this.size.y; 69 | let [sx, sy, sw, sh, dx, dy, dw, dh] = this.getRect(position); 70 | sx = xoff; 71 | sy = yoff; 72 | 73 | let cached = this.frameCache.get(frame.toString()); 74 | let canvas = cached ? cached.canvas : document.createElement('canvas'); 75 | 76 | if (!cached) { 77 | canvas.width = dw; 78 | canvas.height = dh; 79 | cached = canvas.getContext('2d') as CanvasRenderingContext2D; 80 | cached.drawImage(this.canvas, sx, sy, sw, sh, 0, 0, dw, dh); 81 | this.frameCache.set(frame.toString(), cached); 82 | Sprite.cacheCount++; 83 | } 84 | 85 | if (context) { 86 | context.drawImage(canvas, dx, dy); 87 | } 88 | 89 | return canvas; 90 | } 91 | 92 | /** 93 | * Creates a pattern from frame 94 | */ 95 | public createPattern(frame: Vector, repetition: string = 'repeat'): CanvasPattern | null { 96 | const name = [this.source, frame.x, frame.y, repetition].join('.'); 97 | if (Sprite.patterns.has(name)) { 98 | return Sprite.patterns.get(name) || null; 99 | } 100 | 101 | const xoff = frame.x * this.size.x; 102 | const yoff = frame.y * this.size.y; 103 | const sw = this.size.x; 104 | const sh = this.size.y; 105 | 106 | const tempCanvas = document.createElement('canvas'); 107 | tempCanvas.width = sw; 108 | tempCanvas.height = sh; 109 | 110 | const context = tempCanvas.getContext('2d') as CanvasRenderingContext2D; 111 | context.drawImage(this.canvas, xoff, yoff, sw, sh, 0, 0, sw, sh); 112 | 113 | const pattern = context.createPattern(tempCanvas, repetition); 114 | 115 | if (pattern) { 116 | Sprite.patterns.set(name, pattern); 117 | } 118 | 119 | return pattern; 120 | } 121 | 122 | /** 123 | * Initializes sprite image 124 | */ 125 | public init(image: HTMLImageElement): void { 126 | if (!this.loaded) { 127 | this.loaded = true; 128 | this.canvas.width = image.width; 129 | this.canvas.height = image.height; 130 | this.context.drawImage(image, 0, 0); 131 | } 132 | } 133 | 134 | /** 135 | * Gets the rectangle required for rendering on a target 136 | */ 137 | public getRect(position?: Vector): number[] { 138 | const off = this.getClipOffset(); 139 | const sx = 0; 140 | const sy = 0; 141 | const sw = this.size.x; 142 | const sh = this.size.y; 143 | const dx = (position ? position.x : 0) - off.x; 144 | const dy = (position ? position.y : 0) - off.y; 145 | const dw = sw; 146 | const dh = sh; 147 | 148 | return [sx, sy, sw, sh, dx, dy, dw, dh]; 149 | } 150 | 151 | /** 152 | * Gets the rectangle clip offset according to sprite data 153 | */ 154 | public getClipOffset(): Vector { 155 | if (!this.clip.length) { 156 | return new Vector(0, 0); 157 | } 158 | 159 | const [cx, cy, cw, ch] = this.clip; 160 | return new Vector( 161 | cx - (cw / 2), 162 | cy - (ch / 2) 163 | ); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/game/scenes/theatre.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Box, MusicTrack, Scene } from '../../engine'; 7 | import { GameEngine } from '../game'; 8 | import { GameMap } from '../map'; 9 | import { TheatreUI } from '../ui/theatre'; 10 | import { MIXMapData, MIXPlayerName, MIXTheme } from '../mix'; 11 | import { cellFromPoint } from '../physics'; 12 | import { Vector } from 'vector2d'; 13 | 14 | /** 15 | * Theatre Scene 16 | */ 17 | export class TheatreScene extends Scene { 18 | public readonly engine: GameEngine; 19 | public readonly map: GameMap; 20 | public readonly ui: TheatreUI; 21 | public readonly viewport: Box = { x1: 0, x2: 800, y1: 0, y2: 600 }; 22 | public readonly name: string; 23 | private loaded: boolean = false; 24 | 25 | public constructor(name: string, data:MIXMapData, player: MIXPlayerName, engine: GameEngine) { 26 | super(engine); 27 | 28 | this.engine = engine; 29 | this.name = name; 30 | this.map = new GameMap(this.name, data, player, this.engine as GameEngine); 31 | this.ui = new TheatreUI(this); 32 | } 33 | 34 | public toString(): string { 35 | const map = this.map.toString(); 36 | return `Theatre\nMap: ${map}`; 37 | } 38 | 39 | public async init(): Promise { 40 | const playlist = this.engine.sound.getPlaylist(); 41 | 42 | const themes = this.engine.mix.getPlaylistThemes(); 43 | const keys = Array.from(themes.keys()) as string[]; 44 | const list: MusicTrack[] = keys 45 | .map((key: string): MusicTrack => { 46 | const iter = themes.get(key) as MIXTheme; 47 | return { 48 | source: `SCORES.MIX/${key.toLowerCase()}.wav`, 49 | title: iter.Title, 50 | name: key.toLowerCase(), 51 | length: iter.Length 52 | }; 53 | }); 54 | 55 | playlist.setList(list); 56 | 57 | await this.map.init(); 58 | await this.ui.init(); 59 | 60 | this.loaded = true; 61 | 62 | playlist.play('aoi'); 63 | console.debug(this); 64 | } 65 | 66 | public onUIToggle(): void { 67 | this.onResize(); 68 | } 69 | 70 | public onResize(): void { 71 | this.ui.onResize(); 72 | 73 | const v = this.ui.getViewport(); 74 | const s = this.engine.getScale(); 75 | this.viewport.x1 = v.x1 / s; 76 | this.viewport.x2 = v.x2 * s; 77 | this.viewport.y1 = v.y1 / s; 78 | this.viewport.y2 = v.y2 * s; 79 | 80 | this.map.onResize(this.viewport); 81 | } 82 | 83 | public onUpdate(deltaTime: number): void { 84 | if (!this.loaded) { 85 | return; 86 | } 87 | 88 | const { keyboard, mouse } = this.engine; 89 | const skip = this.ui.isMenuOpen(); 90 | 91 | if (!skip) { 92 | if (keyboard.isPressed(['w', 'arrowup'])) { 93 | this.map.moveRelative(new Vector(0, 10)); 94 | } else if (keyboard.isPressed(['s', 'arrowdown'])) { 95 | this.map.moveRelative(new Vector(0, -10)); 96 | } 97 | 98 | if (keyboard.isPressed(['a', 'arrowleft'])) { 99 | this.map.moveRelative(new Vector(10, 0)); 100 | } else if (keyboard.isPressed(['d', 'arrowright'])) { 101 | this.map.moveRelative(new Vector(-10, 0)); 102 | } 103 | 104 | if (this.engine.configuration.debugMode) { 105 | if (keyboard.wasClicked('F10')) { 106 | this.map.toggleFow(); 107 | } 108 | 109 | if (keyboard.wasClicked('Delete')) { 110 | this.map.getSelectedEntities().forEach(e => e.takeDamage(Number.MAX_VALUE)); 111 | } else if (keyboard.wasClicked('End')) { 112 | const cell = cellFromPoint(this.map.getRealMousePosition(mouse.getVector())); 113 | this.map.getSelectedEntities().forEach(e => e.setCell(cell, true)); 114 | } else if (keyboard.wasClicked('PageDown')) { 115 | this.map.getSelectedEntities().forEach(e => e.setHealth(e.getHealth() - 4)); 116 | } else if (keyboard.wasClicked('PageUp')) { 117 | this.map.getSelectedEntities().forEach(e => e.setHealth(e.getHealth() + 4)); 118 | } 119 | } 120 | } 121 | 122 | this.ui.onUpdate(deltaTime); 123 | 124 | if (!skip) { 125 | this.map.onUpdate(deltaTime); 126 | this.updateSoundContext(); 127 | } 128 | } 129 | 130 | public onRender(deltaTime: number): void { 131 | if (this.loaded) { 132 | const context = this.engine.getContext(); 133 | this.map.onRender(deltaTime, context); 134 | this.ui.onRender(deltaTime, context); 135 | } 136 | } 137 | 138 | private updateSoundContext(): void { 139 | const viewport = this.getScaledViewport(); 140 | const position = this.map.getPosition(); 141 | const center = new Vector( 142 | ((viewport.x2 - viewport.x1) / 2) + position.x, 143 | ((viewport.y2 - viewport.y1) / 2) + position.y 144 | ); 145 | 146 | this.engine.sound.setContextPosition(center); 147 | } 148 | 149 | public getScaledViewport(): Box { 150 | const { x1, x2, y1, y2 } = this.viewport; 151 | const scale = this.engine.getScale(); 152 | 153 | return { 154 | x1: x1 * scale, 155 | x2: x2 / scale, 156 | y1: y1 * scale, 157 | y2: y2 / scale 158 | }; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "resolveJsonModule": true, 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | }, 65 | "include": [ 66 | "src/**/*.ts" 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /src/game/entities/infantry.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Animation, randomBetweenInteger } from '../../engine'; 7 | import { GameEntity } from '../entity'; 8 | import { GameMapEntity, GameMapEntityAnimation } from './mapentity'; 9 | import { MIXInfantry, MIXInfantryAnimation, infantryIdleAnimations } from '../mix'; 10 | import { getSubCellOffset } from '../physics'; 11 | import { Vector } from 'vector2d'; 12 | 13 | /** 14 | * Infantry Entity 15 | */ 16 | export class InfantryEntity extends GameMapEntity { 17 | public readonly properties: MIXInfantry = this.engine.mix.infantry.get(this.data.name) as MIXInfantry; 18 | protected dimension: Vector = new Vector(16, 16); 19 | protected directions: number = 8; 20 | protected animation: string = 'Ready'; 21 | protected idleTimer: number = 100; 22 | protected idleAnimation: string = 'Ready'; 23 | protected reportDestroy?: string = 'nuyell1'; // FIXME: Should be handled by projectile 24 | protected reportSelect?: string = 'AWAIT1'; 25 | protected reportMove?: string = 'ACKNO'; 26 | protected reportAttack?: string = 'ACKNO'; 27 | protected zIndex: number = 2; 28 | protected capturing?: GameEntity; 29 | 30 | public toJson(): any { 31 | return { 32 | ...super.toJson(), 33 | type: 'infantry' 34 | }; 35 | } 36 | 37 | public async init(): Promise { 38 | // TODO: Guy 39 | if (this.data.name === 'C10') { 40 | // FIXME: More sounds 41 | this.reportSelect = 'MCOMND1'; 42 | this.reportMove = 'MCOURSE1'; 43 | this.reportAttack = this.reportMove; 44 | } else if (this.properties.IsCivilian) { 45 | this.reportSelect = 'GUYYEAH1'; 46 | this.reportMove = 'GUYOKAY1'; 47 | this.reportAttack = this.reportMove; 48 | } if (this.properties.FemaleCiv) { 49 | this.reportSelect = 'GIRLYEAH'; 50 | this.reportMove = 'GIRLOKAY'; 51 | this.reportAttack = this.reportMove; 52 | } 53 | 54 | const subcell = this.data!.subcell!; 55 | if (subcell >= 0) { 56 | const offset = getSubCellOffset(subcell, this.getDimension()); 57 | this.position.add(offset); 58 | this.subCell = subcell; 59 | } 60 | 61 | const animations = this.engine.mix.infantryAnimations.get(`Sequence_${this.data.name}`) as MIXInfantryAnimation; 62 | Object.keys(animations) 63 | .forEach((name: string): void => { 64 | const [start, number, multiplier] = (animations as any)[name]; 65 | const anim = new GameMapEntityAnimation(name, new Vector(0, start), number, 0.1, multiplier); 66 | this.animations.set(name, anim); 67 | }); 68 | 69 | await super.init(); 70 | } 71 | 72 | public capture(target: GameEntity): void { 73 | if (this.canCapture()) { 74 | const targetCell = target.getCell(); 75 | this.moveTo(targetCell, true, true); 76 | this.capturing = target; 77 | } 78 | } 79 | 80 | public die(): boolean { 81 | if (super.die(false)) { 82 | // FIXME 83 | const animation = this.animations.get('Die1') as Animation; 84 | animation.once('done', () => this.destroy()); 85 | 86 | return true; 87 | } 88 | 89 | return false; 90 | } 91 | 92 | public move(position: Vector, report: boolean = false): boolean { 93 | if (super.move(position, report)) { 94 | this.targetSubCell = 0; // FIXME 95 | return true; 96 | } 97 | 98 | return false; 99 | } 100 | 101 | protected moveTo(position: Vector, report: boolean = false, force: boolean = false): boolean { 102 | this.capturing = undefined; 103 | return super.moveTo(position, report, force); 104 | } 105 | 106 | public onUpdate(deltaTime: number): void { 107 | super.onUpdate(deltaTime); 108 | 109 | let animation = this.currentAction === 'attack' 110 | ? 'FireUp' 111 | : this.dying ? 'Die1' : 112 | this.targetPosition ? 'Walk' : this.idleAnimation; 113 | 114 | if (this.animation !== animation) { 115 | const a = this.animations.get(animation) as Animation; 116 | a.reset(); 117 | 118 | if (animation === this.idleAnimation) { 119 | a.once('done', () => (this.idleAnimation = 'Ready')); 120 | } 121 | } 122 | 123 | if (animation === this.idleAnimation) { 124 | this.idleTimer--; 125 | 126 | if (this.idleTimer < 0) { 127 | this.idleAnimation = infantryIdleAnimations[randomBetweenInteger(0, infantryIdleAnimations.length - 1)]; 128 | this.idleTimer = randomBetweenInteger(200, 1000); 129 | } 130 | } 131 | 132 | if (this.capturing) { 133 | const distance = this.capturing.getCell() 134 | .distance(this.getCell()); 135 | 136 | if (distance <= 1) { 137 | if (this.player) { 138 | this.capturing.setPlayer(this.player); 139 | // FIXME: Need to call player update to get credits etc. 140 | } 141 | this.destroy(); 142 | } 143 | } 144 | 145 | this.animation = animation; 146 | } 147 | 148 | public onRender(deltaTime: number): void { 149 | const context = this.map.objects.getContext(); 150 | super.onRender(deltaTime); 151 | 152 | if (!this.sprite) { 153 | return; 154 | } 155 | 156 | const position = this.getTruncatedPosition(); 157 | const animation = this.animations.get(this.animation) as GameMapEntityAnimation; 158 | const frame = animation.getFrameIndex(this.frameOffset); 159 | const off = Math.round(this.direction) * animation.multiplier; 160 | 161 | frame.add(new Vector(0, off)); 162 | this.sprite!.render(frame, position, context); 163 | } 164 | 165 | public isSelectable(): boolean { 166 | return true; 167 | } 168 | 169 | public isMovable(): boolean { 170 | return true; 171 | } 172 | 173 | public isInfantry(): boolean { 174 | return true; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/game/ui/construction.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | 7 | import { MIXObject } from '../mix'; 8 | import { GameEngine } from '../game'; 9 | import { Player } from '../player'; 10 | import { EventEmitter } from 'eventemitter3'; 11 | 12 | export type ConstructionType = 'structure' | 'unit' | 'aircraft' | 'infantry'; // FIXME: Should be in MIX 13 | export type ConstructionState = 'constructing' | 'hold' | 'ready' | undefined; 14 | export type ConstructionResponse = 'construct' | 'hold' | 'cancel' | 'busy' | 'place' | 'finished' | 'tick'; 15 | 16 | export interface ConstructionObject { 17 | index: number; 18 | name: string; 19 | type: ConstructionType; 20 | state: ConstructionState; 21 | progress: number; 22 | cost: number; 23 | properties?: MIXObject; 24 | available: boolean; 25 | } 26 | 27 | export class ConstructionQueue extends EventEmitter { 28 | protected readonly engine: GameEngine; 29 | protected readonly player: Player; 30 | protected objects: ConstructionObject[] = []; 31 | protected techLevel: number = -1; 32 | protected buildLevel: number = -1; 33 | 34 | public constructor(names: string[], player: Player, engine: GameEngine) { 35 | super(); 36 | this.player = player; 37 | this.engine = engine; 38 | 39 | this.objects = names.map(name => name.toUpperCase()) 40 | .map((name, index) => { 41 | const properties = engine.mix.getProperties(name); 42 | return { 43 | index, 44 | name, 45 | type: engine.mix.getType(name) as ConstructionType, 46 | cost: properties ? properties.Cost : 1, // FIXME 47 | properties, 48 | available: !properties, // FIXME 49 | progress: 0, 50 | state: undefined 51 | }; 52 | }); 53 | 54 | this.player.on('entities-updated', () => this.updateAvailable()); 55 | } 56 | 57 | public onUpdate(deltaTime: number) { 58 | for (let i = 0; i < this.objects.length; i++) { 59 | const item = this.objects[i]; 60 | const done = item.progress >= item.cost; 61 | 62 | if (!item.available) { 63 | continue; 64 | } 65 | 66 | if (item.state === 'constructing') { 67 | if (done) { 68 | item.state = 'ready'; 69 | this.emit('ready', item); 70 | if (['unit', 'infantry'].indexOf(item.type) !== -1) { 71 | this.engine.playArchiveSfx('SPEECH.MIX/unitredy.wav', 'gui', {}, 'eva'); 72 | this.emit('spawn', item); 73 | } else { 74 | this.emit('ready', item); 75 | this.engine.playArchiveSfx('SPEECH.MIX/constru1.wav', 'gui', {}, 'eva'); 76 | } 77 | } else { 78 | // FIXME: Rule 79 | item.progress = Math.min(item.cost, item.progress + 1.0); 80 | this.emit('tick', item);; 81 | this.engine.playArchiveSfx('SOUNDS.MIX/clock1.wav', 'gui', { volume: 0.2, block: true }); 82 | this.player.subScredits(1.0); // FIXME 83 | } 84 | } 85 | } 86 | } 87 | 88 | public updateAvailable(): void { 89 | for (let i = 0; i < this.objects.length; i++) { 90 | let o = this.objects[i]; 91 | if (o.properties) { 92 | 93 | if (this.techLevel !== -1) { 94 | o.available = (o.properties.TechLevel || 0) <= this.techLevel; 95 | } 96 | 97 | if (this.buildLevel !== -1) { 98 | o.available = (o.properties.BuildLevel || 0) <= this.buildLevel; 99 | } 100 | 101 | if (o.available && o.properties.Prerequisites.length > 0) { 102 | o.available = this.player.hasPrequisite(o.properties.Prerequisites); 103 | } 104 | 105 | if (o.available && o.type === 'unit') { 106 | o.available = this.player.canConstructUnit(); 107 | } else if (o.available && o.type === 'infantry') { 108 | o.available = this.player.canConstructInfantry(); 109 | } else if (o.available && o.type === 'structure') { 110 | o.available = this.player.canConstructStructure(); 111 | } 112 | } 113 | } 114 | } 115 | 116 | public reset(item: ConstructionObject) { 117 | if (item.available) { 118 | item.state = undefined; 119 | item.progress = 0; 120 | } 121 | } 122 | 123 | public build(item: ConstructionObject) { 124 | if (item.available) { 125 | if (item.state !== 'constructing') { 126 | this.emit('construct', item); 127 | this.engine.playArchiveSfx('SPEECH.MIX/bldging1.wav', 'gui', {}, 'eva'); 128 | item.state = 'constructing'; 129 | } else { 130 | this.engine.playArchiveSfx('SPEECH.MIX/bldg1.wav', 'gui', {}, 'eva'); 131 | } 132 | } 133 | } 134 | 135 | public cancel(item: ConstructionObject) { 136 | if (item.available) { 137 | if (item.state !== undefined) { 138 | this.emit('cancel', item); 139 | this.engine.playArchiveSfx('SPEECH.MIX/cancel1.wav', 'gui', {}, 'eva'); 140 | this.player.addCredits(item.progress); 141 | 142 | item.state = undefined; 143 | item.progress = 0; 144 | } 145 | } 146 | } 147 | 148 | public hold(item: ConstructionObject) { 149 | if (item.available) { 150 | if (item.state === 'constructing') { 151 | this.emit('hold', item); 152 | this.engine.playArchiveSfx('SPEECH.MIX/onhold1.wav', 'gui', {}, 'eva'); 153 | 154 | item.state = 'hold'; 155 | } 156 | } 157 | } 158 | 159 | public getAvailable(): ConstructionObject[] { 160 | return this.objects.filter(o => o.available); 161 | } 162 | 163 | public setTechLevel(l: number): void { 164 | this.techLevel = l; 165 | } 166 | 167 | public setBuildLevel(l: number): void { 168 | this.buildLevel = l; 169 | } 170 | 171 | public getAvailableCount(): number { 172 | return this.getAvailable().length; 173 | } 174 | 175 | public getNames(): string[] { 176 | return this.objects.map(o => o.name); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /docs/ccfonts.txt: -------------------------------------------------------------------------------- 1 | 2 | C&C and Red Alert *.FNT Files 3 | Revision 2 4 | 5 | by Gordan Ugarkovic (ugordan@yahoo.com) 6 | http://members.xoom.com/ugordan 7 | 8 | 9 | Command & Conquer is a trademark of Westwood Studios, Inc. 10 | Command & Conquer is Copyright (C)1995 Westwood Studios, Inc. 11 | Command & Conquer: Red Alert is a trademark of Westwood Studios, Inc. 12 | Command & Conquer: Red Alert is Copyright (C)1995-1999 Westwood Studios, Inc. 13 | 14 | In this document, I'll try to explain the file format which Command & Conquer 15 | and Red Alert use for storing various fonts. 16 | 17 | I can't guarantee the information given here is correct. There may be things 18 | which are wrong - if you know something is incorrect, contact me so that 19 | I can update this document. Also, sorry if all this sounds too complicated, 20 | but I'm not very good at explaining things. 21 | 22 | 23 | IMPORTANT: 24 | There is a major difference (concerning the previous version of this doc) 25 | in how the fonts are displayed. The procedure I described in the first 26 | version of this document is WRONG. Here, I will explain the correct 27 | procedure. Therefore, you should consider the first release of the document 28 | to be obsolete now. Sorry for the inconvenience... 29 | 30 | 31 | The fonts used in C&C and Red Alert are proportional fonts, which means every 32 | character can have a different width (in pixels). 33 | 34 | The format of the FNT file header is as follows: 35 | 36 | struct FNTHeader 37 | { 38 | word fsize; /* Size of the file */ 39 | word unknown1; /* Unknown entry (always 0x0500) */ 40 | word unknown2; /* Unknown entry (always 0x000e) */ 41 | word unknown3; /* Unknown entry (always 0x0014) */ 42 | word wpos; /* Offset of char. widths array (abs. from beg. of file) */ 43 | word cdata; /* Offset of char. graphics data (abs. from beg. of file) */ 44 | word hpos; /* Offset of char. heights array (abs. from beg. of file) */ 45 | word unknown4; /* Unknown entry (always 0x1012) */ 46 | word nchars; /* Number of characters in font minus 1 */ 47 | byte height; /* Font height */ 48 | byte maxw; /* Max. character width */ 49 | } 50 | 51 | The entries unknown1, unknown2, unknown3 can be used to identify the file 52 | as a FNT, as well as to help locate the fonts in Red Alert 53 | (hex signature: 00 05 0e 00 14 00). 54 | 55 | Following the header comes an array of words that point to each character's 56 | graphics (absolute from start of file). There are nchars+1 of these entries. 57 | 58 | Next, there is an array of bytes that contain widths (in pixels) of each 59 | character. There are nchars+1 entries in the array. The wpos entry in the 60 | header points to the beginning of this array. 61 | I'll refer to this array as wchar[]. 62 | 63 | The packed 4-bit character graphics follow after these arrays. 64 | 65 | At the end of the file, after the graphics, another word array is located. 66 | Its offset is stored in hpos entry of the header. The array has nchars+1 67 | entries. I'll refer to this array as hchar[]. 68 | 69 | Each word entry in hchar[] contains 2 values - the height of the character 70 | graphic located in the hi-byte and the Y position of the graphic in the 71 | character cell located in the lo-byte. 72 | 73 | The character cell for character cn is a bitmap with width=wchar[cn] 74 | and height=hchar[cn]/256 + (hchar[cn] & 256). 75 | 76 | The character graphic for every character is of the same width 77 | as that character's cell, but can be of different height, depending on the 78 | hi-byte of the corresponding hchar[] entry. 79 | 80 | For example, a character (its cell) can be 16x8 81 | [wchar[cn] x (hchar[cn]/256 + (hchar[cn] & 256))] pixels in size, but 82 | its character graphic could be 16x3 [wchar[cn] x (hchar[cn]/256)] 83 | pixels. Clearly, we don't know at what place (height) in the cell the 84 | character graphic should be located. That's what the Y position is used for. 85 | I'll give an example later. 86 | 87 | About the 4-bit packed graphics. Previously, I used to think that each 88 | pixel of the graphic is 8-bit (e.g. can have 256 colors). This is not 89 | the case. The pixels are in fact 4-bit (max. 16 colors), so 2 of them are 90 | packed into one byte. 91 | Let's consider a graphic that's 4 pixels wide. 92 | I'll denote each pixel of the line with it's number: 1 2 3 4 93 | The pixels would then be packed into 2 bytes, like this: 94 | 21 43 95 | || 96 | |+-- LO nibble (val=byte & 15) 97 | +--- HI nibble (val=byte / 16) 98 | 99 | If we had to pack an odd number of pixels (i.e. 7), it would be done similarly: 100 | 21 43 65 x7 101 | || 102 | |+-- Last pixel value (val=byte & 15) 103 | +--- Empty / Unused 104 | 105 | Therefore, with the width of a given graphic (wchar[cn]), the total number 106 | of bytes used to store each line is (width+1)/2 107 | 108 | Example on how to display a character: 109 | Let's say we have a right arrow sign and it's a 6x6 pixels character. 110 | Its cell would look like: 111 | . . . . . . 112 | . . . . . . 113 | . . . . . . 114 | . . . . . . 115 | . . . . . . 116 | . . . . . . 117 | 118 | The corresponding character graphic (after unpacking) is 6x3 pixels: 119 | . . . * . . 120 | * * * * * . 121 | . . . * . . 122 | 123 | We would need to place this graphic in the center of the cell to get the 124 | entire character: 125 | . . . . . . Y=0 126 | . . . . . . 127 | . . . * . . \ 128 | * * * * * . | Inserted graphic -> In this case Y=2 129 | . . . * . . / 130 | . . . . . . Y=5 131 | 132 | That's how every character is displayed. You'll notice that some characters 133 | (mainly the first and the last few) don't have any graphics of their own so 134 | they use another character's graphics. 135 | Also, the characters are stored in normal ASCII order, so if you wanted to 136 | display the character 'A', for example, you would draw the character no. 65 137 | (ASCII code for 'A') 138 | 139 | 140 | ---------------------------------------------------------------- 141 | 142 | Gordan Ugarkovic (ugordan@yahoo.com) 143 | 18 August, 1999 144 | [END-OF-FILE] 145 | -------------------------------------------------------------------------------- /data/GAME.DAT/rules.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | 3 | ; crates 4 | CrateMinimum=1 ; crates are normally one per human player but never below this number 5 | CrateMaximum=255 ; crates can never exceed this quantity 6 | CrateRadius=3.0 ; radius (cells) for area effect crate powerup bonuses 7 | CrateRegen=3 ; average minutes between random powerup crate regeneration 8 | UnitCrateType=none ; specifies specific unit type for unit type crate ['none' means pick randomly] 9 | SoloCrateMoney=2000 ; money to give for money crate in solo play missions 10 | SilverCrate=HealBase ; solo play silver crate bonus 11 | WoodCrate=Money ; solo play wood crate bonus 12 | 13 | ; repair and refit 14 | RefundPercent=50% ; percent of original cost to refund when building/unit is sold 15 | ReloadRate=.04 ; minutes to reload each ammo point for aircraft or helicopters 16 | RepairPercent=20% ; percent cost to fully repair as ratio of full cost 17 | RepairRate=.016 ; minutes between applying repair step 18 | RepairStep=7 ; hit points to heal per repair 'tick' for buildings 19 | 20 | ; combat and damage 21 | IonDamage=600 ; damage points for ion cannon strike 22 | AtomDamage=1000 ; damage points when nuclear bomb explodes (regardless of source) 23 | BallisticScatter=1.0 ; maximum scatter distance (cells) for inaccurate ballistic projectiles 24 | C4Delay=.02 ; minutes to delay after placing C4 before building will explode 25 | Crush=1.5 ; if this close (cells) to crushable target, then crush instead of firing upon it (computer only) 26 | ExpSpread=.3 ; cell damage spread per 256 damage points for exploding object types [if Explodes=yes] 27 | HomingScatter=2.0 ; maximum scatter distance (cells) for inaccurate homing projectiles 28 | MaxDamage=1000 ; maximum damage (after adjustments) per shot 29 | MinDamage=1 ; minimum damage (after adjustments) per shot 30 | TiberiumExplosive=no ; Does the harvester explode big time when destroyed? 31 | PlayerAutoCrush=no ; Will player controlled units automatically try to crush enemy infantry? 32 | PlayerReturnFire=no ; More aggressive return fire from player controlled objects? 33 | PlayerScatter=no ; Will player units scatter, of their own accord, from threats and damage? 34 | ProneDamage=50% ; when infantry is prone, damage is reduced to this percentage 35 | TreeTargeting=no ; Automatically show target cursor when over trees? 36 | Incoming=10 ; If an incoming projectile is as slow or slower than this, then 37 | ; object in the target location will try to run away. Grenades and 38 | ; parachute bombs have this characteristic. 39 | 40 | ; income and production 41 | BailCount=28 ; number of 'bails' carried by a harvester 42 | BuildSpeed=1.0 ; general build speed [time (in minutes) to produce a 1000 credit cost item] 43 | BuildupTime=.06 ; average minutes that building build-up animation runs 44 | TiberiumValue=25 ; credits per 'bail' carried by a harvester 45 | GrowthRate=2 ; minutes between Tiberium growth 46 | SeparateAircraft=no ; Is first helicopter to be purchased separately from helipad? 47 | SurvivorRate=.4 ; fraction of building cost to be converted to survivors when sold 48 | 49 | ; audio/visual map controls 50 | AllyReveal=yes ; Allies automatically reveal radar maps to each other? 51 | ConditionRed=25% ; when damaged to this percentage, health bar turns red 52 | ConditionYellow=50% ; when damaged to this percentage, health bar turns yellow 53 | DropZoneRadius=4 ; distance around drop zone flair that map reveals itself 54 | EnemyHealth=yes ; Show enemy health bar graph when selected? 55 | Gravity=3 ; gravity constant for ballistic projectiles 56 | IdleActionFrequency=.1 ; average minutes between infantry performing idle actions 57 | MessageDelay=.6 ; time duration of multiplayer messages displayed over map 58 | MovieTime=.06 ; minutes that movie recorder will record when activated (debug version only) 59 | NamedCivilians=no ; Show true names over civilians and civilian buildings? 60 | SavourDelay=.03 ; delay between scenario end and ending movie [keep the delay short] 61 | SpeakDelay=2 ; minutes between EVA repeating advice to the player 62 | 63 | ; computer and movement controls 64 | BaseBias=2 ; multiplier to threat target value when enemy is close to friendly base 65 | BaseDefenseDelay=.25 ; minutes delay between sending response teams to deal with base threat 66 | CloseEnough=2.75 ; If distance to destination less than this, then abort movement if otherwise blocked. 67 | DamageDelay=1 ; minutes between applying trivial structure damage when low on power 68 | GameSpeedBias=1 ; multiplier to overall game object movement speed 69 | LZScanRadius=16 ; maximum radius to scan for alternate landing zone if otherwise occupied 70 | Stray=2.0 ; radius distance (cells) that team members may stray without causing regroup action 71 | SubmergeDelay=.02 ; forced delay that subs will remain on surface before allowing to submerge 72 | SuspendDelay=2 ; minutes that suspended teams will remain suspended 73 | SuspendPriority=20 ; teams with less than this priority will suspend during base defense ops 74 | TeamDelay=.6 ; interval between checking for and creating teams 75 | 76 | 77 | ; ******* Special weapon charge times ******* 78 | ; The time (minutes) for recharge of these special weapons. 79 | [Recharge] 80 | Nuke=13 ; nuclear missile 81 | Airstrike=8 ; A-10 strike 82 | IonCannon=10 ; ion cannon 83 | 84 | [Powerups] 85 | Airstrike=3,DEVIATOR ; air strike one time shot 86 | Cloak=5,STEALTH2 ; enable cloaking on nearby objects 87 | Darkness=1,EMPULSE ; cloak entire radar map 88 | Explosion=5,NONE,500 ; high explosive baddie (damage per explosion) 89 | HealBase=1,INVUN ; all buildings to full strength 90 | ICBM=1,MISSILE2 ; nuke missile one time shot 91 | IonCannon=1,EARTH ; ion cannon one time shot 92 | Money=50,DOLLAR,2000 ; a chunk o' cash (maximum cash) 93 | Napalm=5,NONE,600 ; fire explosion baddie (damage) 94 | Nuke=1,NONE,1000 ; nuke explosion (damage) 95 | Reveal=1,EMPULSE ; reveal entire radar map 96 | Squad=20,NONE ; squad of random infantry 97 | Unit=20,NONE ; vehicle 98 | Visceroid=5,NONE ; visceroid 99 | -------------------------------------------------------------------------------- /src/game/weapons.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { Sprite, randomBetweenInteger } from '../engine'; 7 | import { GameMap } from './map'; 8 | import { GameEntity } from './entity'; 9 | import { MIXWeapon, MIXBullet, MIXWarhead, irrelevantBulletImages, humanDirections } from './mix'; 10 | import { cellFromPoint, getDirection, CELL_SIZE } from './physics'; 11 | import { spriteFromName } from './sprites'; 12 | import { Vector } from 'vector2d'; 13 | 14 | export class ProjectileEntity extends GameEntity { 15 | protected readonly bulletName: string; 16 | protected readonly bullet: MIXBullet; 17 | protected readonly warhead: MIXWarhead; 18 | protected readonly target: GameEntity; 19 | protected readonly weapon: Weapon; 20 | protected direction: number = 0; 21 | protected trailTick: number = 0; 22 | 23 | public constructor(name: string, target: GameEntity, weapon: Weapon) { 24 | super(weapon.map); 25 | 26 | this.target = target; 27 | this.weapon = weapon; 28 | this.bulletName = name; 29 | this.bullet = weapon.map.engine.mix.bullets.get(name) as MIXBullet; 30 | this.warhead = weapon.map.engine.mix.warheads.get(this.bullet.Warhead) as MIXWarhead; 31 | this.dimension = weapon.sprite ? weapon.sprite.size.clone() as Vector : new Vector(CELL_SIZE, CELL_SIZE); //FIXME 32 | this.position = weapon.entity.getPosition(); 33 | this.cell = cellFromPoint(this.position); 34 | } 35 | 36 | public destroy(): void { 37 | if (this.destroyed) { 38 | return; 39 | } 40 | 41 | super.destroy(); 42 | 43 | if (this.bullet.Explosion) { 44 | this.map.factory.load('effect', { 45 | name: this.bullet.Explosion, 46 | cell: this.target.getCell() 47 | }, (effect: any): void => effect.setCenterEntity(this)); 48 | } 49 | 50 | this.weapon.map.removeEntity(this); 51 | } 52 | 53 | private createTrail(): void { 54 | if (this.weapon.trailSprite && !this.destroyed) { 55 | this.map.factory.load('effect', { 56 | name: 'SMOKEY', 57 | player: -1, 58 | cell: this.cell 59 | }, (effect: any): void => { 60 | effect.setPosition(this.getPosition()); 61 | effect.setCenterEntity(this); 62 | }); 63 | } 64 | } 65 | 66 | public onUpdate(deltaTime: number): void { 67 | if (this.bullet.BulletSpeed === -1) { 68 | this.onHit(); 69 | } else { 70 | const speed = this.bullet.BulletSpeed / 18; 71 | const directions = 32; 72 | const position = this.position.clone() as Vector; 73 | const targetPosition = this.target.getPosition(); 74 | 75 | const direction = getDirection(targetPosition, position, directions); 76 | const angleRadians = (direction / directions) * 2 * Math.PI; 77 | const vel = new Vector(speed * Math.sin(angleRadians), speed * Math.cos(angleRadians)); 78 | const distance = targetPosition.distance(position); 79 | 80 | if (distance < speed) { 81 | this.onHit(); 82 | } else { 83 | if (this.trailTick <= 0) { 84 | this.createTrail(); 85 | this.trailTick = randomBetweenInteger(4, 12); 86 | } 87 | 88 | this.position.subtract(vel); 89 | this.direction = direction; 90 | this.cell = cellFromPoint(this.position); 91 | } 92 | } 93 | 94 | this.trailTick--; 95 | } 96 | 97 | public onRender(deltaTime: number): void { 98 | if (this.weapon.sprite) { 99 | const frame = new Vector(0, this.direction); 100 | const context = this.weapon.map.overlay.getContext(); 101 | this.weapon.sprite.render(frame, this.position, context); 102 | } 103 | } 104 | 105 | protected onHit(): void { 106 | const damage = this.weapon.weapon.Damage; 107 | const armor = this.target.getArmor(); 108 | const verses = this.warhead.Verses[armor]; 109 | const take = verses / 100; 110 | const finalDamage = damage * take; 111 | 112 | // TODO: Apparently if there's multiple units in same cell, you divide by three 113 | this.target.takeDamage(finalDamage); 114 | this.destroy(); 115 | } 116 | } 117 | 118 | export class Weapon { 119 | public readonly weapon: MIXWeapon; 120 | public readonly map: GameMap; 121 | public readonly entity: GameEntity; 122 | public readonly sprite?: Sprite; 123 | public readonly trailSprite?: Sprite; 124 | private tick: number = 0; 125 | private rof: number = 0; 126 | 127 | public constructor(name: string, map: GameMap, entity: GameEntity) { 128 | this.map = map; 129 | this.weapon = map.engine.mix.weapons.get(name) as MIXWeapon; 130 | this.entity = entity; 131 | this.rof = this.weapon.ROF; 132 | 133 | const bullet = map.engine.mix.bullets.get(this.weapon.Projectile) as MIXBullet; 134 | 135 | if (irrelevantBulletImages.indexOf(bullet.Image) === -1) { 136 | const spriteName = bullet.Image.toLowerCase(); 137 | this.sprite = spriteFromName(`CONQUER.MIX/${spriteName}.png`); 138 | 139 | if (bullet.SmokeTrail) { 140 | this.trailSprite = spriteFromName(`CONQUER.MIX/smokey.png`); 141 | } 142 | } 143 | } 144 | 145 | public async init(): Promise { 146 | if (this.sprite) { 147 | await this.map.engine.loadArchiveSprite(this.sprite); 148 | } 149 | 150 | if (this.trailSprite) { 151 | await this.map.engine.loadArchiveSprite(this.trailSprite); 152 | } 153 | } 154 | 155 | protected fireProjectile(target: GameEntity): void { 156 | const p = new ProjectileEntity(this.weapon.Projectile, target, this); 157 | this.map.addEntity(p); 158 | if (this.weapon.Report) { 159 | this.entity.playSfx(this.weapon.Report.toLowerCase()); 160 | } 161 | 162 | this.createMuzzleFlash(); 163 | } 164 | 165 | public fire(target: GameEntity): void { 166 | const fire = this.tick === 0; 167 | const fireTwice = this.tick === Math.round(this.rof / 4) && this.entity.canFireTwice(); 168 | if (fire || fireTwice) { 169 | this.fireProjectile(target); 170 | } 171 | } 172 | 173 | protected createMuzzleFlash(): void { 174 | if (this.weapon.MuzzleFlash) { 175 | const dir = humanDirections[this.entity.getDirection()]; 176 | const name = this.weapon.MuzzleFlash.replace('-N', `-${dir}`); 177 | 178 | this.map.factory.load('effect', { 179 | name, 180 | cell: this.entity.getCell() 181 | }, (effect: any): void => effect.setCenterEntity(this.entity)); 182 | } 183 | } 184 | 185 | public onUpdate(deltaTime: number): void { 186 | this.tick = (this.tick + 1) % this.rof; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /data/GAME.DAT/grids.ini: -------------------------------------------------------------------------------- 1 | ; Structure and terrain grid definitions 2 | 3 | ; The system will read line "0=", "1=", "2=" etc, and will see any 'x' or 'X' 4 | ; characters as cells of the foundation. 5 | ; Since the read aborts after finding an empty line, empty lines in between 6 | ; HAVE to added. I padded everything with other characters to show a rectangular 7 | ; shape, which solves that problem. 8 | 9 | ; The origin (0,0) point of the grid can be specified with the keys "XShift=" 10 | ; and "YShift=". Basically, this is the amount to subtract from the given cells 11 | ; in both directions to get the true value. These normally default to 0, but 12 | ; can be changed to support grids with negative offsets. 13 | 14 | ; The full offset calculation for any encountered 'x' character is: 15 | ; cell = charPos - XShift + ((lineNr - YShift) * 64) 16 | 17 | ; There are three kinds of grids. 18 | ; Occupy: Basic shape of the foundation of the structure or terrain object 19 | ; Overlap: More cells to refresh besides the actual occupied foundation. 20 | ; Exit: Seems to make a ring around all producing structures. 21 | 22 | ; To facilitate the visualization, I have indicated the cells as following: 23 | ; x : Used grid cell 24 | ; - : Unused cell 25 | ; . : Unused cell indicating occupied 'Shape' cell (on Refresh/Exit grids) 26 | ; Origin point indications used on grids with modified XShift or YShift: 27 | ; X : Origin point on used grid cell 28 | ; + ; Origin point on unused cell 29 | ; * : Origin point on unused cell indicating occupied 'Shape' cell 30 | 31 | [Grids] 32 | 0=TMPLOverlap 33 | 1=TMPLOccupy 34 | 2=HQOverlap 35 | 3=HQOccupy 36 | 4=WEAPOverlap 37 | 5=WEAPOccupy 38 | 6=WEAPExit 39 | 7=GTWROccupy 40 | 8=ATWROverlap 41 | 9=ATWROccupy 42 | 10=FACTOccupy 43 | 11=PROCOverlap 44 | 12=PROCOccupy 45 | 13=SILOOccupy 46 | 14=HPADOccupy 47 | 15=SAMOverlap 48 | 16=SAMOccupy 49 | 17=AFLDOccupy 50 | 18=AFLDExit 51 | 19=NUKEOverlap 52 | 20=NUKEOccupy 53 | 21=PYLEOverlap 54 | 22=PYLEOccupy 55 | 23=PYLEExit 56 | 24=HANDOverlap 57 | 25=HANDOccupy 58 | 26=HANDExit 59 | 27=FIXOverlap 60 | 28=FIXOccupy 61 | 29=V01Overlap 62 | 30=V01Occupy 63 | 31=V03Overlap 64 | 32=V03Occupy 65 | 33=V05Occupy 66 | 34=V21Overlap 67 | 35=V21Occupy 68 | 36=V37Overlap 69 | 37=V37Occupy 70 | 38=T01Occupy 71 | 39=T01Overlap 72 | 40=T04Occupy 73 | 41=T08Occupy 74 | 42=T08Overlap 75 | 43=T10Occupy 76 | 44=T10Overlap 77 | 45=T13Overlap 78 | 46=T18Occupy 79 | 47=T18Overlap 80 | 48=TC01Occupy 81 | 49=TC01Overlap 82 | 50=TC02Occupy 83 | 51=TC02Overlap 84 | 52=TC03Occupy 85 | 53=TC03Overlap 86 | 54=TC04Occupy 87 | 55=TC04Overlap 88 | 56=TC05Occupy 89 | 57=TC05Overlap 90 | 58=ROCK1Overlap 91 | 59=ROCK6Occupy 92 | 60=ROCK6Overlap 93 | 61=ROCK7Occupy 94 | 62=ROCK7Overlap 95 | 96 | ; Structure grids 97 | 98 | [TMPLOverlap] 99 | 0=xxx 100 | 1=... 101 | 2=... 102 | 103 | [TMPLOccupy] 104 | 0=--- 105 | 1=xxx 106 | 2=xxx 107 | 108 | [HQOverlap] 109 | 0=.x 110 | 1=.. 111 | 112 | [HQOccupy] 113 | 0=x- 114 | 1=xx 115 | 116 | [WEAPOverlap] 117 | 0=xxx 118 | 1=... 119 | 2=... 120 | 121 | [WEAPOccupy] 122 | 0=--- 123 | 1=xxx 124 | 2=xxx 125 | 126 | [WEAPExit] 127 | XShift=1 128 | YShift=0 129 | 0=-+--- 130 | 1=x...x 131 | 2=x...x 132 | 3=xxxxx 133 | ;Cells=63,67,127,131,191,192,193,194,195 134 | 135 | [GTWROccupy] 136 | 0=x 137 | 138 | [ATWROverlap] 139 | 0=x 140 | 1=. 141 | 142 | [ATWROccupy] 143 | 0=- 144 | 1=x 145 | 146 | [FACTOccupy] 147 | 0=xxx 148 | 1=xxx 149 | 150 | [PROCOverlap] 151 | 0=x.x 152 | 1=... 153 | 2=xxx 154 | 155 | [PROCOccupy] 156 | 0=-x- 157 | 1=xxx 158 | 2=--- 159 | 160 | [SILOOccupy] 161 | 0=xx 162 | 163 | [HPADOccupy] 164 | 0=xx 165 | 1=xx 166 | 167 | [SAMOverlap] 168 | XShift=0 169 | YShift=1 170 | 0=xx 171 | 1=*. 172 | ;Cells=-64,-63 173 | 174 | [SAMOccupy] 175 | 0=xx 176 | 177 | [AFLDOccupy] 178 | 0=xxxx 179 | 1=xxxx 180 | 181 | [AFLDExit] 182 | ; Cells=-65,-64,-63,-62,-61,-60,-1,4,63,68,127,128,129,130,131,132 183 | XShift=1 184 | YShift=1 185 | 0=xxxxxx 186 | 1=x*...x 187 | 2=x....x 188 | 3=xxxxxx 189 | 190 | [NUKEOverlap] 191 | 0=.x 192 | 1=.. 193 | 194 | [NUKEOccupy] 195 | 0=x- 196 | 1=xx 197 | 198 | [PYLEOverlap] 199 | 0=.. 200 | 1=xx 201 | 202 | [PYLEOccupy] 203 | 0=xx 204 | 1=-- 205 | 206 | [PYLEExit] 207 | ; Cells=-65,-64,-63,-62,-1,2,63,66,127,128,129,130 208 | XShift=1 209 | YShift=1 210 | 0=xxxx 211 | 1=x*.x 212 | 2=x--x 213 | 3=xxxx 214 | 215 | [HANDOverlap] 216 | 0=xx 217 | 1=.. 218 | 2=x. 219 | 220 | [HANDOccupy] 221 | 0=-- 222 | 1=xx 223 | 2=-x 224 | 225 | [HANDExit] 226 | ;Cells=-1,0,1,63,66,127,130,191,192,193,194 227 | XShift=1 228 | YShift=0 229 | 0=xXxx 230 | 1=x..x 231 | 2=x-.x 232 | 3=xxxx 233 | 234 | [FIXOverlap] 235 | 0=x.x 236 | 1=... 237 | 2=x.x 238 | 239 | [FIXOccupy] 240 | 0=-x- 241 | 1=xxx 242 | 2=-x- 243 | 244 | [V01Overlap] 245 | 0=xx 246 | 1=.. 247 | 248 | [V01Occupy] 249 | 0=-- 250 | 1=xx 251 | 252 | [V03Overlap] 253 | 0=x. 254 | 1=.. 255 | 256 | [V03Occupy] 257 | 0=-x 258 | 1=xx 259 | 260 | [V05Occupy] 261 | 0=xx 262 | 263 | [V21Overlap] 264 | 0=.. 265 | 1=x. 266 | 267 | [V21Occupy] 268 | 0=xx 269 | 1=-x 270 | 271 | [V37Overlap] 272 | 0=x... 273 | 1=x... 274 | 275 | [V37Occupy] 276 | 0=-xxx 277 | 1=-xxx 278 | 279 | ; Terrain object grids 280 | 281 | [T01Occupy] 282 | 0=-- 283 | 1=x- 284 | 285 | [T01Overlap] 286 | 0=x- 287 | 1=.x 288 | 289 | [T04Occupy] 290 | 0=x 291 | 292 | [T08Occupy] 293 | 0=x- 294 | 295 | [T08Overlap] 296 | 0=.x 297 | 298 | [T10Occupy] 299 | 0=-- 300 | 1=xx 301 | 302 | [T10Overlap] 303 | 0=xx 304 | 1=.. 305 | 306 | [T13Overlap] 307 | 0=xx 308 | 1=.x 309 | 310 | [T18Occupy] 311 | 0=--- 312 | 1=-x- 313 | 314 | [T18Overlap] 315 | 0=xxx 316 | 1=x.x 317 | 318 | [TC01Occupy] 319 | 0=--- 320 | 1=xx- 321 | 322 | [TC01Overlap] 323 | 0=xx- 324 | 1=..x 325 | 326 | [TC02Occupy] 327 | 0=-x- 328 | 1=xx- 329 | 330 | [TC02Overlap] 331 | 0=x.x 332 | 1=..x 333 | 334 | [TC03Occupy] 335 | 0=xx- 336 | 1=xx- 337 | 338 | [TC03Overlap] 339 | 0=..x 340 | 1=..- 341 | 342 | [TC04Occupy] 343 | 0=---- 344 | 1=xxx- 345 | 2=x--- 346 | 347 | [TC04Overlap] 348 | 0=xxx- 349 | 1=...x 350 | 2=.xx- 351 | 352 | [TC05Occupy] 353 | 0=--x- 354 | 1=xxx- 355 | 2=-xx- 356 | 357 | [TC05Overlap] 358 | 0=xx.- 359 | 1=...x 360 | 2=x..x 361 | 362 | [ROCK1Overlap] 363 | 0=xxx 364 | 1=..x 365 | 366 | [ROCK6Occupy] 367 | 0=--- 368 | 1=xxx 369 | 370 | [ROCK6Overlap] 371 | 0=xx- 372 | 1=... 373 | 374 | [ROCK7Occupy] 375 | 0=xxxx 376 | 377 | [ROCK7Overlap] 378 | 0=....x 379 | -------------------------------------------------------------------------------- /data/GAME.DAT/themes.ini: -------------------------------------------------------------------------------- 1 | ; THEMES.INI 2 | ; That's right. ini-based music settings in C&C1. Go nuts :) 3 | 4 | ; this list determines which scores are read below, and their order in the playlist. 5 | [Themes] 6 | 0=AIRSTRIK 7 | 1=80MX226M 8 | 2=CHRG226M 9 | 3=CREP226M 10 | 4=DRIL226M 11 | 5=DRON226M 12 | 6=FIST226M 13 | 7=RECN226M 14 | 8=VOIC226M 15 | 9=HEAVYG 16 | 10=J1 17 | 11=JDI_V2 18 | 12=RADIO 19 | 13=RAIN 20 | 14=AOI 21 | 15=CCTHANG 22 | 16=DIE 23 | 17=FWP 24 | 18=IND 25 | 19=IND2 26 | 20=JUSTDOIT 27 | 21=LINEFIRE 28 | 22=MARCH 29 | 23=TARGET 30 | 24=NOMERCY 31 | 25=OTP 32 | 26=PRP 33 | 27=ROUT 34 | 28=HEART 35 | 29=STOPTHEM 36 | 30=TROUBLE 37 | 31=WARFARE 38 | 32=BEFEARED 39 | 33=I_AM 40 | 34=WIN1 41 | 35=MAP1 42 | 36=VALKYRIE 43 | 37=NOD_WIN1 44 | 38=NOD_MAP1 45 | 39=OUTTAKES 46 | 47 | ; Note that a "Name=" tag can be added to give a custom name instead of a 48 | ; Name ID, so you can add music without any need to modify the strings file. 49 | [AIRSTRIK] 50 | NameID=627 ; Name ID number in the strings file 51 | FirstMission=0 : First mission on which this theme appears in the playlist 52 | Length=200 ; Length of the theme, in seconds 53 | ShowInPlaylist=Yes ; Show this theme in the playlist? 54 | HasAlternate=No ; Does a .var remix of this theme exist? 55 | AlternateLength=0 ; Length of the .var version, in seconds 56 | Title=Air Strike 57 | IsAvailable=1 58 | 59 | [80MX226M] 60 | NameID=716 61 | FirstMission=0 62 | Length=248 63 | ShowInPlaylist=Yes 64 | HasAlternate=No 65 | AlternateLength=0 66 | 67 | [CHRG226M] 68 | NameID=712 69 | FirstMission=0 70 | Length=256 71 | ShowInPlaylist=Yes 72 | HasAlternate=No 73 | AlternateLength=0 74 | 75 | [CREP226M] 76 | NameID=715 77 | FirstMission=0 78 | Length=222 79 | ShowInPlaylist=Yes 80 | HasAlternate=No 81 | AlternateLength=0 82 | 83 | [DRIL226M] 84 | NameID=717 85 | FirstMission=0 86 | Length=272 87 | ShowInPlaylist=Yes 88 | HasAlternate=No 89 | AlternateLength=0 90 | 91 | [DRON226M] 92 | NameID=713 93 | FirstMission=0 94 | Length=275 95 | ShowInPlaylist=Yes 96 | HasAlternate=No 97 | AlternateLength=0 98 | 99 | [FIST226M] 100 | NameID=714 101 | FirstMission=0 102 | Length=212 103 | ShowInPlaylist=Yes 104 | HasAlternate=No 105 | AlternateLength=0 106 | 107 | [RECN226M] 108 | NameID=719 109 | FirstMission=0 110 | Length=261 111 | ShowInPlaylist=Yes 112 | HasAlternate=No 113 | AlternateLength=0 114 | 115 | [VOIC226M] 116 | NameID=720 117 | FirstMission=0 118 | Length=306 119 | ShowInPlaylist=Yes 120 | HasAlternate=No 121 | AlternateLength=0 122 | 123 | [HEAVYG] 124 | NameID=628 125 | FirstMission=0 126 | Length=180 127 | ShowInPlaylist=Yes 128 | HasAlternate=No 129 | AlternateLength=0 130 | 131 | [J1] 132 | NameID=629 133 | FirstMission=0 134 | Length=187 135 | ShowInPlaylist=Yes 136 | HasAlternate=No 137 | AlternateLength=0 138 | Title=Untamed Land 139 | IsAvailable=1 140 | 141 | [JDI_V2] 142 | NameID=630 143 | FirstMission=0 144 | Length=183 145 | ShowInPlaylist=Yes 146 | HasAlternate=No 147 | AlternateLength=0 148 | Title=Take em out 149 | IsAvailable=1 150 | 151 | [RADIO] 152 | NameID=631 153 | FirstMission=0 154 | Length=183 155 | ShowInPlaylist=Yes 156 | HasAlternate=No 157 | AlternateLength=0 158 | Title=Radio 159 | IsAvailable=1 160 | 161 | [RAIN] 162 | NameID=632 163 | FirstMission=0 164 | Length=156 165 | ShowInPlaylist=Yes 166 | HasAlternate=No 167 | AlternateLength=0 168 | Title=Rain in the night 169 | IsAvailable=1 170 | 171 | [AOI] 172 | NameID=191 173 | FirstMission=0 174 | Length=168 175 | ShowInPlaylist=Yes 176 | HasAlternate=Yes 177 | AlternateLength=172 178 | Title=Act on instinct 179 | IsAvailable=1 180 | 181 | [CCTHANG] 182 | NameID=201 183 | FirstMission=0 184 | Length=193 185 | ShowInPlaylist=Yes 186 | HasAlternate=No 187 | AlternateLength=0 188 | Title=C&C Thang 189 | IsAvailable=1 190 | 191 | [DIE] 192 | NameID=205 193 | FirstMission=0 194 | Length=162 195 | ShowInPlaylist=Yes 196 | HasAlternate=No 197 | AlternateLength=0 198 | 199 | [FWP] 200 | NameID=204 201 | FirstMission=0 202 | Length=53 203 | ShowInPlaylist=Yes 204 | HasAlternate=No 205 | AlternateLength=0 206 | Title=Fight, win, prevail 207 | IsAvailable=1 208 | 209 | [IND] 210 | NameID=193 211 | FirstMission=0 212 | Length=175 213 | ShowInPlaylist=Yes 214 | HasAlternate=No 215 | AlternateLength=0 216 | Title=Industrial 217 | IsAvailable=1 218 | 219 | [IND2] 220 | NameID=633 221 | FirstMission=0 222 | Length=38 223 | ShowInPlaylist=Yes 224 | HasAlternate=No 225 | AlternateLength=0 226 | 227 | [JUSTDOIT] 228 | NameID=197 229 | FirstMission=0 230 | Length=142 231 | ShowInPlaylist=Yes 232 | HasAlternate=Yes 233 | AlternateLength=142 234 | Title=Just do it! 235 | IsAvailable=1 236 | 237 | [LINEFIRE] 238 | NameID=198 239 | FirstMission=0 240 | Length=125 241 | ShowInPlaylist=Yes 242 | HasAlternate=No 243 | AlternateLength=0 244 | Title=In the line of fire 245 | IsAvailable=1 246 | 247 | [MARCH] 248 | NameID=199 249 | FirstMission=0 250 | Length=157 251 | ShowInPlaylist=Yes 252 | HasAlternate=No 253 | AlternateLength=0 254 | Title=March to Doom 255 | IsAvailable=1 256 | 257 | [TARGET] 258 | NameID=207 259 | FirstMission=0 260 | Length=173 261 | ShowInPlaylist=Yes 262 | HasAlternate=No 263 | AlternateLength=0 264 | Title=Mechanical Man 265 | IsAvailable=1 266 | 267 | [NOMERCY] 268 | NameID=206 269 | FirstMission=0 270 | Length=204 271 | ShowInPlaylist=Yes 272 | HasAlternate=Yes 273 | AlternateLength=197 274 | Title=No Mercy 275 | IsAvailable=1 276 | 277 | [OTP] 278 | NameID=195 279 | FirstMission=0 280 | Length=182 281 | ShowInPlaylist=Yes 282 | HasAlternate=No 283 | AlternateLength=0 284 | Title=On the Prowl 285 | IsAvailable=1 286 | 287 | [PRP] 288 | NameID=196 289 | FirstMission=0 290 | Length=211 291 | ShowInPlaylist=Yes 292 | HasAlternate=No 293 | AlternateLength=0 294 | Title=Prepare for Battle 295 | IsAvailable=1 296 | 297 | [ROUT] 298 | NameID=194 299 | FirstMission=0 300 | Length=0 301 | ShowInPlaylist=Yes 302 | HasAlternate=Yes 303 | AlternateLength=121 304 | 305 | [HEART] 306 | NameID=634 307 | FirstMission=0 308 | Length=0 309 | ShowInPlaylist=Yes 310 | HasAlternate=Yes 311 | AlternateLength=206 312 | 313 | [STOPTHEM] 314 | NameID=200 315 | FirstMission=0 316 | Length=187 317 | ShowInPlaylist=Yes 318 | HasAlternate=Yes 319 | AlternateLength=189 320 | Title=Deception 321 | IsAvailable=1 322 | 323 | [TROUBLE] 324 | NameID=192 325 | FirstMission=0 326 | Length=191 327 | ShowInPlaylist=Yes 328 | HasAlternate=Yes 329 | AlternateLength=132 330 | Title=Looks like trouble 331 | IsAvailable=1 332 | 333 | [WARFARE] 334 | NameID=203 335 | FirstMission=0 336 | Length=182 337 | ShowInPlaylist=Yes 338 | HasAlternate=No 339 | AlternateLength=0 340 | Title=Warfare 341 | IsAvailable=1 342 | 343 | [BEFEARED] 344 | NameID=202 345 | FirstMission=0 346 | Length=164 347 | ShowInPlaylist=Yes 348 | AlternateLength=101 349 | HasAlternate=Yes 350 | 351 | [I_AM] 352 | NameID=208 353 | FirstMission=0 354 | Length=161 355 | ShowInPlaylist=Yes 356 | HasAlternate=No 357 | AlternateLength=0 358 | 359 | [WIN1] 360 | NameID=209 361 | FirstMission=0 362 | Length=41 363 | ShowInPlaylist=No 364 | AlternateLength=56 365 | HasAlternate=Yes 366 | 367 | [MAP1] 368 | NameID=209 369 | FirstMission=0 370 | Length=61 371 | ShowInPlaylist=No 372 | HasAlternate=No 373 | AlternateLength=0 374 | 375 | [VALKYRIE] 376 | NameID=645 377 | FirstMission=0 378 | Length=306 379 | ShowInPlaylist=Yes 380 | HasAlternate=No 381 | AlternateLength=0 382 | 383 | [NOD_WIN1] 384 | NameID=209 385 | FirstMission=0 386 | Length=33 387 | ShowInPlaylist=No 388 | HasAlternate=No 389 | AlternateLength=0 390 | 391 | [NOD_MAP1] 392 | NameID=209 393 | FirstMission=0 394 | Length=36 395 | ShowInPlaylist=No 396 | HasAlternate=No 397 | AlternateLength=0 398 | 399 | [OUTTAKES] 400 | NameID=749 401 | FirstMission=0 402 | Length=183 403 | ShowInPlaylist=No 404 | HasAlternate=No 405 | AlternateLength=0 406 | -------------------------------------------------------------------------------- /data/GAME.DAT/overlay.ini: -------------------------------------------------------------------------------- 1 | [Overlay] 2 | 0=CONC 3 | 1=SBAG 4 | 2=CYCL 5 | 3=BRIK 6 | 4=BARB 7 | 5=WOOD 8 | 6=TI1 9 | 7=TI2 10 | 8=TI3 11 | 9=TI4 12 | 10=TI5 13 | 11=TI6 14 | 12=TI7 15 | 13=TI8 16 | 14=TI9 17 | 15=TI10 18 | 16=TI11 19 | 17=TI12 20 | 18=ROAD 21 | 19=SQUISH 22 | 20=V12 23 | 21=V13 24 | 22=V14 25 | 23=V15 26 | 24=V16 27 | 25=V17 28 | 26=V18 29 | 27=FPLS 30 | 28=WCRATE 31 | 29=SCRATE 32 | 33 | [ROAD] 34 | Unknown1=0 ; Crate? 35 | Unknown2=0 36 | Unknown3=0 37 | Unknown4=0 38 | Unknown5=0 ; Tiberium? 39 | Unknown6=0 40 | Unknown7=0 ; LegalTarget? 41 | CanBeBurnt=0 42 | Unknown9=1 43 | Unknown10=0 44 | Unknown11=0 45 | NameId=163 ; "Concrete" 46 | Unknown13=1 47 | 48 | [CONC] 49 | Unknown1=0 50 | Unknown2=0 51 | Unknown3=0 52 | Unknown4=0 53 | Unknown5=0 54 | Unknown6=0 55 | Unknown7=0 56 | CanBeBurnt=0 57 | Unknown9=1 58 | Unknown10=0 59 | Unknown11=0 60 | NameId=163 ; "Concrete" 61 | Unknown13=1 62 | 63 | [SBAG] 64 | Unknown1=0 65 | Unknown2=1 66 | Unknown3=0 67 | Unknown4=0 68 | Unknown5=0 69 | Unknown6=0 70 | Unknown7=1 71 | CanBeBurnt=0 72 | Unknown9=1 73 | Unknown10=20 74 | Unknown11=1 75 | NameId=166 ; "Sandbag Wall" 76 | Unknown13=4 77 | 78 | [CYCL] 79 | Unknown1=0 80 | Unknown2=1 81 | Unknown3=0 82 | Unknown4=0 83 | Unknown5=0 84 | Unknown6=1 85 | Unknown7=1 86 | CanBeBurnt=0 87 | Unknown9=1 88 | Unknown10=10 89 | Unknown11=2 90 | NameId=167 ; "Chain Link Fence" 91 | Unknown13=4 92 | 93 | [BRIK] 94 | Unknown1=0 95 | Unknown2=1 96 | Unknown3=0 97 | Unknown4=1 98 | Unknown5=0 99 | Unknown6=0 100 | Unknown7=1 101 | CanBeBurnt=0 102 | Unknown9=1 103 | Unknown10=70 104 | Unknown11=3 105 | NameId=168 ; "Concrete Wall" 106 | Unknown13=4 107 | 108 | [BARB] 109 | Unknown1=0 110 | Unknown2=1 111 | Unknown3=0 112 | Unknown4=0 113 | Unknown5=0 114 | Unknown6=1 115 | Unknown7=1 116 | CanBeBurnt=0 117 | Unknown9=1 118 | Unknown10=2 119 | Unknown11=1 120 | NameId=169 ; "Barbwire Fence" 121 | Unknown13=4 122 | 123 | [WOOD] 124 | Unknown1=0 125 | Unknown2=1 126 | Unknown3=0 127 | Unknown4=0 128 | Unknown5=0 129 | Unknown6=1 130 | Unknown7=1 131 | CanBeBurnt=1 132 | Unknown9=1 133 | Unknown10=2 134 | Unknown11=1 135 | NameId=170 ; "Wood Fence" 136 | Unknown13=4 137 | 138 | [TI1] 139 | Unknown1=0 140 | Unknown2=0 141 | Unknown3=1 142 | Unknown4=0 143 | Unknown5=1 144 | Unknown6=0 145 | Unknown7=0 146 | CanBeBurnt=0 147 | Unknown9=1 148 | Unknown10=0 149 | Unknown11=0 150 | NameId=66 ; "Tiberium" 151 | Unknown13=5 152 | 153 | [TI2] 154 | Unknown1=0 155 | Unknown2=0 156 | Unknown3=1 157 | Unknown4=0 158 | Unknown5=1 159 | Unknown6=0 160 | Unknown7=0 161 | CanBeBurnt=0 162 | Unknown9=1 163 | Unknown10=0 164 | Unknown11=0 165 | NameId=66 ; "Tiberium" 166 | Unknown13=5 167 | 168 | [TI3] 169 | Unknown1=0 170 | Unknown2=0 171 | Unknown3=1 172 | Unknown4=0 173 | Unknown5=1 174 | Unknown6=0 175 | Unknown7=0 176 | CanBeBurnt=0 177 | Unknown9=1 178 | Unknown10=0 179 | Unknown11=0 180 | NameId=66 ; "Tiberium" 181 | Unknown13=5 182 | 183 | [TI4] 184 | Unknown1=0 185 | Unknown2=0 186 | Unknown3=1 187 | Unknown4=0 188 | Unknown5=1 189 | Unknown6=0 190 | Unknown7=0 191 | CanBeBurnt=0 192 | Unknown9=1 193 | Unknown10=0 194 | Unknown11=0 195 | NameId=66 ; "Tiberium" 196 | Unknown13=5 197 | 198 | [TI5] 199 | Unknown1=0 200 | Unknown2=0 201 | Unknown3=1 202 | Unknown4=0 203 | Unknown5=1 204 | Unknown6=0 205 | Unknown7=0 206 | CanBeBurnt=0 207 | Unknown9=1 208 | Unknown10=0 209 | Unknown11=0 210 | NameId=66 ; "Tiberium" 211 | Unknown13=5 212 | 213 | [TI6] 214 | Unknown1=0 215 | Unknown2=0 216 | Unknown3=1 217 | Unknown4=0 218 | Unknown5=1 219 | Unknown6=0 220 | Unknown7=0 221 | CanBeBurnt=0 222 | Unknown9=1 223 | Unknown10=0 224 | Unknown11=0 225 | NameId=66 ; "Tiberium" 226 | Unknown13=5 227 | 228 | [TI7] 229 | Unknown1=0 230 | Unknown2=0 231 | Unknown3=1 232 | Unknown4=0 233 | Unknown5=1 234 | Unknown6=0 235 | Unknown7=0 236 | CanBeBurnt=0 237 | Unknown9=1 238 | Unknown10=0 239 | Unknown11=0 240 | NameId=66 ; "Tiberium" 241 | Unknown13=5 242 | 243 | [TI8] 244 | Unknown1=0 245 | Unknown2=0 246 | Unknown3=1 247 | Unknown4=0 248 | Unknown5=1 249 | Unknown6=0 250 | Unknown7=0 251 | CanBeBurnt=0 252 | Unknown9=1 253 | Unknown10=0 254 | Unknown11=0 255 | NameId=66 ; "Tiberium" 256 | Unknown13=5 257 | 258 | [TI9] 259 | Unknown1=0 260 | Unknown2=0 261 | Unknown3=1 262 | Unknown4=0 263 | Unknown5=1 264 | Unknown6=0 265 | Unknown7=0 266 | CanBeBurnt=0 267 | Unknown9=1 268 | Unknown10=0 269 | Unknown11=0 270 | NameId=66 ; "Tiberium" 271 | Unknown13=5 272 | 273 | [TI10] 274 | Unknown1=0 275 | Unknown2=0 276 | Unknown3=1 277 | Unknown4=0 278 | Unknown5=1 279 | Unknown6=0 280 | Unknown7=0 281 | CanBeBurnt=0 282 | Unknown9=1 283 | Unknown10=0 284 | Unknown11=0 285 | NameId=66 ; "Tiberium" 286 | Unknown13=5 287 | 288 | [TI11] 289 | Unknown1=0 290 | Unknown2=0 291 | Unknown3=1 292 | Unknown4=0 293 | Unknown5=1 294 | Unknown6=0 295 | Unknown7=0 296 | CanBeBurnt=0 297 | Unknown9=1 298 | Unknown10=0 299 | Unknown11=0 300 | NameId=66 ; "Tiberium" 301 | Unknown13=5 302 | 303 | [TI12] 304 | Unknown1=0 305 | Unknown2=0 306 | Unknown3=1 307 | Unknown4=0 308 | Unknown5=1 309 | Unknown6=0 310 | Unknown7=0 311 | CanBeBurnt=0 312 | Unknown9=1 313 | Unknown10=0 314 | Unknown11=0 315 | NameId=66 ; "Tiberium" 316 | Unknown13=5 317 | 318 | [SQUISH] 319 | Unknown1=0 320 | Unknown2=0 ; putting this to '1' enables the squish mark ingame 321 | Unknown3=0 322 | Unknown4=0 323 | Unknown5=0 324 | Unknown6=0 325 | Unknown7=0 326 | CanBeBurnt=0 327 | Unknown9=0 328 | Unknown10=0 329 | Unknown11=0 330 | NameId=69 ; "Squish mark" 331 | Unknown13=0 332 | 333 | [V12] 334 | Unknown1=0 335 | Unknown2=0 336 | Unknown3=1 337 | Unknown4=0 338 | Unknown5=0 339 | Unknown6=1 340 | Unknown7=0 341 | CanBeBurnt=0 342 | Unknown9=0 343 | Unknown10=0 344 | Unknown11=0 345 | NameId=129 ; "Haystacks" 346 | Unknown13=3 347 | 348 | [V13] 349 | Unknown1=0 350 | Unknown2=0 351 | Unknown3=1 352 | Unknown4=0 353 | Unknown5=0 354 | Unknown6=1 355 | Unknown7=0 356 | CanBeBurnt=0 357 | Unknown9=0 358 | Unknown10=0 359 | Unknown11=0 360 | NameId=130 ; "Haystack" 361 | Unknown13=3 362 | 363 | [V14] 364 | Unknown1=0 365 | Unknown2=0 366 | Unknown3=1 367 | Unknown4=0 368 | Unknown5=0 369 | Unknown6=1 370 | Unknown7=0 371 | CanBeBurnt=0 372 | Unknown9=0 373 | Unknown10=0 374 | Unknown11=0 375 | NameId=131 ; "Wheat Field" 376 | Unknown13=3 377 | 378 | [V15] 379 | Unknown1=0 380 | Unknown2=0 381 | Unknown3=1 382 | Unknown4=0 383 | Unknown5=0 384 | Unknown6=1 385 | Unknown7=0 386 | CanBeBurnt=0 387 | Unknown9=0 388 | Unknown10=0 389 | Unknown11=0 390 | NameId=132 ; "Fallow Field" 391 | Unknown13=3 392 | 393 | [V16] 394 | Unknown1=0 395 | Unknown2=0 396 | Unknown3=1 397 | Unknown4=0 398 | Unknown5=0 399 | Unknown6=1 400 | Unknown7=0 401 | CanBeBurnt=0 402 | Unknown9=0 403 | Unknown10=0 404 | Unknown11=0 405 | NameId=133 ; "Corn Field" 406 | Unknown13=3 407 | 408 | [V17] 409 | Unknown1=0 410 | Unknown2=0 411 | Unknown3=1 412 | Unknown4=0 413 | Unknown5=0 414 | Unknown6=1 415 | Unknown7=0 416 | CanBeBurnt=0 417 | Unknown9=0 418 | Unknown10=0 419 | Unknown11=0 420 | NameId=134 ; "Celery Field" 421 | Unknown13=3 422 | 423 | [V18] 424 | Unknown1=0 425 | Unknown2=0 426 | Unknown3=1 427 | Unknown4=0 428 | Unknown5=0 429 | Unknown6=1 430 | Unknown7=0 431 | CanBeBurnt=0 432 | Unknown9=0 433 | Unknown10=0 434 | Unknown11=0 435 | NameId=135 ; "Potato Field" 436 | Unknown13=3 437 | 438 | [FPLS] 439 | Unknown1=0 440 | Unknown2=0 441 | Unknown3=0 442 | Unknown4=0 443 | Unknown5=0 444 | Unknown6=0 445 | Unknown7=0 446 | CanBeBurnt=0 447 | Unknown9=1 448 | Unknown10=0 449 | Unknown11=0 450 | NameId=240 ; "Flag Location" 451 | Unknown13=0 452 | 453 | [WCRATE] 454 | Unknown1=1 455 | Unknown2=0 456 | Unknown3=0 457 | Unknown4=0 458 | Unknown5=0 459 | Unknown6=0 460 | Unknown7=0 461 | CanBeBurnt=0 462 | Unknown9=0 463 | Unknown10=0 464 | Unknown11=0 465 | NameId=239 ; "Wood Crate" 466 | Unknown13=0 467 | 468 | [SCRATE] 469 | Unknown1=1 470 | Unknown2=0 471 | Unknown3=0 472 | Unknown4=0 473 | Unknown5=0 474 | Unknown6=0 475 | Unknown7=0 476 | CanBeBurnt=0 477 | Unknown9=0 478 | Unknown10=0 479 | Unknown11=0 480 | NameId=238 ; "Steel Crate" 481 | Unknown13=0 482 | -------------------------------------------------------------------------------- /data/GAME.DAT/sounds.ini: -------------------------------------------------------------------------------- 1 | [Sounds] 2 | 0=Bombit1 3 | 1=Cmon1 4 | 2=Gotit1 5 | 3=Keepem1 6 | 4=Laugh1 7 | 5=Lefty1 8 | 6=Noprblm1 9 | 7=Onit1 10 | 8=Ramyell1 11 | 9=Rokroll1 12 | 10=Tuffguy1 13 | 11=Yeah1 14 | 12=Yes1 15 | 13=Yo1 16 | 14=Girlokay 17 | 15=Girlyeah 18 | 16=Guyokay1 19 | 17=Guyyeah1 20 | 18=2dangr1 21 | 19=Ackno 22 | 20=Affirm1 23 | 21=Await1 24 | 22=Movout1 25 | 23=Negatv1 26 | 24=Noprob 27 | 25=Ready 28 | 26=Report1 29 | 27=Ritaway 30 | 28=Roger 31 | 29=Ugotit 32 | 30=Unit1 33 | 31=Vehic1 34 | 32=Yessir1 35 | 33=Bazook1 36 | 34=Bleep2 37 | 35=Bomb1 38 | 36=Button 39 | 37=Comcntr1 40 | 38=Constru2 41 | 39=Crumble 42 | 40=Flamer2 43 | 41=Gun18 44 | 42=Gun19 45 | 43=Gun20 46 | 44=Gun5 47 | 45=Gun8 48 | 46=Gunclip1 49 | 47=Hvydoor1 50 | 48=Hvygun10 51 | 49=Ion1 52 | 50=Mgun11 53 | 51=Mgun2 54 | 52=Nukemisl 55 | 53=Nukexplo 56 | 54=Obelray1 57 | 55=Obelpowr 58 | 56=Powrdn1 59 | 57=Ramgun2 60 | 58=Rocket1 61 | 59=Rocket2 62 | 60=Sammotr2 63 | 61=Scold2 64 | 62=Sidbar1c 65 | 63=Sidbar2c 66 | 64=Squish2 67 | 65=Tnkfire2 68 | 66=Tnkfire3 69 | 67=Tnkfire4 70 | 68=Tnkfire6 71 | 69=Tone15 72 | 70=Tone16 73 | 71=Tone2 74 | 72=Tone5 75 | 73=Toss 76 | 74=Trans1 77 | 75=Treebrn1 78 | 76=Turrfir5 79 | 77=Xplobig4 80 | 78=Xplobig6 81 | 79=Xplobig7 82 | 80=Xplode 83 | 81=Xplos 84 | 82=Xplosml2 85 | 83=Nuyell1 86 | 84=Nuyell3 87 | 85=Nuyell4 88 | 86=Nuyell5 89 | 87=Nuyell6 90 | 88=Nuyell7 91 | 89=Nuyell10 92 | 90=Nuyell11 93 | 91=Nuyell12 94 | 92=Yell1 95 | 93=Myes1 96 | 94=Mcomnd1 97 | 95=Mhello1 98 | 96=Mhmmm1 99 | 97=Mplan3 100 | 98=Mcourse1 101 | 99=Myesyes1 102 | 100=Mtiber1 103 | 101=Mthanks1 104 | 102=Cashturn 105 | 103=Bleep2 106 | 104=Dinomout 107 | 105=Dinoyes 108 | 106=Dinoatk1 109 | 107=Dinodie1 110 | 111 | ; Volume: might be wrong, but the code seems to use this as volume modifier somehow. 112 | ; After recent discoveries in the Dune II code, this MAY also be a kind of sound priority. 113 | ; Class: stored as 0/1/2 internally. 114 | ; Normal: No special options: play .aud 115 | ; HasBeta: Has a .juv beta sound. If beta sounds option is enabled, play the .juv 116 | ; VoiceSet: Unit response voice set: .v00/.v02 for vehicles, .v01/.v03 for infantry 117 | 118 | [Bombit1] 119 | Volume=20 120 | Class=Normal 121 | 122 | [Cmon1] 123 | Volume=20 124 | Class=Normal 125 | 126 | [Gotit1] 127 | Volume=20 128 | Class=Normal 129 | 130 | [Keepem1] 131 | Volume=20 132 | Class=Normal 133 | 134 | [Laugh1] 135 | Volume=20 136 | Class=Normal 137 | 138 | [Lefty1] 139 | Volume=20 140 | Class=Normal 141 | 142 | [Noprblm1] 143 | Volume=20 144 | Class=Normal 145 | 146 | [Onit1] 147 | Volume=20 148 | Class=Normal 149 | 150 | [Ramyell1] 151 | Volume=20 152 | Class=Normal 153 | 154 | [Rokroll1] 155 | Volume=20 156 | Class=Normal 157 | 158 | [Tuffguy1] 159 | Volume=20 160 | Class=Normal 161 | 162 | [Yeah1] 163 | Volume=20 164 | Class=Normal 165 | 166 | [Yes1] 167 | Volume=20 168 | Class=Normal 169 | 170 | [Yo1] 171 | Volume=20 172 | Class=Normal 173 | 174 | [Girlokay] 175 | Volume=20 176 | Class=Normal 177 | 178 | [Girlyeah] 179 | Volume=20 180 | Class=Normal 181 | 182 | [Guyokay1] 183 | Volume=20 184 | Class=Normal 185 | 186 | [Guyyeah1] 187 | Volume=20 188 | Class=Normal 189 | 190 | [2dangr1] 191 | Volume=10 192 | Class=VoiceSet 193 | 194 | [Ackno] 195 | Volume=10 196 | Class=VoiceSet 197 | 198 | [Affirm1] 199 | Volume=10 200 | Class=VoiceSet 201 | 202 | [Await1] 203 | Volume=10 204 | Class=VoiceSet 205 | 206 | [Movout1] 207 | Volume=10 208 | Class=VoiceSet 209 | 210 | [Negatv1] 211 | Volume=10 212 | Class=VoiceSet 213 | 214 | [Noprob] 215 | Volume=10 216 | Class=VoiceSet 217 | 218 | [Ready] 219 | Volume=10 220 | Class=VoiceSet 221 | 222 | [Report1] 223 | Volume=10 224 | Class=VoiceSet 225 | 226 | [Ritaway] 227 | Volume=10 228 | Class=VoiceSet 229 | 230 | [Roger] 231 | Volume=10 232 | Class=VoiceSet 233 | 234 | [Ugotit] 235 | Volume=10 236 | Class=VoiceSet 237 | 238 | [Unit1] 239 | Volume=10 240 | Class=VoiceSet 241 | 242 | [Vehic1] 243 | Volume=10 244 | Class=VoiceSet 245 | 246 | [Yessir1] 247 | Volume=10 248 | Class=VoiceSet 249 | 250 | [Bazook1] 251 | Volume=1 252 | Class=HasBeta 253 | 254 | [Bleep2] 255 | Volume=1 256 | Class=HasBeta 257 | 258 | [Bomb1] 259 | Volume=1 260 | Class=HasBeta 261 | 262 | [Button] 263 | Volume=1 264 | Class=HasBeta 265 | 266 | [Comcntr1] 267 | Volume=10 268 | Class=HasBeta 269 | 270 | [Constru2] 271 | Volume=10 272 | Class=HasBeta 273 | 274 | [Crumble] 275 | Volume=1 276 | Class=HasBeta 277 | 278 | [Flamer2] 279 | Volume=4 280 | Class=HasBeta 281 | 282 | [Gun18] 283 | Volume=4 284 | Class=HasBeta 285 | 286 | [Gun19] 287 | Volume=4 288 | Class=HasBeta 289 | 290 | [Gun20] 291 | Volume=4 292 | Class=HasBeta 293 | 294 | [Gun5] 295 | Volume=4 296 | Class=HasBeta 297 | 298 | [Gun8] 299 | Volume=4 300 | Class=HasBeta 301 | 302 | [Gunclip1] 303 | Volume=1 304 | Class=HasBeta 305 | 306 | [Hvydoor1] 307 | Volume=5 308 | Class=HasBeta 309 | 310 | [Hvygun10] 311 | Volume=1 312 | Class=HasBeta 313 | 314 | [Ion1] 315 | Volume=1 316 | Class=HasBeta 317 | 318 | [Mgun11] 319 | Volume=1 320 | Class=HasBeta 321 | 322 | [Mgun2] 323 | Volume=1 324 | Class=HasBeta 325 | 326 | [Nukemisl] 327 | Volume=1 328 | Class=HasBeta 329 | 330 | [Nukexplo] 331 | Volume=1 332 | Class=HasBeta 333 | 334 | [Obelray1] 335 | Volume=1 336 | Class=HasBeta 337 | 338 | [Obelpowr] 339 | Volume=1 340 | Class=HasBeta 341 | 342 | [Powrdn1] 343 | Volume=1 344 | Class=HasBeta 345 | 346 | [Ramgun2] 347 | Volume=1 348 | Class=HasBeta 349 | 350 | [Rocket1] 351 | Volume=1 352 | Class=HasBeta 353 | 354 | [Rocket2] 355 | Volume=1 356 | Class=HasBeta 357 | 358 | [Sammotr2] 359 | Volume=1 360 | Class=HasBeta 361 | 362 | [Scold2] 363 | Volume=1 364 | Class=HasBeta 365 | 366 | [Sidbar1c] 367 | Volume=1 368 | Class=HasBeta 369 | 370 | [Sidbar2c] 371 | Volume=1 372 | Class=HasBeta 373 | 374 | [Squish2] 375 | Volume=1 376 | Class=HasBeta 377 | 378 | [Tnkfire2] 379 | Volume=1 380 | Class=HasBeta 381 | 382 | [Tnkfire3] 383 | Volume=1 384 | Class=HasBeta 385 | 386 | [Tnkfire4] 387 | Volume=1 388 | Class=HasBeta 389 | 390 | [Tnkfire6] 391 | Volume=1 392 | Class=HasBeta 393 | 394 | [Tone15] 395 | Volume=0 396 | Class=HasBeta 397 | 398 | [Tone16] 399 | Volume=0 400 | Class=HasBeta 401 | 402 | [Tone2] 403 | Volume=1 404 | Class=HasBeta 405 | 406 | [Tone5] 407 | Volume=10 408 | Class=HasBeta 409 | 410 | [Toss] 411 | Volume=1 412 | Class=HasBeta 413 | 414 | [Trans1] 415 | Volume=1 416 | Class=HasBeta 417 | 418 | [Treebrn1] 419 | Volume=1 420 | Class=HasBeta 421 | 422 | [Turrfir5] 423 | Volume=1 424 | Class=HasBeta 425 | 426 | [Xplobig4] 427 | Volume=5 428 | Class=HasBeta 429 | 430 | [Xplobig6] 431 | Volume=5 432 | Class=HasBeta 433 | 434 | [Xplobig7] 435 | Volume=5 436 | Class=HasBeta 437 | 438 | [Xplode] 439 | Volume=1 440 | Class=HasBeta 441 | 442 | [Xplos] 443 | Volume=4 444 | Class=HasBeta 445 | 446 | [Xplosml2] 447 | Volume=5 448 | Class=HasBeta 449 | 450 | [Nuyell1] 451 | Volume=10 452 | Class=Normal 453 | 454 | [Nuyell3] 455 | Volume=10 456 | Class=Normal 457 | 458 | [Nuyell4] 459 | Volume=10 460 | Class=Normal 461 | 462 | [Nuyell5] 463 | Volume=10 464 | Class=Normal 465 | 466 | [Nuyell6] 467 | Volume=10 468 | Class=Normal 469 | 470 | [Nuyell7] 471 | Volume=10 472 | Class=Normal 473 | 474 | [Nuyell10] 475 | Volume=10 476 | Class=Normal 477 | 478 | [Nuyell11] 479 | Volume=10 480 | Class=Normal 481 | 482 | [Nuyell12] 483 | Volume=10 484 | Class=Normal 485 | 486 | [Yell1] 487 | Volume=1 488 | Class=Normal 489 | 490 | [Myes1] 491 | Volume=10 492 | Class=Normal 493 | 494 | [Mcomnd1] 495 | Volume=10 496 | Class=Normal 497 | 498 | [Mhello1] 499 | Volume=10 500 | Class=Normal 501 | 502 | [Mhmmm1] 503 | Volume=10 504 | Class=Normal 505 | 506 | [Mplan3] 507 | Volume=10 508 | Class=Normal 509 | 510 | [Mcourse1] 511 | Volume=10 512 | Class=Normal 513 | 514 | [Myesyes1] 515 | Volume=10 516 | Class=Normal 517 | 518 | [Mtiber1] 519 | Volume=10 520 | Class=Normal 521 | 522 | [Mthanks1] 523 | Volume=10 524 | Class=Normal 525 | 526 | [Cashturn] 527 | Volume=1 528 | Class=Normal 529 | 530 | [Bleep2] 531 | Volume=10 532 | Class=Normal 533 | 534 | [Dinomout] 535 | Volume=10 536 | Class=Normal 537 | 538 | [Dinoyes] 539 | Volume=10 540 | Class=Normal 541 | 542 | [Dinoatk1] 543 | Volume=10 544 | Class=Normal 545 | 546 | [Dinodie1] 547 | Volume=10 548 | Class=Normal 549 | 550 | 551 | -------------------------------------------------------------------------------- /src/game/entities/unit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cnsjs - JavaScript C&C Remake 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | 7 | import { Animation, Sprite } from '../../engine'; 8 | import { MIXUnit, humanDirections } from '../mix'; 9 | import { findClosestPosition, CELL_SIZE } from '../physics'; 10 | import { spriteFromName } from '../sprites'; 11 | import { GameMapEntity } from './mapentity'; 12 | import { TiberiumEntity } from './tiberium'; 13 | import { Vector } from 'vector2d'; 14 | 15 | /** 16 | * Unit Entity 17 | */ 18 | export class UnitEntity extends GameMapEntity { 19 | public readonly properties: MIXUnit = this.engine.mix.units.get(this.data.name) as MIXUnit; 20 | protected dimension: Vector = new Vector(24, 24); 21 | protected wakeSprite?: Sprite; 22 | protected wakeAnimation?: Animation; 23 | protected damagedSmoke?: Sprite; 24 | protected damagedSmokeAnimation?: Animation; 25 | protected reportSelect?: string = 'AWAIT1'; 26 | protected reportMove?: string = 'ACKNO'; 27 | protected reportAttack?: string = 'ACKNO'; 28 | protected zIndex: number = 3; 29 | protected harvesting: boolean = false; 30 | 31 | public toJson(): any { 32 | return { 33 | ...super.toJson(), 34 | type: 'unit' 35 | }; 36 | } 37 | 38 | public async init(): Promise { 39 | if (this.properties.HasTurret) { 40 | this.turretDirection = this.direction; 41 | } 42 | 43 | if (this.data.name === 'HARV') { 44 | this.storageSlots[1] = 10; 45 | } 46 | 47 | await super.init(); 48 | 49 | if (!this.sprite) { 50 | return; 51 | } 52 | 53 | if (this.data.name === 'BOAT') { // FIXME 54 | // FIXME 55 | this.dimension = this.sprite.size.clone() as Vector; 56 | this.directions = 2; 57 | this.direction = this.direction > 16 ? 1 : 0; 58 | 59 | this.wakeSprite = spriteFromName('CONQUER.MIX/wake.png'); 60 | 61 | const half = this.wakeSprite.frames / 2; 62 | this.wakeAnimation = new Animation('Idle', new Vector(0, 0), half, 0.2); 63 | 64 | await this.engine.loadArchiveSprite(this.wakeSprite); 65 | } else { 66 | if (this.sprite.size.x > CELL_SIZE) { 67 | this.offset.setX((this.sprite.size.x / 2) - (CELL_SIZE / 2)); 68 | } 69 | if (this.sprite.size.y > CELL_SIZE) { 70 | this.offset.setY((this.sprite.size.y / 2) - (CELL_SIZE / 2)); 71 | } 72 | 73 | this.damagedSmoke = spriteFromName('CONQUER.MIX/smoke_m.png'); 74 | this.damagedSmokeAnimation = new Animation('Damaged-Smoke', new Vector(0, 0), this.damagedSmoke.frames, 0.5); 75 | await this.engine.loadArchiveSprite(this.damagedSmoke); 76 | } 77 | } 78 | 79 | public deploy(): void { 80 | if (this.isDeployable()) { 81 | this.destroy(); 82 | 83 | this.map.factory.load('structure', { 84 | name: 'FACT', 85 | player: this.data.player, 86 | cell: this.cell 87 | }); 88 | } 89 | } 90 | 91 | protected moveTo(position: Vector, report: boolean = false, force: boolean = false): boolean { 92 | this.animation = ''; 93 | return super.moveTo(position, report, force); 94 | } 95 | 96 | protected returnHome(): void { 97 | if (this.targetPosition) { 98 | return; 99 | } 100 | 101 | if (this.canHarvest()) { 102 | const procs = this.map.getEntities() 103 | .filter(e => e.getPlayerId() === this.getPlayerId()) 104 | .filter(e => e.isRefinery()); 105 | // FIXME: Damage state 106 | 107 | const positions = procs.map(e => e.getPosition()); 108 | const closest = findClosestPosition(this.position, positions); 109 | 110 | if (closest === -1) { 111 | console.log('Could not find a refinery.') 112 | } else { 113 | const proc = procs[closest] as GameMapEntity; 114 | this.targetAction = 'harvest-return'; 115 | this.enter(proc, false); 116 | } 117 | 118 | console.error(closest, this.targetEntity) 119 | } 120 | } 121 | 122 | protected harvestResource(target: GameMapEntity): void { 123 | if (this.storageSlots[0] >= this.storageSlots[1]) { 124 | this.animation = ''; 125 | this.targetEntity = undefined; 126 | this.returnHome(); 127 | return; 128 | } 129 | 130 | const dir = Math.round(this.getDirection() / 4); 131 | const hdir = humanDirections[dir]; 132 | const animationName = `Harvest-${hdir}`; 133 | 134 | if (this.animation === animationName) { 135 | const animation = this.animations.get(this.animation); 136 | if (animation) { 137 | const end = animation.getOffset().y + animation.getCount() - 1; 138 | if (this.frame.y >= end) { 139 | if (!this.harvesting) { 140 | const target = this.targetEntity as TiberiumEntity; 141 | if (target && target.hasTiberium()) { 142 | target.subtractTiberium(); 143 | this.storageSlots[0]++; 144 | } 145 | } 146 | this.harvesting = true; 147 | } else { 148 | this.harvesting = false; 149 | } 150 | } 151 | } 152 | 153 | this.animation = animationName; 154 | } 155 | 156 | public die(): boolean { 157 | // TODO: Crowded unit should spawn a low-health infantry 158 | if (super.die()) { 159 | const name = this.properties!.DeathAnimation; 160 | 161 | this.map.factory.load('effect', { 162 | name, 163 | cell: this.cell.clone() as Vector 164 | }, (effect: any) => effect.setCenterEntity(this)); 165 | 166 | this.destroy(); 167 | 168 | return true; 169 | } 170 | 171 | return false; 172 | } 173 | 174 | public onUpdate(deltaTime: number): void { 175 | super.onUpdate(deltaTime); 176 | 177 | if (this.wakeAnimation) { 178 | this.wakeAnimation.onUpdate(); 179 | } 180 | 181 | if (this.damagedSmokeAnimation) { 182 | this.damagedSmokeAnimation.onUpdate(); 183 | } 184 | 185 | if (this.targetAction === 'harvest-return') { 186 | if (this.currentPath.length === 0) { 187 | if (this.direction !== 14) { 188 | // TODO 189 | } else { 190 | this.targetDirection = 14; 191 | } 192 | } 193 | } 194 | } 195 | 196 | public onRender(deltaTime: number): void { 197 | super.onRender(deltaTime); 198 | 199 | if (!this.sprite) { 200 | return; 201 | } 202 | 203 | const context = this.map.objects.getContext(); 204 | const position = this.getTruncatedPosition(this.offset); 205 | 206 | if (this.wakeSprite) { 207 | const o = new Vector(0, this.direction === 0 ? this.wakeSprite.frames / 2 : 0); 208 | const f = this.wakeAnimation!.getFrameIndex(o); 209 | const p = position.clone().add(new Vector( 210 | -this.dimension.x / 2, 211 | this.dimension.y / 2 212 | )) as Vector; 213 | 214 | this.wakeSprite.render(f, p, context); 215 | } 216 | 217 | if (this.data.name === 'BOAT') { 218 | const spriteOffset = (this.sprite.frames / 2) * this.direction; 219 | const turretOffset = Math.round(this.turretDirection); 220 | const frame = new Vector(this.frameOffset.x, spriteOffset + turretOffset); 221 | this.sprite.render(frame, position, context); 222 | } else { 223 | const frameY = this.animation !== '' ? this.frame.y : Math.round(this.direction); 224 | const frame = new Vector(this.frameOffset.x, frameY); 225 | this.sprite.render(frame, position, context); 226 | 227 | if (this.properties.HasTurret) { 228 | const turretFrame = new Vector( 229 | this.frameOffset.x, 230 | Math.round(this.turretDirection) + this.sprite.frames / 2 231 | ); 232 | this.sprite.render(turretFrame, position, context); 233 | } 234 | 235 | if (this.getDamageState() > 1 && this.damagedSmoke) { 236 | const frame = this.damagedSmokeAnimation!.getFrameIndex(); 237 | const frameposition = position.clone().add(new Vector( 238 | this.damagedSmoke.size.x / 2, 239 | 0 240 | )) as Vector; 241 | 242 | this.damagedSmoke.render(frame, frameposition, context); 243 | } 244 | } 245 | } 246 | 247 | public getRotationSpeed(): number { 248 | return this.properties!.TurnSpeed; 249 | } 250 | 251 | public canRotate(): boolean { 252 | return !this.properties!.CantTurn; 253 | } 254 | 255 | public canFireTwice(): boolean { 256 | return !!this.properties!.FiresTwice; 257 | } 258 | 259 | public isDeployable(): boolean { 260 | return this.isPlayer() && this.data.name === 'MCV'; 261 | } 262 | 263 | public isSelectable(): boolean { 264 | return true; 265 | } 266 | 267 | public isMovable(): boolean { 268 | return this.data.name !== 'BOAT'; // FIXME 269 | } 270 | 271 | public isUnit(): boolean { 272 | return true; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/engine/mouse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tesen - Simple TypeScript 2D Canvas Game Engine 3 | * @author Anders Evenrud 4 | * @license MIT 5 | */ 6 | import { IODevice } from './io'; 7 | import { Vector } from 'vector2d'; 8 | 9 | /** 10 | * How many pixels the curor can move (maximum) before a click is rejected 11 | */ 12 | export const CLICK_MOVEMENT_GRACE = 8; 13 | 14 | /** 15 | * Mouse button map 16 | */ 17 | export const mouseButtonMap: MouseButton[] = ['left', 'middle', 'right']; 18 | 19 | /** 20 | * Mouse button type 21 | */ 22 | export type MouseButton = 'left' | 'middle' | 'right'; 23 | 24 | /** 25 | * Mouse position 26 | */ 27 | export interface MousePosition { 28 | x: number; 29 | y: number; 30 | z: number; 31 | } 32 | 33 | /** 34 | * Mouse Device 35 | */ 36 | export class MouseInput extends IODevice { 37 | private activeButtons: Set = new Set(); 38 | private activePresses: Set = new Set(); 39 | private position: MousePosition = { x: 0, y: 0, z: 0 }; 40 | private lastTouchPosition: Vector = new Vector(0, 0); 41 | private wheelTimeout?: any; 42 | private pressStart?: Vector; 43 | private locked: boolean = false; 44 | 45 | /** 46 | * Initializes mouse input 47 | */ 48 | public async init(): Promise { 49 | console.debug('MouseInput::init()'); 50 | 51 | const onMouseDown = this.onMouseDown.bind(this); 52 | const onMouseUp = this.onMouseUp.bind(this); 53 | const onMouseMove = this.onMouseMove.bind(this); 54 | const onMouseWheel = this.onMouseWheel.bind(this); 55 | 56 | const onTouchStart = this.onTouchStart.bind(this); 57 | const onTouchEnd = this.onTouchEnd.bind(this); 58 | const onTouchMove = this.onTouchMove.bind(this); 59 | 60 | const onPointerLockChange = this.onPointerLockChange.bind(this); 61 | 62 | document.addEventListener('touchstart', onTouchStart); 63 | document.addEventListener('touchend', onTouchEnd); 64 | document.addEventListener('touchmove', onTouchMove); 65 | 66 | document.addEventListener('mousedown', onMouseDown); 67 | document.addEventListener('mouseup', onMouseUp); 68 | document.addEventListener('mousemove', onMouseMove); 69 | document.addEventListener('wheel', onMouseWheel); 70 | 71 | document.addEventListener('pointerlockchange', onPointerLockChange); 72 | 73 | this.reset(); 74 | this.lockCursor(); 75 | } 76 | 77 | /** 78 | * Convert to string (for debugging) 79 | */ 80 | public toString(): string { 81 | return [ 82 | `[${Array.from(this.activeButtons).join(',')}]`, 83 | [this.position.x, this.position.y, this.position.z].join(','), 84 | this.locked ? 'locked' : 'unlocked' 85 | ].join(' '); 86 | } 87 | 88 | /** 89 | * Restores IO from a paused state 90 | */ 91 | public restore(): void { 92 | this.clear(); 93 | } 94 | 95 | /** 96 | * Rests all states 97 | */ 98 | public reset(): void { 99 | this.clear(); 100 | 101 | const dim = this.engine.getScaledDimension(); 102 | this.position.x = Math.round(dim.x / 2); 103 | this.position.y = Math.round(dim.y / 2); 104 | } 105 | 106 | /** 107 | * Clears button states 108 | */ 109 | public clear(): void { 110 | this.activeButtons.clear(); 111 | this.activePresses.clear(); 112 | } 113 | 114 | /** 115 | * Try to lock the cursor 116 | */ 117 | private lockCursor(): void { 118 | if (this.locked) { 119 | return; 120 | } 121 | 122 | if (this.engine.configuration.cursorLock) { 123 | this.engine.getCanvas().requestPointerLock(); 124 | } 125 | } 126 | 127 | /** 128 | * On every game tick 129 | */ 130 | public onUpdate(): void { 131 | this.activePresses.clear(); 132 | } 133 | 134 | /** 135 | * Touch start 136 | */ 137 | private onTouchStart(ev: TouchEvent): void { 138 | const v = new Vector( 139 | ev.touches[0].clientX, 140 | ev.touches[0].clientY 141 | ); 142 | 143 | this.lastTouchPosition = v; 144 | this.onPointerMove(v); 145 | this.onPointerDown('left', v); 146 | 147 | ev.preventDefault(); 148 | } 149 | 150 | /** 151 | * Touch end 152 | */ 153 | private onTouchEnd(ev: TouchEvent): void { 154 | ev.preventDefault(); 155 | 156 | this.onPointerUp('left', this.lastTouchPosition); 157 | } 158 | 159 | /** 160 | * Touch move 161 | */ 162 | private onTouchMove(ev: TouchEvent): void { 163 | const v = new Vector( 164 | ev.touches[0].clientX, 165 | ev.touches[0].clientY 166 | ); 167 | 168 | this.lastTouchPosition = v; 169 | this.onPointerMove(v); 170 | } 171 | 172 | /** 173 | * Mouse down 174 | */ 175 | private onMouseDown(ev: MouseEvent): void { 176 | ev.preventDefault(); 177 | 178 | this.lockCursor(); 179 | this.engine.sound.restoreSound(); 180 | 181 | const btn: MouseButton = mouseButtonMap[ev.button]; 182 | this.onPointerDown(btn, new Vector(ev.clientX, ev.clientY)); 183 | } 184 | 185 | /** 186 | * Mouse up 187 | */ 188 | private onMouseUp(ev: MouseEvent): void { 189 | ev.preventDefault(); 190 | 191 | const btn: MouseButton = mouseButtonMap[ev.button]; 192 | this.onPointerUp(btn, new Vector(ev.clientX, ev.clientY)); 193 | } 194 | 195 | /** 196 | * Mouse move 197 | */ 198 | private onMouseMove(ev: MouseEvent): void { 199 | if (this.locked) { 200 | this.onPointerMove(new Vector(ev.movementX, ev.movementY), true); 201 | } else { 202 | this.onPointerMove(new Vector(ev.clientX, ev.clientY)); 203 | } 204 | } 205 | 206 | /** 207 | * Pointer lock changes 208 | */ 209 | private onPointerLockChange(ev: Event): void { 210 | this.locked = document.pointerLockElement === this.engine.getCanvas(); 211 | } 212 | 213 | /** 214 | * Handle pointer move 215 | */ 216 | private onPointerMove(current: Vector, relative: boolean = false): void { 217 | const scale = this.engine.getScale(); 218 | 219 | if (relative) { 220 | this.position.x += Math.trunc(current.x / scale); 221 | this.position.y += Math.trunc(current.y / scale); 222 | } else { 223 | this.position.x = Math.trunc(current.x / scale); 224 | this.position.y = Math.trunc(current.y / scale); 225 | } 226 | } 227 | 228 | /** 229 | * Handle pointer down 230 | */ 231 | private onPointerDown(btn: MouseButton, current: Vector): void { 232 | const scale = this.engine.getScale(); 233 | const relCurrent = current.clone().divS(scale) as Vector; 234 | this.pressStart = new Vector( 235 | Math.trunc(relCurrent.x), 236 | Math.trunc(relCurrent.y) 237 | ); 238 | this.activeButtons.add(btn); 239 | } 240 | 241 | /** 242 | * Handle pointer up 243 | */ 244 | private onPointerUp(btn: MouseButton, current: Vector): void { 245 | if (this.activeButtons.has(btn)) { 246 | if (this.pressStart) { 247 | const scale = this.engine.getScale(); 248 | const relCurrent = current.clone().divS(scale) as Vector; 249 | const distance = this.pressStart.distance(relCurrent); 250 | if (distance < CLICK_MOVEMENT_GRACE) { 251 | this.activePresses.add(btn); 252 | } 253 | } else { 254 | this.activePresses.add(btn); 255 | } 256 | } 257 | 258 | this.activeButtons.delete(btn); 259 | this.pressStart = undefined; 260 | } 261 | 262 | /** 263 | * Mouse wheel 264 | */ 265 | private onMouseWheel(ev: MouseWheelEvent): void { 266 | if (this.wheelTimeout) { 267 | clearTimeout(this.wheelTimeout); 268 | } 269 | 270 | this.position.z = Math.sign(ev.deltaY); 271 | this.wheelTimeout = setTimeout((): void => { 272 | this.position.z = 0; 273 | }, 100); 274 | } 275 | 276 | /** 277 | * Get current position 278 | */ 279 | public getPosition(): MousePosition { 280 | return { 281 | x: this.position.x, 282 | y: this.position.y, 283 | z: this.position.z 284 | }; 285 | } 286 | 287 | /** 288 | * Gets position as vector (without scroll/z) 289 | */ 290 | public getVector(): Vector { 291 | return new Vector(this.position.x, this.position.y); 292 | } 293 | 294 | /** 295 | * Check if button is pressed 296 | */ 297 | public isPressed(button?: MouseButton | MouseButton[]): boolean { 298 | if (button instanceof Array) { 299 | return button.some((b): boolean => this.activeButtons.has(b)); 300 | } 301 | 302 | return typeof button === 'undefined' 303 | ? this.activeButtons.size > 0 304 | : this.activeButtons.has(button); 305 | } 306 | 307 | /** 308 | * Check is button was clicked 309 | */ 310 | public wasClicked(button?: MouseButton | MouseButton[]): boolean { 311 | if (button instanceof Array) { 312 | return button.some((b): boolean => this.activePresses.has(b)); 313 | } 314 | 315 | return typeof button === 'undefined' 316 | ? this.activePresses.size > 0 317 | : this.activePresses.has(button); 318 | } 319 | } 320 | --------------------------------------------------------------------------------