Code viewer for World: Wordle 3D v1
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);
  }
});