Quick & dirty deck builder for Tabletop Simulator. No command line help yet. Configure at the ed of the script!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# encoding: utf-8 | |
# tested with Ruby 2.7.2 | |
# Builds a deck of cards or similar objects using a list of input images and (optional) multiplicators. | |
# All cards need to have same size | |
class TableTopDeckBuilder | |
attr_accessor :name, :glob, :back_image, :hidden_card_image, :mults, :remove_intermediates | |
attr_accessor :cards, :card_width, :card_height | |
# Creates a deck builder. | |
# param name: Name of the deck, used as the resulting filename. | |
# param glob: Glob to read the images needed for this deck. | |
# param back_image: Name of the image for the back side | |
# param hidden_card_image: Name of the image to be used as the face in hidden hands. If not set, TTS needs to use the back image. | |
# param mults: List of integers defining multipliers of each image. | |
# The order of the images is expected as the sorted glob. | |
# If not given, the default is 1 for each image. | |
# TODO: Will be changed in the future to allow a hash to be given. | |
# param remove_intermediates: If set, all intermediate image files will be removed. Disable for debugging. | |
def initialize(name:, glob:, back_image:, hidden_card_image: nil, mults: nil, remove_intermediates: true) | |
@name = name | |
@glob = glob | |
@back_image = back_image | |
@hidden_card_image = hidden_card_image | |
@remove_intermediates = remove_intermediates | |
@mults = mults | |
end | |
def build | |
prepare_deck! | |
deck_size = hidden_card_image ? 69 : 70 | |
deck_number = cards.length > deck_size ? 1 : nil | |
while cards.length > 0 do | |
deck = Deck.new( | |
name: deck_name(name, deck_number), | |
cards: cards.slice!(0, deck_size), | |
card_width: card_width, | |
card_height: card_height, | |
back_image: back_image, | |
hidden_card_image: hidden_card_image, | |
remove_intermediates: remove_intermediates | |
) | |
deck.build | |
deck_number += 1 if deck_number | |
end | |
end | |
private | |
def prepare_deck! | |
images = read_images | |
@mults = [1] * images.length unless mults | |
abort "Error: No images found" if images.length == 0 | |
abort "Error: Number of multiplicators doesn't match number of images found with glob (#{mults.length} ≠ #{images.length})." if mults.length != images.length | |
@cards = [] | |
images.each_with_index do |image, index| | |
@cards.concat([image] * mults[index]) | |
end | |
end | |
def read_images | |
images = [] | |
Dir.glob(glob).sort.each do |file| | |
check_image!(file) | |
images << file | |
end | |
check_image!(back_image) | |
check_image!(hidden_card_image) if hidden_card_image | |
images | |
end | |
def check_image!(file) | |
w, h = image_size(file) | |
if @card_width | |
abort "Error: Image sizes don't match with previous: #{file}" if w != @card_width || h != @card_height | |
else | |
@card_width = w | |
@card_height = h | |
end | |
end | |
def deck_name(name, number) | |
return name unless number | |
"#{name}_#{number}" | |
end | |
def image_size(file) | |
result = %x(identify -format '%w %h' '#{file}') | |
result.split(" ").map(&:to_i) | |
end | |
end | |
class Deck | |
attr_accessor :name, :cards, :card_width, :card_height, :back_image, :hidden_card_image, :remove_intermediates | |
attr_accessor :columns, :rows | |
def initialize(name:, cards:, card_width:, card_height:, back_image:, hidden_card_image: nil, remove_intermediates: true) | |
@name = name | |
@cards = cards | |
@card_width = card_width | |
@card_height = card_height | |
@back_image = back_image | |
@hidden_card_image = hidden_card_image | |
@remove_intermediates = remove_intermediates | |
find_layout! | |
end | |
def build | |
intermediates = [] | |
total = cards.length | |
final_name = "#{name}.png" | |
vert_command = ["convert"] | |
(1..rows).each do |row| | |
# create image for row, appending all images | |
row_name = "#{name}-row-#{row}.png" | |
intermediates << row_name | |
horz_command = ["convert"] | |
horz_command.concat(cards.slice!(0, columns)) | |
horz_command << "+append" | |
horz_command << "-depth" | |
horz_command << "8" | |
horz_command << row_name | |
vert_command << horz_command[-1] | |
run_system(horz_command) | |
end | |
# create the deck image appending all row images | |
if rows == 1 | |
vert_command.concat %W(-background white -gravity south -splice 0x#{card_height}) | |
else | |
vert_command.concat %w(-append -background white) | |
end | |
horz_name = "#{name}-deck.png" | |
vert_command << horz_name | |
run_system(vert_command) | |
if hidden_card_image | |
hidden_command = ["convert"] | |
hidden_command << horz_name | |
hidden_command << hidden_card_image | |
hidden_command.concat %w(-gravity southeast -compose over -composite) | |
hidden_command << final_name | |
run_system(hidden_command) | |
else | |
run_system(["mv", horz_name, final_name]) | |
end | |
run_system(["cp", back_image, back_image_name]) | |
run_system(["rm", *intermediates]) if remove_intermediates | |
puts "Deck built. Now open Tabletop Simulator, choose Objects -> Components -> Cards -> Custom Deck:" | |
puts "-" * 60 | |
puts "Face: #{name}.png" | |
puts "Unique backs: No" | |
puts "Back: #{back_image_name}" | |
puts "Width: #{columns}" | |
puts "Height: #{rows}" | |
puts "Number: #{total}" | |
puts "Sideways: <only you can tell>" | |
puts "Back is Hidden: #{hidden_card_image ? "No" : "Yes"}" | |
puts "-" * 60 | |
end | |
private | |
def back_image_name | |
"#{name}_back.png" | |
end | |
def find_layout! | |
@columns, @rows = find_factors(cards.length + (hidden_card_image ? 1 : 0)) | |
end | |
# Finds the factorization of a number that provides the most balanced width and height. | |
# Rectangle is constraint to 2..10 x 2..7. | |
def find_factors(number) | |
return [2,2] if number <= 4 | |
factors = [] | |
(2..number/2).each do |n| | |
if number % n == 0 | |
factors << [n, number / n] | |
end | |
end | |
factors.each do |fac| | |
fac.sort! | |
end | |
factors.sort!.uniq!&.reject! { |e| e[0] < 2 || e[0] > 7 || e[1] > 10 } | |
return factors[-1].reverse if factors.any? | |
return find_factors(number+1) | |
end | |
def run_system(command) | |
unless system(*command) | |
raise "Error: $?" | |
end | |
end | |
end | |
# The following call expects that | |
# - there is a directory named "Images" with a couple of Token_* images (see arguments) | |
# - there is a directory named "Decks" | |
# Output will be written to "Decks" | |
TableTopDeckBuilder.new( | |
name: "Decks/Deck_Token_RKDH_PA", | |
glob: "Images/Token_RKDH_PA_??.png", | |
back_image: "Images/Token_PA_back.png", | |
hidden_card_image: nil, | |
remove_intermediates: true | |
).build | |
# Same as above but this time the there is another hidden image used for faced up cards in other players' hands. | |
TableTopDeckBuilder.new( | |
name: "Decks/Deck_With_Hidden_Token_RKDH_PA", | |
glob: "Images/Token_RKDH_PA_??.png", | |
back_image: "Images/Token_PA_back.png", | |
hidden_card_image: "Images/Token_PA_hidden.png", | |
remove_intermediates: true | |
).build |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment