Compare commits
No commits in common. "gh-pages" and "master" have entirely different histories.
9
.eslintrc.js
Normal file
9
.eslintrc.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
"extends": "airbnb-base",
|
||||||
|
"plugins": [
|
||||||
|
"import"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
}
|
||||||
|
};
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
tags
|
||||||
|
Session.vim
|
20
LICENSE.txt
Normal file
20
LICENSE.txt
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2017 Tyler Hallada
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
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.
|
35
README.md
Normal file
35
README.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Transport
|
||||||
|
|
||||||
|
Work-in-progress procedurally generated train network simulation written in
|
||||||
|
Typescript with PixiJs.
|
||||||
|
|
||||||
|
![Screenshot of simulation](img/screenshot.png)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Clone the repo and then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit `http://localhost:8888` in your browser to view the development
|
||||||
|
build. Edit files in `/src` and see the changes reflected in the browser.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
No tests right now. But, to run lint checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run deploy
|
||||||
|
```
|
BIN
img/screenshot.png
Normal file
BIN
img/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 307 KiB |
@ -12,9 +12,9 @@
|
|||||||
<!-- <meta property="og:image:height" content="300" /> -->
|
<!-- <meta property="og:image:height" content="300" /> -->
|
||||||
<!-- <meta property="og:image:alt" content="Screenshot of the animation in action" /> -->
|
<!-- <meta property="og:image:alt" content="Screenshot of the animation in action" /> -->
|
||||||
<!-- <meta property="og:description" content="A procedurally generated and interactive animation created with PixiJS" /> -->
|
<!-- <meta property="og:description" content="A procedurally generated and interactive animation created with PixiJS" /> -->
|
||||||
<link href="main.css" rel="stylesheet"></head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script type="text/javascript" src="vendors.min.js"></script><script type="text/javascript" src="main.min.js"></script></body>
|
</body>
|
||||||
|
|
||||||
<!-- Google Analytics -->
|
<!-- Google Analytics -->
|
||||||
<script>
|
<script>
|
||||||
|
@ -1 +0,0 @@
|
|||||||
{"version":3,"sources":[],"names":[],"mappings":"","file":"main.css","sourceRoot":""}
|
|
2
main.min.js
vendored
2
main.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
13052
package-lock.json
generated
Normal file
13052
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
package.json
Normal file
55
package.json
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "transport",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "NODE_ENV=development ./node_modules/.bin/webpack-dev-server --mode development",
|
||||||
|
"build": "NODE_ENV=production ./node_modules/.bin/webpack --mode production && cp CNAME dist/",
|
||||||
|
"deploy": "./node_modules/.bin/gh-pages -d dist --remote github",
|
||||||
|
"lint": "./node_modules/.bin/tslint src/",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@git.hallada.net:pixi.git"
|
||||||
|
},
|
||||||
|
"author": "Tyler Hallada",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-beta.42",
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.0.0-beta.42",
|
||||||
|
"@babel/plugin-proposal-object-rest-spread": "^7.0.0-beta.42",
|
||||||
|
"@babel/preset-env": "^7.0.0-beta.42",
|
||||||
|
"babel-loader": "^8.0.0-beta.2",
|
||||||
|
"css-loader": "^0.28.11",
|
||||||
|
"eslint": "^3.17.0",
|
||||||
|
"eslint-config-airbnb-base": "^11.1.1",
|
||||||
|
"eslint-config-standard": "^7.0.0",
|
||||||
|
"eslint-plugin-import": "^2.2.0",
|
||||||
|
"eslint-plugin-promise": "^3.5.0",
|
||||||
|
"eslint-plugin-standard": "^2.1.1",
|
||||||
|
"extract-text-webpack-plugin": "^4.0.0-beta.0",
|
||||||
|
"file-loader": "^1.1.11",
|
||||||
|
"gh-pages": "^1.1.0",
|
||||||
|
"html-webpack-plugin": "^3.1.0",
|
||||||
|
"style-loader": "^0.20.3",
|
||||||
|
"ts-loader": "^4.1.0",
|
||||||
|
"tslint": "^5.9.1",
|
||||||
|
"tslint-config-airbnb": "^5.8.0",
|
||||||
|
"typescript": "^2.8.1",
|
||||||
|
"uglifyjs-webpack-plugin": "^1.2.4",
|
||||||
|
"webpack": "^4.4.1",
|
||||||
|
"webpack-cli": "^2.0.13",
|
||||||
|
"webpack-dev-server": "^3.1.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/pixi.js": "^4.7.2",
|
||||||
|
"@types/stats.js": "^0.17.0",
|
||||||
|
"@types/tinycolor2": "^1.4.0",
|
||||||
|
"pixi-viewport": "^1.5.0",
|
||||||
|
"pixi.js": "^4.7.1",
|
||||||
|
"stats.js": "^0.17.0",
|
||||||
|
"tinycolor2": "^1.4.1"
|
||||||
|
}
|
||||||
|
}
|
41
src/Direction.ts
Normal file
41
src/Direction.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import * as PIXI from 'pixi.js';
|
||||||
|
|
||||||
|
import { angleDegrees } from './utils';
|
||||||
|
|
||||||
|
enum Direction {
|
||||||
|
North, // 0
|
||||||
|
Northeast, // 1
|
||||||
|
East, // 2
|
||||||
|
Southeast, // 3
|
||||||
|
South, // 4
|
||||||
|
Southwest, // 5
|
||||||
|
West, // 6
|
||||||
|
Northwest, // 7
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPointDirection = (pointA: PIXI.Point, pointB: PIXI.Point): Direction => {
|
||||||
|
const angle = angleDegrees(pointA, pointB);
|
||||||
|
let direction = null;
|
||||||
|
if (angle >= 337.5 || angle < 22.5) {
|
||||||
|
direction = Direction.North;
|
||||||
|
} else if (angle >= 22.5 && angle < 67.5) {
|
||||||
|
direction = Direction.Northeast;
|
||||||
|
} else if (angle >= 67.5 && angle < 112.5) {
|
||||||
|
direction = Direction.East;
|
||||||
|
} else if (angle >= 112.5 && angle < 157.5) {
|
||||||
|
direction = Direction.Southeast;
|
||||||
|
} else if (angle >= 157.5 && angle < 202.5) {
|
||||||
|
direction = Direction.South;
|
||||||
|
} else if (angle >= 202.5 && angle < 247.5) {
|
||||||
|
direction = Direction.Southwest;
|
||||||
|
} else if (angle >= 247.5 && angle < 292.5) {
|
||||||
|
direction = Direction.West;
|
||||||
|
} else if (angle >= 292.5 && angle < 337.5) {
|
||||||
|
direction = Direction.Northwest;
|
||||||
|
} else {
|
||||||
|
throw Error('Angle between points is not a valid degree');
|
||||||
|
}
|
||||||
|
return direction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Direction;
|
58
src/Line.ts
Normal file
58
src/Line.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import * as tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
|
import Direction, { getPointDirection } from './Direction';
|
||||||
|
import LineConnection from './LineConnection';
|
||||||
|
import Station from './Station';
|
||||||
|
import { distance, randomInt, randomPoint } from './utils';
|
||||||
|
|
||||||
|
const CONNECTION_RADIUS = Math.floor(Math.sqrt(
|
||||||
|
Math.pow(window.innerWidth, 2) + Math.pow(window.innerHeight, 2),
|
||||||
|
) / 8);
|
||||||
|
|
||||||
|
export default class Line {
|
||||||
|
public name: string;
|
||||||
|
public color: tinycolorInstance;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
color: tinycolorInstance,
|
||||||
|
) {
|
||||||
|
this.name = name;
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public connectStations(
|
||||||
|
currentStation: Station,
|
||||||
|
stations: Station[],
|
||||||
|
visitedStations: Station[],
|
||||||
|
connectionLimit: number,
|
||||||
|
) {
|
||||||
|
visitedStations.push(currentStation);
|
||||||
|
const otherStations = stations.filter(station => station !== currentStation);
|
||||||
|
const closeStations = Station.stationsWithinRadius(
|
||||||
|
otherStations,
|
||||||
|
currentStation.location,
|
||||||
|
CONNECTION_RADIUS,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < connectionLimit; i += 1) {
|
||||||
|
if (closeStations.length < 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const largest = Station.largestStation(closeStations);
|
||||||
|
currentStation.connections.push(
|
||||||
|
new LineConnection(largest, this),
|
||||||
|
);
|
||||||
|
closeStations.splice(closeStations.indexOf(largest), 1);
|
||||||
|
}
|
||||||
|
for (const connectedStation of currentStation.connections) {
|
||||||
|
if (visitedStations.indexOf(connectedStation.station) === -1) {
|
||||||
|
this.connectStations(
|
||||||
|
connectedStation.station,
|
||||||
|
stations,
|
||||||
|
visitedStations,
|
||||||
|
connectionLimit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
src/LineConnection.ts
Normal file
15
src/LineConnection.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Line from './Line';
|
||||||
|
import Station from './Station';
|
||||||
|
|
||||||
|
export default class LineConnection {
|
||||||
|
public station: Station;
|
||||||
|
public line: Line;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
station: Station,
|
||||||
|
line: Line,
|
||||||
|
) {
|
||||||
|
this.station = station;
|
||||||
|
this.line = line;
|
||||||
|
}
|
||||||
|
}
|
11
src/Signal.ts
Normal file
11
src/Signal.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import * as PIXI from 'pixi.js';
|
||||||
|
|
||||||
|
export default class Signal {
|
||||||
|
public location: PIXI.Point;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
location: PIXI.Point,
|
||||||
|
) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
}
|
101
src/Station.ts
Normal file
101
src/Station.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import * as tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
|
import Direction, { getPointDirection } from './Direction';
|
||||||
|
import LineConnection from './LineConnection';
|
||||||
|
import { distance, randomPoint, weightedRandom } from './utils';
|
||||||
|
|
||||||
|
let stationCount = 0;
|
||||||
|
|
||||||
|
export default class Station {
|
||||||
|
// Utility methods for working with arrays of Stations
|
||||||
|
public static largestStation(stations: Station[]): Station {
|
||||||
|
let largest: Station = null;
|
||||||
|
for (const station of stations) {
|
||||||
|
if (largest === null || station.population > largest.population) {
|
||||||
|
largest = station;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return largest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static stationsWithinRadius(stations: Station[], point: PIXI.Point,
|
||||||
|
radius: number): Station[] {
|
||||||
|
return stations.filter(station => distance(point, station.location) <= radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static stationsInDirection(stations: Station[], point: PIXI.Point,
|
||||||
|
direction: Direction): Station[] {
|
||||||
|
return stations.filter(station => getPointDirection(point, station.location) === direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static closestStation(stations: Station[], point: PIXI.Point): Station {
|
||||||
|
return stations.reduce(
|
||||||
|
(prev, curr) => distance(point, prev.location) < distance(point, curr.location) ? prev : curr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static randomCloseLargeStation(stations: Station[], point: PIXI.Point,
|
||||||
|
radius: number): Station {
|
||||||
|
const closeStations = Station.stationsWithinRadius(stations, point,
|
||||||
|
radius);
|
||||||
|
const closeStationWeights = closeStations.map(station => station.population);
|
||||||
|
return weightedRandom(closeStations, closeStationWeights);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isPointDistant(point: PIXI.Point, stations: Station[],
|
||||||
|
minDistance: number): boolean {
|
||||||
|
for (const station of stations) {
|
||||||
|
if (distance(point, station.location) < minDistance) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static randomDistantPoint(stations: Station[], minDistance: number): PIXI.Point | null {
|
||||||
|
let tries = 100;
|
||||||
|
while (tries > 0) {
|
||||||
|
const point = randomPoint();
|
||||||
|
if (Station.isPointDistant(point, stations, minDistance)) {
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
tries -= 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public location: PIXI.Point;
|
||||||
|
public population: number;
|
||||||
|
public connections: LineConnection[];
|
||||||
|
public id: number;
|
||||||
|
public label: PIXI.Text;
|
||||||
|
public color: tinycolorInstance;
|
||||||
|
|
||||||
|
private textStyle: object;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
location: PIXI.Point,
|
||||||
|
population: number,
|
||||||
|
color: tinycolorInstance,
|
||||||
|
connections?: LineConnection[],
|
||||||
|
) {
|
||||||
|
this.location = location;
|
||||||
|
this.population = population;
|
||||||
|
this.color = color;
|
||||||
|
this.connections = connections || [];
|
||||||
|
|
||||||
|
// for debugging
|
||||||
|
stationCount += 1;
|
||||||
|
this.id = stationCount;
|
||||||
|
this.textStyle = {
|
||||||
|
fill: '#FFA500',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
};
|
||||||
|
this.renderLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderLabel() {
|
||||||
|
this.label = new PIXI.Text(`${this.id}`, this.textStyle);
|
||||||
|
}
|
||||||
|
}
|
57
src/Train.ts
Normal file
57
src/Train.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import * as tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
|
import Station from './Station';
|
||||||
|
|
||||||
|
let trainCount = 0;
|
||||||
|
|
||||||
|
export default class Train {
|
||||||
|
public location: PIXI.Point;
|
||||||
|
public speed: number;
|
||||||
|
public origin: Station;
|
||||||
|
public destination: Station;
|
||||||
|
public passengers: number;
|
||||||
|
public id: number;
|
||||||
|
public label: PIXI.Text;
|
||||||
|
public color: tinycolorInstance;
|
||||||
|
public sprite: PIXI.Sprite;
|
||||||
|
|
||||||
|
private textStyle: object;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
location: PIXI.Point,
|
||||||
|
speed: number,
|
||||||
|
passengers: number,
|
||||||
|
origin: Station,
|
||||||
|
destination: Station,
|
||||||
|
color: tinycolorInstance,
|
||||||
|
) {
|
||||||
|
this.location = location;
|
||||||
|
this.speed = speed;
|
||||||
|
this.origin = origin;
|
||||||
|
this.destination = destination;
|
||||||
|
this.passengers = passengers;
|
||||||
|
this.color = color;
|
||||||
|
|
||||||
|
this.sprite = new PIXI.Sprite(PIXI.loader.resources.nodeImg.texture);
|
||||||
|
this.sprite.visible = false;
|
||||||
|
|
||||||
|
// for debugging
|
||||||
|
trainCount += 1;
|
||||||
|
this.id = trainCount;
|
||||||
|
this.textStyle = {
|
||||||
|
fill: '#AEAEAE',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
};
|
||||||
|
this.renderLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boardPassengers() {
|
||||||
|
if (this.location === this.origin.location) { // about to leave a station
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderLabel() {
|
||||||
|
this.label = new PIXI.Text(`${this.id}`, this.textStyle);
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@ -18,5 +18,3 @@ canvas {
|
|||||||
bottom: 0 !important;
|
bottom: 0 !important;
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*# sourceMappingURL=main.css.map*/
|
|
269
src/transport.ts
Normal file
269
src/transport.ts
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import * as Viewport from 'pixi-viewport';
|
||||||
|
import * as PIXI from 'pixi.js';
|
||||||
|
import * as Stats from 'stats.js';
|
||||||
|
import * as tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
|
import Direction from './Direction';
|
||||||
|
import Line from './Line';
|
||||||
|
import Station from './Station';
|
||||||
|
import Train from './Train';
|
||||||
|
import { distance, pointsAlmostEqual, pointsEqual, randomInt, randomPoint,
|
||||||
|
rangeMap, weightedRandom } from './utils';
|
||||||
|
|
||||||
|
import * as imgNode from './node.png';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const NODE_RES = 100;
|
||||||
|
|
||||||
|
const MAX_SPEED = 10.0;
|
||||||
|
const ACCELERATION = 0.025;
|
||||||
|
const APPROACH_DISTANCE = 3.0;
|
||||||
|
const TRAIN_CAPACITY = 50;
|
||||||
|
const LINE_CONNECTION_LIMIT = 5;
|
||||||
|
const WORLD_WIDTH = 1000;
|
||||||
|
const WORLD_HEIGHT = 1000;
|
||||||
|
const ZOOM_MIN_WIDTH = 100;
|
||||||
|
const ZOOM_MIN_HEIGHT = 100;
|
||||||
|
const ZOOM_MAX_WIDTH = 4000;
|
||||||
|
const ZOOM_MAX_HEIGHT = 4000;
|
||||||
|
|
||||||
|
const trainTexts: PIXI.Text[] = [];
|
||||||
|
|
||||||
|
const initStations = (numStations: number): Station[] => {
|
||||||
|
const stations: Station[] = [];
|
||||||
|
for (let i = 0; i < numStations; i += 1) {
|
||||||
|
stations.push(new Station(
|
||||||
|
Station.randomDistantPoint(stations, 30),
|
||||||
|
randomInt(300, 2000),
|
||||||
|
tinycolor.random()));
|
||||||
|
}
|
||||||
|
return stations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initTrains = (numTrains: number, stations: Station[]): Train[] => {
|
||||||
|
const trains = [];
|
||||||
|
const stationsWithConnections = stations.filter(station => station.connections.length > 0);
|
||||||
|
for (let i = 0; i < numTrains; i += 1) {
|
||||||
|
const originStation = stationsWithConnections[
|
||||||
|
Math.floor(Math.random() * stationsWithConnections.length)
|
||||||
|
];
|
||||||
|
trains.push(new Train(
|
||||||
|
new PIXI.Point(originStation.location.x, originStation.location.y),
|
||||||
|
0, 0, originStation, undefined, tinycolor('grey')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trains;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initLines = (numLines: number, stations: Station[]): Line[] => {
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < numLines; i += 1) {
|
||||||
|
let color = tinycolor.random();
|
||||||
|
while (color.isDark()) {
|
||||||
|
color = tinycolor.random();
|
||||||
|
}
|
||||||
|
const stationsWithoutConnections = stations.filter(station =>
|
||||||
|
station.connections.length === 0,
|
||||||
|
);
|
||||||
|
let centralHub: Station;
|
||||||
|
if (stationsWithoutConnections.length > 0) {
|
||||||
|
centralHub = Station.largestStation(stationsWithoutConnections);
|
||||||
|
} else {
|
||||||
|
centralHub = stations[randomInt(0, stations.length - 1)];
|
||||||
|
}
|
||||||
|
const line = new Line(`line-${i}`, tinycolor.random());
|
||||||
|
const stationsLeft = stations.slice(0);
|
||||||
|
line.connectStations(centralHub, stationsLeft, [], LINE_CONNECTION_LIMIT);
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveTrains = (trains: Train[], stations: Station[]) => {
|
||||||
|
for (const train of trains) {
|
||||||
|
if (train.origin.connections.length === 0) {
|
||||||
|
// train is stuck at an orphaned station
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// choose a destination randomly with a bias towards larger stations
|
||||||
|
if (train.destination === undefined) {
|
||||||
|
const otherStations = train.origin.connections.map(conn => conn.station);
|
||||||
|
const closeStationWeights = otherStations.map(station => station.population);
|
||||||
|
train.destination = weightedRandom(otherStations, closeStationWeights);
|
||||||
|
|
||||||
|
// board passengers
|
||||||
|
const boardingPassengers = randomInt(0, Math.min(TRAIN_CAPACITY - train.passengers,
|
||||||
|
train.origin.population));
|
||||||
|
// set or mix train color with the color of new passenger origin
|
||||||
|
if (train.passengers === 0) {
|
||||||
|
train.color = train.origin.color;
|
||||||
|
} else {
|
||||||
|
train.color = tinycolor.mix(
|
||||||
|
train.color,
|
||||||
|
train.origin.color,
|
||||||
|
Math.round((boardingPassengers / train.passengers) * 100),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
train.passengers += boardingPassengers;
|
||||||
|
train.origin.population -= boardingPassengers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// train reached destination, stop moving and let passengers off
|
||||||
|
if (pointsAlmostEqual(train.location, train.destination.location)) {
|
||||||
|
train.speed = 0;
|
||||||
|
|
||||||
|
// average destination color with passenger color weighted by ratio
|
||||||
|
// (a simulation of culture mixing)
|
||||||
|
train.destination.color = tinycolor.mix(
|
||||||
|
train.destination.color,
|
||||||
|
train.origin.color,
|
||||||
|
Math.round((train.passengers / train.destination.population) * 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
// transfer passengers to destination
|
||||||
|
const disembarkingPassengers = randomInt(0, train.passengers);
|
||||||
|
train.destination.population += disembarkingPassengers;
|
||||||
|
train.passengers -= disembarkingPassengers;
|
||||||
|
|
||||||
|
// prepare for next journey
|
||||||
|
train.origin = train.destination;
|
||||||
|
train.destination = undefined;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const journeyLeft = distance(train.location, train.destination.location);
|
||||||
|
|
||||||
|
if ((train.speed / ACCELERATION) >= ((journeyLeft / train.speed) - APPROACH_DISTANCE) &&
|
||||||
|
train.speed !== ACCELERATION) {
|
||||||
|
// slowing down
|
||||||
|
train.speed -= ACCELERATION;
|
||||||
|
} else if (train.speed < MAX_SPEED) {
|
||||||
|
// speeding up
|
||||||
|
train.speed += ACCELERATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance train
|
||||||
|
const progress = train.speed / journeyLeft;
|
||||||
|
train.location.x += ((train.destination.location.x - train.location.x) * progress);
|
||||||
|
train.location.y += ((train.destination.location.y - train.location.y) * progress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawStations = (stations: Station[], graphics: PIXI.Graphics) => {
|
||||||
|
for (const station of stations) {
|
||||||
|
const radius = station.population / 150;
|
||||||
|
graphics.beginFill(parseInt(station.color.toHex(), 16), 0.5);
|
||||||
|
graphics.drawCircle(station.location.x, station.location.y, radius);
|
||||||
|
graphics.endFill();
|
||||||
|
station.label.x = station.location.x + radius + 1;
|
||||||
|
station.label.y = station.location.y + radius + 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawTrains = (trains: Train[], graphics: PIXI.Graphics) => {
|
||||||
|
for (const train of trains) {
|
||||||
|
const trainSize = rangeMap(train.passengers, 0, TRAIN_CAPACITY, 1, 5);
|
||||||
|
const scale = trainSize / NODE_RES;
|
||||||
|
train.sprite.visible = true;
|
||||||
|
train.sprite.x = train.location.x;
|
||||||
|
train.sprite.y = train.location.y;
|
||||||
|
train.sprite.scale.x = scale;
|
||||||
|
train.sprite.scale.y = scale;
|
||||||
|
train.sprite.tint = parseInt(train.color.toHex(), 16);
|
||||||
|
train.label.x = train.location.x + scale + 1;
|
||||||
|
train.label.y = train.location.y + scale + 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawLines = (stations: Station[], graphics: PIXI.Graphics) => {
|
||||||
|
for (const station of stations) {
|
||||||
|
for (const connection of station.connections) {
|
||||||
|
let twoWay = false;
|
||||||
|
for (const conn of connection.station.connections) {
|
||||||
|
if (conn.station === station) {
|
||||||
|
twoWay = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graphics.lineStyle(twoWay ? 2 : 1, parseInt(connection.line.color.toHex(), 16), 1);
|
||||||
|
graphics.moveTo(station.location.x, station.location.y);
|
||||||
|
graphics.lineTo(connection.station.location.x, connection.station.location.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
const app = new PIXI.Application({
|
||||||
|
antialias: true,
|
||||||
|
height: window.innerHeight,
|
||||||
|
width: window.innerWidth,
|
||||||
|
});
|
||||||
|
const viewport = new Viewport({
|
||||||
|
screenHeight: window.innerHeight,
|
||||||
|
screenWidth: window.innerWidth,
|
||||||
|
worldHeight: WORLD_HEIGHT,
|
||||||
|
worldWidth: WORLD_WIDTH,
|
||||||
|
});
|
||||||
|
const stats = new Stats();
|
||||||
|
stats.showPanel(0);
|
||||||
|
document.body.appendChild(stats.dom);
|
||||||
|
const ticker = new PIXI.ticker.Ticker();
|
||||||
|
const graphics = new PIXI.Graphics();
|
||||||
|
|
||||||
|
let stations: Station[] = [];
|
||||||
|
let lines: Line[] = [];
|
||||||
|
let stationsWithConnections: Station[] = [];
|
||||||
|
while (stationsWithConnections.length === 0) {
|
||||||
|
// If all stations are too far away to connect, try generating again
|
||||||
|
stations = initStations(30);
|
||||||
|
lines = initLines(4, stations);
|
||||||
|
stationsWithConnections = stations.filter(station => station.connections.length > 0);
|
||||||
|
}
|
||||||
|
const trains = initTrains(50, stations);
|
||||||
|
|
||||||
|
ticker.stop();
|
||||||
|
ticker.add((deltaTime) => {
|
||||||
|
stats.begin();
|
||||||
|
|
||||||
|
moveTrains(trains, stations);
|
||||||
|
|
||||||
|
graphics.clear();
|
||||||
|
|
||||||
|
graphics.lineStyle(1, 0xFFA500, 1);
|
||||||
|
drawStations(stations, graphics);
|
||||||
|
|
||||||
|
graphics.lineStyle(1, 0xAEAEAE, 1);
|
||||||
|
drawTrains(trains, graphics);
|
||||||
|
|
||||||
|
drawLines(stations, graphics);
|
||||||
|
|
||||||
|
stats.end();
|
||||||
|
});
|
||||||
|
ticker.start();
|
||||||
|
|
||||||
|
viewport.addChild(graphics);
|
||||||
|
// add train sprites
|
||||||
|
for (const train of trains) {
|
||||||
|
viewport.addChild(train.sprite);
|
||||||
|
}
|
||||||
|
// Add debug labels
|
||||||
|
for (const train of trains) {
|
||||||
|
viewport.addChild(train.label);
|
||||||
|
}
|
||||||
|
for (const station of stations) {
|
||||||
|
viewport.addChild(station.label);
|
||||||
|
}
|
||||||
|
document.body.appendChild(app.view);
|
||||||
|
app.stage.addChild(viewport);
|
||||||
|
viewport.drag().pinch().wheel().clampZoom({
|
||||||
|
maxHeight: ZOOM_MAX_HEIGHT,
|
||||||
|
maxWidth: ZOOM_MAX_WIDTH,
|
||||||
|
minHeight: ZOOM_MIN_HEIGHT,
|
||||||
|
minWidth: ZOOM_MIN_WIDTH,
|
||||||
|
}).decelerate();
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
app.renderer.resize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
PIXI.loader.add('nodeImg', imgNode).load(run);
|
52
src/utils.ts
Normal file
52
src/utils.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import * as PIXI from 'pixi.js';
|
||||||
|
|
||||||
|
const EPSILON = 1.0;
|
||||||
|
|
||||||
|
export const randomInt = (min: number, max: number): number => (
|
||||||
|
// inclusive of min and max
|
||||||
|
Math.floor(Math.random() * (max - (min + 1))) + min
|
||||||
|
);
|
||||||
|
|
||||||
|
export const weightedRandom = (choices: any[], weights: number[]): any => {
|
||||||
|
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||||
|
const rand = randomInt(0, totalWeight);
|
||||||
|
let cumulWeight = 0;
|
||||||
|
for (let i = 0; i < weights.length; i += 1) {
|
||||||
|
cumulWeight += weights[i];
|
||||||
|
if (rand < cumulWeight) {
|
||||||
|
return choices[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const randomPoint = () => (
|
||||||
|
new PIXI.Point(randomInt(0, window.innerWidth), randomInt(0, window.innerHeight))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pointsEqual = (pointA: PIXI.Point, pointB: PIXI.Point): boolean => (
|
||||||
|
(pointA.x === pointB.x && pointA.y === pointB.y)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pointsAlmostEqual = (pointA: PIXI.Point, pointB: PIXI.Point): boolean => (
|
||||||
|
Math.abs(pointA.x - pointB.x) < EPSILON &&
|
||||||
|
Math.abs(pointA.y - pointB.y) < EPSILON
|
||||||
|
);
|
||||||
|
|
||||||
|
export const distance = (pointA: PIXI.Point, pointB: PIXI.Point): number => {
|
||||||
|
const distX = pointA.x - pointB.x;
|
||||||
|
const distY = pointA.y - pointB.y;
|
||||||
|
return Math.sqrt((distX * distX) + (distY * distY));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rangeMap = (num: number, inMin: number, inMax: number,
|
||||||
|
outMin: number, outMax: number): number => (
|
||||||
|
(num - inMin) * (outMax - outMin) / (inMax - inMin) + outMin
|
||||||
|
);
|
||||||
|
|
||||||
|
export const angleRadians = (pointA: PIXI.Point, pointB: PIXI.Point): number => (
|
||||||
|
Math.atan2(-(pointB.x - pointA.x), pointB.y - pointA.y)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const angleDegrees = (pointA: PIXI.Point, pointB: PIXI.Point): number => (
|
||||||
|
180 + angleRadians(pointA, pointB) * (180 / Math.PI)
|
||||||
|
);
|
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/",
|
||||||
|
"sourceMap": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"module": "es6",
|
||||||
|
"target": "es6",
|
||||||
|
"jsx": "react",
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"typings"
|
||||||
|
]
|
||||||
|
}
|
11
tslint.json
Normal file
11
tslint.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"defaultSeverity": "error",
|
||||||
|
"extends": [
|
||||||
|
"tslint:recommended",
|
||||||
|
"tslint-config-airbnb",
|
||||||
|
"tslint-eslint-rules"
|
||||||
|
],
|
||||||
|
"jsRules": {},
|
||||||
|
"rules": {},
|
||||||
|
"rulesDirectory": []
|
||||||
|
}
|
1
typings/custom.d.ts
vendored
Normal file
1
typings/custom.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module '*.png';
|
37
vendors.min.js
vendored
37
vendors.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
91
webpack.config.js
Normal file
91
webpack.config.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||||
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
|
||||||
|
|
||||||
|
const env = process.env.NODE_ENV;
|
||||||
|
|
||||||
|
const prodPlugins = env === 'production' ? [
|
||||||
|
new UglifyJsPlugin({
|
||||||
|
sourceMap: true,
|
||||||
|
}),
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './src/transport.ts',
|
||||||
|
output: {
|
||||||
|
filename: env === 'production' ? '[name].min.js' : '[name].js',
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
},
|
||||||
|
mode: env === 'production' ? 'production' : 'development',
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.js$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: {
|
||||||
|
loader: 'babel-loader',
|
||||||
|
options: {
|
||||||
|
presets: [
|
||||||
|
'@babel/preset-env',
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
'@babel/proposal-class-properties',
|
||||||
|
'@babel/proposal-object-rest-spread',
|
||||||
|
],
|
||||||
|
cacheDirectory: env === 'development',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
use: 'ts-loader',
|
||||||
|
exclude: /node_modules/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: env === 'production'
|
||||||
|
? ExtractTextPlugin.extract({
|
||||||
|
fallback: 'style-loader',
|
||||||
|
use: ['css-loader'],
|
||||||
|
})
|
||||||
|
: ['style-loader', 'css-loader'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.png$/,
|
||||||
|
use: 'file-loader',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.tsx', '.ts', '.js'],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: path.join(__dirname, 'index.html'),
|
||||||
|
}),
|
||||||
|
new ExtractTextPlugin({
|
||||||
|
disable: env === 'development',
|
||||||
|
filename: '[name].css',
|
||||||
|
}),
|
||||||
|
...prodPlugins,
|
||||||
|
],
|
||||||
|
optimization: {
|
||||||
|
splitChunks: {
|
||||||
|
cacheGroups: {
|
||||||
|
commons: {
|
||||||
|
test: /[\\/]node_modules[\\/]/,
|
||||||
|
name: 'vendors',
|
||||||
|
chunks: 'all',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
devtool: env === 'production' ? 'source-map' : 'cheap-module-eval-source-map',
|
||||||
|
devServer: {
|
||||||
|
contentBase: path.join(__dirname, 'dist'),
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 8888,
|
||||||
|
disableHostCheck: true,
|
||||||
|
},
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user