import * as THREE from '/api/threemodule/libs/three.module.js';
import { randomWord, isValidWord } from '/uploads/jed/wordList.js';
import { EffectComposer } from '/uploads/jed/EffectComposer.js';
import { RenderPass } from '/uploads/jed/RenderPass.js';
import { OutlinePass } from '/uploads/jed/OutlinePass.js';
import { FontLoader } from '/uploads/jed/FontLoader.js';
import { TextGeometry } from '/uploads/jed/TextGeometry.js';
import { ScreenShake } from '/uploads/jed/ScreenShake.js';
import { OrbitControls } from '/uploads/threejs/OrbitControls.js';
// ===== Global variables ===== //
// Change these variables to configure the game
const MAX_LOGS = 10;
const CAMERA_POS = [0, 0, 25];
const CAMERA_TARGET = [0, 0, 0];
const TILE_ROWS = 6; // Change the number of guesses allowed
const TILE_SIZE = 3; // Change the width and height of each tile
const TILE_SPACING = 0.5; // Change the spacing between tiles
// Tile state colors
const PENDING = 0x181b1d;
const INCORRECT = 0x43484c;
const PRESENT = 0xbd9a26;
const CORRECT = 0x2c9c2f;
// Background color
const BG_COLOR = 0x0c0c0d;
// ===== Global constants ===== //
// DO NOT EDIT BELOW THIS LINE
const TILE_COLS = 5;
const GRID_WIDTH = TILE_COLS * (TILE_SIZE + TILE_SPACING) - TILE_SPACING;
const GRID_HEIGHT = TILE_ROWS * (TILE_SIZE + TILE_SPACING) - TILE_SPACING;
// ===== World configuration ===== //
AB.removeRunHeader();
AB.newDiv('container');
const container = document.getElementById('container');
const renderer = new THREE.WebGLRenderer({
antialias: true
});
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // default THREE.PCFShadowMap
container.appendChild(renderer.domElement);
renderer.localClippingEnabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(BG_COLOR);
// scene.add(new THREE.AxesHelper(5));
const camera = new THREE.PerspectiveCamera(
75, window.innerWidth / window.innerHeight, 0.1, 1000
);
camera.position.set(...CAMERA_POS);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(...CAMERA_TARGET);
controls.maxAzimuthAngle = Math.PI * 0.2;
controls.minAzimuthAngle = -Math.PI * 0.2;
controls.maxPolarAngle = Math.PI * 0.7;
controls.minPolarAngle = Math.PI * 0.2;
controls.enableZoom = false;
controls.enablePan = false;
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const outlinePass = new OutlinePass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
scene,
camera
);
outlinePass.edgeStrength = 2;
outlinePass.visibleEdgeColor = new THREE.Color(0xffffff);
composer.addPass(outlinePass);
const strokePass = new OutlinePass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
scene,
camera
);
strokePass.edgeThickness = 1;
strokePass.visibleEdgeColor = new THREE.Color(0x2d2d2d);
composer.addPass(strokePass);
const screenShake = ScreenShake();
function shakeCamera() {
if (!screenShake.enabled) {
screenShake.shake(camera, new THREE.Vector3(1, 0, 0), 300);
}
}
// Simple message header for displaying messages
const message = document.createElement('div');
message.style.display = 'block';
message.style.position = 'absolute';
message.style.top = 50 + 'px';
message.style.left = 50 + '%';
message.style.transform = 'translateX(-50%)';
message.style.color = '#fff';
message.style.fontFamily = 'monospace';
message.style.fontWeight = 'bold';
message.style.fontSize = 21 + 'px';
message.style.textAlign = 'center';
container.appendChild(message);
// Display a message to the player
let messageTimer;
function displayMessage(text, duration) {
message.innerHTML = text;
clearTimeout(messageTimer);
messageTimer = setTimeout(() => {
message.innerHTML = '';
}, duration);
}
// Action logs
let logs = [];
const log = document.createElement('div');
log.style.display = 'block';
log.style.position = 'absolute';
log.style.top = 70 + '%';
log.style.left = 20 + 'px';
log.style.transform = 'translateY(-100%)';
log.style.color = '#888';
log.style.fontFamily = 'monospace';
log.style.fontSize = 14 + 'px';
log.style.textAlign = 'left';
log.style.lineHeight = 1.5;
log.style.whiteSpace = 'pre';
container.appendChild(log);
// Log an action
function logMessage(user, text) {
const message = '[' + user + '] ' + text;
logs.push(message);
if (logs.length > MAX_LOGS) logs.shift();
log.innerHTML = logs.join('\n');
}
// Display for currently joined players
let connectedPlayers = [];
const players = document.createElement('div');
players.style.display = 'block';
players.style.position = 'absolute';
players.style.top = 50 + 'px';
players.style.left = 20 + 'px';
players.style.color = '#fff';
players.style.fontFamily = 'monospace';
players.style.fontSize = 16 + 'px';
players.style.textAlign = 'left';
players.style.lineHeight = 1.5;
players.style.whiteSpace = 'pre';
container.appendChild(players);
// Update the list of players
function updatePlayers() {
players.innerHTML = 'Connected Users:\n';
players.innerHTML += connectedPlayers.map(function (player, i) {
const name = player.id !== 'none' ? player.id : 'Guest';
return `#${i + 1} ${name} ${!i ? '(Host)' : ''}`;
}).join('\n');
}
// ===== Socket connection ===== //
function sendEvent(id, payload) {
if (!AB.runloggedin) return;
console.log('Sending', id);
AB.socketOut({
id: id,
payload: payload
});
}
let playerId;
if (AB.runloggedin) {
AB.socketStart();
playerId = AB.myuserid;
logMessage('Server', 'Logged in as ' + playerId);
}
else {
playerId = 'Guest';
logMessage('Server', 'Playing as Guest');
logMessage('Server', 'To access multiplayer, please log in');
}
// ===== Initialise scene ===== //
// Load font and create text geometry for each alphabet letter
const letterGeometries = {};
const loader = new FontLoader();
loader.load('https://threejs.org/examples/fonts/helvetiker_bold.typeface.json', function (font) {
for (let i = 0; i < 26; i++) {
const letter = String.fromCharCode(65 + i);
const geometry = new TextGeometry(letter, {
font: font,
size: TILE_SIZE * 2 / 5,
height: 0.5,
curveSegments: 2,
bevelEnabled: false
});
geometry.center();
letterGeometries[letter] = geometry;
}
});
// Tile grid background (to receive shadows)
const bgGeometry = new THREE.PlaneGeometry(GRID_WIDTH + TILE_SIZE, GRID_HEIGHT + TILE_SIZE);
const bgMaterial = new THREE.ShadowMaterial();
bgMaterial.opacity = 0.2;
const background = new THREE.Mesh(bgGeometry, bgMaterial);
background.position.set(0, 0, -1);
background.receiveShadow = true;
scene.add(background);
const ambient = new THREE.HemisphereLight(0xffffed, 0xb8b8a7, 0.3);
scene.add(ambient);
// Add light to scene
function addLight(x, y, z, intensity) {
const light = new THREE.DirectionalLight(0xffffed, intensity);
light.position.set(x, y, z);
light.castShadow = true;
light.shadow.darkness = 1;
light.shadow.mapSize.width = 1024;
light.shadow.mapSize.height = 1024;
light.shadow.camera.near = 0.5 ;
light.shadow.camera.far = 500 ;
light.shadow.camera.left = -100 ;
light.shadow.camera.right = 100 ;
light.shadow.camera.top = 100 ;
light.shadow.camera.bottom = -100 ;
scene.add(light);
}
// Tile mesh
function createTile(x, y, z, state) {
const geometry = new THREE.BoxGeometry(TILE_SIZE, TILE_SIZE, 0.5);
const material = new THREE.MeshStandardMaterial({
color: state,
roughness: 0.9,
metalness: 0.1
});
const tile = new THREE.Mesh(geometry, material);
tile.position.set(x, y, z);
tile.castShadow = true;
tile.receiveShadow = true;
strokePass.selectedObjects.push(tile);
tile.userData = {
state: state,
letter: null
};
return tile;
}
// Tile grid
function addTileGrid(x, y, z) {
/**
* @type {THREE.Mesh<THREE.BoxGeometry, THREE.MeshStandardMaterial>[][]}
*/
var grid = [];
for (let row = 0; row < TILE_ROWS; row++) {
grid.push([]);
for (let col = 0; col < TILE_COLS; col++) {
const tile = createTile(
x + col * (TILE_SIZE + TILE_SPACING) + TILE_SIZE / 2,
y - row * (TILE_SIZE + TILE_SPACING) - TILE_SIZE / 2,
z,
PENDING
);
// tile.rotation.x = ((TILE_COLS + TILE_ROWS) - (row + col)) / 5;
grid[row].push(tile);
scene.add(tile);
}
}
return grid;
}
const tileGrid = addTileGrid(-(GRID_WIDTH) / 2, GRID_HEIGHT / 2, 0);
addLight(5, 80, 100, 0.35);
addLight(-8, -30, 50, 0.5);
// ===== Tile grid functions ===== //
/**
* @param {number} row
* @param {number} col
* @param {string} letter
*/
function addTileLetter(row, col, letter) {
const tile = tileGrid[row][col];
const textGeometry = letterGeometries[letter.toUpperCase()];
const textMaterial = new THREE.MeshToonMaterial({
color: 0xffffff
});
const textMesh = new THREE.Mesh(textGeometry, textMaterial);
textMesh.position.set(0, 0, 0.25);
textMesh.castShadow = true;
textMesh.receiveShadow = true;
tile.add(textMesh);
tile.userData.letter = letter;
}
/**
* @param {number} row
* @param {number} col
*/
function removeTileLetter(row, col) {
const tile = tileGrid[row][col];
const textMesh = tile.children[0];
tile.remove(textMesh);
tile.userData.letter = null;
}
/**
* @param {number} row
* @param {number} col
* @param {number} state
*/
function setTileState(row, col, state) {
const tile = tileGrid[row][col];
tile.material.color.setHex(state);
tile.userData.state = state;
}
// ===== Game logic functions ===== //
let row, col, targetWord, guessWord, submittedWords;
row = col = 0;
function updateCursor() {
outlinePass.selectedObjects = [];
// If cursor is not on the grid, do nothing
if (row < 0 || TILE_ROWS <= row) return;
if (col < 0 || TILE_COLS <= col) return;
outlinePass.selectedObjects = [tileGrid[row][col]];
strokePass.selectedObjects = [...tileGrid[row]];
guessWord = Array(TILE_COLS).fill(null);
}
function addGuessLetter(letter) {
// If col is out of bounds, return
if (col >= TILE_COLS) return;
guessWord[col] = letter;
addTileLetter(row, col, letter);
col++;
if (col < TILE_COLS) outlinePass.selectedObjects = [tileGrid[row][col]];
else outlinePass.selectedObjects = [];
}
function removeGuessLetter() {
// If col is out of bounds, return
if (col <= 0) return;
col--;
guessWord[col] = null;
removeTileLetter(row, col);
outlinePass.selectedObjects = [tileGrid[row][col]];
}
function updateTileRow() {
let targetLetters = targetWord.split('');
let correctIndices = [];
// First, set all correct letters to correct color
for (let i = 0; i < TILE_COLS; i++) {
if (guessWord[i] === targetLetters[i]) {
setTileState(row, i, CORRECT);
targetLetters[i] = null;
correctIndices.push(i);
}
}
// Set other tiles to appropriate colors
for (let i = 0; i < TILE_COLS; i++) {
// If tile is already correct, skip
if (correctIndices.includes(i)) continue;
// If tile is present in target word, set to present color
if (targetLetters.includes(guessWord[i])) {
setTileState(row, i, PRESENT);
// Remove letter so it can't be used again
targetLetters[targetLetters.indexOf(guessWord[i])] = null;
}
// Otherwise, set to incorrect color
else {
setTileState(row, i, INCORRECT);
}
}
}
let spam = null;
function submitGuess() {
// If col is out of bounds, return
if (col < TILE_COLS) {
shakeCamera();
return displayMessage('Not enough letters', 2000);
}
// Send event to connected clients
const submitWord = guessWord.join('');
if (submitWord != spam) { // prevent spam
sendEvent('SUBMIT_WORD', {
player: playerId,
word: submitWord
});
}
// If word is invalid, return
if (!isValidWord(submitWord)) {
spam = submitWord; // cannot resubmit invalid word
shakeCamera();
return displayMessage('Invalid word', 2000);
}
spam = null;
// Add word to submitted list
submittedWords.push(submitWord);
// If word is correct, set all tiles in row to correct color
if (submitWord === targetWord) {
for (let i = 0; i < TILE_COLS; i++) {
setTileState(row, i, CORRECT);
}
displayMessage('Correct! The word was ' + targetWord.toUpperCase(), 5000);
logMessage(playerId, 'Submitted ' + targetWord.toUpperCase() + ' ✓');
return setTimeout(() => newGame({ reset: true }), 5000);
}
// Otherwise, set tiles to appropriate colors
logMessage(playerId, 'Submitted ' + submitWord.toUpperCase() + ' ✗');
updateTileRow();
// If row is out of bounds, reset game
if (row >= TILE_ROWS - 1) {
logMessage('Server', 'Game over: The word was ' + targetWord.toUpperCase());
displayMessage('The word was ' + targetWord.toUpperCase(), 5000);
return setTimeout(() => newGame({ reset: true }), 5000);
}
// Otherwise, move to next row
row++;
col = 0;
updateCursor();
}
// Broadcasts game state if host
function broadcastState() {
if (connectedPlayers.length && connectedPlayers[0].id == playerId) {
sendEvent('GAME_STATE', {
state: submittedWords,
target: targetWord
});
}
}
function newGame(options) {
logMessage('Server', 'New game started');
console.log('New game started');
if (options && options.reset) {
for (let row = 0; row < TILE_ROWS; row++) {
for (let col = 0; col < TILE_COLS; col++) {
setTileState(row, col, PENDING);
removeTileLetter(row, col);
}
}
}
// Reset grid
row = 0;
col = 0;
updateCursor();
submittedWords = [];
// Reset target word
targetWord = randomWord();
// Broadcast if host and is not initial game
if (options && options.reset) {
broadcastState();
}
}
newGame();
// ===== Socket handlers ===== //
AB.socketIn = function ({ id, payload }) {
console.log('Received', id);
switch (id) {
case 'GAME_STATE':
// Payload contains grid state and target word.
// If target words match, clients are synced.
const synced = payload.target === targetWord;
// If not synced, update target word and grid.
if (!synced) {
targetWord = payload.target;
submittedWords = payload.state;
row = col = 0;
for (let word of submittedWords) {
guessWord = word;
for (let letter of guessWord) {
removeTileLetter(row, col);
addTileLetter(row, col, letter);
col++;
}
updateTileRow(row);
row++;
col = 0;
}
updateCursor();
}
break;
case 'SUBMIT_WORD':
// Payload contains the submitted word and the submitter.
if (!isValidWord(payload.word)) {
return logMessage(payload.player, 'Submitted ' + payload.word.toUpperCase() + ' ?');
}
submittedWords.push(payload.word);
displayMessage(`${payload.player} submitted ${payload.word.toUpperCase()}`, 5000);
// Save current guess to migrate onto next row
const prevGuess = [...guessWord];
// Fill in submitted word
col = 0;
for (let letter of payload.word) {
removeTileLetter(row, col);
addTileLetter(row, col, letter);
col++;
}
guessWord = payload.word.split('');
updateTileRow(row);
// Revert to previous guess progress
row++;
col = 0;
updateCursor();
prevGuess.forEach(function (letter) {
if (letter !== null) addGuessLetter(letter);
});
// If the word is the target word, reset game.
if (payload.word === targetWord) {
logMessage(payload.player, 'Submitted ' + targetWord.toUpperCase() + ' ✓');
displayMessage(`
${payload.player} submitted ${targetWord.toUpperCase()}:<br/>
Correct! The word was ${targetWord.toUpperCase()}
`, 5000);
return setTimeout(() => newGame({ reset: true }), 5000);
}
logMessage(payload.player, 'Submitted ' + payload.word.toUpperCase() + ' ✗');
// If row is out of bounds, reset game.
if (row >= TILE_ROWS) {
logMessage('Server', 'Game over: The word was ' + targetWord.toUpperCase());
displayMessage(`
${payload.player} submitted ${payload.word.toUpperCase()}:<br/>
The word was ${targetWord.toUpperCase()}
`, 5000);
return setTimeout(() => newGame({ reset: true }), 5000);
}
break;
}
};
AB.socketUserlist = function (users) {
console.log('User list updated');
// Check if any users have disconnected
const disconnected = connectedPlayers.filter(function (player) {
return !users.some(function (user) {
return user[0] === player.id;
});
});
if (disconnected.length) {
disconnected.forEach(function (player) {
logMessage(player.id, 'Disconnected');
});
}
// Check if any users have connected
const connected = users.filter(function (user) {
return !connectedPlayers.some(function (player) {
return user[0] === player.id;
});
});
if (connected.length) {
connected.forEach(function (user) {
logMessage(user[0], 'Connected');
displayMessage(user[0] + ' joined', 3000);
});
}
// Update player list
connectedPlayers = users.map(function (player) {
return {
id: player[0],
name: player[1]
};
});
updatePlayers();
broadcastState();
};
// ===== Game loop ===== //
const animate = function () {
requestAnimationFrame(animate);
screenShake.update(camera);
composer.render();
};
animate();
// ===== Event listeners ===== //
window.addEventListener('resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.render();
}, false);
window.addEventListener('keydown', function (event) {
const key = event.key;
// If the key is a letter, add it to the guess word
if (key.match(/^[a-zA-Z]+$/i) && key.length === 1) {
addGuessLetter(key.toLowerCase());
}
// If the key is backspace, remove the last letter from the guess word
if (key === 'Backspace') {
removeGuessLetter();
}
// If the key is enter, check the guess word
if (key === 'Enter') {
submitGuess();
}
// If the key is delete, reveal the target word (Cheat code)
if (key === 'Delete') {
console.log('Target word:', targetWord);
}
});