mtg_card_maker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/CODE_OF_CONDUCT.md +131 -0
  4. data/LICENSE.txt +20 -0
  5. data/README.md +305 -0
  6. data/bin/mtg_card_maker +7 -0
  7. data/lib/mtg_card_maker/base_card.rb +210 -0
  8. data/lib/mtg_card_maker/cli.rb +221 -0
  9. data/lib/mtg_card_maker/color_palette.rb +135 -0
  10. data/lib/mtg_card_maker/color_scheme.rb +305 -0
  11. data/lib/mtg_card_maker/core_ext/deep_merge.rb +21 -0
  12. data/lib/mtg_card_maker/fonts/Goudy Mediaeval DemiBold.ttf +0 -0
  13. data/lib/mtg_card_maker/fonts/goudy_base64.txt +1 -0
  14. data/lib/mtg_card_maker/icon_service.rb +95 -0
  15. data/lib/mtg_card_maker/icons/black.svg +1 -0
  16. data/lib/mtg_card_maker/icons/blue.svg +1 -0
  17. data/lib/mtg_card_maker/icons/colorless.svg +1 -0
  18. data/lib/mtg_card_maker/icons/green.svg +1 -0
  19. data/lib/mtg_card_maker/icons/jsharp.svg +1 -0
  20. data/lib/mtg_card_maker/icons/qrcode.svg +1 -0
  21. data/lib/mtg_card_maker/icons/red.svg +1 -0
  22. data/lib/mtg_card_maker/icons/white.svg +1 -0
  23. data/lib/mtg_card_maker/layer_config.rb +289 -0
  24. data/lib/mtg_card_maker/layer_factory.rb +122 -0
  25. data/lib/mtg_card_maker/layer_initializer.rb +12 -0
  26. data/lib/mtg_card_maker/layers/art_layer.rb +63 -0
  27. data/lib/mtg_card_maker/layers/border_layer.rb +166 -0
  28. data/lib/mtg_card_maker/layers/frame_layer.rb +62 -0
  29. data/lib/mtg_card_maker/layers/name_layer.rb +82 -0
  30. data/lib/mtg_card_maker/layers/power_layer.rb +69 -0
  31. data/lib/mtg_card_maker/layers/text_box_layer.rb +107 -0
  32. data/lib/mtg_card_maker/layers/type_line_layer.rb +86 -0
  33. data/lib/mtg_card_maker/mana_cost.rb +220 -0
  34. data/lib/mtg_card_maker/metallic_renderer.rb +174 -0
  35. data/lib/mtg_card_maker/sprite_sheet_assets.rb +158 -0
  36. data/lib/mtg_card_maker/sprite_sheet_builder.rb +90 -0
  37. data/lib/mtg_card_maker/sprite_sheet_service.rb +126 -0
  38. data/lib/mtg_card_maker/svg_gradient_service.rb +159 -0
  39. data/lib/mtg_card_maker/text_rendering_service.rb +160 -0
  40. data/lib/mtg_card_maker/version.rb +8 -0
  41. data/lib/mtg_card_maker.rb +268 -0
  42. data/sig/mtg_card_maker.rbs +4 -0
  43. metadata +149 -0
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'layers/art_layer'
4
+ require_relative 'layers/text_box_layer'
5
+ require_relative 'layers/frame_layer'
6
+ require_relative 'layers/name_layer'
7
+ require_relative 'layers/border_layer'
8
+ require_relative 'layers/power_layer'
9
+ require_relative 'layers/type_line_layer'
10
+
11
+ module MtgCardMaker
12
+ # Base class for all Magic: The Gathering card types that provides common
13
+ # functionality with simplified configuration. This class handles the creation
14
+ # of complete MTG cards with all necessary layers (frames, text, art, etc.)
15
+ # using predefined dimensions and layouts.
16
+ #
17
+ # @example
18
+ # card = MtgCardMaker::BaseCard.new(
19
+ # name: "Lightning Bolt",
20
+ # mana_cost: "R",
21
+ # type_line: "Instant",
22
+ # rules_text: "Lightning Bolt deals 3 damage to any target.",
23
+ # color_scheme: :red
24
+ # )
25
+ # card.save("lightning_bolt.svg")
26
+ #
27
+ # @since 0.1.0
28
+ class BaseCard
29
+ # Fixed defaults for card layout and dimensions
30
+ # @return [Hash] the default configuration for card layers and dimensions
31
+ DEFAULTS = {
32
+ layers: {
33
+ border: {
34
+ x: 0,
35
+ y: 0,
36
+ width: 630,
37
+ height: 880
38
+ },
39
+ frame: {
40
+ x: 10,
41
+ y: 10,
42
+ width: 610,
43
+ height: 860
44
+ },
45
+ name_area: {
46
+ x: 30,
47
+ y: 40,
48
+ width: 570,
49
+ height: 50
50
+ },
51
+ art_layer: {
52
+ x: 40,
53
+ y: 95,
54
+ width: 550,
55
+ height: 400,
56
+ corner_radius: { x: 5, y: 5 }
57
+ },
58
+ type_area: {
59
+ x: 30,
60
+ y: 500,
61
+ width: 570,
62
+ height: 40
63
+ },
64
+ text_box: {
65
+ x: 40,
66
+ y: 545,
67
+ width: 550,
68
+ height: 265
69
+ },
70
+ power_area: {
71
+ x: 455,
72
+ y: 790,
73
+ width: 140,
74
+ height: 40
75
+ }
76
+ },
77
+ mask_id: 'artWindowMask',
78
+ frame_stroke_width: 2
79
+ }.freeze
80
+
81
+ # @return [String, nil] the card name
82
+ attr_reader :name
83
+
84
+ # @return [String, nil] the mana cost in MTG notation (e.g., "R", "1U")
85
+ attr_reader :mana_cost
86
+
87
+ # @return [String, nil] the card type (e.g., "Instant", "Creature")
88
+ attr_reader :type_line
89
+
90
+ # @return [String, nil] the card description/rules text
91
+ attr_reader :rules_text
92
+
93
+ # @return [String, nil] the flavor text (italic text at bottom)
94
+ attr_reader :flavor_text
95
+
96
+ # @return [String, nil] the power value for creatures
97
+ attr_reader :power
98
+
99
+ # @return [String, nil] the toughness value for creatures
100
+ attr_reader :toughness
101
+
102
+ # @return [String, nil] the border color
103
+ attr_reader :border_color
104
+
105
+ # @return [ColorScheme] the color scheme for the card
106
+ attr_reader :color_scheme
107
+
108
+ # @return [String, nil] the URL or path for the card artwork
109
+ attr_reader :art
110
+
111
+ # Initialize a new card with the given configuration
112
+ #
113
+ # @param config [Hash] the card configuration
114
+ # @option config [String] :name the card name
115
+ # @option config [String] :mana_cost the mana cost in MTG notation
116
+ # @option config [String] :type_line the card type
117
+ # @option config [String] :rules_text the card rules text
118
+ # @option config [String] :flavor_text the flavor text
119
+ # @option config [String] :power the power value for creatures
120
+ # @option config [String] :toughness the toughness value for creatures
121
+ # @option config [String] :border_color the border color
122
+ # @option config [Symbol, String] :color the color scheme
123
+ # @option config [String] :art the URL or path for card artwork
124
+ def initialize(config)
125
+ assign_attributes(config)
126
+ @template = Template.new
127
+ add_layers
128
+ end
129
+
130
+ # Save the card to an SVG file
131
+ #
132
+ # @param filename [String] the filename to save to
133
+ # @return [void]
134
+ def save(filename)
135
+ @template.save(filename)
136
+ end
137
+
138
+ # @private
139
+ def use_color_scheme(color)
140
+ if color
141
+ ColorScheme.new(color)
142
+ else
143
+ DEFAULT_COLOR_SCHEME
144
+ end
145
+ end
146
+
147
+ # Delegate dimension methods to DEFAULTS for LayerFactory compatibility
148
+ # @private
149
+ def card_width
150
+ CARD_WIDTH
151
+ end
152
+
153
+ # @private
154
+ def card_height
155
+ CARD_HEIGHT
156
+ end
157
+
158
+ # @private
159
+ def dimensions_for_layer(layer_name)
160
+ DEFAULTS[:layers][layer_name.to_sym] || {}
161
+ end
162
+
163
+ # @private
164
+ def art_window_config
165
+ DEFAULTS[:layers][:art_layer]
166
+ end
167
+
168
+ # @private
169
+ def frame_stroke_width
170
+ DEFAULTS[:frame_stroke_width]
171
+ end
172
+
173
+ private
174
+
175
+ def assign_attributes(config)
176
+ @name = config[:name]
177
+ @mana_cost = config[:mana_cost]
178
+ @type_line = config[:type_line]
179
+ @rules_text = config[:rules_text]
180
+ @flavor_text = config[:flavor_text]
181
+ @power = config[:power]
182
+ @toughness = config[:toughness]
183
+ @border_color = config[:border_color]
184
+ @color_scheme = use_color_scheme(config[:color])
185
+ @art = config[:art]
186
+ end
187
+
188
+ def define_art_window_mask
189
+ svg = @template.instance_variable_get(:@svg)
190
+ svg.defs do
191
+ svg.mask id: DEFAULTS[:mask_id] do
192
+ # White rectangle covers the entire card (opaque)
193
+ svg.rect x: 0, y: 0, width: '100%', height: '100%', fill: '#FFF'
194
+ # Black rectangle creates the transparent window at art position
195
+ art_config = art_window_config
196
+ svg.rect x: art_config[:x], y: art_config[:y],
197
+ width: art_config[:width], height: art_config[:height],
198
+ fill: '#000', rx: art_config[:corner_radius][:x], ry: art_config[:corner_radius][:y]
199
+ end
200
+ end
201
+ end
202
+
203
+ def add_layers
204
+ define_art_window_mask
205
+ # Use LayerFactory to create layers in order
206
+ layers = LayerFactory.create_layers_for_card(self, DEFAULTS[:mask_id], self)
207
+ layers.each { |layer| @template.add_layer(layer) }
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'yaml'
5
+ require 'fileutils'
6
+
7
+ module MtgCardMaker
8
+ # Thor-based command-line interface for MTG Card Maker.
9
+ # Provides commands for generating individual cards and sprite sheets
10
+ # from YAML configuration files.
11
+ #
12
+ # @example Generate a single card
13
+ # mtg_card_maker generate_card --name "Lightning Bolt" --type-text "Instant" \
14
+ # --rules-text "Lightning Bolt deals 3 damage to any target." --color red
15
+ #
16
+ # @example Generate a sprite sheet
17
+ # mtg_card_maker generate_sprite cards.yml sprite.svg
18
+ #
19
+ # @example Add a card to YAML file
20
+ # mtg_card_maker add_card cards.yml --name "Lightning Bolt" --type-text "Instant" \
21
+ # --rules-text "Lightning Bolt deals 3 damage to any target." --color red
22
+ #
23
+ # @since 0.1.0
24
+ class CLI < Thor # rubocop:disable Metrics/ClassLength
25
+ package_name 'mtg_card_maker'
26
+ map 'a' => :add_card
27
+ map 'ac' => :add_card
28
+ map 'g' => :generate_card
29
+ map 'gc' => :generate_card
30
+ map 'gcs'=> :generate_sprite
31
+ map 'gs' => :generate_sprite
32
+
33
+ def self.exit_on_failure?
34
+ true
35
+ end
36
+
37
+ # Define the standard card options for CLI commands
38
+ #
39
+ # @return [Hash] the card option definitions for Thor
40
+ def self.card_options
41
+ {
42
+ name: { type: :string, required: true, desc: 'Card name' },
43
+ mana_cost: { type: :string, aliases: ['mana', 'cost'], desc: 'Mana cost (e.g., "2RR", "XG")' },
44
+ type_line: { type: :string, required: true, aliases: ['type'],
45
+ desc: 'Card type & subtype (e.g., "Creature - Dragon", "Instant")' },
46
+ rules_text: { type: :string, required: true, aliases: ['rules'], desc: 'Card rules text' },
47
+ flavor_text: { type: :string, aliases: ['flavor'], desc: 'Flavor text (optional)' },
48
+ power: { type: :string, desc: 'Power (for creatures)' },
49
+ toughness: { type: :string, desc: 'Toughness (for creatures)' },
50
+ border_color: { type: :string, aliases: ['border'], desc: 'Border color (white, black, gold, silver)' },
51
+ color: { type: :string,
52
+ default: 'colorless',
53
+ desc: 'Card color (white, blue, black, red, green, colorless)' },
54
+ art: { type: :string, aliases: ['artwork', 'image'], desc: 'Image URL or path for card artwork' }
55
+ }
56
+ end
57
+
58
+ # Generate a single MTG card with specified parameters
59
+ #
60
+ # @option options [String] :name the card name (required)
61
+ # @option options [String] :mana_cost the mana cost in MTG notation
62
+ # @option options [String] :type_line the card type (required)
63
+ # @option options [String] :rules_text the card rules (required)
64
+ # @option options [String] :flavor_text the flavor text
65
+ # @option options [String] :power the power value for creatures
66
+ # @option options [String] :toughness the toughness value for creatures
67
+ # @option options [String] :border_color the border color
68
+ # @option options [String] :color the card color scheme
69
+ # @option options [String] :art the URL for card artwork
70
+ # @option options [String] :output the output filename (default: output_card.svg)
71
+ # @return [void]
72
+ # @!method generate_card
73
+ # Generate a single MTG card with specified parameters
74
+ desc 'generate_card [OPTIONS]', 'Generate a single MTG card with specified parameters'
75
+ card_options.each { |option, config| option option, config }
76
+ option :output, type: :string, default: 'output_card.svg', desc: 'Output filename'
77
+ def generate_card
78
+ config = build_card_config_from_options
79
+ # Convert string keys to symbols for BaseCard compatibility
80
+ config = config.transform_keys(&:to_sym)
81
+ card = BaseCard.new(config)
82
+ card.save(options[:output])
83
+ puts "✨ Generated #{options[:output]}! ✨"
84
+ end
85
+
86
+ # @!method generate_sprite(yaml_file, output_file)
87
+ # Generate a sprite sheet from YAML configuration
88
+ desc 'generate_sprite YAML_FILE OUTPUT_FILE [OPTIONS]', 'Generate a sprite sheet from YAML configuration'
89
+ option :cards_per_row, type: :numeric, default: 4, desc: 'Number of cards per row in sprite'
90
+ option :spacing, type: :numeric, default: 30, desc: 'Spacing between cards in pixels'
91
+ def generate_sprite(yaml_file, output_file)
92
+ validate_yaml_file(yaml_file)
93
+
94
+ begin
95
+ config = load_yaml_config(yaml_file)
96
+ sprite_service = create_sprite_service
97
+ process_sprite_generation(config, sprite_service, output_file)
98
+ rescue Psych::SyntaxError => e
99
+ handle_yaml_error(e)
100
+ rescue StandardError => e
101
+ handle_general_error(e)
102
+ end
103
+ end
104
+
105
+ # @!method add_card(yaml_file)
106
+ # Add a new card configuration to YAML file
107
+ desc 'add_card YAML_FILE [OPTIONS]', 'Add a new card configuration to YAML file'
108
+ card_options.each { |option, config| option option, config }
109
+ def add_card(yaml_file)
110
+ # Create directory if it doesn't exist
111
+ FileUtils.mkdir_p(File.dirname(yaml_file))
112
+
113
+ # Load existing config or create new one
114
+ config = File.exist?(yaml_file) ? YAML.safe_load_file(yaml_file) : {}
115
+
116
+ # Generate a unique key for the card
117
+ card_key = generate_card_key(options[:name], config)
118
+
119
+ # Build card configuration
120
+ card_config = build_card_config_from_options
121
+
122
+ # Add to config
123
+ config[card_key] = card_config
124
+
125
+ # Save back to file
126
+ File.write(yaml_file, config.to_yaml)
127
+ puts "✨ Added card '#{options[:name]}' to #{yaml_file}! ✨"
128
+ puts "🎴 Key: #{card_key}"
129
+ end
130
+
131
+ private
132
+
133
+ def validate_yaml_file(yaml_file)
134
+ return if File.exist?(yaml_file)
135
+
136
+ warn "❌ YAML file not found: #{yaml_file}"
137
+ exit 1
138
+ end
139
+
140
+ def process_sprite_generation(config, sprite_service, output_file)
141
+ if sprite_service.create_sprite_sheet(config, output_file)
142
+ display_success_message(config, sprite_service, output_file)
143
+ else
144
+ warn '❌ Failed to generate sprite sheet'
145
+ exit 1
146
+ end
147
+ end
148
+
149
+ def handle_yaml_error(error)
150
+ warn "❌ Invalid YAML syntax: #{error.message}"
151
+ exit 1
152
+ end
153
+
154
+ def handle_general_error(error)
155
+ warn "💥 Error: #{error.message}"
156
+ exit 1
157
+ end
158
+
159
+ def display_success_message(config, sprite_service, output_file)
160
+ puts "✨ Generated #{output_file}! ✨"
161
+ width, height = sprite_service.sprite_dimensions(config.length)
162
+ puts "📏 Sprite dimensions: #{width}x#{height} pixels"
163
+ puts "🎨 Contains #{config.length} cards"
164
+ end
165
+
166
+ def load_yaml_config(yaml_file)
167
+ YAML.safe_load_file(yaml_file)
168
+ end
169
+
170
+ def create_sprite_service
171
+ SpriteSheetService.new(
172
+ cards_per_row: options[:cards_per_row],
173
+ spacing: options[:spacing]
174
+ )
175
+ end
176
+
177
+ def build_card_config_from_options
178
+ config = build_required_config
179
+ add_optional_fields(config)
180
+ config
181
+ end
182
+
183
+ def build_required_config
184
+ {
185
+ 'name' => options[:name],
186
+ 'type_line' => options[:type_line],
187
+ 'rules_text' => process_newlines(options[:rules_text])
188
+ }
189
+ end
190
+
191
+ def add_optional_fields(config)
192
+ optional_fields = %w[mana_cost power toughness border_color color art]
193
+ optional_fields.each do |field|
194
+ config[field] = options[field.to_sym] if options[field.to_sym]
195
+ end
196
+
197
+ # Handle flavor_text separately to process newlines
198
+ return unless options[:flavor_text]
199
+
200
+ config['flavor_text'] = process_newlines(options[:flavor_text])
201
+ end
202
+
203
+ def process_newlines(text)
204
+ # Convert literal \n to actual newlines
205
+ text.gsub('\\n', "\n")
206
+ end
207
+
208
+ def generate_card_key(name, existing_config)
209
+ base_key = name.downcase.gsub(/[^a-z0-9]/, '_').squeeze('_').chomp('_')
210
+
211
+ # If key already exists, append a number
212
+ if existing_config.key?(base_key)
213
+ counter = 1
214
+ counter += 1 while existing_config.key?("#{base_key}_#{counter}")
215
+ "#{base_key}_#{counter}"
216
+ else
217
+ base_key
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MtgCardMaker
4
+ # ColorPalette aggregates the five core interface colors for cohesive design.
5
+ # It focuses on background elements, buttons, borders, and other UI components,
6
+ # excluding text color. Multiple instances can be created for different themes
7
+ # (default, dark, corporate, etc.).
8
+ #
9
+ # @example
10
+ # palette = MtgCardMaker::ColorPalette.new(
11
+ # primary_color: '#42A5F5',
12
+ # background_color: '#E3F2FD',
13
+ # border_color: '#1565C0'
14
+ # )
15
+ # palette.primary_color # => "#42A5F5"
16
+ #
17
+ # @example Using predefined themes
18
+ # dark_palette = MtgCardMaker::ColorPalette.dark
19
+ # light_palette = MtgCardMaker::ColorPalette.light
20
+ # default_palette = MtgCardMaker::ColorPalette.default
21
+ #
22
+ # @since 0.1.0
23
+ class ColorPalette
24
+ # @return [String] the primary color
25
+ attr_reader :primary_color
26
+
27
+ # @return [String] the background color
28
+ attr_reader :background_color
29
+
30
+ # @return [String] the border color
31
+ attr_reader :border_color
32
+
33
+ # @return [String] the frame stroke color
34
+ attr_reader :frame_stroke_color
35
+
36
+ # @return [String] the accent color
37
+ attr_reader :accent_color
38
+
39
+ # Default frame stroke color used across the application
40
+ # @return [String] the default frame stroke color
41
+ FRAME_STROKE_COLOR = '#111'
42
+
43
+ # Initialize a new color palette
44
+ #
45
+ # @param primary_color [String, nil] the primary color (default: from default color scheme)
46
+ # @param background_color [String, nil] the background color (default: from default color scheme)
47
+ # @param border_color [String, nil] the border color (default: from default color scheme)
48
+ # @param frame_stroke_color [String] the frame stroke color (default: FRAME_STROKE_COLOR)
49
+ # @param accent_color [String, nil] the accent color (default: from default color scheme)
50
+ def initialize(primary_color: nil, background_color: nil, border_color: nil,
51
+ frame_stroke_color: FRAME_STROKE_COLOR, accent_color: nil)
52
+ @primary_color = primary_color || DEFAULT_COLOR_SCHEME.primary_color
53
+ @background_color = background_color || DEFAULT_COLOR_SCHEME.background_color
54
+ @border_color = border_color || DEFAULT_COLOR_SCHEME.border_color
55
+ @frame_stroke_color = frame_stroke_color
56
+ @accent_color = accent_color || DEFAULT_COLOR_SCHEME.primary_color
57
+ end
58
+
59
+ # Create a palette from a color scheme
60
+ #
61
+ # @param color_scheme [ColorScheme] the color scheme to create palette from
62
+ # @return [ColorPalette] a new color palette based on the color scheme
63
+ def self.from_color_scheme(color_scheme)
64
+ new(
65
+ primary_color: color_scheme.primary_color,
66
+ background_color: color_scheme.background_color,
67
+ border_color: color_scheme.border_color,
68
+ accent_color: color_scheme.primary_color
69
+ )
70
+ end
71
+
72
+ # Default palette using the default color scheme
73
+ #
74
+ # @return [ColorPalette] the default color palette
75
+ def self.default
76
+ from_color_scheme(DEFAULT_COLOR_SCHEME)
77
+ end
78
+
79
+ # Dark theme palette
80
+ #
81
+ # @return [ColorPalette] a dark theme color palette
82
+ def self.dark
83
+ new(
84
+ primary_color: '#2A2A2A',
85
+ background_color: '#1A1A1A',
86
+ border_color: '#4A4A4A',
87
+ frame_stroke_color: '#333',
88
+ accent_color: '#6B6B6B'
89
+ )
90
+ end
91
+
92
+ # Light theme palette
93
+ #
94
+ # @return [ColorPalette] a light theme color palette
95
+ def self.light
96
+ new(
97
+ primary_color: '#E8E8E8',
98
+ background_color: '#F5F5F5',
99
+ border_color: '#D4D4D4',
100
+ frame_stroke_color: '#666',
101
+ accent_color: '#8B8B8B'
102
+ )
103
+ end
104
+
105
+ # Return all colors as a hash for easy access
106
+ def to_h
107
+ {
108
+ primary_color: @primary_color,
109
+ background_color: @background_color,
110
+ border_color: @border_color,
111
+ frame_stroke_color: @frame_stroke_color,
112
+ accent_color: @accent_color
113
+ }
114
+ end
115
+
116
+ # Return all colors as an array
117
+ def to_a
118
+ [@primary_color, @background_color, @border_color, @frame_stroke_color, @accent_color]
119
+ end
120
+
121
+ # Check if this palette matches another
122
+ def ==(other)
123
+ return false unless other.is_a?(ColorPalette)
124
+
125
+ to_h == other.to_h
126
+ end
127
+
128
+ alias_method :eql?, :==
129
+
130
+ # Generate a hash for this palette
131
+ def hash
132
+ to_h.hash
133
+ end
134
+ end
135
+ end