boxespy/boxes/generators/agricolainsert.py

935 lines
30 KiB
Python

#!/usr/bin/env python3
# Copyright (C) 2020 Guillaume Collic
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import math
from functools import partial
from boxes import Boxes, edges
from .dividertray import (
SlotDescriptionsGenerator,
DividerSlotsEdge,
)
class AgricolaInsert(Boxes):
"""
Agricola Revised Edition game box insert, including some expansions.
"""
ui_group = "Misc"
description = """
This insert was designed with 3 mm plywood in mind, and should work fine with
materials around this thickness.
This is an insert for the [Agricola Revised Edition](https://boardgamegeek.com/boardgame/200680/agricola-revised-edition)
board game. It is specifically designed around the [Farmers Of The Moor expansion](https://boardgamegeek.com/boardgameexpansion/257344/agricola-farmers-moor),
and should also store the [5-6 players expansion](https://boardgamegeek.com/boardgameexpansion/210625/agricola-expansion-5-and-6-players)
(not tested, but I tried to take everything into account for it, please inform
us if you tested it).
It can be stored inside the original game box, including the 2 expansions,
with the lid slightly raised.
The parts of a given element are mostly generated next to each other vertically.
It should be straightforward to match them.
Here are the different elements, from left to right in the generated file.
#### Card tray
The cards are all kept in a tray, with paper dividers to sort them easily. When
the tray is not full of cards, wood dividers slides in slots in order to keep
the cards from falling into the empty space.
There should be enough space for the main game, Farmers Of The Moor, and the 5-6
player expansion, but not much more than that.
To keep a lower profile, the cards are at a slight angle, and the paper dividers
tabs are horizontal instead of vertical.
A small wall keeps the card against one side while the tabs protrude on the
other side, above the small wall.
The wall with the big hole is the sloped one. It goes between the two
"comb-like" walls first, with its two small holes at the bottom. Then there is a
low-height long wall with a sloped edge which should go from the sloped wall to
the other side. You can finish the tray with the last wall at the end.
#### Upper level trays
4 trays with movable walls are used to store resources. They were designed to
store them in this order:
* Stone / Vegetable / Pig / Cow
* Reed / Grain / Sheep
* Wood / Clay
* Food / Fire
The wall would probably be better if fixed instead of movable, but I would like
to test with the 5-6 player expansion to be sure their positions are correct
with it too.
The little feet of the movable wall should be glued. The triangles are put
horizontally, with their bases towards the sides.
#### Lower level tray
The lower level tray is used to store the horses.
#### Room/Field tiles
Two boxes are generated to store the room/field tiles. One for the wood/field,
the other for the clay/stone. They are stored with the main opening upside, but
I prefer to use them during play with this face on the side.
#### Moor/Forest and miscellaneous tiles
A box is generated to store the Moor/Forest tiles, and some other tiles such as
the "multiple resources" cardboard tokens.
The Moor/Forest tiles are at the same height as the Room/Field, and the upper
level trays are directly on them. The horse box and player box are slightly
lower. This Moor/Forest box have a lowered corner (the one for the miscellaneous
tiles). Two cardboard pieces can be stored between the smaller boxes and the
upper level trays (as seen on the picture).
Be sure to match the pieces so that the walls with smaller heights are next to
each other.
#### Players bit boxes
Each player has its own box where the bits of his color are stored.
The cardboard bed from Farmers Of The Moor is central to this box.
* The fences are stored inside the bed
* The bed is placed in the box, with holes to keep it there (and to take less
height)
* The stables are stored in the two corners
* The five farmers are stored between the bed and the three walls, alternatively
head up and head down.
During assembly, the small bars are put in the middle holes. The two bigger
holes at the ends are used for the bed feet. The bar keeps the bed from
protruding underneath.
"""
def __init__(self) -> None:
Boxes.__init__(self)
self.addSettingsArgs(edges.FingerJointSettings, surroundingspaces=1.0)
def render(self):
player_box_height = 34.5
player_box_inner_width = 50.5
bigger_box_inner_height = 36.7
row_width = 37.2
tray_inner_height = 17
box_width = 218
card_tray_height = (
self.thickness * 2 + tray_inner_height + bigger_box_inner_height
)
card_tray_width = (
305.35 - player_box_inner_width * 2 - row_width * 2 - 9 * self.thickness
)
self.render_card_divider_tray(card_tray_height, box_width, card_tray_width)
self.render_upper_token_trays(tray_inner_height, box_width)
wood_room_box_width = 39.8
self.render_room_box(wood_room_box_width, bigger_box_inner_height, row_width)
stone_room_box_width = 26.7
self.render_room_box(stone_room_box_width, bigger_box_inner_height, row_width)
moor_box_length = 84.6
self.render_moor_box(
bigger_box_inner_height, player_box_height, row_width, moor_box_length
)
horse_box_margin = 0.5
horse_box_length = (
box_width
- wood_room_box_width
- stone_room_box_width
- moor_box_length
- 6 * self.thickness
- horse_box_margin
)
self.render_horse_box(player_box_height, row_width, horse_box_length)
for _ in range(6):
self.render_player_box(player_box_height, player_box_inner_width)
def render_card_divider_tray(
self, card_tray_height, card_tray_length, card_tray_width
):
"""
The whole tray which contains the cards, including its dividers.
Cards are at an angle, to save height.
"""
self.ctx.save()
tray_inner_length = card_tray_length - self.thickness
margin_for_score_sheet = 0 # 3 if you want more space for score sheet
sleeved_cards_width = 62 + margin_for_score_sheet
rad = math.acos(card_tray_height / sleeved_cards_width)
angle = math.degrees(rad)
cos = math.cos(rad)
tan = math.tan(rad)
sin = math.sin(rad)
slots_number = 19
slot_depth = 30
slot_descriptions = SlotDescriptionsGenerator().generate_all_same_angles(
[tray_inner_length / slots_number for _ in range(slots_number)],
self.thickness,
0.2,
slot_depth,
card_tray_height,
angle,
)
slot_descriptions.adjust_to_target_length(tray_inner_length)
sloped_wall_height = sleeved_cards_width - self.thickness * (tan + 1 / tan)
sloped_wall_posx_at_y0 = (
tray_inner_length - sloped_wall_height * tan - cos * self.thickness
)
sloped_wall_posx = sloped_wall_posx_at_y0 + cos * self.thickness / 2
sloped_wall_posy = sin * self.thickness / 2
dse = DividerSlotsEdge(self, slot_descriptions.descriptions)
for _ in range(2):
self.rectangularWall(
tray_inner_length,
card_tray_height,
["e", "e", dse, "f"],
move="up",
callback=[
partial(
lambda: self.fingerHolesAt(
sloped_wall_posx,
sloped_wall_posy,
sloped_wall_height,
angle=90 - angle,
)
)
],
)
# generate spacer
spacer_height = card_tray_height / 2
spacer_spacing = card_tray_width-99.8
spacer_upper_width = sloped_wall_posx_at_y0 + spacer_height * tan
self.trapezoidWall(
spacer_height,
spacer_upper_width,
sloped_wall_posx_at_y0,
"fefe",
move="up rotated",
)
self.rectangularWall(
card_tray_width,
card_tray_height,
"eFeF",
move="up",
callback=[
partial(
lambda: self.fingerHolesAt(
spacer_spacing - self.thickness / 2, 0, spacer_height
)
)
],
)
self.rectangularWall(
card_tray_width,
sloped_wall_height,
"efef",
move="up",
callback=[
partial(
self.generate_card_tray_sloped_wall_holes,
card_tray_width,
sloped_wall_height,
spacer_height,
spacer_spacing,
rad,
)
],
)
self.ctx.restore()
self.rectangularWall(card_tray_length, 0, "FFFF", move="right only")
self.ctx.save()
divider_height = sleeved_cards_width - self.thickness * tan
self.generate_divider(
card_tray_width, divider_height, slot_depth, spacer_spacing, "up"
)
self.explain(
[
"Wood divider",
"Hard separation to keep the card",
"from slipping in empty space left.",
"Takes more space, but won't move.",
"Duplicate as much as you want",
"(I use 2).",
]
)
self.ctx.restore()
self.rectangularWall(card_tray_width, 0, "ffff", move="right only")
self.ctx.save()
self.generate_paper_divider(
card_tray_width, sleeved_cards_width, slot_depth, spacer_spacing, "up"
)
self.explain(
[
"Paper divider",
"Soft separation to search easily",
"the card group you need",
"(by expansion, number of player,",
"etc.).",
"Duplicate as much as you want",
"(I use 7).",
]
)
self.ctx.restore()
self.rectangularWall(card_tray_width, 0, "ffff", move="right only")
def explain(self, strings):
self.text(
str.join(
"\n",
strings,
),
fontsize=7,
align="bottom left",
)
def generate_sloped_wall_holes(self, side_wall_length, rad, sloped_wall_height):
cos = math.cos(rad)
tan = math.tan(rad)
sin = math.sin(rad)
posx_at_y0 = side_wall_length - sloped_wall_height * tan
posx = posx_at_y0 - cos * self.thickness / 2
posy = sin * self.thickness / 2
self.fingerHolesAt(posx, posy, sloped_wall_height, angle=90 - math.degrees(rad))
def generate_card_tray_sloped_wall_holes(
self, side_wall_length, sloped_wall_height, spacer_height, spacer_spacing, rad
):
# Spacer finger holes
self.fingerHolesAt(
side_wall_length - (spacer_spacing - self.thickness / 2),
# the sloped wall doesn't exactly touch the bottom of the spacer
-self.thickness * math.tan(rad),
spacer_height / math.cos(rad),
)
# Big hole to access "lost" space behind sloped wall
radius = 5
padding = 8
total_loss = 2 * radius + 2 * padding
self.moveTo(radius + padding, padding)
self.polyline(
side_wall_length - total_loss,
(90, radius),
sloped_wall_height - total_loss,
(90, radius),
side_wall_length - total_loss,
(90, radius),
sloped_wall_height - total_loss,
(90, radius),
)
def generate_paper_divider(self, width, height, slot_depth, spacer_spacing, move):
"""
A card separation made of paper, which moves freely in the card tray.
Takes less space and easy to manipulate, but won't block cards in place.
"""
if self.move(width, height, move, True):
return
margin = 0.5
actual_width = width - margin
self.polyline(
actual_width - spacer_spacing,
90,
height - slot_depth,
-90,
spacer_spacing,
90,
slot_depth,
90,
actual_width,
90,
height,
90,
)
# Move for next piece
self.move(width, height, move)
def generate_divider(self, width, height, slot_depth, spacer_spacing, move):
"""
A card separation made of wood which slides in the side slots.
Can be useful to do hard separations, but takes more space and
less movable than the paper ones.
"""
total_width = width + 2 * self.thickness
if self.move(total_width, height, move, True):
return
radius = 16
padding = 20
divider_notch_depth = 35
self.polyline(
self.thickness + spacer_spacing + padding - radius,
(90, radius),
divider_notch_depth - radius - radius,
(-90, radius),
width - 2 * radius - 2 * padding - spacer_spacing,
(-90, radius),
divider_notch_depth - radius - radius,
(90, radius),
self.thickness + padding - radius,
90,
slot_depth,
90,
self.thickness,
-90,
height - slot_depth,
90,
width - spacer_spacing,
90,
height - slot_depth,
-90,
self.thickness + spacer_spacing,
90,
slot_depth,
)
# Move for next piece
self.move(total_width, height, move)
def render_horse_box(self, player_box_height, row_width, width):
"""
Box for the horses on lower level. Same height as player boxes.
"""
length = 2 * row_width + 3 * self.thickness
self.render_simple_tray(width, length, player_box_height)
def render_moor_box(
self, bigger_box_inner_height, player_box_height, row_width, length
):
"""
Box for the moor/forest tiles, and the cardboard tokens with multiple
units of resources.
A corner is lowered (the one for the tokens) at the same height as player boxes
to store 2 levels of small boards there.
"""
self.ctx.save()
height = bigger_box_inner_height
lowered_height = player_box_height - self.thickness
lowered_corner_height = height - lowered_height
corner_length = 53.5
self.rectangularWall(
length,
2 * row_width + self.thickness,
"FfFf",
move="up",
callback=[
partial(
lambda: self.fingerHolesAt(
0, row_width + 0.5 * self.thickness, length, 0
)
)
],
)
for i in range(2):
self.rectangularWall(
length,
lowered_height,
[
"f",
"f",
MoorBoxSideEdge(
self, corner_length, lowered_corner_height, i % 2 == 0
),
"f",
],
move="up",
)
self.rectangularWall(length, height / 2, "ffef", move="up")
for i in range(2):
self.rectangularWall(
2 * row_width + self.thickness,
lowered_height,
[
"F",
"F",
MoorBoxHoleEdge(self, height, lowered_corner_height, i % 2 == 0),
"F",
],
move="up",
callback=[
partial(self.generate_side_finger_holes, row_width, height / 2)
],
)
self.ctx.restore()
self.rectangularWall(length, 0, "FFFF", move="right only")
def generate_side_finger_holes(self, row_width, height):
self.fingerHolesAt(row_width + 0.5 * self.thickness, 0, height)
def render_room_box(self, width, height, row_width):
"""
A box in which storing room/field tiles.
"""
border_height = 12
room_box_length = row_width * 2 + self.thickness
self.ctx.save()
self.rectangularWall(
room_box_length,
height,
"eFfF",
move="up",
callback=[partial(self.generate_side_finger_holes, row_width, height)],
)
self.rectangularWall(
room_box_length,
width,
"FFfF",
move="up",
callback=[partial(self.generate_side_finger_holes, row_width, width)],
)
self.rectangularWall(
room_box_length,
border_height,
"FFeF",
move="up",
callback=[
partial(self.generate_side_finger_holes, row_width, border_height)
],
)
for _ in range(3):
self.trapezoidWall(width, height, border_height, "ffef", move="up")
self.ctx.restore()
self.rectangularWall(room_box_length, 0, "FFFF", move="right only")
def render_player_box(self, player_box_height, player_box_inner_width):
"""
A box in which storing all the bits of a single player,
including (and designed for) the cardboard bed from Farmers Of The Moor.
"""
self.ctx.save()
bed_inner_height = player_box_height - self.thickness
bed_inner_length = 66.75
bed_inner_width = player_box_inner_width
cardboard_bed_foot_height = 6.5
cardboard_bed_hole_margin = 5
cardboard_bed_hole_length = 12
bed_head_length = 20
bed_foot_height = 18
support_length = 38
bed_edge = Bed2SidesEdge(
self, bed_inner_length, bed_head_length, bed_foot_height
)
noop_edge = NoopEdge(self)
self.ctx.save()
optim_180_x = (
bed_inner_length + self.thickness + bed_head_length + 2 * self.spacing
)
optim_180_y = 2 * bed_foot_height - player_box_height + 2 * self.spacing
for _ in range(2):
self.rectangularWall(
bed_inner_length,
bed_inner_height,
["F", bed_edge, noop_edge, "F"],
move="up",
)
self.moveTo(optim_180_x, optim_180_y, -180)
self.ctx.restore()
self.moveTo(0, bed_inner_height + self.thickness + self.spacing + optim_180_y)
self.rectangularWall(
bed_inner_length,
bed_inner_width,
"feff",
move="up",
callback=[
partial(
self.generate_bed_holes,
bed_inner_width,
cardboard_bed_hole_margin,
cardboard_bed_hole_length,
support_length,
)
],
)
self.ctx.save()
self.rectangularWall(
bed_inner_width,
bed_inner_height,
["F", "f", BedHeadEdge(self, bed_inner_height - 15), "f"],
move="right",
)
for _ in range(2):
self.rectangularWall(
cardboard_bed_foot_height - self.thickness,
support_length,
"efee",
move="right",
)
self.ctx.restore()
self.rectangularWall(
bed_inner_width,
bed_inner_height,
"Ffef",
move="up only",
)
self.ctx.restore()
self.rectangularWall(
bed_inner_length + bed_head_length + self.spacing - self.thickness,
0,
"FFFF",
move="right only",
)
def generate_bed_holes(self, width, margin, hole_length, support_length):
support_start = margin + hole_length
bed_width = 29.5
bed_space_to_wall = (width - bed_width) / 2
bed_feet_width = 3
posy_1 = bed_space_to_wall
posy_2 = width - bed_space_to_wall
for y, direction in [(posy_1, 1), (posy_2, -1)]:
bed_feet_middle_y = y + direction * bed_feet_width / 2
support_middle_y = y + direction * self.thickness / 2
self.rectangularHole(
margin,
bed_feet_middle_y,
hole_length,
bed_feet_width,
center_x=False,
)
self.fingerHolesAt(support_start, support_middle_y, support_length, angle=0)
self.rectangularHole(
support_start + support_length,
bed_feet_middle_y,
hole_length,
bed_feet_width,
center_x=False,
)
def render_upper_token_trays(self, tray_inner_height, box_width):
"""
Upper level : multiple trays for each resource
(beside horses which are on the lower level)
"""
tray_height = tray_inner_height + self.thickness
upper_level_width = 196
upper_level_length = box_width
row_width = upper_level_width / 3
# Stone / Vegetable / Pig / Cow
self.render_simple_tray(row_width, upper_level_length, tray_height, 3)
# Reed / Grain / Sheep
self.render_simple_tray(row_width, upper_level_length * 2 / 3, tray_height, 2)
# Wood / Clay
self.render_simple_tray(row_width, upper_level_length * 2 / 3, tray_height, 1)
# Food / Fire
self.render_simple_tray(upper_level_length / 3, row_width * 2, tray_height, 1)
def render_simple_tray(self, outer_width, outer_length, outer_height, dividers=0):
"""
One of the upper level trays, with movable dividers.
"""
width = outer_width - 2 * self.thickness
length = outer_length - 2 * self.thickness
height = outer_height - self.thickness
self.ctx.save()
self.rectangularWall(width, length, "FFFF", move="up")
for _ in range(2):
self.rectangularWall(width, height, "ffef", move="up")
self.ctx.restore()
self.rectangularWall(width, length, "FFFF", move="right only")
for _ in range(2):
self.rectangularWall(height, length, "FfFe", move="right")
if dividers:
self.ctx.save()
for _ in range(dividers):
self.render_simple_tray_divider(width, height, "up")
self.ctx.restore()
self.render_simple_tray_divider(width, height, "right only")
def render_simple_tray_divider(self, width, height, move):
"""
Simple movable divider. A wall with small feet for a little more stability.
"""
if self.move(height, width, move, True):
return
t = self.thickness
self.polyline(
height - t,
90,
t,
-90,
t,
90,
width - 2 * t,
90,
t,
-90,
t,
90,
height - t,
90,
width,
90,
)
self.move(height, width, move)
self.render_simple_tray_divider_feet(width, height, move)
def render_simple_tray_divider_feet(self, width, height, move):
sqr2 = math.sqrt(2)
t = self.thickness
divider_foot_width = 2 * t
full_width = t + 2 * divider_foot_width
move_length = self.spacing + full_width / sqr2
move_width = self.spacing + max(full_width, height)
if self.move(move_width, move_length, move, True):
return
self.ctx.save()
self.polyline(
sqr2 * divider_foot_width,
135,
t,
-90,
t,
-90,
t,
135,
sqr2 * divider_foot_width,
135,
full_width,
135,
)
self.ctx.restore()
self.moveTo(-self.burn / sqr2, self.burn * (1 + 1 / sqr2), 45)
self.moveTo(full_width)
self.polyline(
0,
135,
sqr2 * divider_foot_width,
135,
t,
-90,
t,
-90,
t,
135,
sqr2 * divider_foot_width,
135,
)
self.move(move_width, move_length, move)
class MoorBoxSideEdge(edges.BaseEdge):
"""
Edge for the sides of the moor tiles box
"""
def __init__(self, boxes, corner_length, corner_height, lower_corner) -> None:
super().__init__(boxes, None)
self.corner_height = corner_height
self.lower_corner = lower_corner
self.corner_length = corner_length
def __call__(self, length, **kw):
radius = self.corner_height / 2
if self.lower_corner:
self.polyline(
length - self.corner_height - self.corner_length,
(90, radius),
0,
(-90, radius),
self.corner_length,
)
else:
self.polyline(length)
def startwidth(self) -> float:
return self.corner_height
def endwidth(self) -> float:
return 0.0 if self.lower_corner else self.corner_height
class MoorBoxHoleEdge(edges.BaseEdge):
"""
Edge which does the notches for the moor tiles box
"""
def __init__(self, boxes, height, corner_height, lower_corner) -> None:
super().__init__(boxes, None)
self.height = height
self.corner_height = corner_height
self.lower_corner = lower_corner
def __call__(self, length, **kw):
one_side_width = (length - self.thickness) / 2
notch_width = 20
radius = 6
upper_edge = (one_side_width - notch_width - 2 * radius) / 2
hole_start = 10
lowered_hole_start = 2
hole_depth = self.height - 2 * radius
lower_edge = notch_width - 2 * radius
one_side_polyline = lambda margin1, margin2: [
upper_edge,
(90, radius),
hole_depth - margin1,
(-90, radius),
lower_edge,
(-90, radius),
hole_depth - margin2,
(90, radius),
upper_edge,
]
normal_side_polyline = one_side_polyline(hole_start, hole_start)
corner_side_polyline = one_side_polyline(
lowered_hole_start, lowered_hole_start + self.corner_height
)
full_polyline = (
normal_side_polyline
+ [0, self.thickness, 0]
+ (corner_side_polyline if self.lower_corner else normal_side_polyline)
)
self.polyline(*full_polyline)
def startwidth(self) -> float:
return self.corner_height
def endwidth(self) -> float:
return 0.0 if self.lower_corner else self.corner_height
class BedHeadEdge(edges.BaseEdge):
"""
Edge which does the head side of the Agricola player box
"""
def __init__(self, boxes, hole_depth) -> None:
super().__init__(boxes, None)
self.hole_depth = hole_depth
def __call__(self, length, **kw):
hole_length = 16
upper_corner = 10
lower_corner = 6
depth = self.hole_depth - upper_corner - lower_corner
upper_edge = (length - hole_length - 2 * upper_corner) / 2
lower_edge = hole_length - 2 * lower_corner
self.polyline(
upper_edge,
(90, upper_corner),
depth,
(-90, lower_corner),
lower_edge,
(-90, lower_corner),
depth,
(90, upper_corner),
upper_edge,
)
class Bed2SidesEdge(edges.BaseEdge):
"""
Edge which does a bed like shape, skipping the next corner.
The next edge should be a NoopEdge
"""
def __init__(self, boxes, bed_length, full_head_length, full_foot_height) -> None:
super().__init__(boxes, None)
self.bed_length = bed_length
self.full_head_length = full_head_length
self.full_foot_height = full_foot_height
def __call__(self, bed_height, **kw):
foot_corner = 6
middle_corner = 3
head_corner = 10
foot_height = self.full_foot_height - self.thickness - foot_corner
head_length = self.full_head_length - head_corner - self.thickness
corners = foot_corner + middle_corner + head_corner
head_height = bed_height - foot_height - corners
middle_length = self.bed_length - head_length - corners
self.polyline(
foot_height,
(90, foot_corner),
middle_length,
(-90, middle_corner),
head_height,
(90, head_corner),
head_length,
)
class NoopEdge(edges.BaseEdge):
"""
Edge which does nothing, not even turn or move.
"""
def __init__(self, boxes) -> None:
super().__init__(boxes, None)
def __call__(self, length, **kw):
# cancel turn
self.corner(-90)