// 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 {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.actionState = this.actionRegistry.state; 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]; this.pointerMovementHandle = undefined; // 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) { 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; case 'ArrowLeft': if (this.selfEntity) { this.actionRegistry.state = this.actionRegistry.state.set( 'UseItem', this.selfEntity.activeSlotIndex ); } 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. 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: { 'w': 'MoveUp', 'a': 'MoveLeft', 's': 'MoveDown', 'd': 'MoveRight', }, connectionUrl: window.location.href, 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 = ; // Debug UI. this.debugUiNode = node.querySelector('.debug-container'); const DebugUiComponent = ; // 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(() => { if (this.actionState !== this.actionRegistry.state) { this.actionState = this.actionRegistry.state; this.socket.send(InputPacket.fromState(this.actionState)); this.actionRegistry.state = this.actionRegistry.state.delete( 'UseItem' ); } }, 1000 * config.inputFrequency); // Mouse/touch movement. this.pointerMovementHandle = setInterval(() => { do { let normal; if (this.isPointingAtAnything()) { const toVector = Vector.scale(this.pointingAt, this.isDebugging ? 2 : 1); normal = this.createMoveToNormal(toVector); if (normal) { this.actionRegistry.state = this.actionRegistry.state.withMutations((state) => { if (normal[0]) { state.set('MoveToX', Math.floor(normal[0] * 127)); } if (normal[1]) { state.set('MoveToY', Math.floor(normal[1] * 127)); } }); break; } } this.actionRegistry.state = this.actionRegistry.state.withMutations((state) => { if (!normal || 0 === normal[0]) { state.delete('MoveToX'); } if (!normal || 0 === normal[1]) { state.delete('MoveToY'); } }); } while (false); }, 1000 * config.pointerMovementFrequency); // 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; // Inject input. if (this.selfEntity) { this.selfEntity.inputState = this.actionState; } // Tick. this.worldTime.tick(elapsed); if (this.room) { this.room.tick(elapsed); } // Sample. this.tps.sample(elapsed); }, 1000 * config.simulationFrequency); } stopProcessingInput() { clearInterval(this.pointerMovementHandle); this.pointerMovementHandle = undefined; 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); });