import React, { useRef, useState, useEffect, Fragment } from "react"; import factionBackground from "../assets/factionBackground.png"; import keywordsBackground from "../assets/keywordsBackground.png"; import cardBackground from "../assets/cardBackground.png"; import dgBackground from "../assets/dgBackground.png"; import tauBackground from "../assets/tauBackground.png"; import tyranidBackground from "../assets/tyranidBackground.png"; import mechanicusBackground from "../assets/mechanicusBackground.png"; import orkBackground from "../assets/orkBackground.png"; import necronBackground from "../assets/necronBackground.png"; import custodesBackground from "../assets/custodesBackground.png"; import imperiumBackground from "../assets/imperiumBackground.png"; import sistersBackground from "../assets/sistersBackground.png"; import thousandBackground from "../assets/thousandBackground.png"; import worldEatersBackground from "../assets/worldEatersBackground.png"; import chaosKnightsBackground from "../assets/chaosKnightsBackground.png"; import chaosMarinesBackground from "../assets/chaosMarinesBackground.png"; import { Arrow, wavyLine } from "../assets/icons"; import { Weapons, hasDifferentProfiles } from "./Weapons"; import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB import { ImgEditor } from "./ImgEditor"; import { trySettingLocalStorage } from "../helpers/useLocalStorage"; import { ShortSummaryTable } from "./ShortSummaryTable"; export const Roster = ({ roster, onePerPage, colorUserChoice, primaryColor, }) => { if (!roster) { return null; } const { name, cost, forces } = roster; return (
{forces.map((force, index) => ( ))}
); }; const Force = ({ force, onePerPage, colorUserChoice }) => { const { units, factionRules, rules, catalog } = force; const mergedRules = new Map([...factionRules, ...rules]); // State to manage the order of units const [unitOrder, setUnitOrder] = useState([]); useEffect(() => { const sortedUnits = units.sort((a, b) => { if (a.role === "Character" && b.role !== "Character") return -1; if (a.role !== "Character" && b.role === "Character") return 1; if (a.role === "Battleline" && b.role !== "Battleline") return -1; if (a.role !== "Battleline" && b.role === "Battleline") return 1; return a.name.localeCompare(b.name); }); setUnitOrder(sortedUnits); }, [units]); return (
{unitOrder.map((unit, index) => (
))}
); }; const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => { const [hide, setHide] = useState(false); const [hideModelCount, setHideModelCount] = useState(false); const uploadRef = useRef(); let { name, meleeWeapons, rangedWeapons, abilities, keywords, factions, rules, modelStats, modelList, cost, } = unit; const [image, setImage] = useIndexedDB("image" + name); // Use IndexedDB hook const hasImage = image && image !== "undefined"; const [bgRemoved, setBgRemoved] = useState(false); const weapons = [...meleeWeapons, ...rangedWeapons]; const weaponDescriptions = weapons .filter( (weapon) => (weapon.range === "-" || weapon.range === "") && weapon.abilities, ) .sort((a, b) => a.selectionName.localeCompare(b.selectionName)); const modelsWithDifferentProfiles = weapons.filter((weapon, index) => { const { selectionName, name } = weapon; const previousWeapon = weapons[index - 1]; const nextWeapon = weapons[index + 1]; return ( hasDifferentProfiles(selectionName, name) && ((previousWeapon && hasDifferentProfiles( previousWeapon.selectionName, previousWeapon.name, ) && selectionName === previousWeapon.selectionName) || (nextWeapon && hasDifferentProfiles(nextWeapon.selectionName, nextWeapon.name) && selectionName === nextWeapon.selectionName)) ); }); const overridePrimary = colorUserChoice ? "" : getOverridePrimary(factions, keywords); useEffect(() => { // Check if the browser is Safari, and if so, remove the accept attribute // from the file input element. This is because Safari doesn't support // extensions on the accept attribute for input type=file // (https://caniuse.com/input-file-accept). If set, they will not allow any // file to be selected. if ( navigator.userAgent.match(/AppleWebKit.*Safari/) && !navigator.userAgent.includes("Chrome") ) { uploadRef.current?.removeAttribute("accept"); } }, []); const areAllModelsTheSame = (modelStats) => { if (modelStats.length === 1) return true; const firstModel = modelStats[0]; return modelStats.every( (model) => model.move === firstModel.move && model.toughness === firstModel.toughness && model.save === firstModel.save && model.wounds === firstModel.wounds && model.leadership === firstModel.leadership && model.oc === firstModel.oc && model.bs === firstModel.bs && model.ws === firstModel.ws && model.attacks === firstModel.attacks, ); }; if (areAllModelsTheSame(modelStats)) { modelStats = [modelStats[0]]; } if (modelList.length === 1) { modelList[0] = modelList[0] .replace(name, "") .replace("(", "") .replace(")", ""); } const removeWhiteBackground = (imageSrc) => { return new Promise((resolve) => { const img = new Image(); img.crossOrigin = "Anonymous"; img.src = imageSrc; img.onload = () => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) { data[i + 3] = 0; // Set alpha to 0 } } ctx.putImageData(imageData, 0, 0); resolve(canvas.toDataURL()); }; }); }; return (
{wavyLine}
{name} {cost.points}pts
{modelStats.map((model, index) => ( 1} abilities={abilities.Abilities} /> ))}
{!hideModelCount && modelStats?.[0] && (
1 ? "" : "self-center" }`} style={{ fontSize: "0.7em", zIndex: 101, }} > {modelList.map((model, index) => (
{model}
))}
)}
{hasImage && }
{hasImage && !bgRemoved && ( )} {!hasImage && ( Search for image )} {hasImage && ( )}
{modelsWithDifferentProfiles.length > 0 && !weaponDescriptions.length && ( )}
{Arrow}
Before selecting targets for this weapon, select one of its profiles to make attacks with.
ABILITIES
); }; const backgrounds = { "Imperium - Adeptus Astartes - Dark Angels": imperiumBackground, "Imperium - Adeptus Astartes - Space Wolves": imperiumBackground, "Imperium - Adeptus Astartes - Blood Angels": imperiumBackground, "Imperium - Adeptus Astartes - Black Templars": imperiumBackground, "Imperium - Adeptus Astartes - White Scars": imperiumBackground, "Imperium - Adeptus Astartes - Ultramarines": imperiumBackground, "Imperium - Adeptus Astartes - Deathwatch": imperiumBackground, "Imperium - Adeptus Astartes - Grey Knights": imperiumBackground, "Imperium - Adeptus Astartes - Space Marines": imperiumBackground, "Imperium - Adeptus Astartes - Iron Hands": imperiumBackground, "Imperium - Adeptus Astartes - Salamanders": imperiumBackground, "Imperium - Adeptus Astartes - Raven Guard": imperiumBackground, "Imperium - Adeptus Mechanicus": mechanicusBackground, "Imperium - Adeptus Custodes": custodesBackground, "Imperium - Adepta Sororitas": sistersBackground, "Chaos - Death Guard": dgBackground, "Chaos - Thousand Sons": thousandBackground, "Chaos - World Eaters": worldEatersBackground, "Chaos - Chaos Knights": chaosKnightsBackground, "Chaos - Chaos Space Marines": chaosMarinesBackground, "Xenos - T'au Empire": tauBackground, "Xenos - Tyranids": tyranidBackground, "Xenos - Orks": orkBackground, "Xenos - Necrons": necronBackground, }; const checkAbilitiesForInvul = (abilitiesMap, name) => { const abilities = [...(abilitiesMap?.keys?.() || [])]; for (const ability of abilities) { if (ability.includes(":")) { // if : then only match if the name is the same if ( !name .trim() .toLowerCase() .includes(ability.split(":")[1].trim().toLowerCase()) ) { continue; } } switch ( abilitiesMap.get(ability)?.trim()?.toLowerCase().replaceAll(".", "") ) { case "2+": if (ability?.toLowerCase().includes("invulnerable save")) return "2+"; break; case "3+": if (ability?.toLowerCase().includes("invulnerable save")) return "3+"; break; case "4+": if (ability?.toLowerCase().includes("invulnerable save")) return "4+"; break; case "5+": if (ability?.toLowerCase().includes("invulnerable save")) return "5+"; break; case "6+": if (ability?.toLowerCase().includes("invulnerable save")) return "6+"; break; case "this model has a 2+ invulnerable save": case "this model has an invulnerable save of 2+": case "this unit has a 2+ invulnerable save": case "models in this unit have a 2+ invulnerable save": case "models in this unit have an invulnerable save of 2+": case "models in this units have an invulnerable save of 2+": return "2+"; case "this model has a 3+ invulnerable save": case "this model has an invulnerable save of 3+": case "this unit has a 3+ invulnerable save": case "models in this unit have a 3+ invulnerable save": case "models in this unit have an invulnerable save of 3+": case "models in this units have an invulnerable save of 3+": return "3+"; case "this model has a 4+ invulnerable save": case "this model has an invulnerable save of 4+": case "this unit has a 4+ invulnerable save": case "models in this unit have a 4+ invulnerable save": case "models in this unit have an invulnerable save of 4+": case "models in this units have an invulnerable save of 4+": return "4+"; case "this model has a 5+ invulnerable save": case "this model has an invulnerable save of 5+": case "this unit has a 5+ invulnerable save": case "models in this unit have a 5+ invulnerable save": case "models in this unit have an invulnerable save of 5+": case "models in this units have an invulnerable save of 5+": return "5+"; case "this model has a 6+ invulnerable save": case "this model has an invulnerable save of 6+": case "this unit has a 6+ invulnerable save": case "models in this unit have a 6+ invulnerable save": case "models in this unit have an invulnerable save of 6+": case "models in this units have an invulnerable save of 6+": return "6+"; case "models in this unit have a 2+ invulnerable save against ranged weapons": case "models in this unit have a 2+ invulnerable save against ranged attacks": case "this model has a 2+ invulnerable save against ranged weapons": case "this model has a 2+ invulnerable save against ranged attacks": return "2+*"; case "models in this unit have a 3+ invulnerable save against ranged weapons": case "models in this unit have a 3+ invulnerable save against ranged attacks": case "this model has a 3+ invulnerable save against ranged attacks": case "this model has a 3+ invulnerable save against ranged weapons": return "3+*"; case "models in this unit have a 4+ invulnerable save against ranged weapons": case "models in this unit have a 4+ invulnerable save against ranged attacks": case "this model has a 4+ invulnerable save against ranged attacks": case "this model has a 4+ invulnerable save against ranged weapons": return "4+*"; case "models in this unit have a 5+ invulnerable save against ranged weapons": case "models in this unit have a 5+ invulnerable save against ranged attacks": case "this model has a 5+ invulnerable save against ranged attacks": case "this model has a 5+ invulnerable save against ranged weapons": return "5+*"; case "models in this unit have a 6+ invulnerable save against ranged weapons": case "models in this unit have a 6+ invulnerable save against ranged attacks": case "this model has a 6+ invulnerable save against ranged attacks": case "this model has a 6+ invulnerable save against ranged weapons": return "6+*"; case "this model has a 2+ invulnerable save you cannot re-roll invulnerable saving throws made for this model": return "2+*"; case "this model has a 3+ invulnerable save you cannot re-roll invulnerable saving throws made for this model": return "3+*"; case "this model has a 4+ invulnerable save you cannot re-roll invulnerable saving throws made for this model": return "4+*"; case "this model has a 5+ invulnerable save you cannot re-roll invulnerable saving throws made for this model": return "5+*"; case "this model has a 6+ invulnerable save you cannot re-roll invulnerable saving throws made for this model": return "6+*"; } } return false; }; const ModelStats = ({ modelStat, index, showName, abilities }) => { let { move, toughness, save, wounds, leadership, name, oc, bs, ws, attacks } = modelStat; if (!wounds) { wounds = "/"; } const hasInvul = checkAbilitiesForInvul(abilities, name); return (
{showName && (
{name}
)}
); }; const InvulRow = ({ hasInvul }) => { if (!hasInvul) return null; const isSpecialInvul = hasInvul.includes("*"); const invulWithoutSpecial = hasInvul.replace("*", ""); return (
{invulWithoutSpecial}
INVULNERABLE SAVE{isSpecialInvul ? "*" : ""}
); }; const FactionIcon = ({ catalog }) => { if (!factionIcons[catalog]) { console.log(catalog); } return (
{factionIcons[catalog] || (catalog.includes("Imperium") ? factionIcons.Imperium || "" : "")}
); }; const factionIcons = { Imperium: ( ), "Imperium - Adeptus Mechanicus": ( ), "Imperium - Adeptus Custodes": ( ), "Imperium - Adepta Sororitas": ( ), "Chaos - Death Guard": ( ), "Xenos - Tyranids": ( ), "Xenos - Orks": ( ), "Xenos - Necrons": ( ), "Xenos - T'au Empire": ( ), }; const Keywords = ({ keywords }) => { const joinedKeywords = [...keywords].join(", "); return (
KEYWORDS: 70 ? ".8em" : "1em", fontWeight: 800, marginTop: 1, }} > {joinedKeywords}
); }; const Factions = ({ factions }) => { return (
FACTION KEYWORDS: {[...factions].join(", ")}
); }; const Rules = ({ rules }) => { if (!rules || rules.size === 0) return null; return (
RULES: {[...rules.keys()].map((rule, index) => ( {rule} {index < rules.size - 1 ? ", " : ""} ))}
); }; const removeInvulnsWithoutSpecialRules = (abilities) => { const filteredAbilities = new Map(); for (const [key, value] of abilities) { switch (value?.trim()?.toLowerCase().replaceAll(".", "")) { case "2+": if (!key?.toLowerCase().includes("invulnerable save")) filteredAbilities.set(key, value); break; case "3+": if (!key?.toLowerCase().includes("invulnerable save")) filteredAbilities.set(key, value); break; case "4+": if (!key?.toLowerCase().includes("invulnerable save")) filteredAbilities.set(key, value); break; case "5+": if (!key?.toLowerCase().includes("invulnerable save")) filteredAbilities.set(key, value); break; case "6+": if (!key?.toLowerCase().includes("invulnerable save")) filteredAbilities.set(key, value); break; case "models in this unit have a 2+ invulnerable save": case "models in this unit have a 3+ invulnerable save": case "models in this unit have a 4+ invulnerable save": case "models in this unit have a 5+ invulnerable save": case "models in this unit have a 6+ invulnerable save": case "models in this units have an invulnerable save of 2+": case "models in this units have an invulnerable save of 3+": case "models in this units have an invulnerable save of 4+": case "models in this units have an invulnerable save of 5+": case "models in this units have an invulnerable save of 6+": case "models in this unit have an invulnerable save of 2+": case "models in this unit have an invulnerable save of 3+": case "models in this unit have an invulnerable save of 4+": case "models in this unit have an invulnerable save of 5+": case "models in this unit have an invulnerable save of 6+": case "this model has a 2+ invulnerable save": case "this model has a 3+ invulnerable save": case "this model has a 4+ invulnerable save": case "this model has a 5+ invulnerable save": case "this model has a 6+ invulnerable save": case "this model has an invulnerable save of 2+": case "this model has an invulnerable save of 3+": case "this model has an invulnerable save of 4+": case "this model has an invulnerable save of 5+": case "this model has an invulnerable save of 6+": case "this unit has a 2+ invulnerable save": case "this unit has a 3+ invulnerable save": case "this unit has a 4+ invulnerable save": case "this unit has a 5+ invulnerable save": case "this unit has a 6+ invulnerable save": break; default: filteredAbilities.set(key, value); } } return filteredAbilities; }; const boldKeywords = [ "infantry", "character", "monster", "vehicle", "psyker", "titanic", "mounted", "battleline", "warlord", "death guard", "heretic astartes", "adeptus astartes", "adeptus mechanicus", "halo override", "adeptus custodes", "adeptus sororitas", "tyranids", "termagants", "orks", "necrons", ]; const weaponKeywords = [ "lethal hits", "blast", "assault", "lance", "heavy", "torrent", "indirect fire", "sustained hits 1", "sustained hits 2", "sustained hits x", "devastating wounds", "precision", "ignores cover", ]; const Abilities = ({ abilities }) => { if (!abilities) return null; let filteredAbilities = removeInvulnsWithoutSpecialRules(abilities); // sort abilites so that "Leader" is always at the bottom, "Supreme Commander" also bottom but before "Leader", same for any invulnerable save const abilitiesForEnd = new Map(); for (const [key, value] of filteredAbilities) { if (key.toLowerCase().includes("invulnerable save")) { filteredAbilities.delete(key); abilitiesForEnd.set(key, value); } if (key.toLowerCase().includes("damaged: ")) { filteredAbilities.delete(key); abilitiesForEnd.set(key, value); } } if (filteredAbilities.has("Supreme Commander")) { const supremeCommander = filteredAbilities.get("Supreme Commander"); filteredAbilities.delete("Supreme Commander"); abilitiesForEnd.set("Supreme Commander", supremeCommander); } if (filteredAbilities.has("Leader")) { const leader = filteredAbilities.get("Leader"); filteredAbilities.delete("Leader"); abilitiesForEnd.set("Leader", leader); } filteredAbilities = new Map([...filteredAbilities, ...abilitiesForEnd]); const replacedAbilities = new Map(); // in the value of the filtered abilites, make certain keywords bold for (const [key, value] of filteredAbilities) { replacedAbilities.set(key, makeKeywordsBold(value)); } const keys = [...filteredAbilities.keys()]; return (
{filteredAbilities && keys.map((ability) => (
{ability}:{" "}
))}
); }; const makeKeywordsBold = (text) => { let newValue = text; for (const keyword of boldKeywords) { newValue = newValue.replace( new RegExp(`\\b${keyword}\\b`, "gi"), `${keyword.toUpperCase()}`, ); } // weaponKeywords could be with [] or without for (const keyword of weaponKeywords) { newValue = newValue.replace( new RegExp(`\\[${keyword}\\]`, "gi"), `[${keyword.toUpperCase()}]`, ); if (keyword !== "assault") { // replace without [] only if it is not "assault" and not already replaced newValue = newValue.replace( new RegExp(`(?[${keyword.toUpperCase()}]`, ); } } // replace words wrapped in ^^ ^^ with tags newValue = newValue.replace(/\^\^([^\^]+)\^\^/g, "$1"); // replace two newlines with one but only if there are two newlines in a row return newValue.replace(/\n\n+/g, "\n\n"); }; const Characteristic = ({ title, characteristic, index }) => { return (
{index === 0 && (
{title}
)} {characteristic}
); }; const FancyBox = ({ children, className, style }) => { return (
{children}
); }; const FancyShield = ({ children, className, style }) => { return (
{children}
); }; const OtherAbilities = ({ abilities }) => { const keys = abilities ? [...Object.keys(abilities).filter((key) => key !== "Abilities")] : []; return keys.map((key) => { return ( {key} {[...abilities[key]].map(([name, value]) => ( {name}: {value} ))} ); }); }; const ForceRules = ({ rules, onePerPage }) => { const [hide, setHide] = useState(false); return (
{[...rules.keys()].map((rule) => (
{rule}:{" "}
))}
); }; const getOverridePrimary = (factions, keywords) => { if ( factions.has("Khorne") && factions.has("Tzeentch") && factions.has("Nurgle") && factions.has("Slaanesh") ) return; if (factions.has("Khorne") || keywords.has("Khorne")) return "#883531"; if (factions.has("Tzeentch") || keywords.has("Tzeentch")) return "#015d68"; if (factions.has("Nurgle") || keywords.has("Nurgle")) return "#5c672b"; if (factions.has("Slaanesh") || keywords.has("Slaanesh")) return "#634c74"; };