mirror of
https://github.com/d07RiV/diabloweb.git
synced 2026-06-03 21:41:38 +00:00
587 lines
19 KiB
JavaScript
587 lines
19 KiB
JavaScript
import React from 'react';
|
|
import './App.scss';
|
|
import classNames from 'classnames';
|
|
import ReactGA from 'react-ga';
|
|
|
|
import create_fs from './fs';
|
|
import load_game from './api/loader';
|
|
import { SpawnSize } from './api/load_spawn';
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
ReactGA.initialize('UA-43123589-6');
|
|
ReactGA.pageview('/');
|
|
}
|
|
|
|
function reportLink(e, retail) {
|
|
const message = e.stack || e.message || "Unknown error";
|
|
const url = new URL("https://github.com/d07RiV/diabloweb/issues/new");
|
|
url.searchParams.set("body",
|
|
`**Description:**
|
|
[Please describe what you were doing before the error occurred]
|
|
|
|
**App version:**
|
|
DiabloWeb ${process.env.VERSION} (${retail ? 'Retail' : 'Shareware'})
|
|
|
|
**Error message:**
|
|
|
|
${message.split("\n").map(line => " " + line).join("\n")}
|
|
`);
|
|
return url.toString();
|
|
}
|
|
|
|
function isDropFile(e) {
|
|
if (e.dataTransfer.items) {
|
|
for (let i = 0; i < e.dataTransfer.items.length; ++i) {
|
|
if (e.dataTransfer.items[i].kind === "file") {
|
|
return true;
|
|
}
|
|
}
|
|
} if (e.dataTransfer.files.length) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function getDropFile(e) {
|
|
if (e.dataTransfer.items) {
|
|
for (let i = 0; i < e.dataTransfer.items.length; ++i) {
|
|
if (e.dataTransfer.items[i].kind === "file") {
|
|
return e.dataTransfer.items[i].getAsFile();
|
|
}
|
|
}
|
|
} if (e.dataTransfer.files.length) {
|
|
return e.dataTransfer.files[0];
|
|
}
|
|
}
|
|
|
|
const TOUCH_MOVE = 0;
|
|
const TOUCH_RMB = 1;
|
|
const TOUCH_SHIFT = 2;
|
|
|
|
const Link = ({children, ...props}) => <a target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
|
|
|
|
class App extends React.Component {
|
|
files = new Map();
|
|
state = {started: false, loading: false, touch: false, dropping: 0, has_spawn: false};
|
|
cursorPos = {x: 0, y: 0};
|
|
|
|
touchButtons = [null, null, null, null, null, null];
|
|
touchCtx = [null, null, null, null, null, null];
|
|
touchMods = [false, false, false, false, false, false];
|
|
touchBelt = [-1, -1, -1, -1, -1, -1];
|
|
|
|
fs = create_fs(true);
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.setTouch0 = this.setTouch_.bind(this, 0);
|
|
this.setTouch1 = this.setTouch_.bind(this, 1);
|
|
this.setTouch2 = this.setTouch_.bind(this, 2);
|
|
this.setTouch3 = this.setTouchBelt_.bind(this, 3);
|
|
this.setTouch4 = this.setTouchBelt_.bind(this, 4);
|
|
this.setTouch5 = this.setTouchBelt_.bind(this, 5);
|
|
}
|
|
|
|
componentDidMount() {
|
|
document.addEventListener("drop", this.onDrop, true);
|
|
document.addEventListener("dragover", this.onDragOver, true);
|
|
document.addEventListener("dragenter", this.onDragEnter, true);
|
|
document.addEventListener("dragleave", this.onDragLeave, true);
|
|
|
|
this.fs.then(fs => {
|
|
const spawn = fs.files.get('spawn.mpq');
|
|
if (spawn && spawn.byteLength === SpawnSize) {
|
|
this.setState({has_spawn: true});
|
|
}
|
|
});
|
|
}
|
|
|
|
onDrop = e => {
|
|
const file = getDropFile(e);
|
|
if (file) {
|
|
e.preventDefault();
|
|
this.start(file);
|
|
}
|
|
this.setState({dropping: 0});
|
|
}
|
|
onDragEnter = e => {
|
|
e.preventDefault();
|
|
this.setDropping(1);
|
|
}
|
|
onDragOver = e => {
|
|
if (isDropFile(e)) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
onDragLeave = e => {
|
|
this.setDropping(-1);
|
|
}
|
|
setDropping(inc) {
|
|
this.setState(({dropping}) => ({dropping: Math.max(dropping + inc, 0)}));
|
|
}
|
|
|
|
onError(message, stack) {
|
|
this.setState(({error}) => !error && {error: {message, stack}});
|
|
}
|
|
|
|
openKeyboard(open) {
|
|
if (open) {
|
|
this.showKeyboard = true;
|
|
this.element.classList.add("keyboard");
|
|
this.keyboard.focus();
|
|
} else {
|
|
this.showKeyboard = false;
|
|
this.element.classList.remove("keyboard");
|
|
this.keyboard.blur();
|
|
}
|
|
}
|
|
|
|
setCursorPos(x, y) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
this.cursorPos = {
|
|
x: rect.left + (rect.right - rect.left) * x / 640,
|
|
y: rect.top + (rect.bottom - rect.top) * y / 480,
|
|
};
|
|
setTimeout(() => {
|
|
this.game("DApi_Mouse", 0, 0, 0, x, y);
|
|
});
|
|
}
|
|
|
|
onProgress(progress) {
|
|
this.setState({progress});
|
|
}
|
|
|
|
onExit() {
|
|
if (!this.state.error) {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
setCurrentSave(name) {
|
|
this.saveName = name;
|
|
}
|
|
downloadSave = e => {
|
|
this.fs.then(fs => this.saveName && fs.download(this.saveName));
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
|
|
drawBelt(idx, slot) {
|
|
if (!this.canvas) return;
|
|
if (!this.touchButtons[idx]) {
|
|
return;
|
|
}
|
|
this.touchBelt[idx] = slot;
|
|
if (slot >= 0) {
|
|
this.touchButtons[idx].style.display = "block";
|
|
this.touchCtx[idx].drawImage(this.canvas, 205 + 29 * slot, 357, 28, 28, 0, 0, 28, 28);
|
|
} else {
|
|
this.touchButtons[idx].style.display = "none";
|
|
}
|
|
}
|
|
|
|
updateBelt(belt) {
|
|
if (belt) {
|
|
const used = new Set();
|
|
let pos = 3;
|
|
for (let i = 0; i < belt.length && pos < 6; ++i) {
|
|
if (belt[i] >= 0 && !used.has(belt[i])) {
|
|
this.drawBelt(pos++, i);
|
|
used.add(belt[i]);
|
|
}
|
|
}
|
|
for (; pos < 6; ++pos) {
|
|
this.drawBelt(pos, -1);
|
|
}
|
|
} else {
|
|
this.drawBelt(3, -1);
|
|
this.drawBelt(4, -1);
|
|
this.drawBelt(5, -1);
|
|
}
|
|
}
|
|
|
|
start(file) {
|
|
if (file && file.name.match(/\.sv$/i)) {
|
|
this.fs.then(fs => fs.upload(file)).then(console.log(`Updated ${file.name}`));
|
|
return;
|
|
}
|
|
|
|
document.removeEventListener("drop", this.onDrop, true);
|
|
document.removeEventListener("dragover", this.onDragOver, true);
|
|
document.removeEventListener("dragenter", this.onDragEnter, true);
|
|
document.removeEventListener("dragleave", this.onDragLeave, true);
|
|
this.setState({dropping: 0});
|
|
|
|
const retail = !!(file && file.name.match(/^diabdat\.mpq$/i));
|
|
if (process.env.NODE_ENV === 'production') {
|
|
ReactGA.event({
|
|
category: 'Game',
|
|
action: retail ? 'Start Retail' : 'Start Shareware',
|
|
});
|
|
}
|
|
|
|
this.setState({loading: true, retail});
|
|
|
|
load_game(this, file).then(game => {
|
|
this.game = game;
|
|
|
|
document.addEventListener('mousemove', this.onMouseMove, true);
|
|
document.addEventListener('mousedown', this.onMouseDown, true);
|
|
document.addEventListener('mouseup', this.onMouseUp, true);
|
|
document.addEventListener('keydown', this.onKeyDown, true);
|
|
document.addEventListener('keyup', this.onKeyUp, true);
|
|
document.addEventListener('contextmenu', this.onMenu, true);
|
|
|
|
document.addEventListener('touchstart', this.onTouchStart, {passive: false, capture: true});
|
|
document.addEventListener('touchmove', this.onTouchMove, {passive: false, capture: true});
|
|
document.addEventListener('touchend', this.onTouchEnd, {passive: false, capture: true});
|
|
|
|
document.addEventListener('pointerlockchange', this.onPointerLockChange);
|
|
document.addEventListener('fullscreenchange', this.onFullscreenChange);
|
|
window.addEventListener('resize', this.onResize);
|
|
|
|
this.setState({started: true});
|
|
}, e => this.onError(e.message, e.stack));
|
|
}
|
|
|
|
pointerLocked() {
|
|
return document.pointerLockElement === this.canvas || document.mozPointerLockElement === this.canvas;
|
|
}
|
|
|
|
mousePos(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
if (this.pointerLocked()) {
|
|
this.cursorPos.x = Math.max(rect.left, Math.min(rect.right, this.cursorPos.x + e.movementX));
|
|
this.cursorPos.y = Math.max(rect.top, Math.min(rect.bottom, this.cursorPos.y + e.movementY));
|
|
} else {
|
|
this.cursorPos = {x: e.clientX, y: e.clientY};
|
|
}
|
|
return {
|
|
x: Math.max(0, Math.min(Math.round((this.cursorPos.x - rect.left) / (rect.right - rect.left) * 640), 639)),
|
|
y: Math.max(0, Math.min(Math.round((this.cursorPos.y - rect.top) / (rect.bottom - rect.top) * 480), 479)),
|
|
};
|
|
}
|
|
|
|
mouseButton(e) {
|
|
switch (e.button) {
|
|
case 0: return 1;
|
|
case 1: return 4;
|
|
case 2: return 2;
|
|
case 3: return 5;
|
|
case 4: return 6;
|
|
default: return 1;
|
|
}
|
|
}
|
|
eventMods(e) {
|
|
return ((e.shiftKey || this.touchMods[TOUCH_SHIFT]) ? 1 : 0) + (e.ctrlKey ? 2 : 0) + (e.altKey ? 4 : 0) + (e.touches ? 8 : 0);
|
|
}
|
|
|
|
onResize = () => {
|
|
document.exitPointerLock();
|
|
}
|
|
|
|
onPointerLockChange = () => {
|
|
if (window.screen && window.innerHeight === window.screen.height && !this.pointerLocked()) {
|
|
// assume that the user pressed escape
|
|
this.game("DApi_Key", 0, 0, 27);
|
|
this.game("DApi_Key", 1, 0, 27);
|
|
}
|
|
}
|
|
|
|
onMouseMove = e => {
|
|
if (!this.canvas) return;
|
|
const {x, y} = this.mousePos(e);
|
|
this.game("DApi_Mouse", 0, 0, this.eventMods(e), x, y);
|
|
e.preventDefault();
|
|
}
|
|
|
|
onMouseDown = e => {
|
|
if (!this.canvas) return;
|
|
const {x, y} = this.mousePos(e);
|
|
if (window.screen && window.innerHeight === window.screen.height) {
|
|
// we're in fullscreen, let's get pointer lock!
|
|
if (!this.pointerLocked()) {
|
|
this.canvas.requestPointerLock();
|
|
}
|
|
}
|
|
this.game("DApi_Mouse", 1, this.mouseButton(e), this.eventMods(e), x, y);
|
|
e.preventDefault();
|
|
}
|
|
|
|
onMouseUp = e => {
|
|
if (!this.canvas) return;
|
|
const {x, y} = this.mousePos(e);
|
|
this.game("DApi_Mouse", 2, this.mouseButton(e), this.eventMods(e), x, y);
|
|
e.preventDefault();
|
|
}
|
|
|
|
onKeyDown = e => {
|
|
if (!this.canvas) return;
|
|
this.game("DApi_Key", 0, this.eventMods(e), e.keyCode);
|
|
if (e.keyCode >= 32 && e.key.length === 1 && !this.showKeyboard) {
|
|
this.game("DApi_Char", e.key.charCodeAt(0));
|
|
}
|
|
this.clearKeySel();
|
|
if (!this.showKeyboard) {
|
|
if (e.keyCode === 8 || (e.keyCode >= 112 && e.keyCode <= 119)) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
onMenu = e => {
|
|
e.preventDefault();
|
|
}
|
|
|
|
onKeyUp = e => {
|
|
if (!this.canvas) return;
|
|
this.game("DApi_Key", 1, this.eventMods(e), e.keyCode);
|
|
this.clearKeySel();
|
|
}
|
|
|
|
clearKeySel() {
|
|
if (this.showKeyboard) {
|
|
const len = this.keyboard.value.length;
|
|
this.keyboard.setSelectionRange(len, len);
|
|
}
|
|
}
|
|
|
|
onKeyboard = () => {
|
|
if (this.showKeyboard) {
|
|
const text = this.keyboard.value;
|
|
const valid = (text.match(/[\x20-\x7E]/g) || []).join("").substring(0, 15);
|
|
if (text !== valid) {
|
|
this.keyboard.value = valid;
|
|
}
|
|
this.clearKeySel();
|
|
const values = [...Array(15)].map((_, i) => i < valid.length ? valid.charCodeAt(i) : 0);
|
|
this.game("DApi_SyncText", ...values);
|
|
}
|
|
}
|
|
|
|
parseFile = e => {
|
|
const files = e.target.files;
|
|
if (files.length > 0) {
|
|
this.start(files[0]);
|
|
}
|
|
}
|
|
|
|
touchButton = null;
|
|
touchCanvas = null;
|
|
|
|
onFullscreenChange = () => {
|
|
this.setState({touch: (document.fullscreenElement === this.element)});
|
|
}
|
|
|
|
setTouchMod(index, value, use) {
|
|
if (index < 3) {
|
|
this.touchMods[index] = value;
|
|
if (this.touchButtons[index]) {
|
|
this.touchButtons[index].classList.toggle("active", value);
|
|
}
|
|
} else if (use && this.touchBelt[index] >= 0) {
|
|
const now = performance.now();
|
|
if (!this.beltTime || now - this.beltTime > 750) {
|
|
this.game("DApi_Char", 49 + this.touchBelt[index]);
|
|
this.beltTime = now;
|
|
}
|
|
}
|
|
}
|
|
|
|
updateTouchButton(touches, release) {
|
|
let touchOther = null;
|
|
const btn = this.touchButton;
|
|
for (let {target, identifier, clientX, clientY} of touches) {
|
|
if (btn && btn.id === identifier && this.touchButtons[btn.index] === target) {
|
|
if (touches.length > 1) {
|
|
btn.stick = false;
|
|
}
|
|
btn.clientX = clientX;
|
|
btn.clientY = clientY;
|
|
this.touchCanvas = [...touches].find(t => t.identifier !== identifier);
|
|
if (this.touchCanvas) {
|
|
this.touchCanvas = {clientX: this.touchCanvas.clientX, clientY: this.touchCanvas.clientY};
|
|
}
|
|
delete this.panPos;
|
|
return this.touchCanvas != null;
|
|
}
|
|
const idx = this.touchButtons.indexOf(target);
|
|
if (idx >= 0 && !touchOther) {
|
|
touchOther = {id: identifier, index: idx, stick: true, original: this.touchMods[idx], clientX, clientY};
|
|
}
|
|
}
|
|
if (btn && !touchOther && release && btn.stick) {
|
|
const rect = this.touchButtons[btn.index].getBoundingClientRect();
|
|
const {clientX, clientY} = btn;
|
|
if (clientX >= rect.left && clientX < rect.right && clientY >= rect.top && clientY < rect.bottom) {
|
|
this.setTouchMod(btn.index, !btn.original, true);
|
|
} else {
|
|
this.setTouchMod(btn.index, btn.original);
|
|
}
|
|
} else if (btn) {
|
|
this.setTouchMod(btn.index, false);
|
|
}
|
|
this.touchButton = touchOther;
|
|
if (touchOther) {
|
|
this.setTouchMod(touchOther.index, true);
|
|
if (touchOther.index === TOUCH_MOVE) {
|
|
this.setTouchMod(TOUCH_RMB, false);
|
|
} else if (touchOther.index === TOUCH_RMB) {
|
|
this.setTouchMod(TOUCH_MOVE, false);
|
|
}
|
|
delete this.panPos;
|
|
} else if (touches.length === 2) {
|
|
const x = (touches[1].clientX + touches[0].clientX) / 2, y = (touches[1].clientY + touches[0].clientY) / 2;
|
|
if (this.panPos) {
|
|
const dx = x - this.panPos.x, dy = y - this.panPos.y;
|
|
const step = this.canvas.offsetHeight / 12;
|
|
if (Math.max(Math.abs(dx), Math.abs(dy)) > step) {
|
|
let key;
|
|
if (Math.abs(dx) > Math.abs(dy)) {
|
|
key = (dx > 0 ? 0x25 : 0x27);
|
|
} else {
|
|
key = (dy > 0 ? 0x26 : 0x28);
|
|
}
|
|
this.game("DApi_Key", 0, 0, key);
|
|
// key up is ignored anyway
|
|
this.panPos = {x, y};
|
|
}
|
|
} else {
|
|
this.game("DApi_Mouse", 0, 0, 24, 320, 180);
|
|
this.game("DApi_Mouse", 2, 1, 24, 320, 180);
|
|
this.panPos = {x, y};
|
|
}
|
|
this.touchCanvas = null;
|
|
return false;
|
|
} else {
|
|
delete this.panPos;
|
|
}
|
|
this.touchCanvas = [...touches].find(t => !touchOther || t.identifier !== touchOther.id);
|
|
if (this.touchCanvas) {
|
|
this.touchCanvas = {clientX: this.touchCanvas.clientX, clientY: this.touchCanvas.clientY};
|
|
}
|
|
return this.touchCanvas != null;
|
|
}
|
|
|
|
onTouchStart = e => {
|
|
if (!this.canvas) return;
|
|
e.preventDefault();
|
|
if (this.updateTouchButton(e.touches, false)) {
|
|
const {x, y} = this.mousePos(this.touchCanvas);
|
|
this.game("DApi_Mouse", 0, 0, this.eventMods(e), x, y);
|
|
if (!this.touchMods[TOUCH_MOVE]) {
|
|
this.game("DApi_Mouse", 1, this.touchMods[TOUCH_RMB] ? 2 : 1, this.eventMods(e), x, y);
|
|
}
|
|
}
|
|
}
|
|
onTouchMove = e => {
|
|
if (!this.canvas) return;
|
|
e.preventDefault();
|
|
if (this.updateTouchButton(e.touches, false)) {
|
|
const {x, y} = this.mousePos(this.touchCanvas);
|
|
this.game("DApi_Mouse", 0, 0, this.eventMods(e), x, y);
|
|
}
|
|
}
|
|
onTouchEnd = e => {
|
|
if (!this.canvas) return;
|
|
e.preventDefault();
|
|
const prevTc = this.touchCanvas;
|
|
this.updateTouchButton(e.touches, true);
|
|
if (prevTc && !this.touchCanvas) {
|
|
const {x, y} = this.mousePos(prevTc);
|
|
this.game("DApi_Mouse", 2, 1, this.eventMods(e), x, y);
|
|
this.game("DApi_Mouse", 2, 2, this.eventMods(e), x, y);
|
|
|
|
if (this.touchMods[TOUCH_RMB] && (!this.touchButton || this.touchButton.index !== TOUCH_RMB)) {
|
|
this.setTouchButton(TOUCH_RMB, false);
|
|
}
|
|
}
|
|
if (!document.fullscreenElement) {
|
|
this.element.requestFullscreen();
|
|
}
|
|
}
|
|
|
|
setCanvas = e => this.canvas = e;
|
|
setElement = e => this.element = e;
|
|
setKeyboard = e => this.keyboard = e;
|
|
setTouch_(i, e) {
|
|
this.touchButtons[i] = e;
|
|
}
|
|
setTouchBelt_(i, e) {
|
|
this.touchButtons[i] = e;
|
|
if (e) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 28;
|
|
canvas.height = 28;
|
|
e.appendChild(canvas);
|
|
this.touchCtx[i] = canvas.getContext("2d");
|
|
} else {
|
|
this.touchCtx[i] = null;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const {started, loading, error, progress, dropping, touch, has_spawn} = this.state;
|
|
return (
|
|
<div className={classNames("App", {touch, started, dropping, keyboard: this.showKeyboard})} ref={this.setElement}>
|
|
<div className="touch-ui touch-mods">
|
|
<div className={classNames("touch-button", "touch-button-0", {active: this.touchMods[0]})} ref={this.setTouch0}/>
|
|
<div className={classNames("touch-button", "touch-button-1", {active: this.touchMods[1]})} ref={this.setTouch1}/>
|
|
<div className={classNames("touch-button", "touch-button-2", {active: this.touchMods[2]})} ref={this.setTouch2}/>
|
|
</div>
|
|
<div className="touch-ui touch-belt">
|
|
<div className={classNames("touch-button", "touch-button-0")} ref={this.setTouch3}/>
|
|
<div className={classNames("touch-button", "touch-button-1")} ref={this.setTouch4}/>
|
|
<div className={classNames("touch-button", "touch-button-2")} ref={this.setTouch5}/>
|
|
</div>
|
|
<div className="Body">
|
|
{!error && <canvas ref={this.setCanvas} width={640} height={480}/>}
|
|
<input type="text" className="keyboard" onChange={this.onKeyboard} ref={this.setKeyboard} spellCheck={false}/>
|
|
</div>
|
|
<div className="BodyV">
|
|
{!!error && (
|
|
<Link className="error" href={reportLink(error, this.state.retail)}>
|
|
<p className="header">The following error has occurred:</p>
|
|
<p className="body">{error.message}</p>
|
|
<p className="footer">Click to create an issue on GitHub</p>
|
|
{this.saveName != null && <p className="link" onClick={this.downloadSave}>Download save file</p>}
|
|
</Link>
|
|
)}
|
|
{!!loading && !started && !error && (
|
|
<div className="loading">
|
|
{(progress && progress.text) || 'Loading...'}
|
|
{progress != null && !!progress.total && (
|
|
<span className="progressBar"><span><span style={{width: `${Math.round(100 * progress.loaded / progress.total)}%`}}/></span></span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!started && !loading && !error && (
|
|
<div className="start">
|
|
<p>
|
|
This is a web port of the original Diablo game, based on source code reconstructed by
|
|
GalaXyHaXz and devilution team: <Link href="https://github.com/diasurgical/devilution">https://github.com/diasurgical/devilution</Link>
|
|
</p>
|
|
<p>
|
|
If you own the original game, you can drop the original DIABDAT.MPQ onto this page or click the button below to start playing.
|
|
The game can be purchased from <Link href="https://www.gog.com/game/diablo">GoG</Link>.
|
|
</p>
|
|
{!has_spawn && (
|
|
<p>
|
|
Or you can play the shareware version for free (50MB download).
|
|
</p>
|
|
)}
|
|
<form>
|
|
<label htmlFor="loadFile" className="startButton">Select MPQ</label>
|
|
<input accept=".mpq" type="file" id="loadFile" style={{display: "none"}} onChange={this.parseFile}/>
|
|
</form>
|
|
<span className="startButton" onClick={() => this.start()}>Play Shareware</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default App;
|