Compare commits
4 Commits
87b29a3828
...
50b78f8131
Author | SHA1 | Date | |
---|---|---|---|
50b78f8131 | |||
d82e0fdaf3 | |||
18e1427ed3 | |||
0a6e86e628 |
|
@ -17,7 +17,7 @@ CREATE TABLE sessions (
|
||||||
`token` TEXT NOT NULL UNIQUE,
|
`token` TEXT NOT NULL UNIQUE,
|
||||||
`expires` INTEGER NOT NULL
|
`expires` INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_sessions_user_id ON sessions (`user_id`);
|
CREATE INDEX idx_sessions_token ON sessions (`token`);
|
||||||
|
|
||||||
DROP TABLE IF EXISTS tokens;
|
DROP TABLE IF EXISTS tokens;
|
||||||
CREATE TABLE tokens (
|
CREATE TABLE tokens (
|
||||||
|
|
|
@ -72,6 +72,6 @@ case $1 in
|
||||||
drop_db
|
drop_db
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: $0 {create|populate|reset}"
|
echo "Usage: $0 {create|populate|reset|drop}"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
191
public/assets/css/basic.css
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
@import 'utilities.css';
|
||||||
|
@import 'buttons.css';
|
||||||
|
@import 'forms.css';
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--main-font);
|
||||||
|
background-color: #bcc6cf;
|
||||||
|
background-image: url('/assets/img/bg.jpg');
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-position: center top;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header#basic-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
& > p:not(:last-child) {
|
||||||
|
margin-right: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
background-color: #f7f8fa;
|
||||||
|
color: #111111;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1) inset;
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
background-color: #444c55;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.green {
|
||||||
|
background-color: #a6e3a1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background-color: black;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #666;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: 0 0 0.5rem 0.1rem rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 0.1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-trigger {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1:has(.badge), h2:has(.badge), h3:has(.badge), h4:has(.badge), h5:has(.badge), h6:has(.badge) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& > .badge {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
position: relative;
|
||||||
|
min-height: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
background: #f8f8f9;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
line-height: 1.4285rem;
|
||||||
|
color: rgba(0, 0, 0, .87);
|
||||||
|
transition: opacity .1s ease, color .1s ease, background .1s ease, box-shadow .1s ease;
|
||||||
|
border-radius: .28571429rem;
|
||||||
|
box-shadow: 0 0 0 1px rgba(34, 36, 38, .22) inset, 0 0 0 0 transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
background-color: #f0f9eb;
|
||||||
|
color: #2c662d;
|
||||||
|
border-color: #b3dc9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background-color: #f9e9eb;
|
||||||
|
color: #9f3a38;
|
||||||
|
border-color: #e0b4b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
background-color: #fff8e1;
|
||||||
|
color: #573a08;
|
||||||
|
border-color: #f9e79f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.info {
|
||||||
|
background-color: #f0f9fb;
|
||||||
|
color: #2c7fba;
|
||||||
|
border-color: #b3d7f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #2c2c2c;
|
||||||
|
border-color: #b3b3b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
a[alert-close] {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #4C0515;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #6C0515;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar {
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #444c55;
|
||||||
|
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
|
||||||
|
border: 1px solid;
|
||||||
|
border-color: #3D444C #2F353B #2C3137;
|
||||||
|
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background-image: url('/assets/img/world/rogues.png');
|
||||||
|
|
||||||
|
&.index-0 {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.index-1 {
|
||||||
|
background-position: -32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.index-2 {
|
||||||
|
background-position: -64px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.index-3 {
|
||||||
|
background-position: -96px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.index-4 {
|
||||||
|
background-position: -128px 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,28 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
if ! command -v bun &> /dev/null
|
|
||||||
then
|
|
||||||
echo "Bun is not installed. Please install it from https://bun.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v entr &> /dev/null
|
|
||||||
then
|
|
||||||
echo "entr is not installed. Installing entr..."
|
|
||||||
# For Debian/Ubuntu-based systems
|
|
||||||
if [[ -x "$(command -v apt)" ]]; then
|
|
||||||
sudo apt update && sudo apt install entr -y
|
|
||||||
# For Red Hat-based systems
|
|
||||||
elif [[ -x "$(command -v yum)" ]]; then
|
|
||||||
sudo yum install entr -y
|
|
||||||
# For macOS with Homebrew
|
|
||||||
elif [[ -x "$(command -v brew)" ]]; then
|
|
||||||
brew install entr
|
|
||||||
else
|
|
||||||
echo "Package manager not supported. Please install entr manually."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running 'find src/ | entr -s \"bunx lightningcss-cli --minify --bundle src/main.css -o dragon.css\"'..."
|
|
||||||
find src/ | entr -s 'bunx lightningcss-cli --minify --bundle src/main.css -o dragon.css'
|
|
|
@ -126,3 +126,16 @@
|
||||||
.character-select:not(:has(input[type="radio"]:checked)) > .buttons {
|
.character-select:not(:has(input[type="radio"]:checked)) > .buttons {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form.logout-form {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
& > button {
|
||||||
|
display: inline-block;
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +1,6 @@
|
||||||
@import 'src/buttons.css';
|
@import 'utilities.css';
|
||||||
|
@import 'buttons.css';
|
||||||
:root {
|
@import 'forms.css';
|
||||||
font-size: 16px;
|
|
||||||
--main-font: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #bcc6cf;
|
background-color: #bcc6cf;
|
||||||
|
@ -18,16 +9,13 @@ body {
|
||||||
background-position: center top;
|
background-position: center top;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
font-family: var(--main-font);
|
font-family: var(--main-font);
|
||||||
}
|
|
||||||
|
|
||||||
main#game-container {
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
div#game-ui, div#game-windows {
|
div#ui, div#windows {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -37,7 +25,18 @@ div#game-ui, div#game-windows {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
div#game-windows {
|
div#ui {
|
||||||
|
section#menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div#windows {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@ -47,11 +46,8 @@ div#game-windows {
|
||||||
& > div.window {
|
& > div.window {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
background-color: #bcc6cf;
|
background-color: #bcc6cf;
|
||||||
background-image: url('/assets/img/bg.jpg');
|
background-image: url('/assets/img/ui/bg.webp');
|
||||||
background-attachment: fixed;
|
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5);
|
||||||
background-position: center top;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
box-shadow: 0px 0px 5px black;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
|
@ -63,13 +59,15 @@ div#game-windows {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 1rem 0.5rem 1rem;
|
padding: 0.5rem;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
&:empty {
|
&:empty {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
@ -77,15 +75,20 @@ div#game-windows {
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
width: 1.5rem;
|
width: 16px;
|
||||||
height: 1.5rem;
|
height: 16px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-radius: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
padding: 0 1rem 1rem 1rem;
|
padding: 0.5rem;
|
||||||
|
|
||||||
&:empty {
|
&:empty {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -95,7 +98,7 @@ div#game-windows {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas#game-canvas {
|
canvas#canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -1,442 +0,0 @@
|
||||||
@import 'utilities.css';
|
|
||||||
@import 'buttons.css';
|
|
||||||
@import 'forms.css';
|
|
||||||
@import 'profile.css';
|
|
||||||
@import 'game.css';
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #bcc6cf;
|
|
||||||
background-image: url('/assets/img/bg.jpg');
|
|
||||||
background-attachment: fixed;
|
|
||||||
background-position: center top;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
max-width: 1640px;
|
|
||||||
min-width: 968px;
|
|
||||||
margin: 0px auto;
|
|
||||||
font-family: var(--main-font);
|
|
||||||
}
|
|
||||||
|
|
||||||
header#main-header {
|
|
||||||
height: 76px;
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 1rem;
|
|
||||||
background-image: url('/assets/img/header.jpg');
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
gap: 2rem;
|
|
||||||
|
|
||||||
#center {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
aside {
|
|
||||||
min-width: 200px;
|
|
||||||
|
|
||||||
.box {
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
aside#left nav {
|
|
||||||
& > *:not(:last-child) {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.stack {
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
display: none;
|
|
||||||
|
|
||||||
&:checked ~ div.list {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:checked + label {
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: black;
|
|
||||||
transition: color, background-color 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: 18px;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.text {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: white;
|
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
span.arrow {
|
|
||||||
position: relative;
|
|
||||||
top: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div.list {
|
|
||||||
display: none;
|
|
||||||
|
|
||||||
& > a {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 1rem 0.5rem 1.35rem;
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: black;
|
|
||||||
transition: color, background-color 0.2s ease;
|
|
||||||
|
|
||||||
&:not(:last-child)::before {
|
|
||||||
content: '├';
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child::before {
|
|
||||||
content: '└';
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
top: 3px;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: #444c55;
|
|
||||||
color: #ffffff;
|
|
||||||
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #3D444C #2F353B #2C3137;
|
|
||||||
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > a {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: black;
|
|
||||||
transition: color, background-color 0.2s ease;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
|
|
||||||
&:has(img) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: 18px;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover, &.active {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: #444c55;
|
|
||||||
color: #ffffff;
|
|
||||||
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #3D444C #2F353B #2C3137;
|
|
||||||
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin: 1rem 0;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
|
|
||||||
& > p:not(:last-child) {
|
|
||||||
margin-right: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#character {
|
|
||||||
& > .name {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > div:not(:last-child) {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span.badge {
|
|
||||||
font-size: 10px;
|
|
||||||
background-color: #f7f8fa;
|
|
||||||
color: #111111;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1) inset;
|
|
||||||
|
|
||||||
&.dark {
|
|
||||||
background-color: #444c55;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.green {
|
|
||||||
background-color: #a6e3a1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-meter {
|
|
||||||
background-color: black;
|
|
||||||
height: 16px;
|
|
||||||
min-width: 100px;
|
|
||||||
border-radius: 0.1rem;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 0.1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.hp {
|
|
||||||
background-color: #e57373;
|
|
||||||
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(139, 0, 0, 0.1));
|
|
||||||
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #d32f2f #c62828 #b71c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.mp {
|
|
||||||
background-color: #5a9bd4;
|
|
||||||
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(60, 100, 150, 0.1));
|
|
||||||
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #4a8ab0 #3a7a9c #2a6a88;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tp {
|
|
||||||
background-color: #f4cc67;
|
|
||||||
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
|
|
||||||
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #C59F43 #AA8326 #957321;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background-color: black;
|
|
||||||
color: white;
|
|
||||||
border: 1px solid #666;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
box-shadow: 0 0 0.5rem 0.1rem rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 0.1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-trigger {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-query-log {
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
font-family: monospace;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#center > section {
|
|
||||||
&:not(:last-child) {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1:has(.badge), h2:has(.badge), h3:has(.badge), h4:has(.badge), h5:has(.badge), h6:has(.badge) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& > .badge {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
position: relative;
|
|
||||||
min-height: 1rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
background: #f8f8f9;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
line-height: 1.4285rem;
|
|
||||||
color: rgba(0, 0, 0, .87);
|
|
||||||
transition: opacity .1s ease, color .1s ease, background .1s ease, box-shadow .1s ease;
|
|
||||||
border-radius: .28571429rem;
|
|
||||||
box-shadow: 0 0 0 1px rgba(34, 36, 38, .22) inset, 0 0 0 0 transparent;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
background-color: #f0f9eb;
|
|
||||||
color: #2c662d;
|
|
||||||
border-color: #b3dc9d;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.danger {
|
|
||||||
background-color: #f9e9eb;
|
|
||||||
color: #9f3a38;
|
|
||||||
border-color: #e0b4b4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.warning {
|
|
||||||
background-color: #fff8e1;
|
|
||||||
color: #573a08;
|
|
||||||
border-color: #f9e79f;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.info {
|
|
||||||
background-color: #f0f9fb;
|
|
||||||
color: #2c7fba;
|
|
||||||
border-color: #b3d7f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dark {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #2c2c2c;
|
|
||||||
border-color: #b3b3b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
a[alert-close] {
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 2rem;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #4C0515;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #6C0515;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body::-webkit-scrollbar {
|
|
||||||
width: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
body::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #444c55;
|
|
||||||
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #3D444C #2F353B #2C3137;
|
|
||||||
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
#canvas-container {
|
|
||||||
& > canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 440px;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
image-rendering: crisp-edges;
|
|
||||||
image-rendering: -webkit-optimize-contrast;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
background-image: url('/assets/img/world/rogues.png');
|
|
||||||
|
|
||||||
&.index-0 {
|
|
||||||
background-position: 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.index-1 {
|
|
||||||
background-position: -32px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.index-2 {
|
|
||||||
background-position: -64px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.index-3 {
|
|
||||||
background-position: -96px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.index-4 {
|
|
||||||
background-position: -128px 0;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
section.profile {
|
|
||||||
header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > div.grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
& > section {
|
|
||||||
width: 50%;
|
|
||||||
|
|
||||||
& > div:not(:last-child) {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div.avatar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: 185px;
|
|
||||||
width: 185px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border {
|
|
||||||
width: 250px;
|
|
||||||
height: 250px;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
text-align: center;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: white;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background-image: url('/assets/img/bar.jpg');
|
|
||||||
background-position: bottom center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.stats {
|
|
||||||
& > .grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.25rem;
|
|
||||||
|
|
||||||
& > div.cell {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#equipped-gear {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
div.item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
&.i-1x1 {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background-image: url('/assets/img/ui/1x1.png');
|
|
||||||
}
|
|
||||||
|
|
||||||
&.i-2x2 {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
background-image: url('/assets/img/ui/2x2.png');
|
|
||||||
}
|
|
||||||
|
|
||||||
&.i-2x3 {
|
|
||||||
width: 60px;
|
|
||||||
height: 90px;
|
|
||||||
background-image: url('/assets/img/ui/2x3.png');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 60px;
|
|
||||||
|
|
||||||
&.top, &.bot {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.mid {
|
|
||||||
width: 60px;
|
|
||||||
height: 90px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -39,3 +39,8 @@
|
||||||
width: 960px;
|
width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container-480 {
|
||||||
|
width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
BIN
public/assets/img/dk.png
Normal file
After Width: | Height: | Size: 108 KiB |
Before Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 6.6 KiB |
BIN
public/assets/img/ui/bg.webp
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
public/assets/img/ui/icons/bargraph.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/img/ui/icons/beer.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/img/ui/icons/bullet_red.png
Normal file
After Width: | Height: | Size: 596 B |
BIN
public/assets/img/ui/icons/stop.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/img/ui/icons/user.png
Normal file
After Width: | Height: | Size: 913 B |
|
@ -16,10 +16,12 @@ class WindowManager
|
||||||
let w = this.windows[id]
|
let w = this.windows[id]
|
||||||
w.querySelector('header .title').innerHTML = title
|
w.querySelector('header .title').innerHTML = title
|
||||||
w.querySelector('.body').innerHTML = content
|
w.querySelector('.body').innerHTML = content
|
||||||
|
this.bringToFront(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.createWindow(id, content, title)
|
let w = this.createWindow(id, content, title)
|
||||||
|
this.bringToFront(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
createWindow(id, content, title = '')
|
createWindow(id, content, title = '')
|
||||||
|
@ -40,12 +42,8 @@ class WindowManager
|
||||||
h.appendChild(ht)
|
h.appendChild(ht)
|
||||||
|
|
||||||
// create close button
|
// create close button
|
||||||
ht.insertAdjacentHTML('afterend', `
|
ht.insertAdjacentHTML('afterend', '<a class="close"><img src="/assets/img/ui/icons/bullet_red.png"></a>')
|
||||||
<svg class="close" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
h.querySelector('a.close').addEventListener('click', () => {
|
||||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708"/>
|
|
||||||
</svg>
|
|
||||||
`)
|
|
||||||
h.querySelector('svg').addEventListener('click', () => {
|
|
||||||
this.windows[id].remove()
|
this.windows[id].remove()
|
||||||
delete this.windows[id]
|
delete this.windows[id]
|
||||||
})
|
})
|
||||||
|
@ -60,6 +58,7 @@ class WindowManager
|
||||||
this.makeWindowDraggable(w, this.container)
|
this.makeWindowDraggable(w, this.container)
|
||||||
this.windows[id] = w
|
this.windows[id] = w
|
||||||
this.container.appendChild(w)
|
this.container.appendChild(w)
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
makeWindowDraggable(w, c)
|
makeWindowDraggable(w, c)
|
||||||
|
|
298
public/index.php
|
@ -4,6 +4,7 @@
|
||||||
Setup
|
Setup
|
||||||
*/
|
*/
|
||||||
define('SRC', __DIR__ . '/../src');
|
define('SRC', __DIR__ . '/../src');
|
||||||
|
define('DATABASE_PATH', __DIR__ . '/../database');
|
||||||
require_once SRC . '/bootstrap.php';
|
require_once SRC . '/bootstrap.php';
|
||||||
|
|
||||||
$r = new Router;
|
$r = new Router;
|
||||||
|
@ -11,86 +12,279 @@ $r = new Router;
|
||||||
/*
|
/*
|
||||||
Home
|
Home
|
||||||
*/
|
*/
|
||||||
$r->get('/', function () {
|
$r->get('/', function() {
|
||||||
if (user()) must_have_character();
|
if (user()) redirect('/world');
|
||||||
$GLOBALS['active_nav_tab'] = 'home';
|
echo render('layouts/basic', ['view' => 'pages/hello']);
|
||||||
echo render('layouts/basic', ['view' => 'pages/home']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Auth
|
Auth
|
||||||
*/
|
*/
|
||||||
$r->get('/auth/register', 'auth_controller_register_get');
|
$r->get('/register', 'Actions\Auth::register_get')->middleware('guest_only');
|
||||||
$r->post('/auth/register', 'auth_controller_register_post');
|
$r->post('/register', 'Actions\Auth::register_post')->middleware('guest_only');
|
||||||
$r->get('/auth/login', 'auth_controller_login_get');
|
|
||||||
$r->post('/auth/login', 'auth_controller_login_post');
|
$r->get('/login', function() {
|
||||||
$r->post('/auth/logout', 'auth_controller_logout_post');
|
echo render('layouts/basic', ['view' => 'pages/auth/login']);
|
||||||
|
})->middleware('guest_only');
|
||||||
|
|
||||||
|
$r->post('/login', function() {
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$u = trim($_POST['u'] ?? '');
|
||||||
|
$p = $_POST['p'] ?? '';
|
||||||
|
|
||||||
|
if (empty($u)) $errors['u'][] = 'Username is required.';
|
||||||
|
if (empty($p)) $errors['p'][] = 'Password is required.';
|
||||||
|
|
||||||
|
// If there are errors at this point, send them to the page with errors flashed.
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$GLOBALS['form-errors'] = $errors;
|
||||||
|
echo render('layouts/basic', ['view' => 'pages/auth/login']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::find($u);
|
||||||
|
if ($user === false || !$user->check_password($p)) {
|
||||||
|
$errors['x'][] = 'Invalid username or password.';
|
||||||
|
$GLOBALS['form-errors'] = $errors;
|
||||||
|
echo render('layouts/basic', ['view' => 'pages/auth/login']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['user'] = serialize($user);
|
||||||
|
|
||||||
|
if ($_POST['remember'] ?? false) {
|
||||||
|
$session = Session::create($user->id, strtotime('+30 days'));
|
||||||
|
if ($session === false) error_response(400);
|
||||||
|
set_cookie('remember_me', $session->token, $session->expires);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->char_count() === 0) {
|
||||||
|
redirect('/character/create-first');
|
||||||
|
} elseif (!change_user_character($user->char_id)) {
|
||||||
|
echo "failed to change user character (aclp)";
|
||||||
|
error_response(999);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect('/');
|
||||||
|
})->middleware('guest_only');
|
||||||
|
|
||||||
|
$r->post('/logout', function() {
|
||||||
|
Session::delete(user()->id);
|
||||||
|
unset($_SESSION['user']);
|
||||||
|
set_cookie('remember_me', '', 1);
|
||||||
|
redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
$r->get('/debug/logout', function() {
|
||||||
|
Session::delete(user()->id);
|
||||||
|
unset($_SESSION['user']);
|
||||||
|
set_cookie('remember_me', '', 1);
|
||||||
|
redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Characters
|
Characters
|
||||||
*/
|
*/
|
||||||
$r->get('/characters', 'char_controller_list_get');
|
$r->get('/characters', function() {
|
||||||
$r->post('/characters', 'char_controller_list_post');
|
//echo page('chars/list', ['chars' => user()->char_list()]);
|
||||||
$r->get('/character/create-first', 'char_controller_create_first_get');
|
})->middleware('must_have_character');
|
||||||
$r->post('/character/create', 'char_controller_create_post');
|
|
||||||
$r->post('/character/delete', 'char_controller_delete_post');
|
$r->post('/characters', function() {
|
||||||
|
$char_id = (int) ($_POST['char_id'] ?? 0);
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
// If the character ID is not a number, or the action is not a string, return a 400.
|
||||||
|
if (!is_numeric($char_id) || !is_string($action)) error_response(400);
|
||||||
|
|
||||||
|
// If the character ID is 0, return to the list.
|
||||||
|
if ($char_id === 0) {
|
||||||
|
flash('alert_character_list_1', ['', 'No character selected.']);
|
||||||
|
redirect('/characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the action is not one of the allowed actions, return a 400.
|
||||||
|
if (!in_array($action, ['select', 'delete'])) error_response(400);
|
||||||
|
|
||||||
|
// If the action is to select a character, change the user's selected character.
|
||||||
|
if ($action === 'select') {
|
||||||
|
// If the character ID is the current character, do nothing.
|
||||||
|
if ($char_id === user()->char_id || $char_id === 0) {
|
||||||
|
flash('alert_character_list_1', ['info', 'You are already using <b>' . char()->name . '</b>.']);
|
||||||
|
redirect('/characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
|
||||||
|
|
||||||
|
change_user_character($char_id);
|
||||||
|
|
||||||
|
flash('alert_character_list_1', ['success', 'Switched to <b>' . char()->name . '</b>!']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the action is to delete a character, move to the confirmation page.
|
||||||
|
if ($action === 'delete') {
|
||||||
|
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
|
||||||
|
|
||||||
|
//echo page('chars/delete', ['char' => Character::find($char_id)]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect('/characters');
|
||||||
|
})->middleware('must_have_character');
|
||||||
|
|
||||||
|
$r->get('/character/create-first', function() {
|
||||||
|
// If the user already has a character, redirect them to the main page.
|
||||||
|
if (user()->char_count() > 0) redirect('/');
|
||||||
|
|
||||||
|
//echo page('chars/first');
|
||||||
|
})->middleware('auth_only');
|
||||||
|
|
||||||
|
$r->post('/character/create', function() {
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$name = trim($_POST['n'] ?? '');
|
||||||
|
|
||||||
|
/*
|
||||||
|
A name is required.
|
||||||
|
A name must be between 3 and 18 characters.
|
||||||
|
A name must contain only alphanumeric characters and spaces.
|
||||||
|
*/
|
||||||
|
if (empty($name) || strlen($name) < 3 || strlen($name) > 18 || !ctype_alnum(str_replace(' ', '', $name))) {
|
||||||
|
$errors['n'][] = 'Name is required and must be between 3 and 18 characters long and contain only alphanumeric characters and spaces.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A character's name must be unique.
|
||||||
|
*/
|
||||||
|
if (Character::name_exists($name)) $errors['n'][] = 'Name is already taken.';
|
||||||
|
|
||||||
|
// If there are errors at this point, send them to the page with errors flashed.
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$GLOBALS['form-errors-create-character'] = $errors;
|
||||||
|
|
||||||
|
if (isset($_POST['first']) && $_POST['first'] === 'true') {
|
||||||
|
// If this is the first character, return to the first character creation page.
|
||||||
|
//echo page('chars/first');
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
// If this is not the first character, return to the character list page.
|
||||||
|
//echo page('chars/list', ['chars' => user()->char_list()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($char = Character::create(user()->id, $name)) === false) error_response(400);
|
||||||
|
|
||||||
|
// Create the auxiliary tables
|
||||||
|
$char->create_location();
|
||||||
|
$char->create_gear();
|
||||||
|
|
||||||
|
// Award the Adventurer title.
|
||||||
|
$char->award_title(1);
|
||||||
|
|
||||||
|
// Set the character as the user's selected character
|
||||||
|
change_user_character($char->id);
|
||||||
|
|
||||||
|
flash('alert_character_list_1', ['success', 'Character <b>' . $name . '</b> created!']);
|
||||||
|
redirect('/characters');
|
||||||
|
})->middleware('auth_only');
|
||||||
|
|
||||||
|
$r->post('/character/delete', function() {
|
||||||
|
$char_id = (int) ($_POST['char_id'] ?? 0);
|
||||||
|
|
||||||
|
// If the character ID is not a number, return a 400.
|
||||||
|
if (!is_numeric($char_id)) error_response(400);
|
||||||
|
|
||||||
|
// Ensure the character ID is valid and belongs to the user.
|
||||||
|
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
|
||||||
|
|
||||||
|
$char = Character::find($char_id);
|
||||||
|
|
||||||
|
// Confirm the name matches the name of the character. CASE SENSITIVE.
|
||||||
|
if ($char['name'] !== trim($_POST['n'] ?? '')) {
|
||||||
|
flash('alert_character_list_1', ['danger', 'Failed to delete <b>' . $char['name'] . '</b>. Name confirmation did not match.']);
|
||||||
|
redirect('/characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the character
|
||||||
|
Character::delete($char_id);
|
||||||
|
|
||||||
|
// If the character being deleted is the currently selected character, select the first character.
|
||||||
|
if (user()->char_id === $char_id) {
|
||||||
|
$chars = user()->char_list();
|
||||||
|
if (count($chars) > 0) change_user_character($chars[0]['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
flash('alert_character_list_1', ['danger', 'Character <b>' . $char['name'] . '</b> deleted.']);
|
||||||
|
redirect('/characters');
|
||||||
|
})->middleware('must_have_character');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
World
|
World
|
||||||
*/
|
*/
|
||||||
$r->get('/world', 'world_controller_get');
|
$r->get('/world', function() {
|
||||||
$r->post('/move', 'world_controller_move_post');
|
echo render('layouts/game');
|
||||||
|
})->middleware('must_have_character');
|
||||||
|
|
||||||
/*
|
$r->post('/move', function() {
|
||||||
Profile
|
/*
|
||||||
*/
|
This endpoint is used to move the character around the world. The client sends a POST request with the direction
|
||||||
$r->get('/profile', 'profile_controller_get');
|
they want to move the character. The server will update the character's position in the database and return the
|
||||||
$r->get('/profile/:id', 'profile_controller_show_get');
|
new position to the client.
|
||||||
|
|
||||||
/*
|
We should only be using this endpoint as an AJAX request from the world page. Since we don't need all the character's
|
||||||
Settings
|
data to move them, we can just get and update their lcoation using the user's currently selected character ID.
|
||||||
*/
|
*/
|
||||||
$r->get('/settings', 'settings_controller_get');
|
|
||||||
|
|
||||||
/*
|
define('directions', [
|
||||||
Auctions
|
[0, -1], // Up
|
||||||
*/
|
[0, 1], // Down
|
||||||
$r->get('/auctions', 'auctions_controller_get');
|
[-1, 0], // Left
|
||||||
|
[1, 0] // Right
|
||||||
|
]);
|
||||||
|
|
||||||
|
// direction must exist
|
||||||
|
$d = (int) $_POST['direction'] ?? -1;
|
||||||
|
|
||||||
|
// Update the character's position
|
||||||
|
// 0 = up, 1 = down, 2 = left, 3 = right
|
||||||
|
$x = location('x');
|
||||||
|
$y = location('y');
|
||||||
|
|
||||||
|
if (isset(directions[$d])) {
|
||||||
|
$x += directions[$d][0];
|
||||||
|
$y += directions[$d][1];
|
||||||
|
} else {
|
||||||
|
error_response(999);
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = live_db()->query('UPDATE char_locations SET x = :x, y = :y WHERE char_id = :c', [
|
||||||
|
':x' => $x,
|
||||||
|
':y' => $y,
|
||||||
|
':c' => user()->char_id
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($r === false) throw new Exception('Failed to move character. (wcmp)');
|
||||||
|
|
||||||
|
json_response(['x' => $x, 'y' => $y]);
|
||||||
|
})->middleware('ajax_only')->middleware('must_have_character');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
UI
|
UI
|
||||||
*/
|
*/
|
||||||
router_post($r, '/ui/stats', 'ui_contoller_stats_post');
|
$r->get('/ui/stats', function() {
|
||||||
|
echo c_profile_stats(char());
|
||||||
/*
|
})->middleware('ajax_only')->middleware('must_have_character');
|
||||||
Testing
|
|
||||||
*/
|
|
||||||
if (env('debug')) {
|
|
||||||
$r->get('/give_silver/:x', function (int $amt) {
|
|
||||||
auth_only_and_must_have_character();
|
|
||||||
wallet()->give(Currency::Silver, $amt);
|
|
||||||
redirect('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
$r->get('/take_silver/:x', function (int $amt) {
|
|
||||||
auth_only_and_must_have_character();
|
|
||||||
wallet()->take(Currency::Silver, $amt);
|
|
||||||
redirect('/');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Router
|
Router
|
||||||
*/
|
*/
|
||||||
// [code, handler, params]
|
// [code, handler, params, middleware]
|
||||||
stopwatch_start('router');
|
|
||||||
$l = $r->lookup($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
|
$l = $r->lookup($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
|
||||||
stopwatch_stop('router');
|
|
||||||
|
|
||||||
stopwatch_start('handler');
|
|
||||||
if ($l['code'] !== 200) error_response($l['code']);
|
if ($l['code'] !== 200) error_response($l['code']);
|
||||||
|
if (!empty($l['middleware'])) foreach ($l['middleware'] as $middleware) $middleware();
|
||||||
$l['handler'](...$l['params'] ?? []);
|
$l['handler'](...$l['params'] ?? []);
|
||||||
stopwatch_stop('handler');
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Cleanup
|
Cleanup
|
||||||
|
|
76
src/actions/auth.php
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Actions;
|
||||||
|
|
||||||
|
use \User;
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
public static function register_get(): void
|
||||||
|
{
|
||||||
|
echo render('layouts/basic', ['view' => 'pages/auth/register']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_post(): void
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$u = trim($_POST['u'] ?? '');
|
||||||
|
$e = trim($_POST['e'] ?? '');
|
||||||
|
$p = $_POST['p'] ?? '';
|
||||||
|
|
||||||
|
/*
|
||||||
|
A username is required.
|
||||||
|
A username must be at least 3 characters long and at most 18 characters long.
|
||||||
|
A username must contain only alphanumeric characters and spaces.
|
||||||
|
*/
|
||||||
|
if (empty($u) || strlen($u) < 3 || strlen($u) > 18 || !ctype_alnum(str_replace(' ', '', $u))) {
|
||||||
|
$errors['u'][] = 'Username is required and must be between 3 and 18 characters long and contain only
|
||||||
|
alphanumeric characters and spaces.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
An email is required.
|
||||||
|
An email must be at most 255 characters long.
|
||||||
|
An email must be a valid email address.
|
||||||
|
*/
|
||||||
|
if (empty($e) || strlen($e) > 255 || !filter_var($e, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors['e'][] = 'Email is required must be a valid email address.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A password is required.
|
||||||
|
A password must be at least 6 characters long.
|
||||||
|
*/
|
||||||
|
if (empty($p) || strlen($p) < 6) {
|
||||||
|
$errors['p'][] = 'Password is required and must be at least 6 characters long.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A username must be unique.
|
||||||
|
*/
|
||||||
|
if (User::username_exists($u)) {
|
||||||
|
$errors['u'][] = 'Username is already taken.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
An email must be unique.
|
||||||
|
*/
|
||||||
|
if (User::email_exists($e)) {
|
||||||
|
$errors['e'][] = 'Email is already taken.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are errors at this point, send them to the page with errors flashed.
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$GLOBALS['form-errors'] = $errors;
|
||||||
|
echo render('layouts/basic', ['view' => 'pages/auth/register']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\User::create($u, $e, $p) === false) error_response(400);
|
||||||
|
|
||||||
|
$_SESSION['user'] = serialize(\User::find($u));
|
||||||
|
\Wallet::create(user()->id);
|
||||||
|
redirect('/character/create-first');
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,37 +1,37 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
session_start();
|
|
||||||
|
|
||||||
// SRC is defined as the path to the src/ directory from public/
|
// SRC is defined as the path to the src/ directory from public/
|
||||||
|
|
||||||
define('CLASS_MAP', [
|
/*
|
||||||
'User' => '/models/user.php',
|
============================================================
|
||||||
'Character' => '/models/character.php',
|
Libraries
|
||||||
'Wallet' => '/models/wallet.php'
|
============================================================
|
||||||
]);
|
*/
|
||||||
|
|
||||||
// Source libraries
|
|
||||||
require_once SRC . '/helpers.php';
|
require_once SRC . '/helpers.php';
|
||||||
require_once SRC . '/util/env.php';
|
require_once SRC . '/components.php';
|
||||||
require_once SRC . '/util/database.php';
|
require_once SRC . '/enums.php';
|
||||||
require_once SRC . '/util/auth.php';
|
|
||||||
require_once SRC . '/util/router.php';
|
|
||||||
require_once SRC . '/util/components.php';
|
|
||||||
require_once SRC . '/util/render.php';
|
|
||||||
require_once SRC . '/util/enums.php';
|
|
||||||
|
|
||||||
// Database models
|
/*
|
||||||
require_once SRC . '/models/session.php';
|
============================================================
|
||||||
require_once SRC . '/models/token.php';
|
PHP Sessions
|
||||||
|
============================================================
|
||||||
|
*/
|
||||||
|
session_start();
|
||||||
|
|
||||||
// Controllers
|
/*
|
||||||
require_once SRC . '/controller/char.php';
|
============================================================
|
||||||
require_once SRC . '/controller/auth.php';
|
Autoloading
|
||||||
require_once SRC . '/controller/world.php';
|
============================================================
|
||||||
require_once SRC . '/controller/settings.php';
|
*/
|
||||||
require_once SRC . '/controller/auctions.php';
|
define('CLASS_MAP', [
|
||||||
require_once SRC . '/controller/profile.php';
|
'Database' => '/database.php',
|
||||||
require_once SRC . '/controller/ui.php';
|
'Router' => '/router.php',
|
||||||
|
'User' => '/models/user.php',
|
||||||
|
'Character' => '/models/character.php',
|
||||||
|
'Wallet' => '/models/wallet.php',
|
||||||
|
'Session' => '/models/session.php',
|
||||||
|
'Actions\Auth' => '/actions/auth.php',
|
||||||
|
]);
|
||||||
|
|
||||||
spl_autoload_register(function (string $class) {
|
spl_autoload_register(function (string $class) {
|
||||||
if (array_key_exists($class, CLASS_MAP)) require_once SRC . CLASS_MAP[$class];
|
if (array_key_exists($class, CLASS_MAP)) require_once SRC . CLASS_MAP[$class];
|
||||||
|
@ -41,29 +41,39 @@ spl_autoload_register(function (string $class) {
|
||||||
define('START_TIME', microtime(true));
|
define('START_TIME', microtime(true));
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Load env, set error reporting, etc.
|
============================================================
|
||||||
|
.ENV
|
||||||
|
============================================================
|
||||||
*/
|
*/
|
||||||
env_load(SRC . '/../.env');
|
env_load(SRC . '/../.env');
|
||||||
|
|
||||||
if (env('debug') === 'true') {
|
if (env('debug', false)) {
|
||||||
ini_set('display_errors', '1');
|
ini_set('display_errors', '1');
|
||||||
ini_set('display_startup_errors', '1');
|
ini_set('display_startup_errors', '1');
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
}
|
}
|
||||||
|
|
||||||
stopwatch_start('bootstrap'); // Start the bootstrap stopwatch
|
/*
|
||||||
|
============================================================
|
||||||
|
CSRF
|
||||||
|
============================================================
|
||||||
|
*/
|
||||||
|
csrf(); // generate a CSRF token, or retrieve the current token
|
||||||
|
|
||||||
// Generate a new CSRF token. (if one doesn't exist, that is)
|
// error any request that fails CSRF on these methods
|
||||||
csrf();
|
if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) {
|
||||||
|
$csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? '';
|
||||||
|
if (!hash_equals($_SESSION['csrf'] ?? '', $csrf)) error_response(418);
|
||||||
|
}
|
||||||
|
|
||||||
// Have global counters for queries
|
/*
|
||||||
$GLOBALS['queries'] = 0;
|
============================================================
|
||||||
$GLOBALS['query_time'] = 0;
|
Global State
|
||||||
|
============================================================
|
||||||
|
*/
|
||||||
|
$GLOBALS['databases'] = []; // database interfaces
|
||||||
|
|
||||||
// Set the default page layout
|
// all relevant state to handling requests
|
||||||
page_layout('basic');
|
$GLOBALS['state'] = [
|
||||||
|
'logged_in' => isset($_SESSION['user']) || validate_session()
|
||||||
// Run auth_check to see if we're logged in, since it populates the user data in SESSION
|
];
|
||||||
auth_check();
|
|
||||||
|
|
||||||
stopwatch_stop('bootstrap'); // Stop the bootstrap stopwatch
|
|
||||||
|
|
|
@ -117,3 +117,12 @@ function c_equipped_gear(Character $char): string
|
||||||
{
|
{
|
||||||
return render('components/equipped_gear', ['char' => $char]);
|
return render('components/equipped_gear', ['char' => $char]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a front-end UI window with the given title and content. The WindowManager on the front end will handle
|
||||||
|
* the rest.
|
||||||
|
*/
|
||||||
|
function c_ui_window(string $title = '', string $content = ''): string
|
||||||
|
{
|
||||||
|
return render('components/ui_window', ['title' => $title, 'content' => $content]);
|
||||||
|
}
|
|
@ -1,9 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
function auctions_controller_get()
|
|
||||||
{
|
|
||||||
auth_only();
|
|
||||||
|
|
||||||
$GLOBALS['active_nav_tab'] = 'auctions';
|
|
||||||
echo render('layouts/basic', ['view' => 'pages/auctions/index']);
|
|
||||||
}
|
|
|
@ -1,155 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays the registration page.
|
|
||||||
*/
|
|
||||||
function auth_controller_register_get()
|
|
||||||
{
|
|
||||||
guest_only();
|
|
||||||
echo render('layouts/basic', ['view' => 'pages/auth/register']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the registration form submission.
|
|
||||||
*/
|
|
||||||
function auth_controller_register_post()
|
|
||||||
{
|
|
||||||
guest_only();
|
|
||||||
csrf_ensure();
|
|
||||||
|
|
||||||
$errors = [];
|
|
||||||
|
|
||||||
$u = trim($_POST['u'] ?? '');
|
|
||||||
$e = trim($_POST['e'] ?? '');
|
|
||||||
$p = $_POST['p'] ?? '';
|
|
||||||
|
|
||||||
/*
|
|
||||||
A username is required.
|
|
||||||
A username must be at least 3 characters long and at most 18 characters long.
|
|
||||||
A username must contain only alphanumeric characters and spaces.
|
|
||||||
*/
|
|
||||||
if (empty($u) || strlen($u) < 3 || strlen($u) > 18 || !ctype_alnum(str_replace(' ', '', $u))) {
|
|
||||||
$errors['u'][] = 'Username is required and must be between 3 and 18 characters long and contain only
|
|
||||||
alphanumeric characters and spaces.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
An email is required.
|
|
||||||
An email must be at most 255 characters long.
|
|
||||||
An email must be a valid email address.
|
|
||||||
*/
|
|
||||||
if (empty($e) || strlen($e) > 255 || !filter_var($e, FILTER_VALIDATE_EMAIL)) {
|
|
||||||
$errors['e'][] = 'Email is required must be a valid email address.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
A password is required.
|
|
||||||
A password must be at least 6 characters long.
|
|
||||||
*/
|
|
||||||
if (empty($p) || strlen($p) < 6) {
|
|
||||||
$errors['p'][] = 'Password is required and must be at least 6 characters long.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
A username must be unique.
|
|
||||||
*/
|
|
||||||
if (User::username_exists($u)) {
|
|
||||||
$errors['u'][] = 'Username is already taken.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
An email must be unique.
|
|
||||||
*/
|
|
||||||
if (User::email_exists($e)) {
|
|
||||||
$errors['e'][] = 'Email is already taken.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are errors at this point, send them to the page with errors flashed.
|
|
||||||
if (!empty($errors)) {
|
|
||||||
$GLOBALS['form-errors'] = $errors;
|
|
||||||
echo page('auth/register');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (User::create($u, $e, $p) === false) error_response(400);
|
|
||||||
|
|
||||||
$_SESSION['user'] = serialize(User::find($u));
|
|
||||||
Wallet::create(user()->id);
|
|
||||||
redirect('/character/create-first');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays the login page.
|
|
||||||
*/
|
|
||||||
function auth_controller_login_get()
|
|
||||||
{
|
|
||||||
guest_only();
|
|
||||||
echo render('layouts/basic', ['view' => 'pages/auth/login']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the login form submission.
|
|
||||||
*/
|
|
||||||
function auth_controller_login_post()
|
|
||||||
{
|
|
||||||
guest_only();
|
|
||||||
csrf_ensure();
|
|
||||||
|
|
||||||
$errors = [];
|
|
||||||
|
|
||||||
$u = trim($_POST['u'] ?? '');
|
|
||||||
$p = $_POST['p'] ?? '';
|
|
||||||
|
|
||||||
if (empty($u)) $errors['u'][] = 'Username is required.';
|
|
||||||
if (empty($p)) $errors['p'][] = 'Password is required.';
|
|
||||||
|
|
||||||
// If there are errors at this point, send them to the page with errors flashed.
|
|
||||||
if (!empty($errors)) {
|
|
||||||
$GLOBALS['form-errors'] = $errors;
|
|
||||||
echo render('layouts/basic', ['view' => 'pages/auth/login']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::find($u);
|
|
||||||
if ($user === false || !$user->check_password($p)) {
|
|
||||||
$errors['x'][] = 'Invalid username or password.';
|
|
||||||
$GLOBALS['form-errors'] = $errors;
|
|
||||||
echo render('layouts/basic', ['view' => 'pages/auth/login']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$_SESSION['user'] = serialize($user);
|
|
||||||
|
|
||||||
if ($_POST['remember'] ?? false) {
|
|
||||||
$token = token();
|
|
||||||
$expires = strtotime('+30 days');
|
|
||||||
$result = db_query(
|
|
||||||
db_auth(),
|
|
||||||
"INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)",
|
|
||||||
[':t' => $token, ':u' => user()->id, ':e' => $expires]
|
|
||||||
);
|
|
||||||
if (!$result) error_response(400);
|
|
||||||
set_cookie('remember_me', $token, $expires);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user()->char_count() === 0) {
|
|
||||||
redirect('/character/create-first');
|
|
||||||
} elseif (!change_user_character(user()->char_id)) {
|
|
||||||
echo "failed to change user character (aclp)";
|
|
||||||
error_response(999);
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs the user out.
|
|
||||||
*/
|
|
||||||
function auth_controller_logout_post()
|
|
||||||
{
|
|
||||||
csrf_ensure();
|
|
||||||
session_delete(user()->id);
|
|
||||||
unset($_SESSION['user']);
|
|
||||||
set_cookie('remember_me', '', 1);
|
|
||||||
redirect('/');
|
|
||||||
}
|
|
|
@ -1,172 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display a list of characters for the currently logged in user.
|
|
||||||
*/
|
|
||||||
function char_controller_list_get()
|
|
||||||
{
|
|
||||||
auth_only_and_must_have_character();
|
|
||||||
|
|
||||||
$GLOBALS['active_nav_tab'] = 'chars';
|
|
||||||
echo page('chars/list', ['chars' => user()->char_list()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an action from the character list page.
|
|
||||||
*/
|
|
||||||
function char_controller_list_post()
|
|
||||||
{
|
|
||||||
auth_only_and_must_have_character(); csrf_ensure();
|
|
||||||
|
|
||||||
$GLOBALS['active_nav_tab'] = 'chars';
|
|
||||||
|
|
||||||
$char_id = (int) ($_POST['char_id'] ?? 0);
|
|
||||||
$action = $_POST['action'] ?? '';
|
|
||||||
|
|
||||||
// If the character ID is not a number, or the action is not a string, return a 400.
|
|
||||||
if (!is_numeric($char_id) || !is_string($action)) error_response(400);
|
|
||||||
|
|
||||||
// If the character ID is 0, return to the list.
|
|
||||||
if ($char_id === 0) {
|
|
||||||
flash('alert_character_list_1', ['', 'No character selected.']);
|
|
||||||
redirect('/characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the action is not one of the allowed actions, return a 400.
|
|
||||||
if (!in_array($action, ['select', 'delete'])) error_response(400);
|
|
||||||
|
|
||||||
// If the action is to select a character, change the user's selected character.
|
|
||||||
if ($action === 'select') {
|
|
||||||
// If the character ID is the current character, do nothing.
|
|
||||||
if ($char_id === user()->char_id || $char_id === 0) {
|
|
||||||
flash('alert_character_list_1', ['info', 'You are already using <b>' . char()->name . '</b>.']);
|
|
||||||
redirect('/characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
|
|
||||||
|
|
||||||
change_user_character($char_id);
|
|
||||||
|
|
||||||
flash('alert_character_list_1', ['success', 'Switched to <b>' . char()->name . '</b>!']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the action is to delete a character, move to the confirmation page.
|
|
||||||
if ($action === 'delete') {
|
|
||||||
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
|
|
||||||
|
|
||||||
echo page('chars/delete', ['char' => Character::find($char_id)]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect('/characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a character for the currently logged in user.
|
|
||||||
*/
|
|
||||||
function char_controller_delete_post()
|
|
||||||
{
|
|
||||||
auth_only_and_must_have_character(); csrf_ensure();
|
|
||||||
|
|
||||||
$char_id = (int) ($_POST['char_id'] ?? 0);
|
|
||||||
|
|
||||||
// If the character ID is not a number, return a 400.
|
|
||||||
if (!is_numeric($char_id)) error_response(400);
|
|
||||||
|
|
||||||
// Ensure the character ID is valid and belongs to the user.
|
|
||||||
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
|
|
||||||
|
|
||||||
$char = Character::find($char_id);
|
|
||||||
|
|
||||||
// Confirm the name matches the name of the character. CASE SENSITIVE.
|
|
||||||
if ($char['name'] !== trim($_POST['n'] ?? '')) {
|
|
||||||
flash('alert_character_list_1', ['danger', 'Failed to delete <b>' . $char['name'] . '</b>. Name confirmation did not match.']);
|
|
||||||
redirect('/characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the character
|
|
||||||
Character::delete($char_id);
|
|
||||||
|
|
||||||
// If the character being deleted is the currently selected character, select the first character.
|
|
||||||
if (user()->char_id === $char_id) {
|
|
||||||
$chars = user()->char_list();
|
|
||||||
if (count($chars) > 0) change_user_character($chars[0]['id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
flash('alert_character_list_1', ['danger', 'Character <b>' . $char['name'] . '</b> deleted.']);
|
|
||||||
redirect('/characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Form to create your first character.
|
|
||||||
*/
|
|
||||||
function char_controller_create_first_get()
|
|
||||||
{
|
|
||||||
auth_only();
|
|
||||||
|
|
||||||
$GLOBALS['active_nav_tab'] = 'chars';
|
|
||||||
|
|
||||||
// If the user already has a character, redirect them to the main page.
|
|
||||||
if (user()->char_count() > 0) redirect('/');
|
|
||||||
|
|
||||||
echo page('chars/first');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a character for the currently logged in user.
|
|
||||||
*/
|
|
||||||
function char_controller_create_post()
|
|
||||||
{
|
|
||||||
auth_only(); csrf_ensure();
|
|
||||||
|
|
||||||
$GLOBALS['active_nav_tab'] = 'chars';
|
|
||||||
|
|
||||||
$errors = [];
|
|
||||||
|
|
||||||
$name = trim($_POST['n'] ?? '');
|
|
||||||
|
|
||||||
/*
|
|
||||||
A name is required.
|
|
||||||
A name must be between 3 and 18 characters.
|
|
||||||
A name must contain only alphanumeric characters and spaces.
|
|
||||||
*/
|
|
||||||
if (empty($name) || strlen($name) < 3 || strlen($name) > 18 || !ctype_alnum(str_replace(' ', '', $name))) {
|
|
||||||
$errors['n'][] = 'Name is required and must be between 3 and 18 characters long and contain only alphanumeric characters and spaces.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
A character's name must be unique.
|
|
||||||
*/
|
|
||||||
if (Character::name_exists($name)) $errors['n'][] = 'Name is already taken.';
|
|
||||||
|
|
||||||
// If there are errors at this point, send them to the page with errors flashed.
|
|
||||||
if (!empty($errors)) {
|
|
||||||
$GLOBALS['form-errors-create-character'] = $errors;
|
|
||||||
|
|
||||||
if (isset($_POST['first']) && $_POST['first'] === 'true') {
|
|
||||||
// If this is the first character, return to the first character creation page.
|
|
||||||
echo page('chars/first');
|
|
||||||
exit;
|
|
||||||
} else {
|
|
||||||
// If this is not the first character, return to the character list page.
|
|
||||||
echo page('chars/list', ['chars' => user()->char_list()]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($char = Character::create(user()->id, $name)) === false) error_response(400);
|
|
||||||
|
|
||||||
// Create the auxiliary tables
|
|
||||||
$char->create_location();
|
|
||||||
$char->create_gear();
|
|
||||||
|
|
||||||
// Award the Adventurer title.
|
|
||||||
$char->award_title(1);
|
|
||||||
|
|
||||||
// Set the character as the user's selected character
|
|
||||||
change_user_character($char->id);
|
|
||||||
|
|
||||||
flash('alert_character_list_1', ['success', 'Character <b>' . $name . '</b> created!']);
|
|
||||||
redirect('/characters');
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* View your current character's profile.
|
|
||||||
*/
|
|
||||||
function profile_controller_get()
|
|
||||||
{
|
|
||||||
auth_only_and_must_have_character();
|
|
||||||
|
|
||||||
$GLOBALS['active_nav_tab'] = "profile";
|
|
||||||
echo page('profile/main');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* View another character's profile.
|
|
||||||
*/
|
|
||||||
function profile_controller_show_get($id)
|
|
||||||
{
|
|
||||||
auth_only_and_must_have_character();
|
|
||||||
|
|
||||||
if (($char = Character::find($id)) == false) error_response(999);
|
|
||||||
if (user()->char_id == $id) redirect('/profile');
|
|
||||||
echo page('profile/show', ['c' => $char]);
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
function settings_controller_get()
|
|
||||||
{
|
|
||||||
auth_only();
|
|
||||||
|
|
||||||
$GLOBALS['active_nav_tab'] = 'settings';
|
|
||||||
echo render('layouts/basic', ['view' => 'pages/settings/index']);
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
function ui_contoller_stats_post()
|
|
||||||
{
|
|
||||||
auth_only_and_must_have_character(); ajax_only(); csrf_ensure();
|
|
||||||
echo c_profile_stats(char());
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print the world page.
|
|
||||||
*/
|
|
||||||
function world_controller_get()
|
|
||||||
{
|
|
||||||
auth_only_and_must_have_character();
|
|
||||||
echo render('layouts/game');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a request to move a character.
|
|
||||||
*/
|
|
||||||
function world_controller_move_post()
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
This endpoint is used to move the character around the world. The client sends a POST request with the direction
|
|
||||||
they want to move the character. The server will update the character's position in the database and return the
|
|
||||||
new position to the client.
|
|
||||||
|
|
||||||
We should only be using this endpoint as an AJAX request from the world page. Since we don't need all the character's
|
|
||||||
data to move them, we can just get and update their lcoation using the user's currently selected character ID.
|
|
||||||
*/
|
|
||||||
|
|
||||||
ajax_only(); auth_only(); csrf_ensure();
|
|
||||||
|
|
||||||
define('directions', [
|
|
||||||
[0, -1], // Up
|
|
||||||
[0, 1], // Down
|
|
||||||
[-1, 0], // Left
|
|
||||||
[1, 0] // Right
|
|
||||||
]);
|
|
||||||
|
|
||||||
// direction must exist
|
|
||||||
$d = (int) $_POST['direction'] ?? -1;
|
|
||||||
|
|
||||||
// Update the character's position
|
|
||||||
// 0 = up, 1 = down, 2 = left, 3 = right
|
|
||||||
$x = location('x');
|
|
||||||
$y = location('y');
|
|
||||||
|
|
||||||
if (isset(directions[$d])) {
|
|
||||||
$x += directions[$d][0];
|
|
||||||
$y += directions[$d][1];
|
|
||||||
} else {
|
|
||||||
error_response(999);
|
|
||||||
}
|
|
||||||
|
|
||||||
$r = db_query(db_live(), 'UPDATE char_locations SET x = :x, y = :y WHERE char_id = :c', [
|
|
||||||
':x' => $x,
|
|
||||||
':y' => $y,
|
|
||||||
':c' => user()->char_id
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($r === false) throw new Exception('Failed to move character. (wcmp)');
|
|
||||||
|
|
||||||
json_response(['x' => $x, 'y' => $y]);
|
|
||||||
}
|
|
78
src/database.php
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An extension to the SQLite3 class to add our own logging and binding semantics!
|
||||||
|
*/
|
||||||
|
class Database extends SQLite3
|
||||||
|
{
|
||||||
|
public int $count = 0;
|
||||||
|
public array $log = [];
|
||||||
|
public float $query_time = 0;
|
||||||
|
|
||||||
|
public function __construct(string $db_path)
|
||||||
|
{
|
||||||
|
parent::__construct($db_path);
|
||||||
|
|
||||||
|
parent::exec('PRAGMA cache_size = 32000');
|
||||||
|
parent::exec('PRAGMA journal_mode = WAL');
|
||||||
|
parent::exec('PRAGMA temp_store = MEMORY');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(string $query, array $params = []): SQLite3Result|false
|
||||||
|
{
|
||||||
|
$p = strpos($query, '?') !== false;
|
||||||
|
$stmt = $this->prepare($query);
|
||||||
|
|
||||||
|
if (!empty($params)) {
|
||||||
|
foreach ($params as $k => $v) {
|
||||||
|
$stmt->bindValue($p ? $k + 1 : $k, $v, $this->getSQLiteType($v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$r = $stmt->execute();
|
||||||
|
$this->log($query, microtime(true) - $start);
|
||||||
|
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exec(string $query): bool
|
||||||
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
$r = parent::exec($query);
|
||||||
|
$this->log($query, microtime(true) - $start);
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $table, string $column, mixed $value, bool $case_insensitive = true): bool
|
||||||
|
{
|
||||||
|
if ($case_insensitive) {
|
||||||
|
$query = "SELECT 1 FROM $table WHERE $column = :v COLLATE NOCASE LIMIT 1";
|
||||||
|
} else {
|
||||||
|
$query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1";
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->query($query, [':v' => $value]);
|
||||||
|
return $result->fetchArray(SQLITE3_NUM) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $query, float $time_taken): void
|
||||||
|
{
|
||||||
|
$this->count++;
|
||||||
|
$this->query_time += $time_taken;
|
||||||
|
|
||||||
|
if (env('debug', false)) {
|
||||||
|
$this->log[] = [$query, $time_taken];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSQLiteType(mixed $value): int
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
is_int($value) => SQLITE3_INTEGER,
|
||||||
|
is_float($value) => SQLITE3_FLOAT,
|
||||||
|
is_null($value) => SQLITE3_NULL,
|
||||||
|
default => SQLITE3_TEXT
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
220
src/helpers.php
|
@ -1,9 +1,90 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the environment variables from the .env file.
|
||||||
|
*/
|
||||||
|
function env_load(string $filePath): void
|
||||||
|
{
|
||||||
|
if (!file_exists($filePath)) throw new Exception("The .env file does not exist. (el)");
|
||||||
|
|
||||||
|
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
|
||||||
|
// Skip lines that are empty after trimming or are comments
|
||||||
|
if ($line === '' || str_starts_with($line, '#')) continue;
|
||||||
|
|
||||||
|
// Skip lines without an '=' character
|
||||||
|
if (strpos($line, '=') === false) continue;
|
||||||
|
|
||||||
|
[$name, $value] = explode('=', $line, 2);
|
||||||
|
|
||||||
|
$name = trim($name);
|
||||||
|
$value = trim($value, " \t\n\r\0\x0B\"'"); // Trim whitespace and quotes
|
||||||
|
|
||||||
|
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
|
||||||
|
putenv("$name=$value");
|
||||||
|
$_ENV[$name] = $value;
|
||||||
|
$_SERVER[$name] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve an environment variable.
|
||||||
|
*/
|
||||||
|
function env(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$v = $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default);
|
||||||
|
return match(true) {
|
||||||
|
$v === 'true' => true,
|
||||||
|
$v === 'false' => false,
|
||||||
|
is_numeric($v) => (int) $v,
|
||||||
|
is_float($v) => (float) $v,
|
||||||
|
default => $v
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the path to a view file.
|
||||||
|
*/
|
||||||
|
function template(string $name): string
|
||||||
|
{
|
||||||
|
return SRC . "/../templates/$name.php";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a view with the given data. Looks for `$view` through `template()`.
|
||||||
|
*/
|
||||||
|
function render(string $path_to_base_view, array $data = []): string|false
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
extract($data);
|
||||||
|
require template($path_to_base_view);
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the auth database connection from GLOBALS['databases'], or create it if it doesn't exist.
|
||||||
|
*/
|
||||||
|
function auth_db(): Database
|
||||||
|
{
|
||||||
|
return $GLOBALS['databases']['auth'] ??= new Database(DATABASE_PATH . '/auth.db');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the live database connection from GLOBALS['databases'], or create it if it doesn't exist.
|
||||||
|
*/
|
||||||
|
function live_db(): Database
|
||||||
|
{
|
||||||
|
return $GLOBALS['databases']['live'] ??= new Database(DATABASE_PATH . '/live.db');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a pretty dope token.
|
* Generate a pretty dope token.
|
||||||
*/
|
*/
|
||||||
function token($length = 32)
|
function token($length = 32): string
|
||||||
{
|
{
|
||||||
return bin2hex(random_bytes($length));
|
return bin2hex(random_bytes($length));
|
||||||
}
|
}
|
||||||
|
@ -11,7 +92,7 @@ function token($length = 32)
|
||||||
/**
|
/**
|
||||||
* Redirect to a new location.
|
* Redirect to a new location.
|
||||||
*/
|
*/
|
||||||
function redirect($location)
|
function redirect($location): void
|
||||||
{
|
{
|
||||||
header("Location: $location");
|
header("Location: $location");
|
||||||
exit;
|
exit;
|
||||||
|
@ -54,14 +135,6 @@ function csrf()
|
||||||
return $_SESSION['csrf'];
|
return $_SESSION['csrf'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a CSRF token.
|
|
||||||
*/
|
|
||||||
function csrf_verify($token)
|
|
||||||
{
|
|
||||||
return hash_equals($_SESSION['csrf'] ?? '', $token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a hidden input field for CSRF tokens.
|
* Create a hidden input field for CSRF tokens.
|
||||||
*/
|
*/
|
||||||
|
@ -75,7 +148,8 @@ function csrf_field()
|
||||||
*/
|
*/
|
||||||
function csrf_ensure()
|
function csrf_ensure()
|
||||||
{
|
{
|
||||||
if (!csrf_verify($_POST['csrf'] ?? '')) error_response(418);
|
$csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? '';
|
||||||
|
if (!hash_equals($_SESSION['csrf'] ?? '', $csrf)) error_response(418);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -99,7 +173,7 @@ function set_cookie($name, $value, $expires)
|
||||||
function user(): User|false
|
function user(): User|false
|
||||||
{
|
{
|
||||||
if (empty($_SESSION['user'])) return false;
|
if (empty($_SESSION['user'])) return false;
|
||||||
return unserialize($_SESSION['user']);
|
return $GLOBALS['state']['user'] ??= unserialize($_SESSION['user']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -138,7 +212,7 @@ function change_user_character(int $char_id): bool
|
||||||
// If the character ID is different, update the session and database
|
// If the character ID is different, update the session and database
|
||||||
if (user()->char_id !== $char_id) {
|
if (user()->char_id !== $char_id) {
|
||||||
modify_user_session('char_id', $char_id);
|
modify_user_session('char_id', $char_id);
|
||||||
db_query(db_auth(), "UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user()->id]);
|
auth_db()->query("UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user()->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -175,8 +249,7 @@ function wallet(): Wallet|false
|
||||||
function location($field = '')
|
function location($field = '')
|
||||||
{
|
{
|
||||||
if (empty($GLOBALS['location'])) {
|
if (empty($GLOBALS['location'])) {
|
||||||
$GLOBALS['location'] = db_query(
|
$GLOBALS['location'] = live_db()->query(
|
||||||
db_live(),
|
|
||||||
"SELECT * FROM char_locations WHERE char_id = :c",
|
"SELECT * FROM char_locations WHERE char_id = :c",
|
||||||
[':c' => user()->char_id]
|
[':c' => user()->char_id]
|
||||||
)->fetchArray(SQLITE3_ASSOC);
|
)->fetchArray(SQLITE3_ASSOC);
|
||||||
|
@ -196,35 +269,6 @@ function array_to_ul($array)
|
||||||
return "<ul>$html</ul>";
|
return "<ul>$html</ul>";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a keyed stopwatch to measure the time between two points in the code.
|
|
||||||
*/
|
|
||||||
function stopwatch_start($key)
|
|
||||||
{
|
|
||||||
if (!env('debug', false)) return;
|
|
||||||
$GLOBALS['stopwatch'][$key] = microtime(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop a keyed stopwatch. Stores the time in the global $stopwatch array under the key.
|
|
||||||
*/
|
|
||||||
function stopwatch_stop($key)
|
|
||||||
{
|
|
||||||
if (!env('debug', false)) return;
|
|
||||||
if (empty($GLOBALS['stopwatch'][$key])) return 0;
|
|
||||||
$GLOBALS['stopwatch'][$key] = microtime(true) - $GLOBALS['stopwatch'][$key];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the stopwatch value and format it to within 10 digits.
|
|
||||||
*/
|
|
||||||
function stopwatch_get(string $key): string
|
|
||||||
{
|
|
||||||
if (!env('debug', false)) return '';
|
|
||||||
if (empty($GLOBALS['stopwatch'][$key])) return '';
|
|
||||||
return number_format($GLOBALS['stopwatch'][$key], 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conditional Echo; if the condition is true, echo the value. If the condition is false, echo the $or value.
|
* Conditional Echo; if the condition is true, echo the value. If the condition is false, echo the $or value.
|
||||||
*/
|
*/
|
||||||
|
@ -241,22 +285,6 @@ function is_htmx(): bool
|
||||||
return isset($_SERVER['HTTP_HX_REQUEST']);
|
return isset($_SERVER['HTTP_HX_REQUEST']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get whether the request is an AJAX (fetch) request.
|
|
||||||
*/
|
|
||||||
function is_ajax(): bool
|
|
||||||
{
|
|
||||||
return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Limit a request to AJAX only.
|
|
||||||
*/
|
|
||||||
function ajax_only(): void
|
|
||||||
{
|
|
||||||
if (!is_ajax()) error_response(418);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a JSON response with the given data.
|
* Return a JSON response with the given data.
|
||||||
*/
|
*/
|
||||||
|
@ -289,7 +317,7 @@ function error_response(int $code): void
|
||||||
*/
|
*/
|
||||||
function title(int $title_id): array|false
|
function title(int $title_id): array|false
|
||||||
{
|
{
|
||||||
return db_query(db_live(), 'SELECT * FROM titles WHERE id = ?', [$title_id])->fetchArray();
|
return live_db()->query('SELECT * FROM titles WHERE id = ?', [$title_id])->fetchArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -353,3 +381,73 @@ function parse_bbcode(string $text): array
|
||||||
'char_count' => $charCount
|
'char_count' => $charCount
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand to call fetchArray() on a SQLite3Result. Defaults to SQLITE3_ASSOC but can pass any constant to $mode.
|
||||||
|
*/
|
||||||
|
function db_fetch_array(SQLite3Result $result, int $mode = SQLITE3_ASSOC): array|false
|
||||||
|
{
|
||||||
|
return $result->fetchArray($mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether there is a valid remember cookie, and whether that cookie has a valid session token.
|
||||||
|
*/
|
||||||
|
function validate_session(): bool
|
||||||
|
{
|
||||||
|
if (!isset($_COOKIE['remember_me'])) return false;
|
||||||
|
if (($session = Session::find($_COOKIE['remember_me'])) && $session->validate()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure this is an AJAX-only request.
|
||||||
|
*/
|
||||||
|
function ajax_only(): void
|
||||||
|
{
|
||||||
|
$header = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||||
|
if (!strtolower($header) === 'xmlhttprequest') error_response(418);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a request as guest only.
|
||||||
|
*/
|
||||||
|
function guest_only(): void
|
||||||
|
{
|
||||||
|
if ($GLOBALS['state']['logged_in']) redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a request as auth'd only.
|
||||||
|
*/
|
||||||
|
function auth_only(): void
|
||||||
|
{
|
||||||
|
if (!$GLOBALS['state']['logged_in']) redirect('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a request as needing to have a character selected. Automatically checks auth_only as well.
|
||||||
|
*/
|
||||||
|
function must_have_character(): void
|
||||||
|
{
|
||||||
|
auth_only();
|
||||||
|
$user = user();
|
||||||
|
|
||||||
|
// If there is a character selected, make sure the session is up to date.
|
||||||
|
if ($user->char_id !== 0) {
|
||||||
|
char();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no characters, redirect to create first
|
||||||
|
if ($user->char_count() === 0) redirect('/character/create-first');
|
||||||
|
|
||||||
|
// if no character selected, select the first one
|
||||||
|
if ($user->char_id === 0) {
|
||||||
|
$char = live_db()->query(
|
||||||
|
'SELECT * FROM characters WHERE user_id = :u ORDER BY id ASC LIMIT 1',
|
||||||
|
[':u' => $user->id]
|
||||||
|
)->fetchArray(SQLITE3_ASSOC);
|
||||||
|
change_user_character($char['id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -70,8 +70,7 @@ class Character
|
||||||
*/
|
*/
|
||||||
public static function find(int|string $id): Character|false
|
public static function find(int|string $id): Character|false
|
||||||
{
|
{
|
||||||
$q = db_query(
|
$q = live_db()->query(
|
||||||
db_live(),
|
|
||||||
"SELECT * FROM characters WHERE id = :id OR name = :id COLLATE NOCASE",
|
"SELECT * FROM characters WHERE id = :id OR name = :id COLLATE NOCASE",
|
||||||
[':id' => $id]
|
[':id' => $id]
|
||||||
);
|
);
|
||||||
|
@ -98,13 +97,13 @@ class Character
|
||||||
$v = implode(', ', array_map(fn($x) => ":$x", $k));
|
$v = implode(', ', array_map(fn($x) => ":$x", $k));
|
||||||
|
|
||||||
// Create the character!
|
// Create the character!
|
||||||
if (db_query(db_live(), "INSERT INTO characters ($f) VALUES ($v)", $data) === false) {
|
if (live_db()->query("INSERT INTO characters ($f) VALUES ($v)", $data) === false) {
|
||||||
// @TODO: Log this error
|
// @TODO: Log this error
|
||||||
throw new Exception('Failed to create character. (cc)');
|
throw new Exception('Failed to create character. (cc)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the character ID
|
// Get the character ID
|
||||||
return Character::find(db_live()->lastInsertRowID());
|
return Character::find(live_db()->lastInsertRowID());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -112,8 +111,7 @@ class Character
|
||||||
*/
|
*/
|
||||||
public function create_location(int $x = 0, int $y = 0, int $currently = 0): bool
|
public function create_location(int $x = 0, int $y = 0, int $currently = 0): bool
|
||||||
{
|
{
|
||||||
$l = db_query(
|
$l = live_db()->query(
|
||||||
db_live(),
|
|
||||||
"INSERT INTO char_locations (char_id, x, y, currently) VALUES (:i, :x, :y, :c)",
|
"INSERT INTO char_locations (char_id, x, y, currently) VALUES (:i, :x, :y, :c)",
|
||||||
[':i' => $this->id, ':x' => $x, ':y' => $y, ':c' => $currently]
|
[':i' => $this->id, ':x' => $x, ':y' => $y, ':c' => $currently]
|
||||||
);
|
);
|
||||||
|
@ -126,7 +124,7 @@ class Character
|
||||||
public function create_gear(array $initialGear = []): bool
|
public function create_gear(array $initialGear = []): bool
|
||||||
{
|
{
|
||||||
// @TODO implement initial gear
|
// @TODO implement initial gear
|
||||||
$g = db_query(db_live(), "INSERT INTO char_gear (char_id) VALUES (:i)", [':i' => $this->id]);
|
$g = live_db()->query("INSERT INTO char_gear (char_id) VALUES (:i)", [':i' => $this->id]);
|
||||||
return $g !== false;
|
return $g !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,7 +133,7 @@ class Character
|
||||||
*/
|
*/
|
||||||
public static function name_exists(string $name): bool
|
public static function name_exists(string $name): bool
|
||||||
{
|
{
|
||||||
return db_exists(db_live(), 'characters', 'name', $name);
|
return live_db()->exists('characters', 'name', $name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -143,7 +141,7 @@ class Character
|
||||||
*/
|
*/
|
||||||
public static function exists(int $id): bool
|
public static function exists(int $id): bool
|
||||||
{
|
{
|
||||||
return db_exists(db_live(), 'characters', 'id', $id);
|
return live_db()->exists('characters', 'id', $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -151,8 +149,7 @@ class Character
|
||||||
*/
|
*/
|
||||||
public static function belongs_to(int $id, int $user_id): bool
|
public static function belongs_to(int $id, int $user_id): bool
|
||||||
{
|
{
|
||||||
$q = db_query(
|
$q = live_db()->query(
|
||||||
db_live(),
|
|
||||||
"SELECT 1 FROM characters WHERE id = :i AND user_id = :u LIMIT 1",
|
"SELECT 1 FROM characters WHERE id = :i AND user_id = :u LIMIT 1",
|
||||||
[':i' => $id, ':u' => $user_id]
|
[':i' => $id, ':u' => $user_id]
|
||||||
);
|
);
|
||||||
|
@ -175,8 +172,7 @@ class Character
|
||||||
*/
|
*/
|
||||||
public function award_title(int $title_id): bool
|
public function award_title(int $title_id): bool
|
||||||
{
|
{
|
||||||
$r = db_query(
|
$r = live_db()->query(
|
||||||
db_live(),
|
|
||||||
'INSERT INTO owned_titles (`title_id`, `char_id`) VALUES (:t, :i)',
|
'INSERT INTO owned_titles (`title_id`, `char_id`) VALUES (:t, :i)',
|
||||||
[':t' => $title_id, ':i' => $this->id]
|
[':t' => $title_id, ':i' => $this->id]
|
||||||
);
|
);
|
||||||
|
@ -189,84 +185,9 @@ class Character
|
||||||
public static function delete(int $id)
|
public static function delete(int $id)
|
||||||
{
|
{
|
||||||
// Delete the character
|
// Delete the character
|
||||||
if (db_query(db_live(), "DELETE FROM characters WHERE id = :p", [':p' => $id]) === false) {
|
if (live_db()->query("DELETE FROM characters WHERE id = :p", [':p' => $id]) === false) {
|
||||||
throw new Exception('Failed to delete character. (C::d)');
|
throw new Exception('Failed to delete character. (C::d)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get item IDs from the character's inventory
|
|
||||||
$items = db_query(db_live(), "SELECT item_id FROM char_inventory WHERE char_id = :p", [':p' => $id]);
|
|
||||||
// delete the character's inventory and items
|
|
||||||
while ($row = $items->fetchArray(SQLITE3_ASSOC)) {
|
|
||||||
if (db_query(db_live(), "DELETE FROM char_inventory WHERE char_id = :c", [':c' => $id]) === false) {
|
|
||||||
throw new Exception('Failed to delete character inventory. (C::d)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db_query(db_live(), "DELETE FROM items WHERE id = :p", [':p' => $row['id']]) === false) {
|
|
||||||
throw new Exception('Failed to delete character item slots. (C::d)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the character's location
|
|
||||||
if (db_query(db_live(), "DELETE FROM char_locations WHERE char_id = :p", [':p' => $id]) === false) {
|
|
||||||
throw new Exception('Failed to delete character location. (C::d)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the character's gear
|
|
||||||
if (db_query(db_live(), "DELETE FROM char_gear WHERE char_id = :p", [':p' => $id]) === false) {
|
|
||||||
throw new Exception('Failed to delete character gear. (C::d)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the character's bank
|
|
||||||
if (db_query(db_live(), "DELETE FROM char_bank WHERE char_id = :p", [':p' => $id]) === false) {
|
|
||||||
throw new Exception('Failed to delete character bank. (C::d)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete character's banked items
|
|
||||||
if (db_query(db_live(), "DELETE FROM char_banked_items WHERE char_id = :p", [':p' => $id]) === false) {
|
|
||||||
throw new Exception('Failed to delete character bank items. (C::d)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the user's guild membership
|
|
||||||
if (db_query(db_live(), "DELETE FROM guild_members WHERE char_id = :p", [':p' => $id]) === false) {
|
|
||||||
throw new Exception('Failed to delete character guild membership. (C::d)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the character was a guild leader, hand leadership to the next highest ranking member
|
|
||||||
$guild = db_query(db_live(), "SELECT id FROM guilds WHERE leader_id = :p", [':p' => $id])->fetchArray(SQLITE3_ASSOC);
|
|
||||||
if ($guild !== false) {
|
|
||||||
$members = db_query(db_live(), "SELECT char_id FROM guild_members WHERE guild_id = :p ORDER BY rank DESC", [':p' => $guild['id']]);
|
|
||||||
$newLeader = $members->fetchArray(SQLITE3_ASSOC);
|
|
||||||
if ($newLeader !== false) {
|
|
||||||
db_query(db_live(), "UPDATE guilds SET leader_id = :p WHERE id = :g", [':p' => $newLeader['char_id'], ':g' => $guild['id']]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a list of all pve fight IDs.
|
|
||||||
$pve = db_query(db_fights(), "SELECT id FROM pve WHERE char_id = :p", [':p' => $id]);
|
|
||||||
// Get a list of all pvp fight IDs.
|
|
||||||
$pvp = db_query(db_fights(), "SELECT id FROM pvp WHERE char1_id = :p OR char2_id = :p", [':p' => $id]);
|
|
||||||
|
|
||||||
// Delete all pve fights
|
|
||||||
while ($row = $pve->fetchArray(SQLITE3_ASSOC)) {
|
|
||||||
if (db_query(db_fights(), "DELETE FROM pve WHERE id = :p", [':p' => $row['id']]) === false) {
|
|
||||||
throw new Exception('Failed to delete pve fight. (C::d)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db_query(db_fights(), "DELETE FROM pve_logs WHERE fight_id = :p", [':p' => $row['id']]) === false) {
|
|
||||||
throw new Exception('Failed to delete pve fight logs. (C::d)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all pvp fights
|
|
||||||
while ($row = $pvp->fetchArray(SQLITE3_ASSOC)) {
|
|
||||||
if (db_query(db_fights(), "DELETE FROM pvp WHERE id = :p", [':p' => $row['id']]) === false) {
|
|
||||||
throw new Exception('Failed to delete pvp fight. (C::d)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db_query(db_fights(), "DELETE FROM pvp_logs WHERE fight_id = :p", [':p' => $row['id']]) === false) {
|
|
||||||
throw new Exception('Failed to delete pvp fight logs. (C::d)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -278,8 +199,7 @@ class Character
|
||||||
|
|
||||||
$t = title($this->title_id);
|
$t = title($this->title_id);
|
||||||
|
|
||||||
$q = db_query(
|
$q = live_db()->query(
|
||||||
db_live(),
|
|
||||||
'SELECT awarded FROM owned_titles WHERE char_id = ? AND title_id = ? LIMIT 1',
|
'SELECT awarded FROM owned_titles WHERE char_id = ? AND title_id = ? LIMIT 1',
|
||||||
[$this->id, $this->title_id]
|
[$this->id, $this->title_id]
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,50 +1,46 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
class Session
|
||||||
* Create a session for a user with a token and expiration date. Returns the token on success, or false on failure.
|
|
||||||
*/
|
|
||||||
function session_create($userId, $expires)
|
|
||||||
{
|
{
|
||||||
$token = token();
|
public function __construct(
|
||||||
$result = db_query(db_auth(), "INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)", [
|
public int $user_id,
|
||||||
':t' => $token,
|
public string $token,
|
||||||
':u' => $userId,
|
public int $expires
|
||||||
':e' => $expires
|
) {}
|
||||||
]);
|
|
||||||
if (!$result) return false;
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
public static function create(int $user_id, int $expires): Session|false
|
||||||
* Find a session by token.
|
{
|
||||||
*/
|
$token = token();
|
||||||
function session_find($token)
|
$result = auth_db()->query("INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)", [
|
||||||
{
|
':t' => $token,
|
||||||
$result = db_query(db_auth(), "SELECT * FROM sessions WHERE token = :t", [':t' => $token]);
|
':u' => $user_id,
|
||||||
$session = $result->fetchArray(SQLITE3_ASSOC);
|
':e' => $expires
|
||||||
if (!$session) return false;
|
]);
|
||||||
$result->finalize();
|
if ($result === false) return false;
|
||||||
return $session;
|
return new Session($user_id, $token, $expires);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static function find(string $token): Session|false
|
||||||
* Delete sessions by user id.
|
{
|
||||||
*/
|
$result = auth_db()->query("SELECT * FROM sessions WHERE token = :t", [':t' => $token]);
|
||||||
function session_delete($userId)
|
$session = db_fetch_array($result);
|
||||||
{
|
if ($session === false) return false;
|
||||||
return db_query(db_auth(), "DELETE FROM sessions WHERE user_id = :u", [':u' => $userId]);
|
$result->finalize();
|
||||||
}
|
return new Session($session['user_id'], $session['token'], $session['expires']);
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Validate a session by token and expiration date. If expired, the session is deleted and false is returned.
|
public static function delete(int $user_id): SQLite3Result|false
|
||||||
*/
|
{
|
||||||
function session_validate($token)
|
return auth_db()->query("DELETE FROM sessions WHERE user_id = :u", [':u' => $user_id]);
|
||||||
{
|
}
|
||||||
$session = session_find($token);
|
|
||||||
if (!$session) return false;
|
public function validate(): bool
|
||||||
if ($session['expires'] < time()) {
|
{
|
||||||
session_delete($session['user_id']);
|
if (empty($this->user_id) || empty($this->token)) return false;
|
||||||
return false;
|
if ($this->expires < time()) {
|
||||||
|
self::delete($this->user_id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a token for a user. Returns the token on success, or false on failure.
|
|
||||||
*/
|
|
||||||
function token_create($userId)
|
|
||||||
{
|
|
||||||
$token = token();
|
|
||||||
$result = db_query(db_auth(), "INSERT INTO tokens (token, user_id) VALUES (:t, :u)", [
|
|
||||||
':t' => $token,
|
|
||||||
':u' => $userId
|
|
||||||
]);
|
|
||||||
if (!$result) return false;
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a token by token.
|
|
||||||
*/
|
|
||||||
function token_find($token)
|
|
||||||
{
|
|
||||||
$result = db_query(db_auth(), "SELECT * FROM tokens WHERE token = :t", [':t' => $token]);
|
|
||||||
$token = $result->fetchArray(SQLITE3_ASSOC);
|
|
||||||
if (!$token) return false;
|
|
||||||
$result->finalize();
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a token by token.
|
|
||||||
*/
|
|
||||||
function token_delete($token)
|
|
||||||
{
|
|
||||||
return db_query(db_auth(), "DELETE FROM tokens WHERE token = :t", [':t' => $token]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a token by token and created date. Tokens are invalid if older than 7 days.
|
|
||||||
*/
|
|
||||||
function token_validate($token)
|
|
||||||
{
|
|
||||||
$token = token_find($token);
|
|
||||||
if (!$token) return false;
|
|
||||||
if (strtotime('+7 days') < time()) {
|
|
||||||
token_delete($token['token']);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
|
@ -68,8 +68,7 @@ class User
|
||||||
*/
|
*/
|
||||||
public static function find(string|int $identifier): User|false
|
public static function find(string|int $identifier): User|false
|
||||||
{
|
{
|
||||||
$r = db_query(
|
$r = auth_db()->query(
|
||||||
db_auth(),
|
|
||||||
"SELECT * FROM users WHERE username = :i COLLATE NOCASE OR email = :i COLLATE NOCASE OR id = :i LIMIT 1",
|
"SELECT * FROM users WHERE username = :i COLLATE NOCASE OR email = :i COLLATE NOCASE OR id = :i LIMIT 1",
|
||||||
[':i' => $identifier]
|
[':i' => $identifier]
|
||||||
);
|
);
|
||||||
|
@ -86,7 +85,7 @@ class User
|
||||||
*/
|
*/
|
||||||
public static function create(string $username, string $email, string $password, int $auth = 0): SQLite3Result|false
|
public static function create(string $username, string $email, string $password, int $auth = 0): SQLite3Result|false
|
||||||
{
|
{
|
||||||
return db_query(db_auth(), "INSERT INTO users (username, email, password, auth) VALUES (:u, :e, :p, :a)", [
|
return auth_db()->query("INSERT INTO users (username, email, password, auth) VALUES (:u, :e, :p, :a)", [
|
||||||
':u' => $username,
|
':u' => $username,
|
||||||
':e' => $email,
|
':e' => $email,
|
||||||
':p' => password_hash($password, PASSWORD_ARGON2ID),
|
':p' => password_hash($password, PASSWORD_ARGON2ID),
|
||||||
|
@ -107,7 +106,7 @@ class User
|
||||||
*/
|
*/
|
||||||
public static function username_exists(string $username): bool
|
public static function username_exists(string $username): bool
|
||||||
{
|
{
|
||||||
return db_exists(db_auth(), 'users', 'username', $username);
|
return auth_db()->exists('users', 'username', $username);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -115,7 +114,7 @@ class User
|
||||||
*/
|
*/
|
||||||
public static function email_exists(string $email): bool
|
public static function email_exists(string $email): bool
|
||||||
{
|
{
|
||||||
return db_exists(db_auth(), 'users', 'email', $email);
|
return auth_db()->exists('users', 'email', $email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -123,8 +122,7 @@ class User
|
||||||
*/
|
*/
|
||||||
public static function delete(string|int $identifier): SQLite3Result|false
|
public static function delete(string|int $identifier): SQLite3Result|false
|
||||||
{
|
{
|
||||||
return db_query(
|
return auth_db()->query(
|
||||||
db_auth(),
|
|
||||||
"DELETE FROM users WHERE username = :i OR email = :i OR id = :i",
|
"DELETE FROM users WHERE username = :i OR email = :i OR id = :i",
|
||||||
[':i' => $identifier]
|
[':i' => $identifier]
|
||||||
);
|
);
|
||||||
|
@ -135,8 +133,7 @@ class User
|
||||||
*/
|
*/
|
||||||
public function char_count(): int
|
public function char_count(): int
|
||||||
{
|
{
|
||||||
$c = db_query(
|
$c = live_db()->query(
|
||||||
db_live(),
|
|
||||||
"SELECT COUNT(*) FROM characters WHERE user_id = :u",
|
"SELECT COUNT(*) FROM characters WHERE user_id = :u",
|
||||||
[':u' => $this->id]
|
[':u' => $this->id]
|
||||||
)->fetchArray(SQLITE3_NUM);
|
)->fetchArray(SQLITE3_NUM);
|
||||||
|
@ -150,7 +147,7 @@ class User
|
||||||
*/
|
*/
|
||||||
public function char_list(): array|false
|
public function char_list(): array|false
|
||||||
{
|
{
|
||||||
$q = db_query(db_live(), "SELECT id, name, level FROM characters WHERE user_id = ?", [$this->id]);
|
$q = live_db()->query("SELECT id, name, level FROM characters WHERE user_id = ?", [$this->id]);
|
||||||
if ($q === false) throw new Exception('Failed to list characters. (U->cl)');
|
if ($q === false) throw new Exception('Failed to list characters. (U->cl)');
|
||||||
|
|
||||||
$c = [];
|
$c = [];
|
||||||
|
|
|
@ -10,7 +10,7 @@ class Wallet
|
||||||
|
|
||||||
public static function find(int $user_id): Wallet|false
|
public static function find(int $user_id): Wallet|false
|
||||||
{
|
{
|
||||||
$r = db_query(db_live(), 'SELECT * FROM wallets WHERE user_id = ?', [$user_id]);
|
$r = live_db()->query('SELECT * FROM wallets WHERE user_id = ?', [$user_id]);
|
||||||
if ($r === false) throw new Exception('Failed to query wallet. (W::f)'); // badly formed query
|
if ($r === false) throw new Exception('Failed to query wallet. (W::f)'); // badly formed query
|
||||||
$w = $r->fetchArray(SQLITE3_ASSOC);
|
$w = $r->fetchArray(SQLITE3_ASSOC);
|
||||||
if ($w === false) return false; // no wallet found
|
if ($w === false) return false; // no wallet found
|
||||||
|
@ -19,8 +19,7 @@ class Wallet
|
||||||
|
|
||||||
public static function create(int $user_id, int $silver = -1, int $starGems = -1): SQLite3Result|false
|
public static function create(int $user_id, int $silver = -1, int $starGems = -1): SQLite3Result|false
|
||||||
{
|
{
|
||||||
return db_query(
|
return live_db()->query(
|
||||||
db_live(),
|
|
||||||
"INSERT INTO wallets (user_id, silver, stargem) VALUES (:u, :s, :sg)",
|
"INSERT INTO wallets (user_id, silver, stargem) VALUES (:u, :s, :sg)",
|
||||||
[
|
[
|
||||||
':u' => $user_id,
|
':u' => $user_id,
|
||||||
|
@ -37,7 +36,7 @@ class Wallet
|
||||||
{
|
{
|
||||||
$cs = $c->string(true);
|
$cs = $c->string(true);
|
||||||
$new = $this->{$cs} + $amt;
|
$new = $this->{$cs} + $amt;
|
||||||
return db_query(db_live(), "UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new, $this->user_id]);
|
return live_db()->query("UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new, $this->user_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -47,6 +46,6 @@ class Wallet
|
||||||
{
|
{
|
||||||
$cs = $c->string(true);
|
$cs = $c->string(true);
|
||||||
$new = $this->{$cs} - $amt;
|
$new = $this->{$cs} - $amt;
|
||||||
return db_query(db_live(), "UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new < 0 ? 0 : $new, $this->user_id]);
|
return live_db()->query("UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new < 0 ? 0 : $new, $this->user_id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
183
src/router.php
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A radix-trie based URI router. Seperates URIs into chunks, then turns those chunks into an efficiently parsed
|
||||||
|
* trie. Supports URI variables!
|
||||||
|
*/
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List of valid HTTP verbs.
|
||||||
|
*/
|
||||||
|
private const VALID_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tree of currently registered routes.
|
||||||
|
*/
|
||||||
|
private array $routes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the last inserted node so we can register middleware and attributes to it.
|
||||||
|
*/
|
||||||
|
private array $last_inserted_node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a route to the route tree. The route must be a URI path, and contain dynamic segments
|
||||||
|
* using a colon prefix. (:id, :slug, etc)
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* `$r->add($routes, 'GET', '/posts/:id', function($id) { echo "Viewing post $id"; });`
|
||||||
|
*/
|
||||||
|
public function add(string $method, string $route, callable $handler): Router
|
||||||
|
{
|
||||||
|
$this->validateMethod($method);
|
||||||
|
$this->validateRoute($route);
|
||||||
|
|
||||||
|
// Expand the route into segments and make dynamic segments into a common placeholder
|
||||||
|
$segments = array_map(function($segment) {
|
||||||
|
return str_starts_with($segment, ':') ? ':x' : $segment;
|
||||||
|
}, explode('/', trim($route, '/')));
|
||||||
|
|
||||||
|
// Push each segment into the routes array as a node, except if this is the root node
|
||||||
|
$node = &$this->routes;
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
// skip an empty segment, which allows us to register handlers for the root node
|
||||||
|
if ($segment === '') continue;
|
||||||
|
$node = &$node[$segment]; // build the node tree as we go
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the handler to the last node
|
||||||
|
$node[$method] = ['handler' => $handler, 'middleware' => []];
|
||||||
|
|
||||||
|
// Store a reference to the node so we can add middleware to it.
|
||||||
|
$this->last_inserted_node = &$node[$method];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a lookup in the route tree for a given method and URI. Returns an array with a result code,
|
||||||
|
* a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and
|
||||||
|
* 405 for method not allowed.
|
||||||
|
*
|
||||||
|
* @return array ['code', 'handler', 'params']
|
||||||
|
*/
|
||||||
|
public function lookup(string $method, string $uri): array
|
||||||
|
{
|
||||||
|
// node is a reference to our current location in the node tree
|
||||||
|
$node = $this->routes;
|
||||||
|
|
||||||
|
// params will hold any dynamic segments we find
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
// if the URI is just a slash, we can return the handler for the root node
|
||||||
|
if ($uri === '/') {
|
||||||
|
return isset($node[$method])
|
||||||
|
? ['code' => 200, 'handler' => $node[$method]['handler']]
|
||||||
|
: ['code' => 405];
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll split up the URI into segments and traverse the node tree
|
||||||
|
foreach (explode('/', trim($uri, '/')) as $segment) {
|
||||||
|
// if there is a node for this segment, move to it
|
||||||
|
if (isset($node[$segment])) {
|
||||||
|
$node = $node[$segment];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is a dynamic segment, move to it and store the value
|
||||||
|
if (isset($node[':x'])) {
|
||||||
|
$params[] = $segment;
|
||||||
|
$node = $node[':x'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we can't find a node for this segment, return 404
|
||||||
|
return ['code' => 404];
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we found a handler for the method, return it and any params. if not, return a 405
|
||||||
|
return isset($node[$method])
|
||||||
|
? ['code' => 200, 'handler' => $node[$method]['handler'], 'params' => $params ?? [], 'middleware' => $node[$method]['middleware']]
|
||||||
|
: ['code' => 405];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a middleware function to the last inserted node's stack.
|
||||||
|
*/
|
||||||
|
public function middleware(callable $middleware): Router
|
||||||
|
{
|
||||||
|
$this->last_inserted_node['middleware'][] = $middleware;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand to register a GET route.
|
||||||
|
*/
|
||||||
|
public function get(string $route, callable $handler): Router
|
||||||
|
{
|
||||||
|
return $this->add('GET', $route, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand to register a POST route.
|
||||||
|
*/
|
||||||
|
public function post(string $route, callable $handler): Router
|
||||||
|
{
|
||||||
|
return $this->add('POST', $route, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand to register a PUT route.
|
||||||
|
*/
|
||||||
|
public function put(string $route, callable $handler): Router
|
||||||
|
{
|
||||||
|
return $this->add('PUT', $route, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand to register a DELETE route.
|
||||||
|
*/
|
||||||
|
public function delete(string $route, callable $handler): Router
|
||||||
|
{
|
||||||
|
return $this->add('DELETE', $route, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand to register a PATCH route.
|
||||||
|
*/
|
||||||
|
public function patch(string $route, callable $handler): Router
|
||||||
|
{
|
||||||
|
return $this->add('PATCH', $route, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the given method against valid HTTP verbs.
|
||||||
|
*/
|
||||||
|
private function validateMethod(string $method): void
|
||||||
|
{
|
||||||
|
if (!in_array($method, self::VALID_METHODS)) {
|
||||||
|
throw new InvalidArgumentException("Invalid HTTP method: $method");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a new route follows expected formatting.
|
||||||
|
*/
|
||||||
|
private function validateRoute(string $route): void
|
||||||
|
{
|
||||||
|
if ($route === '') {
|
||||||
|
throw new InvalidArgumentException("Route cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure route starts with a slash
|
||||||
|
if (!str_starts_with($route, '/')) {
|
||||||
|
throw new InvalidArgumentException("Route must start with a '/'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Check for consecutive dynamic segments or invalid characters
|
||||||
|
if (preg_match('/(:x.*){2,}/', $route)) {
|
||||||
|
throw new InvalidArgumentException("Invalid route pattern: consecutive dynamic segments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,73 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for a user session. If $_SESSION['user'] already exists, return early. If not, check for a remember me
|
|
||||||
* cookie. If a remember me cookie exists, validate the session and set $_SESSION['user'].
|
|
||||||
*/
|
|
||||||
function auth_check()
|
|
||||||
{
|
|
||||||
if (isset($_SESSION['user'])) return true;
|
|
||||||
|
|
||||||
if (isset($_COOKIE['remember_me'])) {
|
|
||||||
$session = session_validate($_COOKIE['remember_me']);
|
|
||||||
if ($session === true) {
|
|
||||||
$user = User::find($session['user_id']);
|
|
||||||
$_SESSION['user'] = serialize($user);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a user is logged in, or redirect to the login page. This will also check for a remember me cookie and
|
|
||||||
* populate the $_SESSION['user'] array.
|
|
||||||
*/
|
|
||||||
function auth_only()
|
|
||||||
{
|
|
||||||
if (!auth_check()) redirect('/auth/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If there is a user logged in, redirect to the home page. Used for when we have a guest-only page.
|
|
||||||
*/
|
|
||||||
function guest_only()
|
|
||||||
{
|
|
||||||
if (auth_check()) redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the user has a character selected. If they have no character, redirect to the character creation page. Otherwise,
|
|
||||||
* select the first character attached to the user.
|
|
||||||
*/
|
|
||||||
function must_have_character()
|
|
||||||
{
|
|
||||||
// If there is a character selected, make sure the session is up to date.
|
|
||||||
if (user()->char_id !== 0) {
|
|
||||||
char();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no characters, redirect to create first
|
|
||||||
if (user()->char_count() === 0) redirect('/character/create-first');
|
|
||||||
|
|
||||||
// if no character selected, select the first one
|
|
||||||
if (user()->char_id === 0) {
|
|
||||||
$char = db_query(
|
|
||||||
db_live(),
|
|
||||||
'SELECT * FROM characters WHERE user_id = :u ORDER BY id ASC LIMIT 1',
|
|
||||||
[':u' => user()->id]
|
|
||||||
)->fetchArray(SQLITE3_ASSOC);
|
|
||||||
change_user_character($char['id']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user must be authenticated and have a character.
|
|
||||||
*/
|
|
||||||
function auth_only_and_must_have_character()
|
|
||||||
{
|
|
||||||
auth_only();
|
|
||||||
must_have_character();
|
|
||||||
}
|
|
|
@ -1,121 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
define('DBP', SRC . '/../database');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a connection to a database.
|
|
||||||
*/
|
|
||||||
function db_open($path)
|
|
||||||
{
|
|
||||||
$db = new SQLite3($path);
|
|
||||||
|
|
||||||
// Increase cache size to 32MB
|
|
||||||
$db->exec('PRAGMA cache_size = 32000');
|
|
||||||
// Enable WAL mode
|
|
||||||
$db->exec('PRAGMA journal_mode = WAL');
|
|
||||||
// Move temp store to memory
|
|
||||||
$db->exec('PRAGMA temp_store = MEMORY');
|
|
||||||
|
|
||||||
return $db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a connection to the auth database.
|
|
||||||
*/
|
|
||||||
function db_auth()
|
|
||||||
{
|
|
||||||
return $GLOBALS['db_auth'] ??= db_open(DBP . '/auth.db');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a connection to the live database.
|
|
||||||
*/
|
|
||||||
function db_live()
|
|
||||||
{
|
|
||||||
return $GLOBALS['db_live'] ??= db_open(DBP . '/live.db');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a connection to the fights database.
|
|
||||||
*/
|
|
||||||
function db_fights()
|
|
||||||
{
|
|
||||||
return $GLOBALS['db_fights'] ??= db_open(DBP . '/fights.db');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a connection to the blueprints database.
|
|
||||||
*/
|
|
||||||
function db_blueprints()
|
|
||||||
{
|
|
||||||
return $GLOBALS['db_blueprints'] ??= db_open(DBP . '/blueprints.db');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take a SQLite3 database connection, a query string, and an array of parameters. Prepare the query and
|
|
||||||
* bind the parameters with proper type casting. Then execute the query and return the result.
|
|
||||||
*/
|
|
||||||
function db_query(SQLite3 $db, string $query, array $params = []): SQLite3Result|false
|
|
||||||
{
|
|
||||||
$p = strpos($query, '?') !== false; // are generic placeholders?
|
|
||||||
$stmt = $db->prepare($query);
|
|
||||||
if (!empty($params)) {
|
|
||||||
foreach ($params as $k => $v) $stmt->bindValue($p ? $k + 1 : $k, $v, getSQLiteType($v));
|
|
||||||
}
|
|
||||||
$start = microtime(true);
|
|
||||||
$r = $stmt->execute();
|
|
||||||
db_log($query, microtime(true) - $start);
|
|
||||||
return $r;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take a SQLite3 database connection and a query string. Execute the query and return the result.
|
|
||||||
*/
|
|
||||||
function db_exec($db, $query)
|
|
||||||
{
|
|
||||||
$start = microtime(true);
|
|
||||||
$r = $db->exec($query);
|
|
||||||
db_log($query, microtime(true) - $start);
|
|
||||||
return $r;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take a SQLite3 database connection, a column name, and a value. Execute a SELECT query to see if the value
|
|
||||||
* exists in the column. Return true if the value exists, false otherwise.
|
|
||||||
*/
|
|
||||||
function db_exists(SQLite3 $db, string $table, string $column, mixed $value, bool $caseInsensitive = true): bool
|
|
||||||
{
|
|
||||||
if ($caseInsensitive) {
|
|
||||||
$query = "SELECT 1 FROM $table WHERE $column = :v COLLATE NOCASE LIMIT 1";
|
|
||||||
} else {
|
|
||||||
$query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1";
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = db_query($db, $query, [':v' => $value]);
|
|
||||||
return $result->fetchArray(SQLITE3_NUM) !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the appropriate SQLite type casting for the value.
|
|
||||||
*/
|
|
||||||
function getSQLiteType($value): int
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
is_int($value) => SQLITE3_INTEGER,
|
|
||||||
is_float($value) => SQLITE3_FLOAT,
|
|
||||||
is_null($value) => SQLITE3_NULL,
|
|
||||||
default => SQLITE3_TEXT
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log the given query string to the db debug log.
|
|
||||||
*/
|
|
||||||
function db_log($query, $timeTaken = 0)
|
|
||||||
{
|
|
||||||
$GLOBALS['queries']++;
|
|
||||||
$GLOBALS['query_time'] += $timeTaken;
|
|
||||||
if (env('debug', false)) $GLOBALS['query_log'][] = [$query, $timeTaken];
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the environment variables from the .env file.
|
|
||||||
*/
|
|
||||||
function env_load($filePath)
|
|
||||||
{
|
|
||||||
if (!file_exists($filePath)) throw new Exception("The .env file does not exist. (el)");
|
|
||||||
|
|
||||||
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$line = trim($line);
|
|
||||||
|
|
||||||
// Skip lines that are empty after trimming or are comments
|
|
||||||
if ($line === '' || str_starts_with($line, '#')) continue;
|
|
||||||
|
|
||||||
// Skip lines without an '=' character
|
|
||||||
if (strpos($line, '=') === false) continue;
|
|
||||||
|
|
||||||
[$name, $value] = explode('=', $line, 2);
|
|
||||||
|
|
||||||
$name = trim($name);
|
|
||||||
$value = trim($value, " \t\n\r\0\x0B\"'"); // Trim whitespace and quotes
|
|
||||||
|
|
||||||
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
|
|
||||||
putenv("$name=$value");
|
|
||||||
$_ENV[$name] = $value;
|
|
||||||
$_SERVER[$name] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve an environment variable.
|
|
||||||
*/
|
|
||||||
function env($key, $default = null)
|
|
||||||
{
|
|
||||||
$v = $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default);
|
|
||||||
return match(true) {
|
|
||||||
$v === 'true' => true,
|
|
||||||
$v === 'false' => false,
|
|
||||||
is_numeric($v) => (int) $v,
|
|
||||||
is_float($v) => (float) $v,
|
|
||||||
default => $v
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,37 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the path to a view file.
|
|
||||||
*/
|
|
||||||
function template($name)
|
|
||||||
{
|
|
||||||
return SRC . "/../templates/$name.php";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a view with the given data. Looks for `$view` through `template()`.
|
|
||||||
*/
|
|
||||||
function render($pathToBaseView, $data = [])
|
|
||||||
{
|
|
||||||
ob_start();
|
|
||||||
extract($data);
|
|
||||||
require template($pathToBaseView);
|
|
||||||
return ob_get_clean();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set/retrieve the current page layout in/from GLOBALS.
|
|
||||||
*/
|
|
||||||
function page_layout($layout = '')
|
|
||||||
{
|
|
||||||
if ($layout !== '') $GLOBALS['page-layout'] = $layout;
|
|
||||||
return $GLOBALS['page-layout'] ?? 'basic';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shorthand to render a page with the current layout.
|
|
||||||
*/
|
|
||||||
function page($view, $data = [])
|
|
||||||
{
|
|
||||||
return render("layouts/" . page_layout(), ['view' => "pages/$view"] + $data);
|
|
||||||
}
|
|
|
@ -1,91 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
class Router
|
|
||||||
{
|
|
||||||
private array $routes = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a route to the route tree. The route must be a URI path, and contain dynamic segments
|
|
||||||
* using a colon prefix. (:id, :slug, etc)
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* `$r->add($routes, 'GET', '/posts/:id', function($id) { echo "Viewing post $id"; });`
|
|
||||||
*/
|
|
||||||
public function add(string $method, string $route, callable $handler): Router
|
|
||||||
{
|
|
||||||
// Expand the route into segments and make dynamic segments into a common placeholder
|
|
||||||
$segments = array_map(function($segment) {
|
|
||||||
return str_starts_with($segment, ':') ? ':x' : $segment;
|
|
||||||
}, explode('/', trim($route, '/')));
|
|
||||||
|
|
||||||
// Push each segment into the routes array as a node, except if this is the root node
|
|
||||||
$node = &$this->routes;
|
|
||||||
foreach ($segments as $segment) {
|
|
||||||
// skip an empty segment, which allows us to register handlers for the root node
|
|
||||||
if ($segment === '') continue;
|
|
||||||
$node = &$node[$segment]; // build the node tree as we go
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the handler to the last node
|
|
||||||
$node[$method] = $handler;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform a lookup in the route tree for a given method and URI. Returns an array with a result code,
|
|
||||||
* a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and
|
|
||||||
* 405 for method not allowed.
|
|
||||||
*
|
|
||||||
* @return array ['code', 'handler', 'params']
|
|
||||||
*/
|
|
||||||
public function lookup(string $method, string $uri): array
|
|
||||||
{
|
|
||||||
// node is a reference to our current location in the node tree
|
|
||||||
$node = $this->routes;
|
|
||||||
|
|
||||||
// params will hold any dynamic segments we find
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
// if the URI is just a slash, we can return the handler for the root node
|
|
||||||
if ($uri === '/') {
|
|
||||||
return isset($node[$method])
|
|
||||||
? ['code' => 200, 'handler' => $node[$method], 'params' => null]
|
|
||||||
: ['code' => 405, 'handler' => null, 'params' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
// We'll split up the URI into segments and traverse the node tree
|
|
||||||
foreach (explode('/', trim($uri, '/')) as $segment) {
|
|
||||||
// if there is a node for this segment, move to it
|
|
||||||
if (isset($node[$segment])) {
|
|
||||||
$node = $node[$segment];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there is a dynamic segment, move to it and store the value
|
|
||||||
if (isset($node[':x'])) {
|
|
||||||
$params[] = $segment;
|
|
||||||
$node = $node[':x'];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we can't find a node for this segment, return 404
|
|
||||||
return ['code' => 404, 'handler' => null, 'params' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we found a handler for the method, return it and any params. if not, return a 405
|
|
||||||
return isset($node[$method])
|
|
||||||
? ['code' => 200, 'handler' => $node[$method], 'params' => $params ?? []]
|
|
||||||
: ['code' => 405, 'handler' => null, 'params' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(string $route, callable $handler): Router
|
|
||||||
{
|
|
||||||
return $this->add('GET', $route, $handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function post(string $route, callable $handler): Router
|
|
||||||
{
|
|
||||||
return $this->add('POST', $route, $handler);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
<div class="debug-query-log">
|
|
||||||
<h3>Query Log</h3>
|
|
||||||
<p class="mb-2"><?= $GLOBALS['queries'] ?> queries were executed.</p>
|
|
||||||
<?php
|
|
||||||
if (!empty($GLOBALS['query_log']))
|
|
||||||
foreach ($GLOBALS['query_log'] as $query) {
|
|
||||||
$time = number_format($query[1], 6);
|
|
||||||
echo "<p>({$time}s) {$query[0]}</p>";
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</div>
|
|
|
@ -1,7 +0,0 @@
|
||||||
<div class="debug-query-log">
|
|
||||||
<h3>Stopwatches</h3>
|
|
||||||
<p class="mb-2">Page execution took <?= number_format((microtime(true) - START_TIME), 10) ?> seconds.</p>
|
|
||||||
<p>Bootstrap: <?= stopwatch_get('bootstrap') ?> seconds</p>
|
|
||||||
<p>Router: <?= stopwatch_get('router') ?> seconds</p>
|
|
||||||
<p>Handler: <?= stopwatch_get('handler') ?> seconds</p>
|
|
||||||
</div>
|
|
|
@ -1,18 +0,0 @@
|
||||||
<nav>
|
|
||||||
<?php
|
|
||||||
const links = [
|
|
||||||
['/', 'home', 'home', 'Home'],
|
|
||||||
['/world', 'world', 'earth', 'World'],
|
|
||||||
['/profile', 'profile', 'user1', 'Profile'],
|
|
||||||
['/auctions', 'auctions', 'shop', 'Auctions'],
|
|
||||||
['/characters', 'chars', 'user1', 'Characters'],
|
|
||||||
['/settings', 'settings', 'settings', 'Settings']
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach (links as $link): ?>
|
|
||||||
<a href="<?= $link[0] ?>" class="<?= ce(($GLOBALS['active_nav_tab'] ?? '') == $link[1], 'active') ?>">
|
|
||||||
<img src="/assets/img/icons/<?= $link[2] ?>.png" title="<?= $link[3] ?>">
|
|
||||||
<?= $link[3] ?>
|
|
||||||
</a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</nav>
|
|
|
@ -1,4 +0,0 @@
|
||||||
<form action="/auth/logout" method="post">
|
|
||||||
<input type="hidden" name="csrf" value="<?= csrf() ?>">
|
|
||||||
<input type="submit" value="Logout" class="ui button secondary">
|
|
||||||
</form>
|
|
|
@ -1,37 +0,0 @@
|
||||||
<div id="character" class="box">
|
|
||||||
<div class="name">
|
|
||||||
<?= $c->name ?>
|
|
||||||
<?php if ($c->att_points > 0): ?>
|
|
||||||
<span class="ui button primary badge ml-2 tooltip-hover" data-tooltip-content="Attribute Points"><?= $c->att_points ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
L<?= $c->level ?> <?= $c->title()['name'] ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<?= wallet()->silver ?> s
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="char-meter">
|
|
||||||
<div class="hp" style="width: <?= percent($c->hp, $c->m_hp) ?>%"></div>
|
|
||||||
<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Health<br><?= $c->hp ?> / <?= $c->m_hp ?>"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="char-meter">
|
|
||||||
<div class="mp" style="width: <?= percent($c->mp, $c->m_mp) ?>%"></div>
|
|
||||||
<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Mana<br><?= $c->mp ?> / <?= $c->m_mp ?>"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="char-meter">
|
|
||||||
<div class="tp" style="width: <?= percent($c->tp, $c->m_tp) ?>%"></div>
|
|
||||||
<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Travel Points<br><?= $c->tp ?> / <?= $c->m_tp ?>"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -4,54 +4,22 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Dragon Knight</title>
|
<title>Dragon Knight</title>
|
||||||
<link rel="stylesheet" href="/assets/css/dragon.css">
|
<link rel="stylesheet" href="/assets/css/basic.css">
|
||||||
<script src="/assets/scripts/htmx.js"></script>
|
<script src="/assets/scripts/htmx.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="main-header">
|
<header id="basic-header">
|
||||||
<div class="left">
|
<img src="/assets/img/dk.png" alt="Dragon Knight" width="480">
|
||||||
<h1>Dragon Knight</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="right">
|
|
||||||
<?php if (user()): ?>
|
|
||||||
<p>Welcome, <?= user()->username ?></p>
|
|
||||||
<?= c_logout_button() ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<a class="ui button primary" href="/auth/login">Login</a>
|
|
||||||
<a class="ui button secondary" href="/auth/register">Register</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main id="basic-main">
|
||||||
<aside id="left">
|
<?= render($view, $data) ?>
|
||||||
<?php if (user() && user()->char_id > 0) echo c_left_nav($activeTab ?? 0); ?>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div id="center">
|
|
||||||
<?= render($view, $data) ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside id="right">
|
|
||||||
<?php if (user() && user()->char_id > 0) echo c_right_nav(); ?>
|
|
||||||
</aside>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer id="basic-footer">
|
||||||
<p>© <?= date('Y') ?> Dragon Knight</p>
|
© <?= date('Y') ?> Dragon Knight
|
||||||
<p>q<?= $GLOBALS['queries'] ?></p>
|
|
||||||
<p>qt<?= number_format($GLOBALS['query_time'], 3) ?></p>
|
|
||||||
<p>t<?= number_format((microtime(true) - START_TIME), 3) ?></p>
|
|
||||||
<p>v<?= env('version') ?></p>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<?php
|
|
||||||
if (env('debug', false)) {
|
|
||||||
echo c_debug_query_log();
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import Tooltip from '/assets/scripts/tooltip.js';
|
import Tooltip from '/assets/scripts/tooltip.js';
|
||||||
Tooltip.init({
|
Tooltip.init({
|
||||||
|
|
|
@ -9,79 +9,65 @@
|
||||||
<script src="/assets/scripts/WindowManager.js"></script>
|
<script src="/assets/scripts/WindowManager.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="game-container">
|
<canvas id="canvas"></canvas>
|
||||||
<div id="game-ui">
|
|
||||||
<section id="character-hud">
|
|
||||||
<span id="character-name"><?= char()->name ?></span>
|
|
||||||
<span id="character-title">L<?= char()->level ?> <?= char()->title()['name'] ?></span>
|
|
||||||
|
|
||||||
<div class="hud-meter">
|
<div id="ui">
|
||||||
<div class="hp" style="width: <?= percent(char()->hp, char()->m_hp) ?>%"></div>
|
<section id="menu">
|
||||||
<!--<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Health<br><?= char()->hp ?> / <?= char()->m_hp ?>"></div>-->
|
<a id="btn-chars"><img src="/assets/img/ui/icons/user.png"></a>
|
||||||
</div>
|
<a id="btn-stats"><img src="/assets/img/ui/icons/bargraph.png"></a>
|
||||||
|
<form class="logout-form" action="/logout" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button type="submit"><img src="/assets/img/ui/icons/stop.png"></button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="hud-meter">
|
<section id="character-hud">
|
||||||
<div class="mp" style="width: <?= percent(char()->mp, char()->m_mp) ?>%"></div>
|
<span id="character-name">(<?= char()->level ?>) <?= char()->name ?>, <?= char()->title()['name'] ?></span>
|
||||||
<!--<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Mana<br><?= char()->mp ?> / <?= char()->m_mp ?>"></div>-->
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="menu">
|
<div class="hud-meter">
|
||||||
<button id="stats-button" class="ui button primary">Stats</button>
|
<div class="hp" style="width: <?= percent(char()->hp, char()->m_hp) ?>%"></div>
|
||||||
<button id="rand-button" class="ui button primary">Rand</button>
|
<!--<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Health<br><?= char()->hp ?> / <?= char()->m_hp ?>"></div>-->
|
||||||
</section>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="game-windows"></div>
|
<div class="hud-meter">
|
||||||
|
<div class="mp" style="width: <?= percent(char()->mp, char()->m_mp) ?>%"></div>
|
||||||
|
<!--<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Mana<br><?= char()->mp ?> / <?= char()->m_mp ?>"></div>-->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<canvas id="game-canvas"></canvas>
|
<div id="windows"></div>
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const csrf = '<?= csrf() ?>'
|
const csrf = '<?= csrf() ?>'
|
||||||
let WM = new WindowManager(document.getElementById('game-windows'))
|
const WM = new WindowManager(document.getElementById('windows'))
|
||||||
|
|
||||||
const statsButton = document.getElementById('stats-button')
|
const uiBtns = {
|
||||||
const randButton = document.getElementById('rand-button')
|
'stats': document.getElementById('btn-stats'),
|
||||||
|
'chars': document.getElementById('btn-chars')
|
||||||
|
}
|
||||||
|
|
||||||
statsButton.addEventListener('click', function () {
|
uiBtns.stats.addEventListener('click', function () {
|
||||||
fetch('/ui/stats', {
|
fetch('/ui/stats', {
|
||||||
method: 'POST',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
},
|
'X-CSRF': csrf
|
||||||
body: `csrf=${csrf}`
|
|
||||||
}).then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
return response.text()
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to move character');
|
|
||||||
}
|
}
|
||||||
}).then(text => {
|
}).then(response => {
|
||||||
WM.updateWindow('stats', text, 'Stats')
|
return response.text()
|
||||||
|
}).then(html => {
|
||||||
|
WM.updateWindow('stats', html, 'Stats')
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
randButton.addEventListener('click', function () {
|
|
||||||
WM.updateWindow('rand', generateRandomString(32), 'Random')
|
|
||||||
})
|
|
||||||
|
|
||||||
function generateRandomString(length = 8) {
|
|
||||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
||||||
let result = '';
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const game = {
|
const game = {
|
||||||
canvas: document.getElementById('game-canvas'),
|
canvas: document.getElementById('canvas'),
|
||||||
tiles: {
|
tiles: {
|
||||||
size: 32,
|
size: 32,
|
||||||
img: new Image(),
|
img: new Image(),
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
<h1>Auctions</h1>
|
|
||||||
<p>@TODO</p>
|
|
|
@ -1,4 +1,4 @@
|
||||||
<div class="container-960">
|
<div class="container-480">
|
||||||
<h1 class="mb-2">Login</h1>
|
<h1 class="mb-2">Login</h1>
|
||||||
|
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<?= c_form_errors() ?>
|
<?= c_form_errors() ?>
|
||||||
|
|
||||||
<form action="/auth/login" method="post">
|
<form action="/login" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<div class="form group">
|
<div class="form group">
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="ui button primary mb-4">Login</button>
|
<button type="submit" class="ui button primary mb-4">Login</button>
|
||||||
<a href="/auth/register">New adventurer? Start here!</a>
|
<a href="/register">New adventurer? Start here!</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<div class="container-960" autocomplete="off">
|
<div class="container-480" autocomplete="off">
|
||||||
<h1 class="mb-2">Register</h1>
|
<h1 class="mb-2">Register</h1>
|
||||||
|
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
<?= c_form_errors() ?>
|
<?= c_form_errors() ?>
|
||||||
|
|
||||||
<form action="/auth/register" method="post">
|
<form action="/register" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<div class="form group">
|
<div class="form group">
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="ui button primary mr-4">Register</button>
|
<button type="submit" class="ui button primary mr-4">Register</button>
|
||||||
<a href="/auth/login">Already an adventurer? Login!</a>
|
<a href="/login">Already an adventurer? Login!</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
4
templates/pages/hello.php
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<h1>Welcome!</h1>
|
||||||
|
<a class="ui button primary" href="/login">Login</a>
|
||||||
|
<a class="ui button secondary" href="/register">Register</a>
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
<?php if (!user()): ?>
|
|
||||||
<h1>Welcome!</h1>
|
|
||||||
<a href="/auth/login" class="ui button primary">Login</a>
|
|
||||||
<a href="/auth/register" class="ui button secondary">Register</a>
|
|
||||||
<?php else: ?>
|
|
||||||
<h1>Home</h1>
|
|
||||||
<p>Welcome, <?= user()->username ?>!</p>
|
|
||||||
<?php endif; ?>
|
|
|
@ -1,2 +0,0 @@
|
||||||
<h1>Settings</h1>
|
|
||||||
<p>@TODO</p>
|
|
|
@ -1,234 +0,0 @@
|
||||||
<h1>World</h1>
|
|
||||||
<p>Use WASD keys to move the character</p>
|
|
||||||
<p>Current location: <span id="char_x"><?= location('x') ?></span>, <span id="char_y"><?= location('y') ?></span></p>
|
|
||||||
|
|
||||||
<div id="canvas-container">
|
|
||||||
<canvas id="canvas"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const game = {
|
|
||||||
canvas: document.getElementById('canvas'),
|
|
||||||
csrf: '<?= csrf() ?>',
|
|
||||||
tiles: {
|
|
||||||
size: 32,
|
|
||||||
img: new Image(),
|
|
||||||
cols: 3
|
|
||||||
},
|
|
||||||
sprites: {
|
|
||||||
size: 32,
|
|
||||||
img: new Image(),
|
|
||||||
cols: 6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = [
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 1, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
|
||||||
]
|
|
||||||
|
|
||||||
let loc_span = {
|
|
||||||
x: document.getElementById('char_x'),
|
|
||||||
y: document.getElementById('char_y')
|
|
||||||
}
|
|
||||||
|
|
||||||
let player = {
|
|
||||||
location: { x: <?= location('x') ?>, y: <?= location('y') ?> },
|
|
||||||
current: { x: <?= location('x') ?>, y: <?= location('y') ?> },
|
|
||||||
target: { x: <?= location('x') ?>, y: <?= location('y') ?> },
|
|
||||||
char: 23, sprite: { x: 0, y: 0 },
|
|
||||||
tweenDuration: 0.2, // seconds
|
|
||||||
tweenProgress: 0
|
|
||||||
}
|
|
||||||
let camera = { x: 0, y: 0 }
|
|
||||||
let visible = { x: 0, y: 0 }
|
|
||||||
|
|
||||||
game.tiles.img.src = '/assets/img/world/tiles.jpg';
|
|
||||||
game.sprites.img.src = '/assets/img/world/rogues.png';
|
|
||||||
|
|
||||||
let lastFrameTime = 0;
|
|
||||||
let fps = 0;
|
|
||||||
|
|
||||||
let debounce = false;
|
|
||||||
|
|
||||||
function getPlayerSprite() {
|
|
||||||
let col = player.char % game.sprites.cols
|
|
||||||
let row = Math.floor(player.char / game.sprites.cols)
|
|
||||||
player.sprite = { x: col * game.sprites.size, y: row * game.sprites.size }
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCanvasSize() {
|
|
||||||
game.canvas.width = game.canvas.clientWidth
|
|
||||||
game.canvas.height = game.canvas.clientHeight
|
|
||||||
visible.x = Math.ceil(game.canvas.width / game.tiles.size)
|
|
||||||
visible.y = Math.ceil(game.canvas.height / game.tiles.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupEventListeners() {
|
|
||||||
window.addEventListener('resize', updateCanvasSize)
|
|
||||||
window.addEventListener('keydown', handleKeyPress)
|
|
||||||
window.addEventListener('keyup', () => debounce = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle keyboard input
|
|
||||||
function handleKeyPress(e) {
|
|
||||||
if (debounce) return;
|
|
||||||
debounce = true;
|
|
||||||
let moved = false;
|
|
||||||
const newPos = { ...player }
|
|
||||||
|
|
||||||
// 0 = up, 1 = down, 2 = left, 3 = right
|
|
||||||
direction = {
|
|
||||||
'w': 0,
|
|
||||||
's': 1,
|
|
||||||
'a': 2,
|
|
||||||
'd': 3,
|
|
||||||
'ArrowUp': 0,
|
|
||||||
'ArrowDown': 1,
|
|
||||||
'ArrowLeft': 2,
|
|
||||||
'ArrowRight': 3
|
|
||||||
}[e.key];
|
|
||||||
|
|
||||||
// Direction vectors: [up, down, left, right]
|
|
||||||
const dx = [0, 0, -1, 1];
|
|
||||||
const dy = [-1, 1, 0, 0];
|
|
||||||
|
|
||||||
// Calculate new position
|
|
||||||
const newX = player.location.x + dx[direction];
|
|
||||||
const newY = player.location.y + dy[direction];
|
|
||||||
|
|
||||||
if (direction !== undefined) {
|
|
||||||
// Check if the new position is outside the map bounds
|
|
||||||
if (newX < 0 || newX >= map[0].length || newY < 0 || newY >= map.length) return;
|
|
||||||
|
|
||||||
if (map[newY][newX] !== 0) return;
|
|
||||||
|
|
||||||
// Execute a POST request to /move. If successful, the server will return a new x,y position
|
|
||||||
fetch('/move', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
body: `direction=${direction}&csrf=${game.csrf}`
|
|
||||||
}).then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
response.json().then(data => {
|
|
||||||
player.location = { x: data.x, y: data.y }
|
|
||||||
player.target = { x: data.x, y: data.y }
|
|
||||||
player.tweenProgress = 0
|
|
||||||
loc_span.x.textContent = player.location.x
|
|
||||||
loc_span.y.textContent = player.location.y
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to move character');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update camera position
|
|
||||||
function updateCamera() {
|
|
||||||
camera.x = player.current.x * game.tiles.size - canvas.width / 2;
|
|
||||||
camera.y = player.current.y * game.tiles.size - canvas.height / 2;
|
|
||||||
|
|
||||||
// Clamp camera to map bounds
|
|
||||||
camera.x = Math.max(0, Math.min(camera.x,
|
|
||||||
map[0].length * game.tiles.size - canvas.width));
|
|
||||||
camera.y = Math.max(0, Math.min(camera.y,
|
|
||||||
map.length * game.tiles.size - canvas.height));
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(start, end, t) {
|
|
||||||
return start + (end - start) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the game
|
|
||||||
function render(t) {
|
|
||||||
const ctx = game.canvas.getContext('2d')
|
|
||||||
|
|
||||||
// Calculate FPS
|
|
||||||
if (lastFrameTime) {
|
|
||||||
const delta = (t - lastFrameTime) / 1000;
|
|
||||||
fps = Math.round(1 / delta);
|
|
||||||
}
|
|
||||||
lastFrameTime = t;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, game.canvas.width, game.canvas.height)
|
|
||||||
|
|
||||||
// Calculate visible tile range
|
|
||||||
const startTileX = Math.floor(camera.x / game.tiles.size)
|
|
||||||
const startTileY = Math.floor(camera.y / game.tiles.size)
|
|
||||||
const endTileX = startTileX + visible.x + 1
|
|
||||||
const endTileY = startTileY + visible.y + 1
|
|
||||||
|
|
||||||
// Only render visible tiles
|
|
||||||
for (let y = startTileY; y < endTileY; y++) {
|
|
||||||
if (y >= map.length) continue
|
|
||||||
|
|
||||||
for (let x = startTileX; x < endTileX; x++) {
|
|
||||||
if (x >= map[0].length) continue
|
|
||||||
|
|
||||||
const screenX = Math.round(x * game.tiles.size - camera.x)
|
|
||||||
const screenY = Math.round(y * game.tiles.size - camera.y)
|
|
||||||
|
|
||||||
ctx.drawImage(game.tiles.img, map[y][x] * game.tiles.size, 0, game.tiles.size, game.tiles.size,
|
|
||||||
screenX, screenY, game.tiles.size, game.tiles.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tween player position
|
|
||||||
if (player.tweenProgress < 1) {
|
|
||||||
player.tweenProgress += 1 / player.tweenDuration / 60
|
|
||||||
player.current.x = lerp(player.current.x, player.target.x, player.tweenProgress)
|
|
||||||
player.current.y = lerp(player.current.y, player.target.y, player.tweenProgress)
|
|
||||||
} else {
|
|
||||||
player.current = { x: player.current.x, y: player.current.y }
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCamera()
|
|
||||||
|
|
||||||
// Render the player on top of the map using their current position
|
|
||||||
const playerX = Math.round((player.current.x * game.tiles.size) - camera.x)
|
|
||||||
const playerY = Math.round((player.current.y * game.tiles.size) - camera.y)
|
|
||||||
ctx.drawImage(game.sprites.img, player.sprite.x, player.sprite.y, game.sprites.size, game.sprites.size,
|
|
||||||
playerX, playerY, game.sprites.size, game.sprites.size)
|
|
||||||
|
|
||||||
// Render FPS counter
|
|
||||||
ctx.fillStyle = 'white';
|
|
||||||
ctx.font = '16px Arial';
|
|
||||||
ctx.fillText(`FPS: ${fps}`, game.canvas.width - 70, 20);
|
|
||||||
|
|
||||||
requestAnimationFrame(render);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
getPlayerSprite()
|
|
||||||
updateCanvasSize()
|
|
||||||
setupEventListeners()
|
|
||||||
requestAnimationFrame(render)
|
|
||||||
})
|
|
||||||
</script>
|
|