Skip to content

Instantly share code, notes, and snippets.

@fmaylinch
Last active May 18, 2024 15:29
Show Gist options
  • Save fmaylinch/f888755668ad83a869926ab1aefb42c0 to your computer and use it in GitHub Desktop.
Save fmaylinch/f888755668ad83a869926ab1aefb42c0 to your computer and use it in GitHub Desktop.
// Last change: added UseHooks
const tokens = [
"+1", 0, 0, -1, -1, -1,
-2, -2, -3, -4,
"💀", "💀", "🧙", "🦑", "🌟"
];
function diceMessageGenerator(value) {
switch (value) {
case "🌟": return "Special Effect!";
case "🦑": return "FAIL (and your total is 0)";
case "+1": return random(["oh yeah!", "rock & roll!", "I'm on a roll!"]);
case -3: return random(["damn, was I prepared?", "ouch, let's count...", "did I buff myself enough?"]);
case -4: return random(["cruel world...", "no waaay", "this is not fair!"]);
//case "💀": return "-1 (fail? -3 deck cards)";
//case "🧙": return "-1 (discarded 10 cards? -3)";
case "💀": return "-2 (-2 resources ~> 0)";
case "🧙": return "-3 (succeed? +3 resources)";
case "🫀": return "-X (-2 deck cards, X = cost sum)";
default: return "";
}
}
const tokenDiceOptions = {
values: tokens,
rollingText: () => random([
"searching bag...", "I'm feeling lucky...", "I think I will get a...", "come on pretty bag..."
]),
valueMessage: diceMessageGenerator
};
function buildLayout(data, update) {
return group("arkham", [
group("progress", [
select("phase", _ => [
"📈 mythos - doom", "🃏 mythos - cards", "🔎 investigation", "🦑 enemy", "📊 keep-up - stats", "🃏 keep-up - cards"
]),
group("stats", [
counter("act", {showId: true}),
counter("agenda", {showId: true}),
counter("doom", {showId: true})
], { noTitle: true })
]),
groupAdder("places", _ => [
counter("clues")
], { row: true, titleAtEnd: true }),
groupAdder("enemies", _ => [
select("place", optionSources({
"📍": optionsFrom("arkham.places"),
"👤": optionsFrom("arkham.players")
})),
rowGroup("stats", [
counter("damage"),
counter("doom")
])
], { drawBorder: true }),
group("dice", [
dice("Token", tokenDiceOptions),
dice("Random 0..99", {
values: 100,
rollingText: () => "doing some math...",
}),
], { noTitle: true }),
groupAdder("players", _ => [
select("place", optionSources({
"📍": optionsFrom("arkham.places"),
})),
rowGroup("main", [
counter("actions", {showId: true})
]),
rowGroup("collectables", [
counter("resources"),
counter("clues")
]),
rowGroup("life", [
counter("damage"),
counter("horror")
]),
adder("items", (id, value) => [
group(id, value.isAlly
? [ counter("damage"), counter("horror") ]
: [ counter("uses") ],
{ row: true, titleAtEnd: !value.isAlly, clearable: true })
], { inputParser: input => {
// if input is "dog ally", the id will be "dog", and value `{isAlly:true}`
const [id, type] = input.split(" ");
return {id, value: {isAlly: type === "ally"}};
}, hint: "e.g. gun, dog ally" }
)
], { // options for group "players"
drawBorder: true,
inputParser: initialValue( {
main: { actions: 3 },
collectables: { resources: 5 },
life: { damage: 6, horror: 6 } } )
}),
separator(),
textArea("notes"),
separator(),
logger("log"),
button("clear log", () => update("arkham.log", []))
]);
}
toastr.options.closeDuration = 10000;
function customDataChanges(path, value, data) {
if (path === "arkham.progress.phase") {
if (value === "📈 mythos - doom") {
// doom +1
const doomPath = "arkham.progress.stats.doom";
const doom = getValue(data, doomPath) || 0;
const newData = updateDataPathAndLog(data, doomPath, doom + 1);
toastr.info(`Doom: ${doom} -> ${doom + 1}`);
toastr.warning(`Doom limit reached?`);
return newData;
} else if (value === "🃏 mythos - cards") {
toastr.warning("Draw encounter cards");
} else if (value === "🦑 enemy") {
ensurePlayersUsedAllActions(data);
} else if (value === "📊 keep-up - stats") {
if (!ensurePlayersUsedAllActions(data)) {
return data; // ignore changes
}
// reset actions, +1 resource
let newData = data;
const playerIds = getObjectKeys(data, "arkham.players");
for (let playerId of playerIds) {
const actionsPath = `arkham.players.${playerId}.main.actions`;
const resourcesPath = `arkham.players.${playerId}.collectables.resources`;
const resources = getValue(data, resourcesPath) || 0;
newData = updateDataPathAndLog(newData, actionsPath, 3);
newData = updateDataPathAndLog(newData, resourcesPath, resources + 1);
toastr.info(`${playerId} resources: ${resources} -> ${resources + 1}`)
}
toastr.info("Actions reset");
return newData;
} else if (value === "🃏 keep-up - cards") {
toastr.warning("Draw cards");
}
}
return data;
}
function ensurePlayersUsedAllActions(data) {
const playerIds = getObjectKeys(data, "arkham.players");
let foundError = false;
for (let playerId of playerIds) {
const actionsPath = `arkham.players.${playerId}.main.actions`;
const actions = getValue(data, actionsPath) || 0;
if (actions != 0) {
toastr.error(`${playerId} has still ${actions} actions`);
foundError = true;
}
}
return !foundError;
}
function updateDataPathAndLog(data, path, value) {
const oldValue = getValue(data, path);
const newData = updateDataPath(data, path, value);
return updateLog(path, value, newData, oldValue);
}
function updateLog(path, value, data, oldValue) {
if (path == "arkham.log" || path == "arkham.notes" || path == "remoteStorageId") {
return data;
}
if (value && typeof value != "number" && typeof value != "string") {
console.log("not logging value", value);
return data;
}
const newEntry = {path, oldValue, value};
const logPath = "arkham.log"
const log = getValue(data, logPath) || [];
const [firstEntry, ...rest] = log;
let newLog;
if (firstEntry && firstEntry.path === newEntry.path) {
// merge entries of same path
const mergedEntry = {...newEntry, oldValue: firstEntry.oldValue};
if (mergedEntry.oldValue != mergedEntry.value) {
newLog = [mergedEntry, ...rest]; // replace entry with merged entry
} else {
newLog = rest; // remove entry, because the change was undone
}
} else {
newLog = [newEntry, ...log]; // add new entry normally
}
return updateDataPath(data, logPath, newLog);
}
function updateDataPath(data, path, value) {
const ids = path.split(".");
return updateNestedProperty(data, ids, value);
}
// sets a value in a nested property (`ids` is an array of properties)
// e.g. updateNestedProperty(obj, ["zoey", "damage"], 2) will return `obj` with also `{zoey: {damage: 2}}`
function updateNestedProperty(obj, ids, value) {
const [id, ...otherIds] = ids;
if (otherIds.length === 0) {
if (value) {
return {...obj, [id]: value};
} else {
// we remove falsy values (false, null, 0, etc)
let { [id]: _, ...restOfObj } = obj; // remove id property
return restOfObj;
}
} else {
return {...obj, [id]: updateNestedProperty(obj?.[id], otherIds, value)}
}
}
// imports don't work here, but React and UseHooks are already imported
const { useState, createContext, useContext, useEffect } = React;
const { useDebounce } = UseHooks;
const UpdateContext = createContext(null); // will be used to provide `updateData` function
const DataContext = createContext(null); // will be used to provide `data`
function App() {
const [data, setData] = useState({});
const [rawData, setRawData] = useState("");
// local storage
const [lastSaveDate, setLastSaveDate] = useState(null);
const [isSavingEnabled, setSavingEnabled] = useState(false);
const debouncedData = useDebounce(data, 2000);
// remote storage
const [remoteStorageMessage, setRemoteStorageMessage] = useState("");
// --- local storage management ---
const dataKey = "arkham";
useEffect(() => {
console.log("loading data from local storage...");
let dataFromStorage = localStorage.getItem(dataKey);
let loadedData = JSON.parse(dataFromStorage || "{}")
console.log("loaded data from local storage", loadedData);
setDataWithSaving(loadedData, false);
}, []);
useEffect(() => {
if (!isSavingEnabled) {
console.log("saving to local storage not enabled yet");
return;
}
console.log("saving data to local storage", debouncedData);
localStorage.setItem(dataKey, JSON.stringify(debouncedData || {}));
setLastSaveDate(new Date());
}, [debouncedData]);
// --- data management ---
// This function is provided via UpdateContext
function updateData(path, value) {
const updatedData = updateDataPathAndLog(data, path, value);
const newData = customDataChanges(path, value, updatedData, data);
setDataWithSaving(newData, true);
}
function setDataWithSaving(newData, enableSaving) {
setData(newData);
setSavingEnabled(enableSaving);
setRawData(prettyStringify(newData));
}
function updateDataFromRawData() {
const newData = rawData.trim() ? JSON.parse(rawData) : {};
setDataWithSaving(newData, true); // we will set rawData again, but it should be ok
}
// --- remote storage ---
const remoteStorageInstanceId = 'fmaylinch.github.io/arkham-horror-lgc-tracker';
const remoteStorageIdPath = "remoteStorageId";
function loadDataFromRemoteStorage() {
const remoteStorageId = data[remoteStorageIdPath];
if (!remoteStorageId) {
setRemoteStorageMessage("Storage ID is required")
return;
}
setRemoteStorageMessage("Loading data...")
const remoteStorage = new RemoteStorage({
userId: remoteStorageId,
instanceId: remoteStorageInstanceId
})
remoteStorage.getItem('data').then(newData => {
if (newData) {
setRemoteStorageMessage("Remote data loaded")
setDataWithSaving(newData, true);
} else {
setRemoteStorageMessage("Remote data not found");
}
});
}
function saveDataToRemoteStorage() {
const remoteStorageId = data[remoteStorageIdPath];
if (!remoteStorageId) {
setRemoteStorageMessage("Storage ID is required")
return;
}
setRemoteStorageMessage("Saving data...")
const remoteStorage = new RemoteStorage({
userId: remoteStorageId,
instanceId: remoteStorageInstanceId
})
remoteStorage.setItem('data', data).then(() => {
setRemoteStorageMessage("Remote data saved");
});
}
// --- tracker setup ---
// builds a root group
function main(builder) {
const id = builder.id;
return (
<UpdateContext.Provider value={updateData}>
<DataContext.Provider value={data}>
<div style={{padding: "10px"}}>
{/* TODO - fix CSS so some children like textarea() can take 100% of width in phone */}
{ builder.build(id, data?.[id]) }
<hr style={{marginTop: 20}} />
<div style={{width: "100%", display: "flex", flexDirection: "column", alignItems: "stretch"}}>
<div style={{marginBottom: 10}}>Last local save date: {lastSaveDate ? "" + lastSaveDate : ""}</div>
<div style={{marginBottom: 20}}>
<div>
Remote storage ID:
<input value={data[remoteStorageIdPath] || ""} style={{marginLeft: 10}}
onChange={e => updateData(remoteStorageIdPath, e.target.value)}/>
</div>
<div>
<button onClick={loadDataFromRemoteStorage}>Load</button>
<button onClick={saveDataToRemoteStorage}>Save</button>
<span style={{marginLeft: 10}}>{remoteStorageMessage}</span>
</div>
</div>
<div>
<button onClick={updateDataFromRawData}>Update UI from this JSON</button>
</div>
<textarea rows={10} value={rawData} onChange={e => setRawData(e.target.value)} />
</div>
</div>
</DataContext.Provider>
</UpdateContext.Provider>
);
}
return (
main(buildLayout(data, updateData))
);
}
// These are builders: adder(), groupAdder(), group(), counter(), select(), etc.
// They return an object with { id, build }.
// The `id` is the identifier of the object (e.g. "damage", "rex", "players").
// The `build` is a builder method that takes (path, value).
// - The `path` is something like "arkham.enemies.rat.damage".
// - The `value` is the data associated with that path (it can be a simple value or an object).
// For example, a counter can receive path = "arkham.enemies.rat.damage" and value = 1.
// A group can receive path = "arkham.players.rex" and value = {place: "bar", stats:{damage:2}}
function adder(id, buildersGenerator, options = {}) {
const build = (path, value) => {
// value may contain existing items (added before)
let builders = [];
if (value) {
for (let id of Object.keys(value)) {
builders = [...builders, ...buildersGenerator(id, value[id])];
}
}
return group(id, [...builders, adderInput(id, options.inputParser, options.hint)]).build(path, value);
};
return { id, build };
}
function adderInput(id, inputParser, hint) {
// build method will receive an undefined value (because builder.id = ""),
// and the path will be like "arkham.players".
// See what Group does when `id` is "" (falsy).
const build = (path, _) => (
<AdderInput name={id} path={path} hint={hint}
inputParser={inputParser ?? initialValue({})} />
);
return { id: "", build };
}
// convenience inputParser that provides a fixed initial value, and uses the input as id
function initialValue(value) {
return input => ({id: input, value});
}
// the inputParser (from the input entered), decides the id and value of the new element
function AdderInput({ name, path, hint, inputParser }) {
const updateData = useContext(UpdateContext);
const [input, setInput] = useState("");
// path contains e.g. "arkham.players", so if input is "zoey",
// in updateData() we will send "arkham.players.zoey"
return (
<div style={{margin: "0 10px", display: "flex"}}>
<input style={{margin: "0 7px", padding: "0 10px"}} value={input}
placeholder={hint ? hint : `add ${name}`}
onChange={e => setInput(e.target.value)} />
<button style={{padding: "0 10px"}} onClick={() => {
if (!input) return;
const {id, value} = inputParser(input);
// path already ends with ".", so we just append the id
updateData(path + "." + id, value);
setInput("");
}}>+</button>
</div>
);
}
// convenience, for adders that add single groups, and with {clearable: true} in the group options.
function groupAdder(id, buildersGenerator, options) {
return adder(id, (id, value) => [
group(id, buildersGenerator(id), {...options, clearable: true})
], options);
}
function group(id, builders, options = {}) {
const build = (path, value) => (
<Group name={id} path={path} options={options} >
{ builders.map((builder, index) => (
<div key={builder.id || index}>
{ builder.build(
builder.id ? `${path}.${builder.id}` : path,
builder.id ? value?.[builder.id] : value)
}
</div>
)) }
</Group>
);
return { id, build };
}
// Convenience, for typical row group
function rowGroup(id, builders, options) {
const fullOptions = {...options, row: true, noTitle: true};
return group(id, builders, fullOptions);
}
// TODO - can we extract some layout logic out of here? (e.g. the title)
function Group({ name, path, options, children }) {
const updateData = useContext(UpdateContext);
const titleAndButton = !options.noTitle && (
<div key="$title" style={options.titleAtEnd ? (options.row ? {marginLeft: "5px"} : {marginTop: "5px"}) : {marginBottom: "5px"}}>
<span>{name}</span>
{options.clearable &&
<button style={{padding: "0 10px", borderRadius: "5px", marginLeft: "7px", backgroundColor: "#cc2222"}}
onClick={() => updateData(path, null)}>X</button>
}
</div>
);
const boxStyle = options.drawBorder ? {border: "1px rgba(255, 255, 255, 0.15) solid", padding: 7} : {};
return (
<div style={{margin: 7, display: "flex"}}>
<div style={{display: "flex", ...boxStyle}}>
<div>
{!options.titleAtEnd && titleAndButton }
<div style={{display: "flex", flexDirection: options.row ? "row" : "column"}}>
{[...children, options.titleAtEnd && titleAndButton]}
</div>
</div>
</div>
</div>
)
}
function button(id, callback) {
const build = (p, _) => (
<button onClick={callback}>{id}</button>
);
return { id, build };
}
function dice(id, options) {
const build = (p, _) => <Dice title={id} options={options} />;
return { id, build };
}
function Dice({ title, options }) {
const [value, setValue] = useState("");
const [rollingMessage, setRollingMessage] = useState("");
const [message, setMessage] = useState("");
function throwDice() {
setValue("");
setMessage("");
setRollingMessage(options.rollingText());
setTimeout(() => {
setRollingMessage("");
const rnd = random(options.values);
setValue(rnd);
if (options.valueMessage) {
const message = options.valueMessage(rnd);
setTimeout(() => setMessage(message), 1000);
}
}, 1000);
}
return (
<div style={{margin: "5px 0"}}>
<button style={{marginRight: 10}} onClick={throwDice}>{title}</button>
<span>{rollingMessage}</span>
<span style={{fontSize: 18, color: "#DED3FF"}}>{value}</span>
<span style={{marginLeft: 10, color: "rgba(255, 255, 255, 0.45)"}}>{message}</span>
</div>
)
}
function textArea(id) {
const build = (p, v) => <TextArea path={p} value={v} />;
return { id, build };
}
function TextArea({ path, value }) {
const updateData = useContext(UpdateContext);
const val = value || "";
return (
<textarea
placeholder={getId(path)} rows={5} style={{width: 300}}
value={val} onChange={e => updateData(path, e.target.value)} />
)
}
function logger(id) {
const build = (p, v) => <Logger path={p} value={v} />;
return { id, build };
}
function Logger({ path, value }) {
const val = value || [];
const renderedValue = val.map(e => `${simplifyPath(e.path)}: ${e.oldValue || "∅"} ~> ${e.value || "∅"}`).join("\n");
return (
<textarea
disabled
placeholder={getId(path)} rows={5} style={{width: 300}}
value={renderedValue} />
)
}
const knownPaths = [
"arkham.players.",
"arkham.enemies.",
"arkham.places.",
"arkham.progress.",
"stats.",
"life.",
"main.",
"items.",
"collectables."
];
function simplifyPath(path) {
return knownPaths.reduce((p, k) => p.replace(k, ""), path);
}
function counter(id, options = {}) {
const build = (p, v) => <Counter path={p} value={v} options={options} />;
return { id, build };
}
function Counter({ path, value, options }) {
const val = value || 0; // 0 by default
const updateData = useContext(UpdateContext);
const color = getCounterColor(path, val);
return (
<div style={{margin: "0 10px", display: "flex"}}>
<button style={{padding: "0 10px"}} onClick={() => updateData(path, val - 1)}>-</button>
<span style={{margin: "0 7px", padding: "0 10px", backgroundColor: color}}>{val}</span>
<button style={{padding: "0 10px"}} onClick={() => updateData(path, val + 1)}>+</button>
{options.showId &&
<span style={{marginLeft: "7px"}}>{getId(path)}</span>
}
</div>
);
}
function select(id, optionSupplier) {
const build = (p, v) => <Select title={id} path={p} value={v} optionSupplier={optionSupplier} />;
return { id, build };
}
// optionSupplier resulting of multiple suppliers (as map)
function optionSources(supplierMap) {
return data => {
let result = [];
for (const key in supplierMap) {
const subResult = supplierMap[key](data).map(o => `${key}: ${o}`);
result = [...result, ...subResult];
}
return result;
}
}
// optionSupplier gets options from ids of data in given path
function optionsFrom(path) {
return data => getObjectKeys(data, path);
}
function getObjectKeys(data, path) {
const d = getValue(data, path);
return Object.keys(d ?? {});
}
function getValue(data, path) {
const ids = path.split(".");
let d = data;
for (let id of ids) {
d = d?.[id];
}
return d;
}
function Select({ title, path, value, optionSupplier }) {
const updateData = useContext(UpdateContext);
const data = useContext(DataContext);
const val = value || "";
const realOptions = optionSupplier(data);
const optionNames = ["", ...realOptions];
const options = optionNames.map(option => <option key={option}>{option}</option>);
if (value && realOptions.indexOf(value) < 0) {
// This happens when one of the selected options is removed (when options are dynamic)
console.log(`Clearing select ${path} because ${value} doesn't exist in ${realOptions}`);
updateData(path, null);
}
function moveToNextOption() {
const index = optionNames.indexOf(val);
const newValue = optionNames[index + 1];
updateData(path, newValue); // no problem if we get to an undefined index
}
return (
<div>
{/* <span style={{marginRight: "5px"}}>{title}</span> */}
<select value={val} onChange={e => updateData(path, e.target.value)}>
{ options }
</select>
<button style={{marginLeft: 5}} onClick={moveToNextOption}>Next</button>
</div>
);
}
function separator() {
const build = (p, v) => <hr style={{margin: 20}} />;
return { id: "", build };
}
const colors = {
clues: "#008000",
damage: "#990000",
doom: "#9f1da5",
horror: "#0052cc",
location: "#084408",
phase: "#b74e91",
resources: "#663300",
uses: "#bb6622"
};
function getId(path) {
return path.split(".").at(-1);
}
function getCounterColor(path, value) {
const defaultColor = "#312450";
if (value < 0) {
return "gray";
}
const result = colors[getId(path)] || defaultColor;
return value == 0 ? result + "80" : result;
}
function prettyStringify(data) {
return JSON.stringify(data, null, 2);
}
// Returns a random integer number between 0 and n-1.
// If n is an array, returns a random element of n.
function random(n) {
if (Array.isArray(n)) {
return n[random(n.length)]
}
return Math.floor(Math.random() * n)
}
addCustomStyle(`
button {
border: 1px rgb(60, 43, 100) solid;
border-top: 1px gray solid;
border-left: 1px gray solid;
}
select {
padding: 5px;
}
hr {
border: 1px #ffffff20 solid;
}
::placeholder {
color: white;
opacity: 0.3;
}
`);
function addCustomStyle(css) {
const styleId = 'style';
let style = document.getElementById(styleId);
if (!style) {
style = document.createElement('style');
style.setAttribute('id', styleId);
style.type = 'text/css';
document.head.appendChild(style);
}
style.textContent = css;
}
reactRoot.render(<App />);
// export default App
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment