474 lines
15 KiB
Ruby
474 lines
15 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Generative World Builder Component
|
|
# Rails 8 ViewComponent for world-building parameter editor
|
|
#
|
|
# Features:
|
|
# - Procedural generation parameter controls
|
|
# - Terrain, climate, and resource distribution editors
|
|
# - Real-time preview via Turbo Frames
|
|
# - Accessibility-first parameter adjustment
|
|
#
|
|
# @use Stimulus for interactive sliders and preview updates
|
|
# @test RSpec component tests
|
|
# @accessibility WCAG AA, keyboard navigation, ARIA live regions
|
|
|
|
module GameAdmin
|
|
class WorldBuilderComponent < AdminComponent
|
|
# World builder component for procedural generation
|
|
#
|
|
# Provides:
|
|
# - Terrain parameter controls (height, erosion, biome)
|
|
# - Climate system editor (temperature, precipitation)
|
|
# - Resource distribution (ores, vegetation, settlements)
|
|
# - Real-time preview rendering
|
|
#
|
|
# @param world_id [Integer] world to edit
|
|
# @param params [Hash] generation parameters
|
|
# @return [String] rendered HTML component
|
|
def render(world_id:, params: {})
|
|
@world_id = world_id
|
|
@params = params.permit(
|
|
:terrain_scale, :terrain_erosion, :biome_distribution,
|
|
:climate_temperature, :climate_precipitation,
|
|
:resource_density, :settlement_frequency
|
|
)
|
|
|
|
@world = World.find(world_id)
|
|
|
|
content_tag :div, class: 'world-builder', data: {
|
|
controller: 'world-builder',
|
|
world_builder_world_id: world_id,
|
|
world_builder_preview_url: '/admin/worlds/preview'
|
|
}, aria: {
|
|
label: 'World Building Editor',
|
|
role: 'application'
|
|
} do
|
|
concat render_header
|
|
concat render_parameter_panel
|
|
concat render_preview_panel
|
|
concat render_generation_controls
|
|
end
|
|
end
|
|
|
|
# Builder header with world info
|
|
def render_header
|
|
content_tag :header, class: 'builder-header' do
|
|
concat content_tag(:h1, 'World Builder', class: 'builder-title')
|
|
concat render_world_info(@world)
|
|
concat render_actions
|
|
end
|
|
end
|
|
|
|
# World information display
|
|
def render_world_info(world)
|
|
content_tag :div, class: 'world-info', aria: {
|
|
label: 'World information'
|
|
} do
|
|
concat content_tag(:h2, world.name, class: 'world-name')
|
|
concat content_tag(:p, "#{world.biomes.count} biomes", class: 'world-stat')
|
|
concat content_tag(:p, "#{world.size_km} km²", class: 'world-stat')
|
|
concat content_tag(:p, "Seed: #{world.seed}", class: 'world-seed')
|
|
end
|
|
end
|
|
|
|
# Action buttons (Save, Export, Regenerate)
|
|
def render_actions
|
|
content_tag :div, class: 'builder-actions' do
|
|
concat link_to('Save', "/admin/worlds/#{@world_id}", class: 'btn-primary')
|
|
concat link_to('Export', "/admin/worlds/#{@world_id}/export", class: 'btn-secondary')
|
|
concat link_to('Regenerate', "/admin/worlds/#{@world_id}/regenerate",
|
|
class: 'btn-warning',
|
|
data: {
|
|
confirm: 'Regenerate will erase current world. Continue?'
|
|
}
|
|
)
|
|
end
|
|
end
|
|
|
|
# Parameter panel with all generation controls
|
|
def render_parameter_panel
|
|
content_tag :section, class: 'parameter-panel', aria: {
|
|
label: 'Generation Parameters',
|
|
role: 'region'
|
|
}, data: {
|
|
world_builder_target: 'parameters'
|
|
} do
|
|
concat content_tag(:h2, 'Generation Parameters', class: 'panel-title')
|
|
|
|
# Terrain parameters
|
|
concat render_terrain_section
|
|
concat render_climate_section
|
|
concat render_biome_section
|
|
concat render_resource_section
|
|
end
|
|
end
|
|
|
|
# Terrain parameter controls
|
|
def render_terrain_section
|
|
content_tag :div, class: 'parameter-section terrain', data: {
|
|
section: 'terrain'
|
|
} do
|
|
concat content_tag(:h3, 'Terrain', class: 'section-title')
|
|
|
|
concat render_slider_control(
|
|
id: 'terrain_scale',
|
|
label: 'Scale',
|
|
min: 0, max: 100,
|
|
value: @params[:terrain_scale] || 50,
|
|
description: 'Feature size and frequency'
|
|
)
|
|
|
|
concat render_slider_control(
|
|
id: 'terrain_erosion',
|
|
label: 'Erosion',
|
|
min: 0, max: 100,
|
|
value: @params[:terrain_erosion] || 30,
|
|
description: 'Weathering and sedimentation'
|
|
)
|
|
|
|
concat render_slider_control(
|
|
id: 'terrain_ruggedness',
|
|
label: 'Ruggedness',
|
|
min: 0, max: 100,
|
|
value: @params[:terrain_ruggedness] || 40,
|
|
description: 'Height variation intensity'
|
|
)
|
|
end
|
|
end
|
|
|
|
# Climate parameter controls
|
|
def render_climate_section
|
|
content_tag :div, class: 'parameter-section climate', data: {
|
|
section: 'climate'
|
|
} do
|
|
concat content_tag(:h3, 'Climate', class: 'section-title')
|
|
|
|
concat render_slider_control(
|
|
id: 'climate_temperature',
|
|
label: 'Base Temperature',
|
|
min: -50, max: 50,
|
|
value: @params[:climate_temperature] || 20,
|
|
unit: '°C',
|
|
description: 'Global temperature baseline'
|
|
)
|
|
|
|
concat render_slider_control(
|
|
id: 'climate_precipitation',
|
|
label: 'Precipitation',
|
|
min: 0, max: 200,
|
|
value: @params[:climate_precipitation] || 100,
|
|
unit: 'mm/year',
|
|
description: 'Annual rainfall distribution'
|
|
)
|
|
|
|
concat render_slider_control(
|
|
id: 'climate_seasonality',
|
|
label: 'Seasonality',
|
|
min: 0, max: 100,
|
|
value: @params[:climate_seasonality] || 50,
|
|
description: 'Seasonal variation intensity'
|
|
)
|
|
end
|
|
end
|
|
|
|
# Biome distribution controls
|
|
def render_biome_section
|
|
content_tag :div, class: 'parameter-section biome', data: {
|
|
section: 'biome'
|
|
} do
|
|
concat content_tag(:h3, 'Biome Distribution', class: 'section-title')
|
|
|
|
concat render_biome_picker
|
|
concat render_vegetation_density_control
|
|
end
|
|
end
|
|
|
|
# Biome type picker with accessibility
|
|
def render_biome_picker
|
|
content_tag :div, class: 'biome-picker', role: 'group', aria: {
|
|
label: 'Biome types'
|
|
} do
|
|
BiomeType.all.each do |biome|
|
|
concat content_tag :div, class: 'biome-option' do
|
|
concat tag.label(class: 'biome-label') do
|
|
concat tag.checkbox_tag(
|
|
'biomes',
|
|
value: biome.id,
|
|
checked: @params[:biome_distribution]&.include?(biome.id.to_s)
|
|
)
|
|
concat content_tag(:span, biome.name, class: 'biome-name')
|
|
concat content_tag(:span, biome.icon, class: 'biome-icon', aria: {
|
|
hidden: true
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Vegetation density slider
|
|
def render_vegetation_density_control
|
|
render_slider_control(
|
|
id: 'vegetation_density',
|
|
label: 'Vegetation Density',
|
|
min: 0, max: 100,
|
|
value: @params[:vegetation_density] || 60,
|
|
description: 'Plant coverage percentage'
|
|
)
|
|
end
|
|
|
|
# Resource distribution controls
|
|
def render_resource_section
|
|
content_tag :div, class: 'parameter-section resource', data: {
|
|
section: 'resource'
|
|
} do
|
|
concat content_tag(:h3, 'Resource Distribution', class: 'section-title')
|
|
|
|
concat render_slider_control(
|
|
id: 'resource_density',
|
|
label: 'Resource Density',
|
|
min: 0, max: 100,
|
|
value: @params[:resource_density] || 40,
|
|
description: 'Ore and material spawn rate'
|
|
)
|
|
|
|
concat render_slider_control(
|
|
id: 'resource_rarity',
|
|
label: 'Rarity Gradient',
|
|
min: 0, max: 100,
|
|
value: @params[:resource_rarity] || 50,
|
|
description: 'Rare vs common distribution'
|
|
)
|
|
|
|
concat render_settlement_controls
|
|
end
|
|
end
|
|
|
|
# Settlement frequency and type controls
|
|
def render_settlement_controls
|
|
content_tag :div, class: 'settlement-controls' do
|
|
concat render_slider_control(
|
|
id: 'settlement_frequency',
|
|
label: 'Settlements',
|
|
min: 0, max: 100,
|
|
value: @params[:settlement_frequency] || 30,
|
|
description: 'Town/city spawn frequency'
|
|
)
|
|
|
|
concat render_settlement_type_picker
|
|
end
|
|
end
|
|
|
|
# Settlement type options
|
|
def render_settlement_type_picker
|
|
content_tag :div, class: 'settlement-picker', role: 'group', aria: {
|
|
label: 'Settlement types'
|
|
} do
|
|
SettlementType.all.each do |type|
|
|
concat content_tag :div, class: 'settlement-option' do
|
|
concat tag.label(class: 'settlement-label') do
|
|
concat tag.checkbox_tag(
|
|
'settlement_types',
|
|
value: type.id,
|
|
checked: true
|
|
)
|
|
concat content_tag(:span, type.name, class: 'settlement-name')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Generic slider control with accessibility
|
|
#
|
|
# @param id [String] parameter identifier
|
|
# @param label [String] display label
|
|
# @param min [Integer] minimum value
|
|
# @param max [Integer] maximum value
|
|
# @param value [Integer] current value
|
|
# @param description [String] help text
|
|
# @param unit [String] unit suffix (optional)
|
|
def render_slider_control(id:, label:, min:, max:, value:, description:, unit: nil)
|
|
content_tag :div, class: 'slider-control', data: {
|
|
world_builder_target: 'slider',
|
|
slider_param: id
|
|
} do
|
|
concat content_tag(:label, label, for: id, class: 'slider-label')
|
|
concat content_tag(:p, description, class: 'slider-description', id: "#{id}_desc")
|
|
|
|
concat content_tag(:div, class: 'slider-input-container') do
|
|
concat tag.input(
|
|
type: 'range',
|
|
id: id,
|
|
name: id,
|
|
min: min,
|
|
max: max,
|
|
value: value,
|
|
aria: {
|
|
describedby: "#{id}_desc",
|
|
valuenow: value,
|
|
valuemin: min,
|
|
valuemax: max
|
|
},
|
|
data: {
|
|
action: 'world-builder#updatePreview',
|
|
preview_param: id
|
|
}
|
|
)
|
|
|
|
value_display = unit ? "#{value} #{unit}" value.to_s
|
|
concat content_tag(:span, value_display, class: 'slider-value', aria: {
|
|
hidden: true
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
# Preview panel with Turbo Frame live updates
|
|
def render_preview_panel
|
|
content_tag :section, class: 'preview-panel', aria: {
|
|
label: 'Generation Preview',
|
|
role: 'region'
|
|
}, data: {
|
|
world_builder_target: 'preview'
|
|
} do
|
|
concat content_tag(:h2, 'Live Preview', class: 'panel-title')
|
|
|
|
# Turbo Frame for incremental updates
|
|
concat turbo_frame_tag '/admin/worlds/preview', id: 'world-preview', data: {
|
|
world_builder_target: 'previewFrame',
|
|
params: @params.to_json
|
|
} do
|
|
concat render_preview_content
|
|
end
|
|
end
|
|
end
|
|
|
|
# Preview content (rendered inside Turbo Frame)
|
|
def render_preview_content
|
|
content_tag :div, class: 'preview-content' do
|
|
# Terrain heatmap visualization
|
|
concat render_terrain_preview
|
|
# Biome overlay
|
|
concat render_biome_overlay
|
|
# Resource markers
|
|
concat render_resource_markers
|
|
end
|
|
end
|
|
|
|
# Terrain preview heatmap
|
|
def render_terrain_preview
|
|
content_tag :div, class: 'terrain-preview', aria: {
|
|
label: 'Terrain elevation preview'
|
|
} do
|
|
# In production: Canvas-based rendering via Stimulus
|
|
# Server-side: SVG generation for progressive enhancement
|
|
concat tag.svg(class: 'terrain-svg', viewBox: '0 0 500 500') do
|
|
concat tag.defs
|
|
# Height-based path generation
|
|
concat render_elevation_paths
|
|
end
|
|
end
|
|
end
|
|
|
|
# Elevation path rendering
|
|
def render_elevation_paths
|
|
# Generate SVG paths based on elevation data
|
|
# In production: Cached preview from generation pipeline
|
|
(0..10).map do |level|
|
|
tag.path(
|
|
d: generate_elevation_path(level),
|
|
fill: elevation_color(level),
|
|
aria: { hidden: true }
|
|
)
|
|
end.join
|
|
end
|
|
|
|
def generate_elevation_path(level)
|
|
# Procedural path generation
|
|
'M 0,500 L 500,500 L 500,' + (50 - level * 5).to_s + ' L 0,' + (50 - level * 5).to_s + ' Z'
|
|
end
|
|
|
|
def elevation_color(level)
|
|
case level
|
|
when 0..2
|
|
'#4a90e2' # Water
|
|
when 3..5
|
|
'#7b68ee' # Plains
|
|
when 6..8
|
|
'#90a86b' # Hills
|
|
else
|
|
'#8b7d6b' # Mountains
|
|
end
|
|
end
|
|
|
|
# Biome overlay on terrain
|
|
def render_biome_overlay
|
|
content_tag :div, class: 'biome-overlay', aria: {
|
|
hidden: true
|
|
} do
|
|
@params[:biome_distribution]&.each do |biome_id|
|
|
biome = BiomeType.find(biome_id)
|
|
concat content_tag(:span, biome.icon, class: 'biome-indicator')
|
|
end
|
|
end
|
|
end
|
|
|
|
# Resource distribution markers
|
|
def render_resource_markers
|
|
content_tag :div, class: 'resource-markers', aria: {
|
|
label: 'Resource spawn locations'
|
|
} do
|
|
# Marker visualization for ore/vegetation spawns
|
|
concat content_tag(:p, "Resource density: #{@params[:resource_density]}%")
|
|
end
|
|
end
|
|
|
|
# Generation controls (Generate, Apply, Reset)
|
|
def render_generation_controls
|
|
content_tag :section, class: 'generation-controls', role: 'navigation' do
|
|
concat content_tag(:h2, 'Generation Controls', class: 'controls-title')
|
|
|
|
concat content_tag :div, class: 'control-buttons' do
|
|
concat link_to('Generate', '/admin/worlds/generate',
|
|
method: :post,
|
|
params: { world_id: @world_id, params: @params },
|
|
class: 'btn-primary',
|
|
data: {
|
|
world_builder_target: 'generateButton',
|
|
disable: 'true'
|
|
}
|
|
)
|
|
|
|
concat link_to('Apply Parameters', '/admin/worlds/apply',
|
|
method: :post,
|
|
params: { world_id: @world_id, params: @params },
|
|
class: 'btn-success'
|
|
)
|
|
|
|
concat link_to('Reset', '/admin/worlds/reset',
|
|
method: :post,
|
|
params: { world_id: @world_id },
|
|
class: 'btn-secondary'
|
|
)
|
|
end
|
|
|
|
# Progress indicator (Turbo Stream)
|
|
concat content_tag :div, class: 'generation-progress', data: {
|
|
world_builder_target: 'progress',
|
|
turbo_stream_source: 'generation'
|
|
}, aria: {
|
|
live: 'polite',
|
|
label: 'Generation progress'
|
|
} do
|
|
concat content_tag(:span, 'Ready', class: 'progress-status')
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :world_id, :params, :world
|
|
end
|
|
end |