initial commit

This commit is contained in:
Evert Prants 2022-04-09 14:29:54 +03:00
commit 9267e50415
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
28 changed files with 19721 additions and 0 deletions

5
.babelrc Normal file
View File

@ -0,0 +1,5 @@
{
"plugins": [
"@babel/plugin-transform-runtime"
]
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
dist/
node_modules/
/*.png
/*.db
/config.toml

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

BIN
assets/clean-pony2fix.glb Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
--------------------------------------------------------------------------------
-- Up
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Down
--------------------------------------------------------------------------------

17996
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "icydraw",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build:prod": "tsc && webpack --mode=production",
"watch": "concurrently \"tsc -w\" \"nodemon dist/server/\" \"webpack --mode=development -w\""
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"connect-redis": "^6.1.3",
"express": "^4.17.3",
"express-session": "^1.17.2",
"jimp": "^0.16.1",
"passport": "^0.5.2",
"passport-icynet": "^0.0.2",
"redis": "^3.1.2",
"remove": "^0.1.5",
"socket.io": "^4.4.1",
"sqlite": "^4.0.25",
"sqlite3": "^5.0.2",
"three": "^0.139.2",
"toml": "^3.0.0"
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.17.0",
"@babel/preset-env": "^7.16.11",
"@babel/preset-typescript": "^7.16.7",
"@types/connect-redis": "^0.0.18",
"@types/express-session": "^1.17.4",
"@types/passport": "^1.0.7",
"@types/passport-oauth2": "^1.4.11",
"@types/seedrandom": "^3.0.2",
"@types/sqlite3": "^3.1.8",
"@types/three": "^0.139.0",
"@types/webpack-dev-server": "^4.7.2",
"babel-loader": "^8.2.4",
"concurrently": "^7.1.0",
"css-loader": "^6.7.1",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.6.0",
"nodemon": "^2.0.15",
"prettier": "^2.6.1",
"regenerator-runtime": "^0.13.9",
"sass": "^1.49.10",
"sass-loader": "^12.6.0",
"seedrandom": "^3.0.5",
"socket.io-client": "^4.4.1",
"typescript": "^4.6.3",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.7.4"
}
}

135
src/client/game.ts Normal file
View File

@ -0,0 +1,135 @@
import { Socket } from 'socket.io-client';
import { PerspectiveCamera, Scene } from 'three';
import { isMobileOrTablet } from '../common/helper';
import { CompositePacket } from '../common/types/packet';
import { IcyNetUser } from '../common/types/user';
import { ThirdPersonCamera } from './object/camera';
import { Chat } from './object/chat';
import { Joystick } from './object/joystick';
import { Player } from './object/player';
import { PlayerEntity } from './object/player-entity';
import modelLoaderInstance from './object/pony-loader';
export class Game {
public players: (Player | PlayerEntity)[] = [];
public player!: Player;
public me!: IcyNetUser;
public thirdPersonCamera!: ThirdPersonCamera;
public joystick!: Joystick;
public chat!: Chat;
constructor(
public socket: Socket,
public camera: PerspectiveCamera,
public scene: Scene,
public canvas: HTMLElement,
) {}
async initialize(): Promise<void> {
await modelLoaderInstance.loadPonyModel();
this.bindSocket();
this.chat = new Chat();
this.chat.initialize();
this.socket.connect();
this.chat.registerSendFunction((message) => {
this.socket.emit('chat-send', message);
});
}
public dispose() {
this.players.forEach((player) => {
this.scene.remove(player.container);
});
this.thirdPersonCamera?.dispose();
this.joystick?.dispose();
this.players.length = 0;
}
public update(dt: number) {
this.players.forEach((player) => player.update(dt));
this.player?.createPacket(this.socket);
this.thirdPersonCamera?.update(dt);
this.joystick?.update(dt);
}
bindSocket() {
this.socket.on('connect', () => {
this.dispose();
console.log('connected');
});
this.socket.on('me', (user) => {
console.log(user);
if (!user) {
window.location.href = '/login';
return;
}
this.me = user;
const player = Player.fromUser(user, this.scene);
this.players.push(player);
this.player = player;
this.thirdPersonCamera = new ThirdPersonCamera(
this.camera,
this.player.container,
this.canvas,
);
this.thirdPersonCamera.initialize();
this.joystick = new Joystick(player);
this.joystick.initialize();
if (isMobileOrTablet()) {
this.joystick.show();
}
});
this.socket.on('playerjoin', (user) => {
if (user.id === this.me.id) {
return;
}
const newplayer = PlayerEntity.fromUser(user, this.scene);
this.players.push(newplayer);
});
this.socket.on('playerleave', (user) => {
const findPlayer = this.players.find((item) => item.user.id === user.id);
if (findPlayer) {
this.scene.remove(findPlayer.container);
this.players.splice(this.players.indexOf(findPlayer), 1);
}
});
this.socket.on('players', (list: CompositePacket[]) => {
list.forEach((player) => {
if (player.id === this.me.id) {
return;
}
const newplayer = PlayerEntity.fromUser(player, this.scene);
newplayer.addUncommittedChanges(player);
this.players.push(newplayer);
});
});
this.socket.on('playerupdate', (data) => {
const player = this.players.find((player) => player.user.id === data.id);
if (
player &&
player instanceof PlayerEntity &&
player.user.id !== this.me.id
) {
player.addUncommittedChanges(data);
}
});
this.socket.on('chat', (event) => {
this.chat.addMessage(event.sender.display_name, event.message);
});
}
}

53
src/client/index.ts Normal file
View File

@ -0,0 +1,53 @@
import SocketIO, { Socket } from 'socket.io-client';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { Game } from './game';
import { ThirdPersonCamera } from './object/camera';
const socket = SocketIO({ autoConnect: false });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
renderer.setClearColor(0x00ddff);
const sun = new THREE.DirectionalLight(0xffffff);
sun.position.set(10, 10, 0);
scene.add(sun);
const ambient = new THREE.AmbientLight(0xe8e8e8, 0.2);
scene.add(ambient);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(100, 1, 100, 10, 1, 10),
new THREE.MeshToonMaterial({ color: 0x32a852 }),
);
cube.position.set(0, -0.5, 0);
scene.add(cube);
camera.position.set(0, 4, 4);
const game = new Game(socket, camera, scene, renderer.domElement);
const clock = new THREE.Clock();
let delta: number;
function animate() {
requestAnimationFrame(animate);
delta = clock.getDelta();
game.update(delta);
renderer.render(scene, camera);
}
animate();
game.initialize().catch((e) => console.error(e));

162
src/client/object/camera.ts Normal file
View File

@ -0,0 +1,162 @@
import {
Object3D,
PerspectiveCamera,
Quaternion,
Vector2,
Vector3,
} from 'three';
import { clamp } from 'three/src/math/MathUtils';
import { deg2rad } from '../../common/convert';
export class ThirdPersonCamera {
private currentPosition = new Vector3();
private currentLookAt = new Vector3();
private offsetFromPlayer = new Vector3();
private mousePos = new Vector2();
private prevMousePos = new Vector2();
private angleAroundPlayer = 180;
private pitch = -45;
private distance = 3;
private panning = false;
private pinching = false;
private previousPinchLength = 0;
constructor(
private camera: PerspectiveCamera,
private target: Object3D,
private eventTarget: HTMLElement,
) {}
private dragEvent = (x: number, y: number) => {
this.prevMousePos.copy(this.mousePos);
this.mousePos = this.mousePos.fromArray([x, y]);
if (this.panning) {
const offset = this.prevMousePos.clone().sub(this.mousePos);
this.angleAroundPlayer =
this.angleAroundPlayer + ((offset.x * 0.3) % 360);
this.pitch = clamp(this.pitch + offset.y * 0.3, -90, 90);
this.calculateCameraOffset();
}
};
events = {
contextmenu: (e: MouseEvent) => e.preventDefault(),
mousedown: (e: MouseEvent) => {
if (e.button === 2) {
this.panning = true;
}
},
mouseup: (e: MouseEvent) => {
if (e.button === 2) {
this.panning = false;
}
},
mousemove: (e: MouseEvent) => this.dragEvent(e.clientX, e.clientY),
wheel: (e: WheelEvent) => {
e.deltaY < 0 ? (this.distance /= 1.2) : (this.distance *= 1.2);
this.distance = clamp(this.distance, 4, 20);
this.calculateCameraOffset();
},
// mobile
touchstart: (ev: TouchEvent) => {
ev.preventDefault();
const touch = ev.touches[0] || ev.changedTouches[0];
this.mousePos.fromArray([touch.pageX, touch.pageY]);
this.panning = true;
if (ev.touches.length === 2) {
this.pinching = true;
}
},
touchmove: (ev: TouchEvent) => {
ev.preventDefault();
if (ev.touches.length === 2 && this.pinching) {
const pinchLength = Math.hypot(
ev.touches[0].pageX - ev.touches[1].pageX,
ev.touches[0].pageY - ev.touches[1].pageY,
);
if (this.previousPinchLength) {
const delta = pinchLength / this.previousPinchLength;
delta > 0 ? (this.distance *= delta) : (this.distance /= delta);
this.distance = clamp(this.distance, 4, 20);
this.calculateCameraOffset();
}
this.previousPinchLength = pinchLength;
} else {
this.dragEvent(ev.touches[0].clientX, ev.touches[0].clientY);
}
},
touchend: (ev: TouchEvent) => {
this.pinching = false;
this.previousPinchLength = 0;
if (!ev.touches?.length) {
this.panning = false;
}
},
};
initialize() {
Object.keys(this.events).forEach((key) => {
this.eventTarget.addEventListener(key, this.events[key]);
});
this.calculateCameraOffset();
}
dispose() {
Object.keys(this.events).forEach((key) => {
this.eventTarget.removeEventListener(key, this.events[key]);
});
}
update(dt: number) {
const offset = this.getTargetOffset();
const lookAt = this.getTargetLookAt();
// https://www.youtube.com/watch?v=UuNPHOJ_V5o
const factor = 1.0 - Math.pow(0.001, dt);
this.currentPosition.lerp(offset, factor);
this.currentLookAt.lerp(lookAt, factor);
this.camera.position.copy(this.currentPosition);
this.camera.lookAt(this.currentLookAt);
}
private calculateCameraOffset() {
const hdist = this.distance * Math.cos(deg2rad(this.pitch));
const vdist = this.distance * Math.sin(deg2rad(this.pitch));
this.offsetFromPlayer.set(
hdist * Math.sin(deg2rad(this.angleAroundPlayer)),
-vdist,
hdist * Math.cos(deg2rad(this.angleAroundPlayer)),
);
}
private getTargetOffset(): Vector3 {
const offset = this.offsetFromPlayer.clone();
const quat = new Quaternion();
this.target.getWorldQuaternion(quat);
offset.applyQuaternion(quat);
offset.add(this.target.position);
return offset;
}
private getTargetLookAt(): Vector3 {
const offset = new Vector3(0, 1.5, 0.5);
const quat = new Quaternion();
this.target.getWorldQuaternion(quat);
offset.applyQuaternion(quat);
offset.add(this.target.position);
return offset;
}
}

View File

@ -0,0 +1,48 @@
import { CanvasTexture, LinearFilter, ClampToEdgeWrapping } from 'three';
export class CanvasUtils {
public createTextCanvas(
text: string,
bold = true,
fontSize = 16,
padding = 4,
): { texture: CanvasTexture; width: number; height: number } {
const ctx = document.createElement('canvas').getContext('2d');
const font = `${fontSize}px${bold ? ' bold' : ''} sans`;
// Measure the text bounds
ctx.font = font;
const measure = ctx.measureText(text);
const width = measure.width + padding * 2;
const height = fontSize + padding * 2;
// Resize canvas
ctx.canvas.width = width;
ctx.canvas.height = height;
// Set text parameters
ctx.font = font;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
// Draw background
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, width, height);
// Scale the text to fit within the canvas
const scaleFactor = Math.min(1, width / measure.width);
ctx.translate(width / 2 - padding, height / 2 - padding);
ctx.scale(scaleFactor, 1);
ctx.fillStyle = '#000';
ctx.fillText(text, padding, padding);
// Create texture with appropriate flags
const texture = new CanvasTexture(ctx.canvas);
texture.minFilter = LinearFilter;
texture.wrapS = ClampToEdgeWrapping;
texture.wrapT = ClampToEdgeWrapping;
return { texture, width: width, height: height };
}
}

184
src/client/object/chat.ts Normal file
View File

@ -0,0 +1,184 @@
import { isMobileOrTablet, rand } from '../../common/helper';
import seedrandom from 'seedrandom';
const nameColorPallete = {
H: [1, 360],
S: [30, 100],
L: [50, 100],
};
export class Chat {
public element = document.createElement('div');
private _history = document.createElement('div');
private _wrapper = document.createElement('div');
private _mobToggle = document.createElement('button');
private _inpBox = document.createElement('div');
private _input = document.createElement('input');
private _visible = true;
private _rehide = false;
private _keyhandler = true;
private _sendFn?: (message: string) => void;
get visible() {
return this._visible;
}
initialize() {
this.element.classList.add('chat__wrapper');
this._wrapper.classList.add('chat');
this._history.classList.add('chat__history');
this._inpBox.classList.add('chat__input-wrapper');
this._input.classList.add('chat__input');
this._mobToggle.classList.add('chat__toggle');
this._inpBox.append(this._input);
this._wrapper.append(this._history, this._inpBox);
this.element.append(this._mobToggle, this._wrapper);
this._input.setAttribute(
'placeholder',
'Type a message here and press Enter to send...',
);
this._input.setAttribute('maxlength', '260');
this._input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
if (!this._input.value?.trim()) {
this._input.blur();
if (this._rehide) {
this.hide();
this._rehide = false;
}
return;
}
if (this._sendFn) {
this._sendFn(this._input.value);
}
this._input.value = null;
if (this._rehide) {
this.hide();
this._rehide = false;
}
} else if (e.key === 'Escape') {
this._input.blur();
this._input.value = null;
if (this._rehide) {
this.hide();
this._rehide = false;
}
}
});
this._input.addEventListener('focus', () => {
this.element.classList.add('focused');
});
this._input.addEventListener('blur', () => {
this.element.classList.remove('focused');
});
this._mobToggle.addEventListener('click', () => {
if (this._visible) {
this.hide();
} else {
this.focus();
}
});
window.addEventListener('keydown', (e) => {
if (!this._keyhandler) {
return;
}
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'INPUT') {
this.focus(!this.visible);
}
});
document.body.append(this.element);
if (!isMobileOrTablet()) {
this.show();
} else {
this.hide();
}
}
public registerSendFunction(fn: (message: string) => void) {
this._sendFn = fn;
}
public show() {
this._visible = true;
this._wrapper.classList.add('visible');
this._wrapper.classList.remove('invisible');
}
public hide() {
this._visible = false;
this._wrapper.classList.remove('visible');
this._wrapper.classList.add('invisible');
}
public focus(rehide = false) {
if (!this._visible) {
this.show();
}
this._input.focus();
this._rehide = rehide;
}
public addMessage(sender: string, message: string, meta?: any) {
const msg = document.createElement('div');
const msgSender = document.createElement('div');
const msgTime = document.createElement('div');
const msgContent = document.createElement('div');
msg.classList.add('chat__message');
msgTime.classList.add('chat__message-timestamp');
msgSender.classList.add('chat__message-sender');
msgContent.classList.add('chat__message-content');
msg.append(msgTime, msgSender, msgContent);
const stamp = meta?.time ? new Date(meta.time) : new Date();
// TODO: timestamp utility
msgTime.innerText = `${stamp.getHours().toString().padStart(2, '0')}:${stamp
.getMinutes()
.toString()
.padStart(2, '0')}`;
msgSender.innerText = sender;
msgSender.style.setProperty('--name-color', this.getNameColor(sender));
msgContent.innerText = message;
// const bottomed = this._history.scrollTop ,this._history.scrollHeight;
this._history.append(msg);
this._history.scrollTop = this._history.scrollHeight;
setTimeout(() => {
msg.classList.add('chat__message--old');
}, 5000);
}
public getNameColor(name: string) {
const randgen = seedrandom(name);
const h = rand(randgen, nameColorPallete.H[0], nameColorPallete.H[1]);
const s = rand(randgen, nameColorPallete.S[0], nameColorPallete.S[1]);
const l = rand(randgen, nameColorPallete.L[0], nameColorPallete.L[1]);
return 'hsl(' + h + ',' + s + '%,' + l + '%)';
}
public setKeyHandlerEnabled(isEnabled: boolean) {
this._keyhandler = isEnabled;
}
}

View File

@ -0,0 +1,101 @@
import { Vector2 } from 'three';
import { Player } from './player';
export class Joystick {
public element = document.createElement('div');
private knob = document.createElement('div');
private mousePos = new Vector2();
private prevMousePos = new Vector2();
private center = new Vector2();
private knobCenter = new Vector2();
private mouseCenterOffset = new Vector2();
private radiusSquare = new Vector2();
private negRadiusSquare = new Vector2();
private appliedForce = new Vector2();
private dragging = false;
constructor(private player: Player, public radius = 60) {}
initialize() {
this.element.classList.add('joystick', 'joystick__wrapper');
this.knob.classList.add('joystick__knob');
this.element.append(this.knob);
document.body.append(this.element);
this.element.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0] || e.changedTouches[0];
this.mousePos.fromArray([touch.pageX, touch.pageY]);
this.dragging = true;
});
this.element.addEventListener('touchmove', (e) => {
e.preventDefault();
if (this.dragging) {
this.prevMousePos.copy(this.mousePos);
this.mousePos.fromArray([e.touches[0].clientX, e.touches[0].clientY]);
this.mouseCenterOffset.copy(this.center).sub(this.mousePos);
this.mouseCenterOffset.clamp(this.negRadiusSquare, this.radiusSquare);
const knobPosition = new Vector2();
knobPosition.copy(this.knobCenter).sub(this.mouseCenterOffset);
this.knob.style.transform = `translate(${knobPosition.x}px, ${knobPosition.y}px)`;
this.appliedForce
.copy(this.mouseCenterOffset)
.divideScalar(this.radius);
}
});
this.element.addEventListener('touchend', (e) => {
this.dragging = false;
this.centerKnob();
});
window.addEventListener('resize', this._windowEventBound);
this.getCenter();
this.centerKnob();
}
getCenter() {
const rect = this.element.getBoundingClientRect();
this.center.fromArray([rect.left + this.radius, rect.top + this.radius]);
this.knobCenter.fromArray([this.radius / 2 - 2, this.radius / 2 - 2]);
}
centerKnob() {
this.appliedForce.set(0, 0);
this.knob.style.transform = `translate(${this.knobCenter.x}px, ${this.knobCenter.y}px)`;
}
reset() {
this.radiusSquare.set(this.radius, this.radius);
this.negRadiusSquare.set(-this.radius, -this.radius);
this.element.style.setProperty('--size', `${this.radius * 2}px`);
this.getCenter();
}
update(dt: number) {
if (this.appliedForce.length() !== 0) {
this.player.applyForce(this.appliedForce);
}
}
dispose() {
this.element.parentElement.removeChild(this.element);
}
show() {
this.element.style.display = 'block';
this.reset();
}
private _windowEvent() {
this.reset();
}
private _windowEventBound = this._windowEvent.bind(this);
}

View File

@ -0,0 +1,38 @@
import { CanvasTexture, Sprite, SpriteMaterial } from 'three';
import { CanvasUtils } from './canvas-utils';
export class NameTag {
public tag!: Sprite;
public width!: number;
private texture!: CanvasTexture;
private material!: SpriteMaterial;
constructor(private builder: CanvasUtils, private name: string) {
this.create();
}
create() {
const { texture, width, height } = this.builder.createTextCanvas(this.name);
this.texture = texture;
this.width = width;
this.material = new SpriteMaterial({
map: texture,
transparent: true,
});
const label = new Sprite(this.material);
const labelBaseScale = 0.01;
label.scale.x = width * labelBaseScale;
label.scale.y = height * labelBaseScale;
this.tag = label;
}
dispose() {
this.material.dispose();
this.texture.dispose();
}
}

View File

@ -0,0 +1,75 @@
import { IcyNetUser } from '../../common/types/user';
import * as THREE from 'three';
import { PonyEntity } from './pony';
import { Packet } from '../../common/types/packet';
export class PlayerEntity extends PonyEntity {
private uncommittedPacket: Packet = {};
constructor(public user: IcyNetUser) {
super();
}
public static fromUser(user: IcyNetUser, scene: THREE.Scene): PlayerEntity {
const entity = new PlayerEntity(user);
entity.initialize();
entity.addNameTag(user.display_name);
scene.add(entity.container);
return entity;
}
public setVelocity(velocity: THREE.Vector3) {
this.velocity.copy(velocity);
}
public setAngularVelocity(velocity: THREE.Vector3) {
this.angularVelocity.copy(velocity);
}
public setPosition(pos: THREE.Vector3) {
this.container.position.copy(pos);
}
public setRotation(rot: THREE.Vector3 | THREE.Euler) {
this.container.rotation.copy(
rot instanceof THREE.Euler
? rot
: new THREE.Euler(rot.x, rot.y, rot.z, 'XYZ'),
);
}
public addUncommittedChanges(packet: Packet) {
this.uncommittedPacket = { ...this.uncommittedPacket, ...packet };
}
public update(dt: number) {
super.update(dt);
this.commitServerUpdate();
}
private setFromPacket(packet: Packet) {
if (packet.velocity) {
this.setVelocity(new THREE.Vector3().fromArray(packet.velocity));
}
if (packet.position) {
this.setPosition(new THREE.Vector3().fromArray(packet.position));
}
if (packet.rotation) {
this.setRotation(new THREE.Euler().fromArray(packet.rotation));
}
if (packet.angular) {
this.setAngularVelocity(new THREE.Vector3().fromArray(packet.angular));
}
if (packet.animState) {
this.setWalkAnimationState(packet.animState);
}
}
private commitServerUpdate() {
this.setFromPacket(this.uncommittedPacket);
this.uncommittedPacket = {};
}
}

137
src/client/object/player.ts Normal file
View File

@ -0,0 +1,137 @@
import * as THREE from 'three';
import { IcyNetUser } from '../../common/types/user';
import { Socket } from 'socket.io-client';
import { PonyEntity } from './pony';
import { Vector2 } from 'three';
export class Player extends PonyEntity {
public keydownMap: { [x: string]: boolean } = {};
private _prevKeydownMap: { [x: string]: boolean } = {};
/**
* Normal vector of the currently faced direction
*/
private _lookVector = new THREE.Vector3();
/**
* Direction of the current movement.
* X axis controls angular velocity, Y axis controls linear velocity direction by world coordinates.
*/
private _direction = new THREE.Vector2();
/**
* Joystick or other external movement usage.
*/
private _externalDirection = new THREE.Vector2();
/**
* Was external movement used this tick
*/
private _externalForce = false;
/**
* Was moving last tick
*/
private _wasMoving = false;
/**
* Was turning last tick
*/
private _wasTurning = false;
constructor(public user: IcyNetUser) {
super();
}
public static fromUser(user: IcyNetUser, scene: THREE.Scene): Player {
const entity = new Player(user);
entity.initialize();
scene.add(entity.container);
return entity;
}
initialize(): void {
super.initialize();
window.addEventListener('keydown', (e) => {
this.keydownMap[e.key] = true;
});
window.addEventListener('keyup', (e) => {
this.keydownMap[e.key] = false;
});
}
public createPacket(socket: Socket): void {
if (Object.keys(this.changes).length) {
socket.emit('move', this.changes);
this.changes = {};
}
}
public applyForce(vec: Vector2) {
this._externalDirection.copy(vec);
this._externalForce = true;
}
public moveCharacter(dt: number) {
const vector = new THREE.Vector2();
const wasExternalForce = this._externalForce;
this._externalForce = false;
if (wasExternalForce) {
vector.copy(this._externalDirection);
} else {
vector.copy(this._direction);
}
if (vector.x !== 0) {
this.angularVelocity.set(0, Math.PI * dt * vector.x, 0);
this.changes.angular = this.angularVelocity.toArray();
this._wasTurning = true;
} else if (this._wasTurning && !wasExternalForce) {
this._wasTurning = false;
this.angularVelocity.set(0, 0, 0);
this.changes.rotation = this.container.rotation.toArray();
this.changes.angular = this.angularVelocity.toArray();
}
if (vector.y !== 0) {
this.velocity.copy(
this._lookVector.clone().multiplyScalar(vector.y * dt),
);
this.changes.velocity = this.velocity.toArray();
this._wasMoving = true;
} else if (this._wasMoving && !wasExternalForce) {
this._wasMoving = false;
this.velocity.set(0, 0, 0);
this.changes.position = this.container.position.toArray();
this.changes.velocity = this.velocity.toArray();
}
}
update(dt: number): void {
this.container.getWorldDirection(this._lookVector);
if (this.keydownMap['w']) {
this._direction.y = 1;
} else if (this.keydownMap['s']) {
this._direction.y = -1;
} else {
this._direction.y = 0;
}
if (this.keydownMap['a']) {
this._direction.x = 1;
} else if (this.keydownMap['d']) {
this._direction.x = -1;
} else {
this._direction.x = 0;
}
this.moveCharacter(dt);
super.update(dt);
this._prevKeydownMap = { ...this.keydownMap };
}
}

View File

@ -0,0 +1,49 @@
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
// Instantiate a loader
const loader = new GLTFLoader();
// Optional: Provide a DRACOLoader instance to decode compressed mesh data
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/examples/js/libs/draco/');
loader.setDRACOLoader(dracoLoader);
class PonyModel {
public ponyModel!: THREE.Group;
public animations!: THREE.AnimationClip[];
loadPonyModel(): Promise<THREE.Group> {
return new Promise((resolve, reject) => {
// Load a glTF resource
loader.load(
// resource URL
'/assets/clean-pony2fix.glb',
// called when the resource is loaded
(gltf) => {
this.ponyModel = gltf.scene;
this.animations = gltf.animations;
resolve(gltf.scene);
// gltf.animations; // Array<THREE.AnimationClip>
// gltf.scene; // THREE.Group
// gltf.scenes; // Array<THREE.Group>
// gltf.cameras; // Array<THREE.Camera>
// gltf.asset; // Object
},
// called while loading is progressing
(xhr) => {
console.log((xhr.loaded / xhr.total) * 100 + '% loaded');
},
// called when loading has errors
(error) => {
console.log('An error happened');
reject(error);
},
);
});
}
}
const modelLoaderInstance = new PonyModel();
export default modelLoaderInstance;

71
src/client/object/pony.ts Normal file
View File

@ -0,0 +1,71 @@
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils';
import * as THREE from 'three';
import modelLoaderInstance from './pony-loader';
import { Packet } from '../../common/types/packet';
import { NameTag } from './nametag';
import { CanvasUtils } from './canvas-utils';
const nameTagBuilder = new CanvasUtils();
export class PonyEntity {
public velocity = new THREE.Vector3(0, 0, 0);
public angularVelocity = new THREE.Vector3(0, 0, 0);
public mixer!: THREE.AnimationMixer;
public container!: THREE.Object3D;
public model!: THREE.Object3D;
public walkAnimationState = 0;
public idleAction: THREE.AnimationAction;
public walkAction: THREE.AnimationAction;
public nameTag?: NameTag;
public changes: Packet = {};
initialize() {
this.model = (SkeletonUtils as any).clone(modelLoaderInstance.ponyModel);
this.mixer = new THREE.AnimationMixer(this.model);
this.idleAction = this.mixer.clipAction(modelLoaderInstance.animations[0]);
this.walkAction = this.mixer.clipAction(modelLoaderInstance.animations[2]);
this.idleAction.play();
this.container = new THREE.Object3D();
this.container.add(this.model);
}
update(dt: number) {
this.container.position.add(this.velocity);
this.container.rotation.setFromVector3(
new THREE.Vector3(
this.container.rotation.x,
this.container.rotation.y,
this.container.rotation.z,
).add(this.angularVelocity),
);
this.mixer.update(dt);
if (this.velocity.length() > 0) {
this.setWalkAnimationState(1);
} else {
this.setWalkAnimationState(0);
}
}
public addNameTag(name: string) {
this.nameTag = new NameTag(nameTagBuilder, name);
this.nameTag.tag.position.set(0, 1.8, 0.5);
this.container.add(this.nameTag.tag);
}
public setWalkAnimationState(index: number) {
const previousState = this.walkAnimationState;
this.walkAnimationState = index;
if (previousState === this.walkAnimationState) {
return;
}
this.changes.animState = index;
if (index === 1) {
this.walkAction.reset().crossFadeFrom(this.idleAction, 0.5, false).play();
} else {
this.walkAction.crossFadeTo(this.idleAction.reset(), 0.5, false).play();
}
}
}

164
src/client/scss/index.scss Normal file
View File

@ -0,0 +1,164 @@
*, *::before, *::after {
box-sizing: border-box;
}
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.joystick {
display: none;
position: absolute;
width: var(--size);
height: var(--size);
bottom: 10px;
left: calc(50% - var(--size) / 2);
background-color: #e7e7e7b3;
border-radius: 100%;
border: 2px solid #ddd;
z-index: 1000;
&__knob {
width: calc(var(--size) / 2);
height: calc(var(--size) / 2);
position: absolute;
background-color: #fff;
border: 2px solid #ddd;
border-radius: 100%;
cursor:grab;
transform-origin: center center;
}
}
.chat {
min-width: 200px;
width: 40vw;
max-height: 180px;
height: 180px;
display: flex;
flex-direction: column;
&__toggle {
width: 24px;
height: 24px;
appearance: none;
background-color: transparent;
background-position: center center;
background-repeat: no-repeat;
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27currentColor%27 d=%27M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2M20 16H5.2L4 17.2V4H20V16Z%27 /%3E%3C/svg%3E");
padding: 1rem;
border: 2px solid #ddd;
border-radius: 100%;
background-size: 70%;
margin-bottom: 0.5rem;
}
&__wrapper {
position: absolute;
bottom: 8px;
left: 8px;
z-index: 2000;
}
&__history {
flex-grow: 1;
overflow-y: scroll;
padding: 0.15rem;
}
&__input {
flex-grow: 1;
&-wrapper {
display: flex;
flex-direction: row;
}
}
&__message {
display: flex;
flex-direction: row;
gap: 0.5rem;
&-timestamp {
color: #646464;
&::before {
content: '[';
}
&::after {
content: ']';
}
}
&-sender {
color: var(--name-color);
&::before {
content: '<';
}
&::after {
content: '>';
}
}
&-content {
overflow-wrap: anywhere;
}
}
&.invisible {
.chat {
&__input-wrapper {
display: none;
}
&__history {
overflow: hidden;
}
&__message {
visibility: visible;
opacity: 1;
&--old {
visibility: hidden;
opacity: 0;
transition: visibility 0s 2s, opacity 2s linear;
}
}
}
}
&.visible {
.chat__history {
background-color: #6464644d;
}
}
}
@media all and (max-width: 737px) {
.chat {
&__wrapper {
right: 8px;
&:not(.focused) {
&, *:not(input):not(button) {
pointer-events: none;
}
button {
pointer-events: all;
}
}
}
min-width: 100%;
width: 100%;
}
}

35
src/common/convert.ts Normal file
View File

@ -0,0 +1,35 @@
export function convertHex(hex: number): { r: number; g: number; b: number } {
return {
r: (hex >> 16) & 255,
g: (hex >> 8) & 255,
b: hex & 255,
};
}
export function hexToString(hex: number): string {
const { r, g, b } = convertHex(hex);
return `#${r.toString(16).padStart(2, '0')}${g
.toString(16)
.padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
export function to2D(index: number, size: number): { x: number; y: number } {
const y = index / size;
const x = index % size;
return { x, y };
}
export function to1D(x: number, y: number, size: number): number {
return y * size + x;
}
export function storeHex(hex: string): number {
return Number(hex.replace('#', '0x'));
}
export function deg2rad(deg: number): number {
return deg * (Math.PI / 180);
}
export function rad2deg(rad: number): number {
return rad * (180 / Math.PI);
}

33
src/common/helper.ts Normal file
View File

@ -0,0 +1,33 @@
export function clamp(x: number, min: number, max: number): number {
return Math.min(Math.max(x, min), max);
}
export function debounce(func: Function, timeout = 300) {
let timer: any;
return (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, timeout);
};
}
export function isMobileOrTablet(): boolean {
let check = false;
(function (a) {
if (
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(
a,
) ||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
a.substring(0, 4),
)
)
check = true;
})(navigator.userAgent || navigator.vendor);
return check;
}
export function rand(randgen: () => number, min: number, max: number) {
return Math.floor(randgen() * (max - min + 1)) + min;
}

View File

@ -0,0 +1,11 @@
import { IcyNetUser } from './user';
export interface Packet {
velocity?: number[];
angular?: number[];
position?: number[];
rotation?: (number | string)[];
animState?: number;
}
export interface CompositePacket extends IcyNetUser, Packet {}

7
src/common/types/user.ts Normal file
View File

@ -0,0 +1,7 @@
export interface IcyNetUser {
id: number;
uuid: string;
username: string;
display_name: string;
accessToken: string;
}

7
src/server/config.ts Normal file
View File

@ -0,0 +1,7 @@
import toml from 'toml';
import fs from 'fs';
import { join } from 'path';
export const config = toml.parse(
fs.readFileSync(join(__dirname, '..', '..', 'config.toml'), 'utf-8'),
);

114
src/server/index.ts Normal file
View File

@ -0,0 +1,114 @@
import express, { RequestHandler } from 'express';
import session from 'express-session';
import passport from 'passport';
import * as redis from 'redis';
import * as icynetstrat from 'passport-icynet';
import connectRedis from 'connect-redis';
import http from 'http';
import { join } from 'path';
import { Server } from 'socket.io';
import { IcyNetUser } from '../common/types/user';
import { config } from './config';
import { Game } from './object/game';
const RedisStore = connectRedis(session);
const redisClient = config.redis?.enabled ? redis.createClient() : undefined;
const sessionMiddleware = session({
secret: config.server.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production', sameSite: 'strict' },
store: config.redis?.enabled
? new RedisStore({ client: redisClient })
: undefined,
});
// todo: store less info in session
passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((obj: IcyNetUser, done) => {
done(null, obj);
});
passport.use(
new icynetstrat.Strategy(
{
clientID: config.auth.clientID,
clientSecret: config.auth.clientSecret,
callbackURL: config.auth.callbackURL,
scope: [],
},
function (
accessToken: string,
refreshToken: string,
profile: any,
done: Function,
) {
process.nextTick(function () {
return done(null, profile);
});
},
),
);
const app = express();
if (process.env.NODE_ENV === 'production') {
app.enable('trust proxy');
}
const server = http.createServer(app);
const io = new Server(server);
const checkAuth: RequestHandler = (req, res, next) => {
if (req.isAuthenticated()) {
return next();
}
res.send('not logged in :(');
};
app.use(sessionMiddleware);
app.use(passport.initialize());
app.use(passport.session());
app.get(
'/login',
passport.authenticate('icynet', { scope: [] }),
(req, res) => {},
);
app.get(
'/callback',
passport.authenticate('icynet', { failureRedirect: '/?login=false' }),
(req, res) => {
res.redirect('/?login=true');
}, // auth success
);
app.get('/logout', (req, res) => {
req.logout();
res.redirect('/');
});
app.get('/info', checkAuth, (req, res) => {
res.json(req.user);
});
app.use('/assets', express.static(join(__dirname, '..', '..', 'assets')));
app.use(express.static(join(__dirname, '..', 'public')));
///
const game = new Game(io, sessionMiddleware);
game.initialize().then(() =>
server.listen(config.server.port, config.server.bind, () => {
console.log(`Listening at http://localhost:${config.server.port}/`);
}),
);

86
src/server/object/game.ts Normal file
View File

@ -0,0 +1,86 @@
import { Server, Socket } from 'socket.io';
import { config } from '../config';
import { RequestHandler } from 'express';
import { IcyNetUser } from '../../common/types/user';
const PLACEHOLDER_USER = (socket: Socket): IcyNetUser => {
const randomName = `player-${socket.id.substring(0, 8)}`;
return {
id: Math.random() * 1000 + 1000,
username: randomName,
display_name: randomName,
uuid: socket.id,
accessToken: 'player',
};
};
export class Game {
private _connections: Socket[] = [];
constructor(private io: Server, private session: RequestHandler) {}
private mapPlayer(user: IcyNetUser): any {
return {
id: user.id,
username: user.username,
display_name: user.display_name,
};
}
async initialize() {
this.io.use((socket, next) =>
this.session(socket.request as any, {} as any, next as any),
);
this.io.on('connection', (socket) => {
const session = (socket.request as any).session;
const user =
process.env.SKIP_LOGIN === 'true'
? PLACEHOLDER_USER(socket)
: (session?.passport?.user as IcyNetUser);
const publicUserInfo = user ? this.mapPlayer(user) : null;
this._connections.push(socket);
socket.data.user = user;
socket.data.playerinfo = {
velocity: [0, 0, 0],
angular: [0, 0, 0],
position: [0, 0, 0],
rotation: [0, 0, 0],
animState: 0,
};
socket.emit('me', publicUserInfo);
socket.emit(
'players',
this._connections.map((conn) => ({
...this.mapPlayer(conn.data.user),
...conn.data.playerinfo,
})),
);
socket.broadcast.emit('playerjoin', publicUserInfo);
socket.on('disconnect', () => {
this._connections.splice(this._connections.indexOf(socket), 1);
this.io.emit('playerleave', publicUserInfo);
});
socket.on('move', (packet) => {
socket.data.playerinfo = {
...socket.data.playerinfo,
...packet,
};
socket.broadcast.emit('playerupdate', {
id: user.id,
...packet,
});
});
socket.on('chat-send', (raw) => {
const message = raw.trim().substring(0, 260);
this.io.emit('chat', { sender: publicUserInfo, message });
});
});
}
}

100
tsconfig.json Normal file
View File

@ -0,0 +1,100 @@
{
"include": [
"src/server/**/*.ts",
"src/common/types/user.ts"
],
"exclude": [
"src/client/**/*.ts"
],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

36
webpack.config.js Normal file
View File

@ -0,0 +1,36 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: ['./src/client/index.ts', './src/client/scss/index.scss'],
module: {
rules: [
{
test: /\.(ts|js)?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-typescript',],
},
},
},
{
test: /\.(css|scss)/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
},
],
},
resolve: {
extensions: ['.ts', '.js'],
},
plugins: [new HtmlWebpackPlugin({
title: 'Icy3D World Experiment'
}), new MiniCssExtractPlugin()],
output: {
path: path.resolve(__dirname, 'dist', 'public'),
filename: 'bundle.js',
},
devtool: 'source-map'
};