basic render

This commit is contained in:
Robin Appelman 2017-04-14 22:55:31 +02:00
commit 9e7d0c4eaa
12 changed files with 654 additions and 3 deletions

2
.gitignore vendored
View file

@ -1,3 +1,3 @@
.idea
.ideaabfdb9d2d9abee3fa2055f10f3eb860954e4a8c4
node_modules
build

View file

@ -1,14 +1,17 @@
{
"devDependencies": {
"@types/node": "^7.0.12",
"@types/react": "^15.0.21",
"@types/react-dom": "^0.14.23",
"clean-webpack-plugin": "^0.1.16",
"css-loader": "^0.28.0",
"extract-text-webpack-plugin": "^2.1.0",
"html-webpack-plugin": "^2.28.0",
"postcss-cssnext": "^2.10.0",
"postcss-loader": "^1.3.3",
"postcss-nested": "^1.0.1",
"react-hot-loader": "^3.0.0-beta.6",
"style-loader": "^0.16.1",
"ts-loader": "^2.0.3",
"typescript": "^2.2.2",
"webpack": "^2.4.1",

11
src/App.css Normal file
View file

@ -0,0 +1,11 @@
.background {
position: absolute;
top: 0;
left: 0;
user-select: none;
pointer-events: none;
}
.background {
opacity: 0.5;
}

View file

@ -1,11 +1,102 @@
import * as React from 'react';
import {Demo} from 'tf2-demo/build/Demo';
import {World} from 'tf2-demo/build/Data/World';
import {DemoViewer} from "./DemoViewer";
export interface AppState {
scale: number;
originalHeight: number;
originalWidth: number;
demo: Demo | null;
background: string | null;
world: World;
}
import './App.css';
import {MapContainer} from "./MapContainer";
export class App extends React.Component<{}, AppState> {
state: AppState = {
scale: 8,
originalWidth: 2560,
originalHeight: 1440,
demo: null,
background: null,
world: {
boundaryMin: {x: 0, y: 0, z: 0},
boundaryMax: {x: 1000, y: 1000, z: 1000},
}
};
onNumberInputChange(key, event) {
const value = parseInt(event.target.value, 10);
const update = {};
update[key] = value;
this.setState(update);
}
onSelectDemo = (event) => {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = () => {
const fileData = reader.result as ArrayBuffer;
const demo = new Demo(fileData);
const parser = demo.getParser();
parser.readHeader();
const match = parser.match;
while (match.world.boundaryMin.x === 0) {
parser.tick();
}
this.setState({demo, world: match.world});
};
reader.readAsArrayBuffer(file);
};
onSelectImage = (event) => {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = () => {
const background = reader.result as string;
this.setState({background});
};
reader.readAsDataURL(file);
};
getDimensions(): { width: number, height: number } {
const originalHeight = 1024 * this.state.scale; // 1024 is a magic number straight from cl_leveloverview code
const originalWidth = originalHeight * (this.state.originalWidth / this.state.originalHeight);
return {width: originalWidth, height: originalHeight};
}
render() {
return <div>app</div>;
const {width, height} = this.getDimensions();
const demoWidth = (this.state.world.boundaryMax.x - this.state.world.boundaryMin.x) / 10;
const demoHeight = (this.state.world.boundaryMax.y - this.state.world.boundaryMin.y) / 10;
const size = Math.max(demoWidth, demoHeight);
console.log(demoWidth, demoHeight);
return <div>
<input onChange={this.onNumberInputChange.bind(this, 'scale')}
placeholder="scale"
value={this.state.scale}/>
<input
onChange={this.onNumberInputChange.bind(this, 'originalHeight')}
placeholder="screen height"
value={this.state.originalHeight}/>
<input
onChange={this.onNumberInputChange.bind(this, 'originalWidth')}
placeholder="screen width"
value={this.state.originalWidth}/>
<input onChange={this.onSelectDemo} type="file"/>
<MapContainer contentSize={{width: demoWidth, height: demoHeight}}>
<DemoViewer className="viewer" height={demoHeight}
width={demoWidth}
demo={this.state.demo}/>
</MapContainer>
<input onChange={this.onSelectImage} type="file"/>
</div>;
}
}

69
src/DemoRender.ts Normal file
View file

@ -0,0 +1,69 @@
import {Demo} from 'tf2-demo/build/Demo';
export class DemoRender {
static render(canvas: HTMLCanvasElement, demo: Demo, width: number, height: number) {
const ctx = <CanvasRenderingContext2D>canvas.getContext('2d');
const parser = demo.getParser();
const match = parser.match;
const scaleX = width / (match.world.boundaryMax.x - match.world.boundaryMin.x);
const scaleY = height / (match.world.boundaryMax.y - match.world.boundaryMin.y);
const scale = Math.max(scaleX, scaleY);
const boundaryMin = match.world.boundaryMin;
const translatePosition = (position) => {
return {
x: Math.floor(position.x - boundaryMin.x) * scale,
y: Math.floor(position.y - boundaryMin.y) * scale
}
};
const drawScale = Math.max(width, height) / 1500;
const drawTick = () => {
for (const player of match.players) {
if (!player.user.team) {
continue;// spec
}
if (player.team === 2) {
ctx.fillStyle = 'red';
} else {
ctx.fillStyle = 'blue';
}
const pos = translatePosition(player.position);
ctx.fillRect(
pos.x,
pos.y,
drawScale,
drawScale
);
}
};
const drawNext = () => {
let tick;
for (let i = 0; i < 1000; i++) {
tick = parser.tick();
if (tick) {
drawTick();
} else {
return;
}
}
if (tick) {
// queue up the next batch of ticks
requestAnimationFrame(drawNext);
}
};
requestAnimationFrame(drawNext);
}
static clear(canvas: HTMLCanvasElement) {
const ctx = <CanvasRenderingContext2D>canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}

45
src/DemoViewer.tsx Normal file
View file

@ -0,0 +1,45 @@
import * as React from 'react';
import {Demo} from 'tf2-demo/build/Demo';
import {DemoRender} from "./DemoRender";
export interface DemoViewerProps {
demo: Demo | null;
width: number;
height: number;
className?: string;
}
export class DemoViewer extends React.Component<DemoViewerProps, {}> {
canvas: HTMLCanvasElement;
componentDidMount() {
this.updateCanvas();
}
updateCanvas() {
if (this.props.demo) {
DemoRender.render(this.canvas, this.props.demo, this.props.width, this.props.height);
}
}
componentWillReceiveProps(nextProps: DemoViewerProps) {
if (nextProps.demo !== this.props.demo && nextProps.width === nextProps.width && nextProps.height === nextProps.height) {
if (this.canvas) {
DemoRender.clear(this.canvas);
}
requestAnimationFrame(() => {
this.updateCanvas();
});
}
}
render() {
return <canvas
width={this.props.width}
height={this.props.height}
className={this.props.className}
ref={(canvas) => {
this.canvas = canvas
}}/>
}
}

5
src/MapContainer.css Normal file
View file

@ -0,0 +1,5 @@
.map-container {
width: 100%;
height: 100%;
background-color: black;
}

66
src/MapContainer.tsx Normal file
View file

@ -0,0 +1,66 @@
import * as React from 'react';
import {Panner} from "./Panner/Panner";
import './MapContainer.css';
export class MapContainerProps {
children?: any;
contentSize: {
width: number;
height: number;
};
onScale?: (scale: number) => any;
}
export class MapContainerState {
width: number;
height: number;
}
export class MapContainer extends React.Component<MapContainerProps, MapContainerState> {
container: Element;
state: MapContainerState = {
width: 500,
height: 800
};
sendScale = false;
componentDidMount() {
if (this.container.clientWidth == this.state.width && this.container.clientHeight == this.state.height) {
return;
}
this.setState({
height: this.container.clientHeight,
width: this.container.clientWidth
});
}
componentWillReceiveProps({contentSize}: MapContainerProps) {
this.setState({
height: this.container.clientHeight,
width: this.container.clientWidth
});
}
render() {
const scale = Math.min(this.state.width / this.props.contentSize.width, this.state.height / this.props.contentSize.height);
if (!this.sendScale && this.props.onScale) {
if (isFinite(scale)) {
setTimeout(() => {
this.props.onScale && this.props.onScale(scale);
this.sendScale = true;
}, 1);
}
}
return (
<div className="map-container" ref={(div) => this.container = div}>
<Panner width={this.state.width} height={this.state.height}
scale={scale} contentSize={this.props.contentSize}
onScale={this.props.onScale}>
{this.props.children}
</Panner>
</div>
)
}
}

View file

@ -0,0 +1,102 @@
import {Viewport, Point} from './Viewport';
export interface CenteredPanZoomOptions {
screenWidth: number;
screenHeight: number;
scale?: number;
contentSize: {
width: number;
height: number;
}
}
export class CenteredPanZoom {
screen: Viewport;
viewport: Viewport;
scale: number;
contentSize: {
width: number;
height: number;
};
constructor(options: CenteredPanZoomOptions) {
this.screen = new Viewport({
x: 0,
y: 0,
width: options.screenWidth,
height: options.screenHeight
});
this.viewport = new Viewport({
x: 0,
y: 0,
width: options.screenWidth,
height: options.screenHeight
});
this.scale = options.scale || 1;
this.contentSize = options.contentSize;
}
setSize(width, height) {
this.screen.width = width;
this.screen.height = height;
this.viewport.width = width * this.scale;
this.viewport.height = height * this.scale;
this.constrainPan(true);
}
setContentSize(width, height) {
this.contentSize = {width, height};
this.constrainPan(true);
}
pan(screenX, screenY) {
this.viewport.x = this.viewport.x + screenX;
this.viewport.y = this.viewport.y + screenY;
this.constrainPan();
}
constrainPan(strict = false) {
const minX = (strict) ? 0 : this.screen.width * 0.5;
const minY = (strict) ? 0 : this.screen.height * 0.5;
this.viewport.x = Math.min(minX, this.viewport.x);
this.viewport.y = Math.min(minY * 0.5, this.viewport.y);
const maxY = (this.screen.height - (this.contentSize.height * this.scale)) - (strict ? 0 : this.screen.height * 0.5);
const maxX = (this.screen.width - (this.contentSize.width * this.scale)) - (strict ? 0 : this.screen.width * 0.5);
this.viewport.y = Math.max(maxY, this.viewport.y);
this.viewport.x = Math.max(maxX, this.viewport.x);
if (maxY > ((strict) ? 0 : this.screen.height * 0.5)) {
this.viewport.y = Math.floor(maxY / 2);
}
if (maxX > ((strict) ? 0 : this.screen.width * 0.5)) {
this.viewport.x = Math.floor(maxX / 2);
}
}
panFrom(screenStart: Point, screenEnd: Point) {
this.pan(screenEnd.x - screenStart.x, screenEnd.y - screenStart.y);
}
//find zoom point in pre-zoom viewport
//make that point the same in the post-zoom viewport
zoom(scale: number, screenCenter: Point) {
const v1 = Viewport.convert(screenCenter, {
from: this.screen,
to: this.viewport
});
this.viewport.x = this.viewport.x * (scale / this.scale);
this.viewport.y = this.viewport.y * (scale / this.scale);
this.viewport.width = this.screen.width * scale;
this.viewport.height = this.screen.height * scale;
const minScale = Math.min(this.screen.width / this.contentSize.width, this.screen.height / this.contentSize.height);
scale = Math.max(minScale, scale);
this.scale = scale;
const v2 = Viewport.convert(screenCenter, {
from: this.screen,
to: this.viewport
});
const deltaX = v2.x - v1.x;
const deltaY = v2.y - v1.y;
this.pan(deltaX * scale, deltaY * scale);
}
}

54
src/Panner/Panner.css Normal file
View file

@ -0,0 +1,54 @@
.pan-zoom-element {
border: 1px solid #ccc;
position: relative;
overflow: hidden;
cursor: default;
.content-container {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.noselect {
user-select: none;
}
}
.zoommenu {
position: fixed;
color: #888;
bottom: 100px;
right: 0;
margin: 10px;
font-size: 200%;
opacity: 0.5;
transition: all 0.5s;
&:hover {
opacity: 1;
}
background-color: #f8f8f8;
border-radius: 5px;
div {
transition: all 0.5s;
width: 32px;
text-align: center;
cursor: pointer;
user-select: none;
&:hover {
color: #000;
}
}
div:first-child {
border-bottom: 1px solid #ddd;
margin: 0;
}
}

169
src/Panner/Panner.tsx Normal file
View file

@ -0,0 +1,169 @@
import * as React from 'react';
import {CenteredPanZoom} from './CenteredPanZoom';
import './Panner.css';
export interface PannerState {
scale: number;
translate: {
x: number;
y: number;
}
}
export interface PannerProps {
width: number;
height: number;
scale: number;
contentSize: {
width: number;
height: number;
};
onScale?: (scale: number) => any;
}
export class Panner extends React.Component<PannerProps, PannerState> {
private startX = 0;
private startY = 0;
private panner: CenteredPanZoom;
state: PannerState = {
scale: 0,
translate: {
x: 0,
y: 0
}
};
constructor(props) {
super(props);
this.panner = new CenteredPanZoom({
screenHeight: this.props.height,
screenWidth: this.props.width,
scale: this.props.scale,
contentSize: props.contentSize
});
}
componentDidMount() {
this.setState({
translate: {
x: this.panner.viewport.x,
y: this.panner.viewport.y
},
scale: this.panner.scale
});
}
componentWillReceiveProps({width, height, scale, contentSize}:PannerProps) {
if (
width == this.panner.screen.width && height == this.panner.screen.height) {
return;
}
this.panner.scale = scale;
this.panner.setSize(width, height);
this.panner.setContentSize(contentSize.width, contentSize.height);
this.setState({scale});
this.setState({
translate: {
x: this.panner.viewport.x,
y: this.panner.viewport.y
},
scale: this.panner.scale
});
}
render() {
const style = {
transform: `translate(${this.state.translate.x}px, ${this.state.translate.y}px) scale(${this.state.scale})`,
transformOrigin: 'top left'
};
const center = {
x: Math.floor(this.panner.screen.width / 2),
y: Math.floor(this.panner.screen.height / 2)
};
const setZoomFactor = this.zoomFactor;
return (
<div className="pan-zoom-element"
ref="element"
style={{width: this.props.width, height: this.props.height}}
onMouseDown={this._onMouseDown}
onWheel={this._onWheel}>
<div ref="content" className="content-container noselect"
style={style}>
{this.props.children}
</div>
<div className="zoommenu">
<div className="plus"
onClick={()=>{setZoomFactor(1.10, center)}}>+
</div>
<div className="minus"
onClick={()=>{setZoomFactor(0.90, center)}}>
-
</div>
</div>
</div>
);
}
_onMouseDown = (event) => {
this.startX = event.pageX;
this.startY = event.pageY;
document.addEventListener('mouseup', this._onMouseUp, true);
document.addEventListener('mousemove', this._onMouseMove, true);
};
_onMouseUp = () => {
document.removeEventListener('mouseup', this._onMouseUp, true);
document.removeEventListener('mousemove', this._onMouseMove, true);
};
_onMouseMove = (event) => {
const {pageX, pageY} = event;
this.panner.panFrom(
{
x: this.startX,
y: this.startY
},
{
x: pageX,
y: pageY
}
);
this.startX = event.pageX;
this.startY = event.pageY;
this.setState({
translate: {
x: this.panner.viewport.x,
y: this.panner.viewport.y
},
scale: this.panner.scale
});
};
zoomFactor = (zoomFactor, point) => {
const newScale = this.state.scale * zoomFactor;
this.panner.zoom(newScale, point);
this.setState({
translate: {
x: this.panner.viewport.x,
y: this.panner.viewport.y
},
scale: this.panner.scale
});
if (this.props.onScale) {
this.props.onScale(this.panner.scale);
}
};
_onWheel = (event) => {
event.preventDefault();
const center = {x: event.pageX, y: event.pageY};
if (event.deltaY < 0) {
this.zoomFactor(1.05, center);
} else {
this.zoomFactor(0.95, center);
}
};
}

36
src/Panner/Viewport.ts Normal file
View file

@ -0,0 +1,36 @@
export interface ViewPortOptions {
x?: number;
y?: number;
width: number;
height: number;
}
export interface Point {
x: number;
y: number;
}
export class Viewport {
x: number;
y: number;
width: number;
height: number;
constructor(options: ViewPortOptions) {
this.x = options.x || 0;
this.y = options.y || 0;
this.width = options.width;
this.height = options.height;
}
static convert(point: Point, {from, to}: {from: Viewport, to: Viewport}): Point {
const widthScale = from.width / to.width;
const heightScale = from.height / to.height;
return {
x: point.x * widthScale - to.x * widthScale,
y: point.y * heightScale - to.y * heightScale
};
}
}