/**
* Input handlers for the Wowngeon game
*/
// Is a blocking dialog on screen?
//
// The document-level Enter shortcut submits a ranked (paid-intent) entry. The entry-choice and
// payment modals advertise "ENTER" on their own cards, so anything that takes over the screen must
// swallow the shortcut; otherwise Enter submits an entry the player has not chosen and the
// following click submits a second one.
function modalIsOpen() {
if (document.getElementById('entryChoiceOverlay')) return true;
var overlays = document.querySelectorAll('.modal-overlay');
for (var i = 0; i < overlays.length; i++) {
if (!overlays[i].classList.contains('hidden')) return true;
}
var paymentUi = document.getElementById('payment-ui');
if (paymentUi && paymentUi.offsetParent !== null) return true;
return false;
}
// Enter inside any text field belongs to that field, not to the game.
function typingInAField() {
var el = document.activeElement;
if (!el) return false;
if (el.isContentEditable) return true;
var tag = (el.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select';
}
const InputHandler = {
_lastMoveTime: 0,
_moveCooldown: 100, // Minimum 100ms between moves
_pendingMove: null,
_initialized: false, // Guards against repeated init() calls binding duplicate handlers
init: function() {
if (this._initialized) {
return;
}
this.setupChatForm();
this.setupFocusHandlers();
this.setupKeyboardControls();
this.setupClickHandlers();
this.setupModeToggle();
this._initialized = true;
},
setupChatForm: function() {
const MAX_CHAT_LENGTH = 200;
$('#chatInput').on('input', function() {
const len = $(this).val().length;
const counter = $('#charCounter');
counter.text(len + '/' + MAX_CHAT_LENGTH);
// Red at the limit, amber within the last 20%.
if (len >= MAX_CHAT_LENGTH) {
counter.css('color', '#f66');
} else if (len >= MAX_CHAT_LENGTH * 0.8) {
counter.css('color', '#fa0');
} else {
counter.css('color', '#666');
}
});
$('#chatForm').submit(function(e) {
e.preventDefault();
var msg = $('#chatInput').val().trim();
if (!msg) return false;
// "enter" always goes to the server, which owns queueing logic and timing.
if (msg.toLowerCase() === 'enter') {
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
if (!SocketHandlers.emitFairGameStart('join_queue')) return false;
} else {
socket.emit('chat message', msg);
}
// The server controls screen state via payment_created, waiting_status and similar
// events, so this path does not switch to the waiting screen itself.
$('#messages').append($('
').text("🔑 Processing game entry request..."));
UI.scrollChat();
} else {
socket.emit('chat message', msg);
}
$('#chatInput').val('');
$('#charCounter').text('0/' + MAX_CHAT_LENGTH).css('color', '#666');
return false;
});
$('#chatInput').focus();
UI.updateFocusIndicator();
},
setupFocusHandlers: function() {
// Focus/blur drive the visual focus indicator.
$('#chatInput').on('focus', UI.updateFocusIndicator).on('blur', UI.updateFocusIndicator);
$('#game-display').on('focus', UI.updateFocusIndicator).on('blur', UI.updateFocusIndicator);
// tabindex -1 makes the game display programmatically focusable without entering tab order.
$('#game-display').attr('tabindex', '-1');
},
setupKeyboardControls: function() {
const self = this; // The keydown handler runs with its own `this`, so capture InputHandler.
$(document).on('keydown', function(e) {
if (e.key === 'Escape' && typeof SocketHandlers !== 'undefined' && SocketHandlers._spectatorMode) {
e.preventDefault();
if (window.socket) {
socket.emit('leave_spectate');
}
return;
}
if (document.activeElement === $('#game-display')[0]) {
if (Game && Game._gameActive) {
// Spectators drive no character, so movement keys are ignored.
if (typeof Game !== 'undefined' && Game._isSpectating) {
return;
}
let dx = 0;
let dy = 0;
let moved = false;
let direction = null;
switch(e.key) {
case 'w': case 'ArrowUp': direction = 'up'; dy = -1; moved = true; break;
case 's': case 'ArrowDown': direction = 'down'; dy = 1; moved = true; break;
case 'a': case 'ArrowLeft': direction = 'left'; dx = -1; moved = true; break;
case 'd': case 'ArrowRight': direction = 'right'; dx = 1; moved = true; break;
}
if (moved && direction) {
e.preventDefault(); // Arrow keys would otherwise scroll the page.
// Moves are throttled to one per _moveCooldown; a move arriving inside the
// window replaces any pending one so only the latest direction is sent.
const now = Date.now();
if (now - self._lastMoveTime >= self._moveCooldown) {
socket.emit('player_move', { direction });
self._lastMoveTime = now;
self._pendingMove = null;
} else {
self._pendingMove = { direction };
const timeToWait = self._moveCooldown - (now - self._lastMoveTime);
setTimeout(() => {
if (self._pendingMove) {
socket.emit('player_move', self._pendingMove);
self._lastMoveTime = Date.now();
self._pendingMove = null;
}
}, timeToWait);
}
}
} else {
// Game display has focus with no active game (welcome/waiting screen): the only
// key handled is the animation toggle.
if (e.key === 'A' || e.key === 'a') {
e.preventDefault();
if (typeof ScreenManager !== 'undefined') {
ScreenManager.toggleAnimation();
}
}
}
} else if (e.key === 'Enter' && document.activeElement !== $('#chatInput')[0]
&& !modalIsOpen() && !typingInAField()) {
if (typeof Game !== 'undefined' && !Game._gameActive) {
// Enter on the welcome screen requests a game start.
e.preventDefault();
const isDebugMode = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.protocol === 'file:';
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
if (!SocketHandlers.emitFairGameStart('join_queue')) return;
} else {
socket.emit('chat message', 'enter');
}
if (isDebugMode || ScreenManager.canEnterGame()) {
const addressRequired = typeof SocketHandlers !== 'undefined' && SocketHandlers.payoutAddressRequired();
const hasAddress = typeof SocketHandlers !== 'undefined' && SocketHandlers._hasPayoutAddress;
const canAfford = typeof SocketHandlers !== 'undefined' && SocketHandlers.canAffordGame();
const isFree = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'FREE';
// Free play always waits; otherwise the player needs an affordable entry and,
// where required, a payout address on file.
const shouldShowWaiting = isFree || (canAfford && (!addressRequired || hasAddress));
if (shouldShowWaiting && typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) {
// Paid entries show the awaiting-payment state up front, except when
// credits mode already has enough credits to cover the game.
const isPaidCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'PAID_CREDITS';
const hasEnoughCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._creditsBalance >= (SocketHandlers._creditsPerGame || 1);
if (!isFree && typeof Game !== 'undefined' && !Game._unconfirmedPayment) {
if (!(isPaidCredits && hasEnoughCredits)) {
Game._awaitingPayment = true;
}
}
ScreenManager.drawWaitingScreen();
}
} else {
$('#messages').append($('').text("* You have been added to the queue! Game will start after next block."));
UI.scrollChat();
}
$('#messages').append($('').text("🔑 Game start requested..."));
UI.scrollChat();
} else {
// With a game active, Enter moves focus to chat.
$('#chatInput').focus();
UI.updateFocusIndicator();
}
}
});
},
setupClickHandlers: function() {
$('#startButton').click(function(e) {
// The wait-vs-drop-in timing choice appears whenever the game would start instantly with
// no upfront payment: free play enabled, a FREE instance, or paid-credits with credits in
// hand. The modal only picks when you enter; the payment method still flows through
// auto_start / join_queue unchanged.
if (typeof SocketHandlers !== 'undefined') {
var mode = SocketHandlers._gameMode;
var freeAvailable = SocketHandlers._freePlayEnabled || mode === 'FREE';
var hasCredits = SocketHandlers._creditsBalance >= (SocketHandlers._creditsPerGame || 1);
var instantStart = freeAvailable || (mode === 'PAID_CREDITS' && hasCredits);
if (instantStart) {
SocketHandlers.showEntryChoiceModal({ freeAvailable: freeAvailable, hasCredits: hasCredits });
return;
}
}
// Otherwise attempt an immediate start; payment-required modes go through their own flow.
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
if (!SocketHandlers.emitFairGameStart('auto_start')) return;
} else {
socket.emit('auto_start');
}
const addressRequired = typeof SocketHandlers !== 'undefined' && SocketHandlers.payoutAddressRequired();
const hasAddress = typeof SocketHandlers !== 'undefined' && SocketHandlers._hasPayoutAddress;
const canAfford = typeof SocketHandlers !== 'undefined' && SocketHandlers.canAffordGame();
const isFree = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'FREE';
// Free play always waits; otherwise the player needs an affordable entry and, where
// required, a payout address on file.
const shouldShowWaiting = isFree || (canAfford && (!addressRequired || hasAddress));
if (shouldShowWaiting) {
// Paid modes with no payment detected yet show the awaiting-payment state, except
// when credits mode already has enough credits to cover the game.
const isPaidCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'PAID_CREDITS';
const hasEnoughCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._creditsBalance >= (SocketHandlers._creditsPerGame || 1);
if (!isFree && typeof Game !== 'undefined' && !Game._unconfirmedPayment) {
if (!(isPaidCredits && hasEnoughCredits)) {
Game._awaitingPayment = true;
}
}
if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) {
ScreenManager.drawWaitingScreen();
}
} else {
// A missing payout address is the actionable case, so name it in chat.
if (addressRequired && !hasAddress) {
$('#messages').append($('').text("⚠️ Please set a payout address using the button below before starting."));
UI.scrollChat();
}
}
$('#messages').append($('').text("🖱️ Game start requested..."));
UI.scrollChat();
});
// Watch games button - toggle the games list panel
$('#watchGamesButton').click(function(e) {
const $panel = $('#gamesListPanel');
if ($panel.length && $panel.is(':visible')) {
$panel.hide();
} else {
if (window.socket) {
socket.emit('get_active_games', { page: 1, pageSize: 20 });
}
if (typeof SocketHandlers !== 'undefined') {
SocketHandlers._showGamesPanel();
}
}
});
$('#animationToggleButton').click(function(e) {
if (typeof ScreenManager !== 'undefined' && ScreenManager.toggleAnimation) {
ScreenManager.toggleAnimation();
// The waiting screen is drawn once, so it needs a redraw to pick up the new setting.
if (typeof ScreenManager.drawWaitingScreen === 'function') {
ScreenManager.drawWaitingScreen();
}
}
const status = ScreenManager._animationEnabled ? "enabled" : "disabled";
$('#messages').append($('').text(`🎬 Animation ${status}`));
UI.scrollChat();
});
$('#manageAddressButton').click(function(e) {
e.preventDefault();
if (window.socket) {
socket.emit('address:prompt');
}
if (typeof AddressModal !== 'undefined') {
AddressModal.show();
}
});
},
setupModeToggle: function() {
var toggleButton = document.getElementById('toggle-mode');
if (toggleButton) {
toggleButton.addEventListener('click', function() {
try {
toggleButton.textContent = 'Mode switching disabled';
toggleButton.disabled = true;
} catch (e) {
console.error("Error switching display mode:", e);
}
});
}
}
};
if (typeof window !== 'undefined') {
window.InputHandler = InputHandler;
}
// index.html calls InputHandler.init() after DOM ready, once the modules it reaches into
// (UI, Game, ScreenManager, SocketHandlers) are defined.