feat: Implement Objectives System with UI and Server Sync

- Added ObjectivesManager to track mission objectives and tasks.
- Created ObjectivesPanel for displaying objectives in a collapsible HUD.
- Integrated objectives state restoration from the server during game initialization.
- Implemented task completion and unlocking mechanisms via game actions.
- Added CSS styles for the objectives panel with a pixel-art aesthetic.
- Developed a test scenario to validate the objectives system functionality.
- Updated database schema to include fields for tracking completed objectives and tasks.
This commit is contained in:
Z. Cliffe Schreuders
2025-11-26 00:50:32 +00:00
parent 150518b4c4
commit 9d6d7709c3
16 changed files with 1571 additions and 124 deletions

View File

@@ -2,7 +2,7 @@ require 'open3'
module BreakEscape
class GamesController < ApplicationController
before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :unlock, :inventory]
before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :unlock, :inventory, :objectives, :complete_task, :update_task_progress]
def show
authorize @game if defined?(Pundit)
@@ -323,6 +323,58 @@ module BreakEscape
end
end
# ==========================================
# Objectives System
# ==========================================
# GET /games/:id/objectives
# Returns current objectives and their state
def objectives
authorize @game if defined?(Pundit)
render json: @game.objectives_state
end
# POST /games/:id/objectives/tasks/:task_id
# Complete a specific task
def complete_task
authorize @game if defined?(Pundit)
task_id = params[:task_id]
unless task_id.present?
return render json: { success: false, error: 'Missing task_id' }, status: :bad_request
end
result = @game.complete_task!(task_id, params[:validation_data])
if result[:success]
Rails.logger.info "[BreakEscape] Task completed: #{task_id}"
render json: result
else
Rails.logger.warn "[BreakEscape] Task completion failed: #{task_id} - #{result[:error]}"
render json: result, status: :unprocessable_entity
end
end
# PUT /games/:id/objectives/tasks/:task_id
# Update task progress (for collect_items tasks)
def update_task_progress
authorize @game if defined?(Pundit)
task_id = params[:task_id]
progress = params[:progress].to_i
unless task_id.present?
return render json: { success: false, error: 'Missing task_id' }, status: :bad_request
end
result = @game.update_task_progress!(task_id, progress)
Rails.logger.debug "[BreakEscape] Task progress updated: #{task_id} = #{progress}"
render json: result
end
private
def set_game

View File

@@ -338,8 +338,183 @@ module BreakEscape
nil
end
# ==========================================
# Objectives System
# ==========================================
# Initialize objectives state structure
def initialize_objectives
return unless scenario_data['objectives'].present?
player_state['objectivesState'] ||= {
'aims' => {}, # { aimId: { status, completedAt } }
'tasks' => {}, # { taskId: { status, progress, completedAt } }
'itemCounts' => {} # { itemType: count } for collect objectives
}
end
# Complete a task with server-side validation
def complete_task!(task_id, validation_data = {})
initialize_objectives
task = find_task_in_scenario(task_id)
return { success: false, error: 'Task not found' } unless task
# Check if already completed
if player_state.dig('objectivesState', 'tasks', task_id, 'status') == 'completed'
return { success: true, taskId: task_id, message: 'Already completed' }
end
# Validate based on task type
case task['type']
when 'collect_items'
unless validate_collection(task)
return { success: false, error: 'Insufficient items collected' }
end
when 'unlock_room'
unless room_unlocked?(task['targetRoom'])
return { success: false, error: 'Room not unlocked' }
end
when 'unlock_object'
unless object_unlocked?(task['targetObject'])
return { success: false, error: 'Object not unlocked' }
end
when 'npc_conversation'
unless npc_encountered?(task['targetNpc'])
return { success: false, error: 'NPC not encountered' }
end
when 'enter_room'
# Room entry is validated by the client having discovered the room
# Trust the client for this low-stakes validation
when 'custom'
# Custom tasks are completed via ink tags - no validation needed
end
# Mark task complete
player_state['objectivesState']['tasks'][task_id] = {
'status' => 'completed',
'completedAt' => Time.current.iso8601
}
# Process onComplete actions
process_task_completion(task)
# Check if aim is now complete
check_aim_completion(task['aimId'])
# Update statistics
self.tasks_completed = (self.tasks_completed || 0) + 1
save!
{ success: true, taskId: task_id }
end
# Update task progress (for collect_items tasks)
def update_task_progress!(task_id, progress)
initialize_objectives
player_state['objectivesState']['tasks'][task_id] ||= {}
player_state['objectivesState']['tasks'][task_id]['progress'] = progress
save!
{ success: true, taskId: task_id, progress: progress }
end
# Get current objectives state
def objectives_state
{
'objectives' => scenario_data['objectives'],
'state' => player_state['objectivesState'] || {}
}
end
# Aim/Task status helpers
def aim_status(aim_id)
player_state.dig('objectivesState', 'aims', aim_id, 'status') || 'active'
end
def task_status(task_id)
player_state.dig('objectivesState', 'tasks', task_id, 'status') || 'active'
end
def task_progress(task_id)
player_state.dig('objectivesState', 'tasks', task_id, 'progress') || 0
end
private
# Find a task in scenario objectives by taskId
def find_task_in_scenario(task_id)
scenario_data['objectives']&.each do |aim|
task = aim['tasks']&.find { |t| t['taskId'] == task_id }
return task.merge('aimId' => aim['aimId']) if task
end
nil
end
# Validate collection tasks
def validate_collection(task)
inventory = player_state['inventory'] || []
target_items = Array(task['targetItems'])
count = inventory.count do |item|
item_type = item['type'] || item.dig('scenarioData', 'type')
target_items.include?(item_type)
end
count >= (task['targetCount'] || 1)
end
# Check if NPC was encountered
def npc_encountered?(npc_id)
player_state['encounteredNPCs']&.include?(npc_id)
end
# Process task.onComplete actions
def process_task_completion(task)
return unless task['onComplete']
if task['onComplete']['unlockTask']
unlock_objective_task!(task['onComplete']['unlockTask'])
end
if task['onComplete']['unlockAim']
unlock_objective_aim!(task['onComplete']['unlockAim'])
end
end
# Unlock a task (change status to active)
def unlock_objective_task!(task_id)
player_state['objectivesState']['tasks'][task_id] ||= {}
player_state['objectivesState']['tasks'][task_id]['status'] = 'active'
end
# Unlock an aim (change status to active)
def unlock_objective_aim!(aim_id)
player_state['objectivesState']['aims'][aim_id] ||= {}
player_state['objectivesState']['aims'][aim_id]['status'] = 'active'
end
# Check if all tasks in an aim are complete
def check_aim_completion(aim_id)
aim = scenario_data['objectives']&.find { |a| a['aimId'] == aim_id }
return unless aim
all_complete = aim['tasks'].all? do |task|
task_status(task['taskId']) == 'completed'
end
if all_complete
player_state['objectivesState']['aims'][aim_id] = {
'status' => 'completed',
'completedAt' => Time.current.iso8601
}
self.objectives_completed = (self.objectives_completed || 0) + 1
end
end
# ==========================================
# End Objectives System
# ==========================================
def filter_requires_and_contents_recursive(obj)
case obj
when Hash

View File

@@ -51,6 +51,7 @@
<link rel="stylesheet" href="/break_escape/css/password-minigame.css">
<link rel="stylesheet" href="/break_escape/css/text-file-minigame.css">
<link rel="stylesheet" href="/break_escape/css/npc-barks.css">
<link rel="stylesheet" href="/break_escape/css/objectives.css">
</head>
<body>
<div id="game-container">