feat: Add player preferences modal for character configuration and enhance sprite selection functionality

This commit is contained in:
Z. Cliffe Schreuders
2026-02-17 01:25:44 +00:00
parent 3d1570a030
commit 8dfc5f04f4
9 changed files with 610 additions and 26 deletions

View File

@@ -2,6 +2,8 @@ require 'open3'
module BreakEscape
class GamesController < ApplicationController
helper PlayerPreferencesHelper
before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :update_room, :unlock, :inventory, :objectives, :complete_task, :update_task_progress, :submit_flag]
# GET /games/new?mission_id=:id
@@ -99,6 +101,15 @@ module BreakEscape
def show
authorize @game if defined?(Pundit)
@mission = @game.mission
# Load player preference data for the in-game modal
@player_preference = current_player_preference || create_default_preference
@available_sprites = PlayerPreference::AVAILABLE_SPRITES
# Debug logging
Rails.logger.info "[BreakEscape] Loading game#show for player: #{current_player.class.name}##{current_player.id}"
Rails.logger.info "[BreakEscape] Player preference: #{@player_preference.inspect}"
Rails.logger.info "[BreakEscape] Selected sprite: #{@player_preference.selected_sprite.inspect}"
end
# GET /games/:id/scenario
@@ -1335,7 +1346,8 @@ module BreakEscape
if current_player.respond_to?(:break_escape_preference)
current_player.break_escape_preference
elsif current_player.respond_to?(:preference)
current_player.preference
# Reload association to ensure fresh data
current_player.reload.preference
end
end

View File

@@ -11,20 +11,52 @@ module BreakEscape
# PATCH /break_escape/configuration
def update
Rails.logger.info "[BreakEscape] Updating preference for player: #{current_player.class.name}##{current_player.id}"
Rails.logger.info "[BreakEscape] Params: #{player_preference_params.inspect}"
if @player_preference.update(player_preference_params)
flash[:notice] = 'Character configuration saved!'
Rails.logger.info "[BreakEscape] Preference updated successfully: selected_sprite=#{@player_preference.selected_sprite}, in_game_name=#{@player_preference.in_game_name}"
respond_to do |format|
format.html do
flash[:notice] = 'Character configuration saved!'
# Redirect to game if came from validation flow
if params[:game_id].present?
redirect_to game_path(params[:game_id])
else
redirect_to configuration_path
# Redirect to game if came from validation flow
if params[:game_id].present?
redirect_to game_path(params[:game_id])
else
redirect_to configuration_path
end
end
format.json do
render json: {
success: true,
message: 'Character configuration saved!',
data: {
selected_sprite: @player_preference.selected_sprite,
in_game_name: @player_preference.in_game_name
}
}
end
end
else
flash.now[:alert] = 'Please select a character sprite.'
@available_sprites = PlayerPreference::AVAILABLE_SPRITES
@scenario = load_scenario_if_validating
render :show, status: :unprocessable_entity
Rails.logger.error "[BreakEscape] Failed to update preference: #{@player_preference.errors.full_messages.join(', ')}"
respond_to do |format|
format.html do
flash.now[:alert] = 'Please select a character sprite.'
@available_sprites = PlayerPreference::AVAILABLE_SPRITES
@scenario = load_scenario_if_validating
render :show, status: :unprocessable_entity
end
format.json do
render json: {
success: false,
error: 'Please select a character sprite.',
errors: @player_preference.errors.full_messages
}, status: :unprocessable_entity
end
end
end
end
@@ -32,13 +64,15 @@ module BreakEscape
def set_player_preference
@player_preference = current_player_preference || create_default_preference
Rails.logger.info "[BreakEscape] set_player_preference: #{@player_preference.inspect}"
end
def current_player_preference
if current_player.respond_to?(:break_escape_preference)
current_player.break_escape_preference
elsif current_player.respond_to?(:preference)
current_player.preference
# Reload association to ensure fresh data
current_player.reload.preference
end
end

View File

@@ -34,6 +34,7 @@
<link rel="stylesheet" href="/break_escape/css/notifications.css">
<link rel="stylesheet" href="/break_escape/css/panels.css">
<link rel="stylesheet" href="/break_escape/css/hud.css">
<link rel="stylesheet" href="/break_escape/css/player_preferences.css">
<link rel="stylesheet" href="/break_escape/css/minigames-framework.css">
<link rel="stylesheet" href="/break_escape/css/dusting.css">
<link rel="stylesheet" href="/break_escape/css/lockpicking.css">
@@ -127,6 +128,9 @@
</div>
</div>
<%# Player Preferences Modal %>
<%= render 'break_escape/player_preferences/modal' %>
<%# Popup Overlay %>
<div class="popup-overlay"></div>
@@ -138,8 +142,11 @@
assetsPath: '/break_escape/assets',
csrfToken: '<%= form_authenticity_token %>',
hacktivityMode: <%= BreakEscape::Mission.hacktivity_mode? ? 'true' : 'false' %>,
vmSetId: <%= @game.player_state.is_a?(Hash) ? (@game.player_state['vm_set_id'] || 'null') : 'null' %>
vmSetId: <%= @game.player_state.is_a?(Hash) ? (@game.player_state['vm_set_id'] || 'null') : 'null' %>,
playerSprite: '<%= @player_preference&.selected_sprite || 'male_hacker' %>'
};
console.log('🔧 breakEscapeConfig initialized:', window.breakEscapeConfig);
console.log('🎨 Player sprite from server:', window.breakEscapeConfig.playerSprite);
</script>
<%# Load required libraries before the game module %>

View File

@@ -0,0 +1,214 @@
<%# Player Preferences Modal - Rendered inline in game view %>
<div id="player-preferences-modal" class="modal-overlay" style="display: none;" onclick="if(event.target === this) window.playerHUD.closePlayerPreferences()">
<div class="player-preferences-modal-content">
<div class="modal-header">
<h2>Character Configuration</h2>
<button class="modal-close-button" onclick="window.playerHUD.closePlayerPreferences()">×</button>
</div>
<%= form_with model: @player_preference,
url: configuration_path,
method: :patch,
local: false,
id: 'preference-form-modal',
data: { turbo: false } do |f| %>
<!-- In-Game Name -->
<div class="form-group">
<%= f.label :in_game_name, "Your Code Name" %>
<%= f.text_field :in_game_name,
class: 'form-control',
maxlength: 20,
placeholder: 'Zero' %>
<small>1-20 characters (letters, numbers, spaces, underscores only)</small>
</div>
<!-- Sprite Selection -->
<div class="form-group">
<%= f.label :selected_sprite, "Select Your Character" %>
<% if @player_preference.selected_sprite.blank? %>
<p class="selection-required">⚠️ Character selection required</p>
<% end %>
<div class="sprite-selection-layout">
<!-- 160x160 animated preview of selected sprite -->
<div class="sprite-preview-large">
<div id="sprite-preview-canvas-container-modal"></div>
<p class="preview-label">Selected character</p>
</div>
<!-- Grid of static headshots -->
<div class="sprite-grid" id="sprite-selection-grid-modal">
<% @available_sprites.each_with_index do |sprite, index| %>
<%
# In-game modal: no scenario restrictions (or use current game's scenario if available)
is_valid = true
is_selected = @player_preference.selected_sprite == sprite
%>
<label for="sprite_modal_<%= sprite %>"
class="sprite-card <%= 'invalid' unless is_valid %> <%= 'selected' if is_selected %>"
data-sprite="<%= sprite %>">
<div class="sprite-headshot-container">
<%= image_tag sprite_headshot_path(sprite),
class: 'sprite-headshot',
alt: sprite.humanize,
loading: 'lazy',
onerror: "this.onerror=null; this.style.display='none'; var n=this.nextElementSibling; if(n) n.classList.remove('headshot-fallback-hidden');" %>
<span class="headshot-fallback headshot-fallback-hidden"><%= sprite.humanize %></span>
</div>
<div class="sprite-info">
<%= f.radio_button :selected_sprite,
sprite,
id: "sprite_modal_#{sprite}",
disabled: !is_valid,
class: 'sprite-radio' %>
<span class="sprite-label"><%= sprite.humanize %></span>
</div>
</label>
<% end %>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="form-actions modal-footer">
<%= f.submit 'Save Configuration', class: 'btn btn-primary', id: 'save-preferences-btn' %>
<button type="button" class="btn btn-secondary" onclick="window.playerHUD.closePlayerPreferences()">Cancel</button>
</div>
<% end %>
</div>
</div>
<script type="module" nonce="<%= content_security_policy_nonce %>">
import { initializeSpritePreview } from '/break_escape/js/ui/sprite-grid.js?v=2';
// Initialize sprite preview when modal is shown
window.initPlayerPreferencesModal = function() {
const sprites = <%= raw @available_sprites.to_json %>;
// Use current sprite or default
let selectedSprite = '<%= @player_preference.selected_sprite.presence || "female_hacker_hood" %>';
initializeSpritePreview(sprites, selectedSprite, 'sprite-preview-canvas-container-modal');
// Click on headshot selects radio and updates selected class
const grid = document.getElementById('sprite-selection-grid-modal');
if (grid) {
grid.addEventListener('click', function(e) {
const label = e.target.closest('label.sprite-card');
if (!label) return;
const radio = label.querySelector('input[type="radio"]');
if (radio && !radio.disabled) {
radio.checked = true;
radio.dispatchEvent(new Event('change', { bubbles: true }));
// Remove selected from all, add to clicked
document.querySelectorAll('#sprite-selection-grid-modal label.sprite-card').forEach(l => l.classList.remove('selected'));
label.classList.add('selected');
}
});
}
};
// Handle ESC key to close modal
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('player-preferences-modal');
if (modal && modal.style.display !== 'none') {
if (window.playerHUD) {
window.playerHUD.closePlayerPreferences();
}
}
}
});
// Handle form submission via AJAX
const form = document.getElementById('preference-form-modal');
if (form) {
form.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(form);
const submitButton = document.getElementById('save-preferences-btn');
// Disable submit button during request
submitButton.disabled = true;
submitButton.textContent = 'Saving...';
try {
const response = await fetch(form.action, {
method: 'PATCH',
body: formData,
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
credentials: 'same-origin'
});
if (response.ok) {
// Success - get the saved values from server response
const result = await response.json();
const newSprite = result.data?.selected_sprite;
const newName = result.data?.in_game_name;
console.log('✅ Configuration saved to server:', { sprite: newSprite, name: newName });
// Update breakEscapeConfig
if (window.breakEscapeConfig) {
window.breakEscapeConfig.playerSprite = newSprite;
}
// Update HUD avatar
if (window.playerHUD) {
window.playerHUD.updateAvatarSprite(newSprite);
}
// Update player sprite in game using the player module's update function
if (window.updatePlayerSprite) {
try {
await window.updatePlayerSprite(newSprite);
// Close modal after successful update
if (window.playerHUD) {
window.playerHUD.closePlayerPreferences();
}
// Show success notification including name if it was changed
if (window.NotificationManager) {
const message = newName
? `Configuration saved! Code name: ${newName}`
: 'Configuration saved successfully!';
window.NotificationManager.show(message, 'success');
}
} catch (error) {
console.error('Failed to update player sprite:', error);
alert('Sprite changed but display update failed. Please reload the page.');
}
} else {
console.warn('updatePlayerSprite not available, closing modal');
if (window.playerHUD) {
window.playerHUD.closePlayerPreferences();
}
}
// Re-enable submit button
submitButton.disabled = false;
submitButton.textContent = 'Save Configuration';
} else {
// Error - show message
const data = await response.json();
alert(data.error || 'Failed to save configuration. Please try again.');
submitButton.disabled = false;
submitButton.textContent = 'Save Configuration';
}
} catch (error) {
console.error('Error saving preferences:', error);
alert('Failed to save configuration. Please try again.');
submitButton.disabled = false;
submitButton.textContent = 'Save Configuration';
}
});
}
</script>