mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
Refactor character assets and player preferences
- Deleted unused character images: woman_in_science_lab_coat.png and woman_with_black_long_hair_bow_in_hair_long_sleeve_(1).png. - Added new padlock icon asset for UI. - Introduced player_preferences.css for styling the player preferences configuration screen. - Updated game.js to load new character atlases with simplified filenames. - Enhanced player.js to create custom idle animations for characters. - Implemented sprite-grid.js for sprite selection UI, including a preview feature. - Updated database schema to include break_escape_player_preferences table for storing player settings. - Modified convert_pixellab_to_spritesheet.py to map character names to simplified filenames and extract headshots from character images.
This commit is contained in:
@@ -79,7 +79,21 @@ module BreakEscape
|
||||
@game.player_state = initial_player_state
|
||||
@game.save!
|
||||
|
||||
redirect_to game_path(@game)
|
||||
# Check if player's sprite is valid for this scenario
|
||||
player_pref = current_player_preference || create_default_preference
|
||||
|
||||
if !player_pref.sprite_selected?
|
||||
# No sprite selected - MUST configure
|
||||
flash[:alert] = 'Please select your character before starting.'
|
||||
redirect_to configuration_path(game_id: @game.id)
|
||||
elsif !player_pref.sprite_valid_for_scenario?(@game.scenario_data)
|
||||
# Sprite selected but invalid for this scenario
|
||||
flash[:alert] = 'Your selected character is not available for this mission. Please choose another.'
|
||||
redirect_to configuration_path(game_id: @game.id)
|
||||
else
|
||||
# All good - start game
|
||||
redirect_to game_path(@game)
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -1193,5 +1207,28 @@ module BreakEscape
|
||||
# Generate identifier: "desktop-flag1" (1-indexed for display)
|
||||
"#{vm_id}-flag#{flag_index + 1}"
|
||||
end
|
||||
|
||||
# Get current player's preference record
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
# Create default preference for player
|
||||
def create_default_preference
|
||||
if current_player.respond_to?(:ensure_break_escape_preference!)
|
||||
current_player.ensure_break_escape_preference!
|
||||
current_player.break_escape_preference
|
||||
elsif current_player.respond_to?(:ensure_preference!)
|
||||
current_player.ensure_preference!
|
||||
current_player.preference
|
||||
else
|
||||
# Fallback: create directly
|
||||
PlayerPreference.create!(player: current_player)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
module BreakEscape
|
||||
class PlayerPreferencesController < ApplicationController
|
||||
before_action :set_player_preference
|
||||
before_action :authorize_preference
|
||||
|
||||
# GET /break_escape/configuration
|
||||
def show
|
||||
@available_sprites = PlayerPreference::AVAILABLE_SPRITES
|
||||
@scenario = load_scenario_if_validating
|
||||
end
|
||||
|
||||
# PATCH /break_escape/configuration
|
||||
def update
|
||||
if @player_preference.update(player_preference_params)
|
||||
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
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_player_preference
|
||||
@player_preference = current_player_preference || create_default_preference
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
def create_default_preference
|
||||
if current_player.respond_to?(:ensure_break_escape_preference!)
|
||||
current_player.ensure_break_escape_preference!
|
||||
current_player.break_escape_preference
|
||||
elsif current_player.respond_to?(:ensure_preference!)
|
||||
current_player.ensure_preference!
|
||||
current_player.preference
|
||||
else
|
||||
# Fallback: create directly
|
||||
PlayerPreference.create!(player: current_player)
|
||||
end
|
||||
end
|
||||
|
||||
def authorize_preference
|
||||
authorize(@player_preference) if defined?(Pundit)
|
||||
end
|
||||
|
||||
def player_preference_params
|
||||
params.require(:player_preference).permit(:selected_sprite, :in_game_name)
|
||||
end
|
||||
|
||||
def load_scenario_if_validating
|
||||
return nil unless params[:game_id].present?
|
||||
|
||||
game = Game.find_by(id: params[:game_id])
|
||||
return nil unless game
|
||||
|
||||
# Return scenario data with validSprites info
|
||||
game.scenario_data
|
||||
end
|
||||
end
|
||||
end
|
||||
39
app/helpers/break_escape/player_preferences_helper.rb
Normal file
39
app/helpers/break_escape/player_preferences_helper.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
module BreakEscape
|
||||
module PlayerPreferencesHelper
|
||||
def sprite_valid_for_scenario?(sprite, scenario_data)
|
||||
return true unless scenario_data['validSprites'].present?
|
||||
|
||||
valid_sprites = Array(scenario_data['validSprites'])
|
||||
|
||||
valid_sprites.any? do |pattern|
|
||||
sprite_matches_pattern?(sprite, pattern)
|
||||
end
|
||||
end
|
||||
|
||||
# Headshot filename for sprite (prefer _down_headshot for hacker_hood, else _headshot)
|
||||
def sprite_headshot_path(sprite)
|
||||
base = if sprite == 'woman_bow'
|
||||
'woman_blowse' # filename typo in assets
|
||||
else
|
||||
sprite
|
||||
end
|
||||
if sprite.end_with?('_hood_down')
|
||||
"/break_escape/assets/characters/#{base}_headshot.png"
|
||||
else
|
||||
"/break_escape/assets/characters/#{base}_headshot.png"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sprite_matches_pattern?(sprite, pattern)
|
||||
return true if pattern == '*'
|
||||
|
||||
# Convert wildcard pattern to regex
|
||||
regex_pattern = Regexp.escape(pattern).gsub('\*', '.*')
|
||||
regex = /\A#{regex_pattern}\z/
|
||||
|
||||
sprite.match?(regex)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ module BreakEscape
|
||||
self.table_name = 'break_escape_demo_users'
|
||||
|
||||
has_many :games, as: :player, class_name: 'BreakEscape::Game'
|
||||
has_one :preference, as: :player, class_name: 'BreakEscape::PlayerPreference', dependent: :destroy
|
||||
|
||||
validates :handle, presence: true, uniqueness: true
|
||||
|
||||
@@ -14,5 +15,11 @@ module BreakEscape
|
||||
def account_manager?
|
||||
role == 'account_manager'
|
||||
end
|
||||
|
||||
# Ensure preference exists
|
||||
def ensure_preference!
|
||||
create_preference! unless preference
|
||||
preference
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -653,6 +653,9 @@ module BreakEscape
|
||||
|
||||
# Generate with VM context (or empty context for non-VM missions)
|
||||
self.scenario_data = mission.generate_scenario_data(vm_context)
|
||||
|
||||
# Inject player preferences into scenario
|
||||
inject_player_preferences(self.scenario_data)
|
||||
end
|
||||
|
||||
def initialize_player_state
|
||||
@@ -743,6 +746,24 @@ module BreakEscape
|
||||
end
|
||||
end
|
||||
|
||||
# Inject player preferences into scenario data
|
||||
def inject_player_preferences(scenario_data)
|
||||
player_pref = if player.respond_to?(:break_escape_preference)
|
||||
player.break_escape_preference
|
||||
elsif player.respond_to?(:preference)
|
||||
player.preference
|
||||
end
|
||||
|
||||
return unless player_pref&.selected_sprite # Safety: don't inject if nil
|
||||
|
||||
# Map simplified sprite name to actual filename
|
||||
sprite_filename = PlayerPreference.sprite_filename(player_pref.selected_sprite)
|
||||
|
||||
scenario_data['player'] ||= {}
|
||||
scenario_data['player']['spriteSheet'] = sprite_filename
|
||||
scenario_data['player']['displayName'] = player_pref.in_game_name
|
||||
end
|
||||
|
||||
public
|
||||
|
||||
# ==========================================
|
||||
|
||||
101
app/models/break_escape/player_preference.rb
Normal file
101
app/models/break_escape/player_preference.rb
Normal file
@@ -0,0 +1,101 @@
|
||||
module BreakEscape
|
||||
class PlayerPreference < ApplicationRecord
|
||||
self.table_name = 'break_escape_player_preferences'
|
||||
|
||||
# Associations
|
||||
belongs_to :player, polymorphic: true
|
||||
|
||||
# Constants - Available sprite sheets (must match game.js preload and assets on disk)
|
||||
AVAILABLE_SPRITES = %w[
|
||||
female_hacker_hood
|
||||
female_hacker_hood_down
|
||||
female_office_worker
|
||||
female_security_guard
|
||||
female_telecom
|
||||
female_spy
|
||||
female_scientist
|
||||
woman_bow
|
||||
male_hacker_hood
|
||||
male_hacker_hood_down
|
||||
male_office_worker
|
||||
male_security_guard
|
||||
male_telecom
|
||||
male_spy
|
||||
male_scientist
|
||||
male_nerd
|
||||
].freeze
|
||||
|
||||
# Mapping from UI key to game texture key (game.js loads these atlas keys)
|
||||
# woman_bow -> woman_blowse (filename typo in assets); others are identity
|
||||
SPRITE_FILE_MAPPING = {
|
||||
'woman_bow' => 'woman_blowse'
|
||||
}.freeze
|
||||
|
||||
# Get the texture key for game injection (must match game.js preload keys)
|
||||
def self.sprite_filename(sprite_name)
|
||||
SPRITE_FILE_MAPPING[sprite_name] || sprite_name
|
||||
end
|
||||
|
||||
# Validations
|
||||
validates :player, presence: true
|
||||
validates :selected_sprite, inclusion: { in: AVAILABLE_SPRITES }, allow_nil: true
|
||||
validates :in_game_name, presence: true, length: { in: 1..20 }, format: {
|
||||
with: /\A[a-zA-Z0-9_ ]+\z/,
|
||||
message: 'only allows letters, numbers, spaces, and underscores'
|
||||
}
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_defaults, on: :create
|
||||
|
||||
# Check if selected sprite is valid for a given scenario
|
||||
def sprite_valid_for_scenario?(scenario_data)
|
||||
# If no sprite selected, invalid (player must choose)
|
||||
return false if selected_sprite.blank?
|
||||
|
||||
# If scenario has no restrictions, any sprite is valid
|
||||
return true unless scenario_data['validSprites'].present?
|
||||
|
||||
valid_sprites = Array(scenario_data['validSprites'])
|
||||
|
||||
# Check if sprite matches any pattern
|
||||
valid_sprites.any? do |pattern|
|
||||
sprite_matches_pattern?(selected_sprite, pattern)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if player has selected a sprite
|
||||
def sprite_selected?
|
||||
selected_sprite.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_defaults
|
||||
# Seed in_game_name from player.handle if available
|
||||
if in_game_name.blank? && player.respond_to?(:handle) && player.handle.present?
|
||||
self.in_game_name = player.handle
|
||||
end
|
||||
|
||||
# Fallback to 'Zero' if still blank
|
||||
self.in_game_name = 'Zero' if in_game_name.blank?
|
||||
|
||||
# NOTE: selected_sprite left NULL - player MUST choose before first game
|
||||
end
|
||||
|
||||
# Pattern matching for sprite validation
|
||||
# Supports:
|
||||
# - Exact match: "female_hacker"
|
||||
# - Wildcard: "female_*" (all female sprites)
|
||||
# - Wildcard: "*_hacker" (all hacker sprites)
|
||||
# - Wildcard: "*" (all sprites)
|
||||
def sprite_matches_pattern?(sprite, pattern)
|
||||
return true if pattern == '*'
|
||||
|
||||
# Convert wildcard pattern to regex
|
||||
regex_pattern = Regexp.escape(pattern).gsub('\*', '.*')
|
||||
regex = /\A#{regex_pattern}\z/
|
||||
|
||||
sprite.match?(regex)
|
||||
end
|
||||
end
|
||||
end
|
||||
22
app/policies/break_escape/player_preference_policy.rb
Normal file
22
app/policies/break_escape/player_preference_policy.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
module BreakEscape
|
||||
class PlayerPreferencePolicy < ApplicationPolicy
|
||||
def show?
|
||||
# All authenticated players can view their preferences
|
||||
player_owns_preference?
|
||||
end
|
||||
|
||||
def update?
|
||||
# All authenticated players can update their preferences
|
||||
player_owns_preference?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def player_owns_preference?
|
||||
return false unless user
|
||||
|
||||
# Check if user owns this preference record
|
||||
record.player_type == user.class.name && record.player_id == user.id
|
||||
end
|
||||
end
|
||||
end
|
||||
139
app/views/break_escape/player_preferences/show.html.erb
Normal file
139
app/views/break_escape/player_preferences/show.html.erb
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Character Configuration - BreakEscape</title>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<%# Load configuration CSS %>
|
||||
<link rel="stylesheet" href="/break_escape/css/player_preferences.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="configuration-container">
|
||||
<h1>Character Configuration</h1>
|
||||
|
||||
<% if params[:game_id].present? %>
|
||||
<p class="config-prompt">⚠️ Please select your character before starting the mission.</p>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @player_preference,
|
||||
url: configuration_path,
|
||||
method: :patch,
|
||||
local: true,
|
||||
id: 'preference-form' 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"></div>
|
||||
<p class="preview-label">Selected character</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid of static headshots -->
|
||||
<div class="sprite-grid" id="sprite-selection-grid">
|
||||
<% @available_sprites.each_with_index do |sprite, index| %>
|
||||
<%
|
||||
is_valid = @scenario.nil? || sprite_valid_for_scenario?(sprite, @scenario)
|
||||
is_selected = @player_preference.selected_sprite == sprite
|
||||
%>
|
||||
|
||||
<label for="sprite_<%= 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>
|
||||
|
||||
<% unless is_valid %>
|
||||
<div class="sprite-lock-overlay">
|
||||
<%= image_tag '/break_escape/assets/icons/padlock_32.png',
|
||||
class: 'lock-icon',
|
||||
alt: 'Locked' %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="sprite-info">
|
||||
<%= f.radio_button :selected_sprite,
|
||||
sprite,
|
||||
id: "sprite_#{sprite}",
|
||||
disabled: !is_valid,
|
||||
class: 'sprite-radio' %>
|
||||
<span class="sprite-label"><%= sprite.humanize %></span>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden field for game_id if validating -->
|
||||
<% if params[:game_id].present? %>
|
||||
<%= hidden_field_tag :game_id, params[:game_id] %>
|
||||
<% end %>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="form-actions">
|
||||
<%= f.submit 'Save Configuration', class: 'btn btn-primary' %>
|
||||
|
||||
<% if params[:game_id].blank? %>
|
||||
<%= link_to 'Cancel', root_path, class: 'btn btn-secondary' %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Load Phaser for 160x160 animated preview only -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js" nonce="<%= content_security_policy_nonce %>"></script>
|
||||
<script type="module" nonce="<%= content_security_policy_nonce %>">
|
||||
import { initializeSpritePreview } from '/break_escape/js/ui/sprite-grid.js?v=2';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sprites = <%= raw @available_sprites.to_json %>;
|
||||
// Use preferred sprite for Mission 1 if none selected
|
||||
let selectedSprite = '<%= @player_preference.selected_sprite.presence || "female_hacker_hood" %>';
|
||||
|
||||
initializeSpritePreview(sprites, selectedSprite);
|
||||
|
||||
// Click on headshot selects radio and updates selected class
|
||||
document.getElementById('sprite-selection-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('label.sprite-card').forEach(l => l.classList.remove('selected'));
|
||||
label.classList.add('selected');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user