humus-old/client/app.js
2019-10-15 01:07:36 -05:00

625 lines
17 KiB
JavaScript

// 3rd party.
import React from 'react';
import ReactDOM from 'react-dom';
// 2nd party.
import {compose, EventEmitter, Property} from '@avocado/core';
import {Stage} from '@avocado/graphics';
import {ActionRegistry, InputPacket} from '@avocado/input';
import {Vector} from '@avocado/math';
import {SocketClient} from '@avocado/net/client/socket';
import {
BundlePacket,
ClientSynchronizer,
idFromSynchronized,
SocketIoParser,
SynchronizedCreatePacket,
} from '@avocado/net';
import {clearAnimation, setAnimation} from '@avocado/timing';
import {World} from '@avocado/physics/matter/world';
import {Room, RoomView} from '@avocado/topdown';
// 1st party.
import {actionIds} from '../common/action-ids';
import {augmentParserWithThroughput} from '../common/parser-throughput';
import {SelfEntityPacket} from '../common/packets/self-entity.packet';
import {WorldTime} from '../common/world-time.synchronized';
import {CycleTracker} from './cycle-tracker';
import {showMessage} from './overlay';
import Ui from './ui';
import DebugUi from './ui/debug';
const decorate = compose(
EventEmitter,
Property('isDebugging', {
default: false,
track: true,
}),
Property('isMenuOpened', {
default: false,
track: true,
}),
Property('isFocused', {
default: false,
track: true,
}),
Property('darkness', {
default: 0,
track: true,
}),
Property('rps', {
track: true,
}),
Property('selfEntity', {
track: true,
}),
Property('selfEntityUuid', {
track: true,
}),
Property('tps', {
track: true,
}),
);
export class App extends decorate(class {}) {
constructor() {
super();
const config = this.readConfig();
// Room.
this.room = null;
// World time.
this.worldTime = new WorldTime();
// Graphics.
this.debugUiNode = undefined;
this.reactContainer = undefined;
this.lastIntensity = undefined;
this.renderDrainHandle = undefined;
this.renderHandle = undefined;
this.rps = new CycleTracker(1 / 60); // Refresh rate, actually.
this.stage = new Stage(config.visibleSize, config.visibleScale);
this.roomView = null;
this.on('darknessChanged', this.applyLighting, this);
this.on('isFocusedChanged', this.applyMuting, this)
this.on('isMenuOpenedChanged', this.applyMuting, this)
// Input.
this.actionRegistry = new ActionRegistry();
this.actionRegistry.mapKeysToActions(config.actionKeyMap);
this.actionRegistry.setActionTransformerFor('UseItem', (type) => {
if ('keyDown' === type) {
return this.selfEntity.activeSlotIndex;
}
else {
return -1;
}
})
InputPacket.setActionIds(actionIds());
this.inputHandle = undefined;
this.actionRegistry.listen(this.stage.inputNormalizer);
this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this);
// Global keys.
this.pointingAt = [-1, -1];
// Net.
this.AugmentedParser = augmentParserWithThroughput(SocketIoParser);
this.hasReceivedState = false;
this.isConnected = false;
this.socket = undefined;
// Simulation.
this.tps = new CycleTracker(config.simulationFrequency);
this.simulationHandle = undefined;
this.world = config.doPhysicsSimulation ? new World() : undefined;
this.world.stepTime = config.simulationFrequency;
// State synchronization.
this.state = undefined;
this.synchronizer = new ClientSynchronizer();
this.synchronizer.addSynchronized(this.worldTime);
}
acceptPacket(packet) {
this.synchronizer.acceptPacket(packet);
// Keep refs to new synchronizeds.
if (packet instanceof SynchronizedCreatePacket) {
const roomId = idFromSynchronized(Room);
const {type, id} = packet.data.synchronized;
switch (type) {
// Track room.
case roomId:
const room = this.synchronizer.synchronized(type, id);
this.onRoomCreated(room);
break;
}
}
if (packet instanceof SelfEntityPacket) {
// Set self entity.
const selfEntity = this.room.findEntity(packet.data);
if (selfEntity) {
this.selfEntity = selfEntity;
this.selfEntityUuid = undefined;
// Add back our self entity traits.
const selfEntityOnlyTraits = [
'controllable',
'followed',
];
for (const type of selfEntityOnlyTraits) {
selfEntity.addTrait(type);
}
const {camera} = selfEntity;
this.stage.camera = camera;
// Avoid the initial 'lerp.
camera.realPosition = camera.position;
}
}
}
applyLighting() {
const roomView = this.roomView;
if (!roomView) {
return;
}
const layersView = roomView.layersView;
if (!layersView) {
return;
}
const layerViews = layersView.layerViews;
if (!layerViews) {
return;
}
for (const layerIndex in layerViews) {
const layerView = layerViews[layerIndex];
if (!layerView) {
return;
}
const entityListView = layerView.entityListView;
const entityList = entityListView.entityList;
for (const entity of entityList) {
if (entity.isDarkened) {
entity.container.night(this.darkness);
}
}
const layerContainer = layerView.layerContainer;
layerContainer.night(this.darkness);
}
}
applyMuting() {
if (!this.isFocused || this.isMenuOpened) {
this.stage.sepia();
}
else {
this.stage.removeAllFilters();
}
}
calculateDarkness() {
let darkness = 0;
if (this.worldTime.hour >= 21 || this.worldTime.hour < 4) {
darkness = 0.8;
}
if (this.worldTime.hour >= 4 && this.worldTime.hour < 7) {
darkness = 0.8 * ((7 - this.worldTime.hour) / 3);
}
if (this.worldTime.hour >= 18 && this.worldTime.hour < 21) {
darkness = 0.8 * ((3 - (21 - this.worldTime.hour)) / 3);
}
this.darkness = Math.floor(darkness * 1000) / 1000;
}
connect() {
const config = this.readConfig();
this.socket = new SocketClient(config.connectionUrl, {
parser: this.AugmentedParser,
});
this.socket.on('connect', () => {
this.removeFromDom(document.querySelector('.app'));
this.room = null;
this.selfEntity = undefined;
this.selfEntityUuid = undefined;
this.isConnected = true;
});
this.socket.on('disconnect', () => {
this.hasReceivedState = false;
this.isConnected = false;
this.stopRendering();
this.stopSimulation();
this.stopProcessingInput();
});
this.socket.on('packet', this.onPacket, this);
}
createMoveToNormal(position) {
if (!this.selfEntity || !this.selfEntity.is('existent')) {
return;
}
const realEntityPosition = Vector.sub(
this.selfEntity.position,
this.selfEntity.camera.realOffset,
);
const magnitude = Vector.magnitude(position, realEntityPosition);
if (magnitude < 8) {
return;
}
const diff = Vector.sub(position, realEntityPosition);
return Vector.normalize(diff);
}
createReactContainer() {
const reactContainer = window.document.createElement('div');
reactContainer.className = 'react';
reactContainer.style.width = '100%';
reactContainer.style.height = '100%';
return reactContainer;
}
eventIsInUi(event) {
let walk = event.nativeEvent.target;
while (walk) {
if (walk === this.stage.ui) {
return true;
}
walk = walk.parentNode;
}
return false;
}
isPointingAtAnything() {
return -1 !== this.pointingAt[0] && -1 !== this.pointingAt[1];
}
onBlur() {
this.isFocused = false;
}
onFocus() {
this.isFocused = true;
}
onKeyDown(key) {
switch (key) {
case 'F2':
this.isDebugging = !this.isDebugging;
break;
case 'e':
this.isMenuOpened = !this.isMenuOpened;
break;
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case '0':
const slotIndex = (parseInt(key) + 9) % 10;
this.setActiveSlotIndex(slotIndex);
break;
}
}
onKeyUp(key) {
}
onPacket(packet) {
if (!this.hasReceivedState) {
this.renderIntoDom(document.querySelector('.app')).then(() => {
this.startProcessingInput();
this.startSimulation();
this.startRendering();
});
this.hasReceivedState = true;
}
if (packet instanceof BundlePacket) {
for (let i = 0; i < packet.data.length; i++) {
this.onPacket(packet.data[i]);
}
}
this.acceptPacket(packet);
}
onPointerDown(event) {
if (!this.eventIsInUi(event)) {
return;
}
this.pointingAt = event.position;
}
onPointerMove(event) {
if (!this.eventIsInUi(event)) {
return;
}
if (!this.isPointingAtAnything()) {
return;
}
this.pointingAt = event.position;
}
onPointerUp(event) {
this.pointingAt = [-1, -1];
}
onRoomCreated(room) {
// Keep tabs on the room.
if (this.room) {
this.room.off('entityAdded', this.onRoomEntityAdded);
}
this.room = room;
// View.
if (this.roomView) {
this.roomView.destroy();
}
this.stage.removeChild(this.roomView);
this.roomView = new RoomView(this.room, this.stage.renderer);
this.stage.addChild(this.roomView);
// Listen for new entities.
this.room.on('entityAdded', this.onRoomEntityAdded, this);
const allEntities = this.room.allEntities();
for (let i = 0; i < allEntities.length; i++) {
this.onRoomEntityAdded(allEntities[i]);
}
// Physics.
this.room.world = this.world;
}
onRoomEntityAdded(entity) {
// Traits that shouldn't be on client.
const noClientTraits = [
'behaved',
'informed',
];
// If there's no physics, then remove physical trait.
if (!this.room.world) {
noClientTraits.push('physical');
}
// Traits that only make sense for our self entity on the client.
const selfEntityOnlyTraits = [
'controllable',
'followed',
];
const nixedTraits = selfEntityOnlyTraits.concat(noClientTraits);
for (let i = 0; i < nixedTraits.length; ++i) {
const type = nixedTraits[i];
if (entity.is(type)) {
entity.removeTrait(type);
}
}
// Client only traits.
const clientOnlyTraits = [
'darkened',
'staged',
];
for (let i = 0; i < clientOnlyTraits.length; ++i) {
const type = clientOnlyTraits[i];
if (!entity.is(type)) {
entity.addTrait(type);
}
}
// Set darkness up front.
if (entity.isDarkened) {
entity.container.night(this.darkness);
}
entity.stage = this.stage;
}
onWheel(event) {
if (!this.selfEntity) {
return;
}
const activeSlotIndex = this.selfEntity.activeSlotIndex;
if (event.deltaY > 0) {
this.setActiveSlotIndex((activeSlotIndex + 1) % 10);
}
else if (event.deltaY < 0) {
this.setActiveSlotIndex((activeSlotIndex + 9) % 10);
}
}
// Stub for now!
readConfig() {
return {
actionKeyMap: {
'MoveUp': 'w',
'MoveLeft': 'a',
'MoveDown': 's',
'MoveRight': 'd',
'UseItem': 'ArrowLeft',
'Interact': 'Enter',
},
connectionUrl: window.location.protocol + '//' + window.location.hostname + ':8420/',
doPhysicsSimulation: true,
inputFrequency: 1 / 60,
pointerMovementFrequency: 1 / 15,
simulationFrequency: 1 / 60,
visibleScale: [1, 1],
visibleSize: [320, 180],
};
}
removeFromDom(node) {
if (this.debugUiNode) {
ReactDOM.unmountComponentAtNode(this.debugUiNode);
}
if (this.reactContainer) {
ReactDOM.unmountComponentAtNode(this.reactContainer);
this.stage.ui.removeChild(this.reactContainer);
this.reactContainer = undefined;
}
this.stage.removeFromDom(node);
}
renderIntoDom(node) {
// Maintain damage element.
const promise = this.stage.findUiElement('.damage');
promise.then((inner) => {
const innerStyles = () => {
const {realOffset} = this.stage.camera;
return {
transform: `translate(-${realOffset[0]}px, -${realOffset[1]}px)`,
};
}
let lastRoundedRealOffset = [-1, -1];
const onRealOffsetChanged = () => {
const {realOffset} = this.stage.camera;
const roundedRealOffset = Vector.round(realOffset);
if (Vector.equals(lastRoundedRealOffset, roundedRealOffset)) {
return;
}
lastRoundedRealOffset = roundedRealOffset;
const styles = innerStyles();
for (const key in styles) {
const style = styles[key];
inner.style[key] = style;
}
}
const listenForCameraChanges = () => {
if (this.stage.camera) {
onRealOffsetChanged();
this.stage.camera.on('realOffsetChanged', onRealOffsetChanged);
}
};
listenForCameraChanges();
this.stage.on('cameraChanged', (oldCamera, newCamera) => {
if (oldCamera) {
oldCamera.off('realOffsetChanged', onRealOffsetChanged);
}
if (newCamera) {
listenForCameraChanges();
}
})
});
// Add graphics stage.
this.stage.addToDom(node);
// UI.
this.reactContainer = this.createReactContainer();
this.stage.ui.appendChild(this.reactContainer);
const UiComponent = <Ui app={this} />;
// Debug UI.
this.debugUiNode = node.querySelector('.debug-container');
const DebugUiComponent = <DebugUi app={this} />;
// Deferred render.
return Promise.all([
promise,
new Promise((resolve) => {
ReactDOM.render(UiComponent, this.reactContainer, () => {
this.stage.flushUiElements();
resolve();
});
}),
new Promise((resolve) => {
ReactDOM.render(DebugUiComponent, this.debugUiNode, () => {
resolve();
});
}),
]);
}
setActiveSlotIndex(slotIndex) {
if (this.selfEntity) {
this.selfEntity.activeSlotIndex = slotIndex;
}
}
startProcessingInput() {
const config = this.readConfig();
// Key input.
this.stage.on('keyDown', this.onKeyDown, this);
this.stage.on('keyUp', this.onKeyUp, this);
// Pointer input.
this.stage.on('pointerDown', this.onPointerDown, this);
this.stage.on('pointerMove', this.onPointerMove, this);
this.stage.on('pointerUp', this.onPointerUp, this);
this.stage.on('wheel', this.onWheel, this)
// Lol, Apple...
window.addEventListener('touchmove', (event) => {
event.preventDefault();
});
// Blur/focus.
this.stage.element.addEventListener('blur', this.onBlur);
this.stage.element.addEventListener('focus', this.onFocus);
// Input messages.
this.inputHandle = setInterval(() => {
const inputStream = this.actionRegistry.drain();
if (inputStream.length > 0) {
// Inject input.
if (this.selfEntity) {
this.selfEntity.inputStream = inputStream;
}
this.socket.send(new InputPacket(inputStream));
}
}, 1000 * config.inputFrequency);
// Focus the stage.
this.stage.focus();
this.isFocused = true;
}
startRendering() {
let lastTime = performance.now();
const animate = () => {
const now = performance.now();
const elapsed = (now - lastTime) / 1000;
lastTime = now;
this.calculateDarkness();
this.stage.renderTick(elapsed);
// Sample.
this.rps.sample(elapsed);
};
this.renderHandle = setAnimation(animate);
// Drain animation if a little time has passed. raf may never call if the
// window isn't active, which can cause major backup for any poorly-written
// renderTick implementations that do work.
this.renderDrainHandle = setInterval(() => {
const now = performance.now();
const elapsed = (now - lastTime) / 1000;
if (elapsed >= 0.1) {
animate();
}
}, 100);
}
startSimulation() {
const config = this.readConfig();
let lastTime = performance.now();
this.simulationHandle = setInterval(() => {
const now = performance.now();
const elapsed = (now - lastTime) / 1000;
lastTime = now;
// Tick.
this.worldTime.tick(elapsed);
if (this.room) {
this.room.tick(elapsed);
}
// Sample.
this.tps.sample(elapsed);
}, 1000 * config.simulationFrequency);
}
stopProcessingInput() {
clearInterval(this.inputHandle);
this.inputHandle = undefined;
this.stage.element.removeEventListener('focus', this.onFocus);
this.stage.element.removeEventListener('blur', this.onBlur);
this.stage.off('keyDown', this.onKeyDown);
this.stage.off('pointerUp', this.onPointerUp);
this.stage.off('pointerMove', this.onPointerMove);
this.stage.off('pointerDown', this.onPointerDown);
this.stage.off('wheel', this.onWheel);
}
stopRendering() {
clearAnimation(this.renderHandle);
this.renderHandle = undefined;
clearInterval(this.renderDrainHandle);
this.renderDrainHandle = undefined;
}
stopSimulation() {
clearInterval(this.simulationHandle);
this.simulationHandle = undefined;
}
}
window.addEventListener('error', (event) => {
showMessage(event.error);
});