initial commit
This commit is contained in:
commit
9267e50415
5
.babelrc
Normal file
5
.babelrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-runtime"
|
||||
]
|
||||
}
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
dist/
|
||||
node_modules/
|
||||
/*.png
|
||||
/*.db
|
||||
/config.toml
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
BIN
assets/clean-pony2fix.glb
Normal file
BIN
assets/clean-pony2fix.glb
Normal file
Binary file not shown.
7
migrations/001-initial.sql
Normal file
7
migrations/001-initial.sql
Normal file
@ -0,0 +1,7 @@
|
||||
--------------------------------------------------------------------------------
|
||||
-- Up
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Down
|
||||
--------------------------------------------------------------------------------
|
17996
package-lock.json
generated
Normal file
17996
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
package.json
Normal file
58
package.json
Normal 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
135
src/client/game.ts
Normal 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
53
src/client/index.ts
Normal 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
162
src/client/object/camera.ts
Normal 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;
|
||||
}
|
||||
}
|
48
src/client/object/canvas-utils.ts
Normal file
48
src/client/object/canvas-utils.ts
Normal 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
184
src/client/object/chat.ts
Normal 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;
|
||||
}
|
||||
}
|
101
src/client/object/joystick.ts
Normal file
101
src/client/object/joystick.ts
Normal 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);
|
||||
}
|
38
src/client/object/nametag.ts
Normal file
38
src/client/object/nametag.ts
Normal 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();
|
||||
}
|
||||
}
|
75
src/client/object/player-entity.ts
Normal file
75
src/client/object/player-entity.ts
Normal 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
137
src/client/object/player.ts
Normal 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 };
|
||||
}
|
||||
}
|
49
src/client/object/pony-loader.ts
Normal file
49
src/client/object/pony-loader.ts
Normal 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
71
src/client/object/pony.ts
Normal 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
164
src/client/scss/index.scss
Normal 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
35
src/common/convert.ts
Normal 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
33
src/common/helper.ts
Normal 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;
|
||||
}
|
11
src/common/types/packet.ts
Normal file
11
src/common/types/packet.ts
Normal 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
7
src/common/types/user.ts
Normal 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
7
src/server/config.ts
Normal 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
114
src/server/index.ts
Normal 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
86
src/server/object/game.ts
Normal 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
100
tsconfig.json
Normal 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
36
webpack.config.js
Normal 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'
|
||||
};
|
Loading…
Reference in New Issue
Block a user