Jump to content
Sign in to follow this  
Yemachu

Card maker Devlog

Recommended Posts

Logo by me


Quite a while a ago, the first version of this site's card maker has gone online. While it does do its thing, there are quite a few points on which it could be improved. This entails both reworking the code (as it is quite a mess that hardly uses any of the available tooling), and adding new features that makes using it a great experience. Some parts of the development process will be detailed in this blog.


Tooling improvements

This first instalment will be about the original implementation and improvements to the toolchain for developing the newer version of the card maker.

Bundling the code

As indicated in the intro of this blog, the original implementation hardly used any tooling. Most of the code was written in plain HTML, CSS, and JavaScript. I beleive that, at the time, browsers hadn't standardized how to import dependencies from the code itself at the time (and even if they did, support among different browsers was subpar). This meant that you'd have to orchetrate the imports yourself, or place everything in one big file.

Plain JavaScript

<script src="level-editor.js"></script>
<script src="name-editor.js"></script>
<!-- And many more such lines… -->
<script src="card-maker.js"></script>

As some parts of the code depend on the content of another file, you'd need to make sure the order which the files are imported is correct; files without dependencies first, any others after all of their dependencies. As you can imagine, this is quite error prone.

You could probably hack something together like the following to have a file specify it's own dependencies. But there are some edge cases involved that are easy to get wrong.

function dependOn(url) {
  var dependency = document.createElement("script");
  dependency.src = url;
  dependency.onLoad = callback;
  document.body.appendChild(dependency);
}

dependOn("level-editor.js");
// At this point, the dependency would not have been loaded yet,
// so using any of the code in the relevant file would lead to
// errors.
new LevelEditor();

// Using a callback instead that is invoked when the file has loaded
// is more likely to work out correctly.
dependOn("level-editor.js", function() {
  // There is however no guarantee that the code in "level-editor.js" has
  // run at this point. Any definitions might thus not be in effect yet.
});

Another issue with the naïve implementation above is that it does not check whether the file has already been imported, for example when multiple different files have the same dependency. This could lead to useless duplication of code.

RequireJS

Luckily there was a project available that took care of most of these issues, and had some other utilities that you might not have thought about. I'm referring to RequireJS. You'd wrap the content of your file in a function called "define", where you specify on which other files it depends, and what to do once all dependencies have loaded.

// level-editor.js
define([], function(){
  return function LevelEditor() {
    // …
  }
});
// card-maker.js
define(["level-editor"], function(LevelEditor){
  return function CardMaker() {
    // …
  }
});

Optionally, you could even specify what to do when the dependency failed to load. Most code was thus wrapped in those "define" function calls. Now only the entry point needed to be included, and the dependencies would automatically resolve themselves.

This was nice for development purposes, but for users and the server it isn't as great. First you download the HTML that specifies it needs a script file; fair enough lets fetch that as well. The browser then finds out that it needs another file(s) first, so download those as well; same story. This could go on for a little while, making the site slow to load.

Luckily RequireJS also provided an optimizer that bundles all those files into a single file (or split something into different files that can be loaded later when actually needed). It also performed other optimizations, such as shortening function names, removing comments and whitespace. This would all lead to faster load times.

This all sounds great, so why change it? Well… as it turns out not every dependency does support RequireJS. And in cases where they do, everything but the kitchensink is included. Not great for efficiency (as is typically noted in the relevant documentation). Manually managing software versions of used libraries is also not ideal.

NPM/Yarn/etc

As it turns out, there are a few options out there for managing dependencies of your project, irrespecitive of whether those are part of the output, or solely for helping with development (such as bundlers, linters that check whether your code follows a standard, or transpilers that take care of turning modern code into code understood by older browsers). Most of them are interchangeable; they pull in the code from a software library (and its dependencies), and keep track of which versions have been installed. I have picked "yarn". So if I were to add some dependencies I'd write something akin to:

$ yarn add react react-dom
$ yarn add --dev typescript

Most libraries that you pull in this way also document how you should import them; effectively the same in all instances, but different compared to the code listed prior ("define"). Not too big of a deal, and looks somewhat neater when a lof of dependencies are involved.

import React from "react";
import { render } from "react-dom";
import { Rating } from "@mui/material";

// Rating (by default) uses stars to let the users input a number; ideal for the level editor.
render(React.createElement(Rating, { min: 0, max: 12 }), document.getElementById("root"));

Now, if we were to directly use the above file, we'd probably run into the issue of the browser not understanding import statements; it is syntax that was introduced for a different code execution environment (Node). A transpiler/bundler could take care of resolving those dependencies.

Webpack

But wait, didn't we just go over RequireJS, and it not being suitable for the job? Yes, but there are quite a few other options out there. And unlike RequireJS they support the import syntax that is actually supported in some contexts. In my experience Webpack is most commonly used, including in scripts that help set-up boiler-plate code.

So for example, if you have the following two files:

// card-maker.js
import { LevelEditor } from "./level-editor.js";

function CardMaker() {
  LevelEditor();
}
CardMaker();
// level-editor.js
export function LevelEditor() {
  console.log("Editing levels!");
}

Webpack would generate output akin to the following (which has been somewhat simplified compared to actual output).

(function(modules) {
  var installedModules = { };
  function require(id) {
    if (installedModules[id]) { return installedModules[id].exports; }
    var module = installedModules[id] = {
      id: id,
      loaded: false,
      exports: {}
    };
    modules[id](module, module.exports, require);
    module.loaded = true;
    return module.exports;
  }
  
  require("./card-maker.js");
})({
  "./card-maker.js": (function(module, exports, require) {
    var LevelEditor = require("./level-editor.js")["default"];
    (function CardMaker() { LevelEditor() })();
  }),
  "./level-editor.js": (function(module, exports, require) {
    exports["LevelEditor"] = function() { console.log("Editing levels!"); }
  }
});

This way the dependencies get correctly resolved in the required order, making it far easier to work with compared to plain JavaScript.

Now at this point you might wonder whether all that hassle was worth the somewhat hard to read output, compared to where we started: manually ordering imports. I would say very much so. Though it might not be all that apparent from the code examples. Some benefits you also get (sometimes requiring plugins for Webpack) include:

  • Optimizing output by stripping useless whitespace and comments, as well as renaming functions and variables to something less verbose, saving a lot of bytes in the long run
  • Splitting code that is relevant for different pages into reusable packages, or only importing code when actually necessary
  • Hot reloading, so you do not manually need to rebuild the code and refresh the page each time you make a change
  • Allow for importing different file types, to make sure they are bundled. 

And I'm probably forgetting a few other advantages.


That might have sounded like quite a few hoops to jump through, and might actually be the case. However, there have been some kind developers that have created some script that take care of most  this stuff. Thanks to those scripts, it essentially boils down to running a single command, akin to the following (this is but one such command).

$ yarn create next-app --typescript

There might still be some configuration required depending on your use case, but I reckon it still greatly simplifies things fo you.


And with that I'll end of this post; it has already gone on for longer than I initally intended, despite hardly involving any code of the card maker itself.

Share this post


Link to post
Share on other sites

Components

When developing a website HTML, CSS and JavaScript are a given. Though to what degree tends to depend on the purpose of the website. A website for your local hairdresser/dentist/etc. might only need to put opening hours and contact information online, maybe including a contact form; for those sites some simple HTML, CSS, and JavaScript would suffice.

On the other hand, if you are creating a web application, standard HTML, CSS, and JavaScript might not be quite as convenient to use. Partially because different browsers tend to be, well…, different (his sometimes makes it hard to have certain features behave consistently cross-browser), and partially because HTML, CSS and JavaScript were not originally desgined for creating applications. As a result, linking different components can be a bit involded (for example: detailing why your password is not accepted). Things become even more complicated when you want to let users drag-and-drop images, add rich text editors, rearrange lists, open dialogs, create custom context menus, and so on.

Luckily there are quite a few software libraries out there that take care of normalizing user input, and "rendering" HTML based on some state. Unfortunately, there are quite a few libraries out there, making it hard to choose. Some big ones include:

React

They go about things somewhat differently, but also have certain things in common. And an argument could be used for using any of them; coming somewhat down to preference. In the case of this site's card maker, React is used. Out of the listed options it was the least involvedfor me to to get it to work. But I could have equally well chosen one of the many other options.

Now, with React there are two main ways in which you can write your code: using plain JavaScript, or using special JSX syntax. The former works straight out of the box, where the other requires some configuration for your build tools. Due to not actually using a toolchain when initially developing the card maker, I used the "createElement" option; though I have since switched over. Unfortunately the current version still uses the plain JavaScript version, thus making maintaining it a tad… annoying.

// Plain JavaScript example
import React from "react";

export function Card(props) {
  return React.createElement(
    "div", 
    { className: "card" }, 
    React.createElement("h1", { className: "name" }, props.name),
    React.createElement("div", { className: "effect" }, props.effect)
  );
}
// JSX example
import React from "react";

export function Card(props) {
  return <div className="card">
    <h1 className="name">{props.name}</h1>
    <div className="effect">{props.effect}</div>
  </div>
}

Those two examples are equavelent to one another. The syntax of the latter is closer to the output that is generated, and is thus why I would consider it more readable. If we were to use this "Card" component to render Dark Magician, the output would look like the following.

<div class="card">
  <h1 class="name">Dark Magician</h1>
  <div class="effect">The ultimate wizard in terms of attack and defense.</div>
</div>

That is all fine and dandy, but how does this help compared to writing the output directy? In context of those examples it is indeed a bit excessive, but the use becomes apparent once user input comes into the picture. Say we still wanted the above output, but instead of rendering "Dark Magician's" name and effect, the output should reflect what the user has typed into a set of text fields. The way the state is handled in the example could be improved, but that is a topic for a different time.

import React, { useState, useCallback } from "react";
import { Card } from "./Card";

export function CardMaker() {
  const [name, setName] = useState("");
  const [effect, setEffect] = useState("");
  
  return <div>
    <Card name={name} effect={effect} />
      
    <input type="text" value={name} onChange={(evt) => {setName(evt.currentTarget.value);}} />
    <textarea value={effect} onChange={(evt) => {setEffect(evt.currentTarget.value);}} />
  </div>
}
Example using plain JavaScript

The following code using regular JavaScript and HTML could probably yield the same results, but is less composable.  It could probably have been written slightly more optimally, but the general gist should remain the same; it is more unwieldy than using a component library.


(function(container) {
  var card = document.createElement("div");
  card.classList.add("card");
  var name = document.createElement("h1");
  name.classList.add("name");
  var effect = document.createElement("div");
  effect.classList.add("effect");
  
  var nameInput = document.createElement("input"); 
  nameInput.type = "text"; // Technically not needed, as it is the default.
  nameInput.addEventListener("change", function onChange(evt) {
    // The user input actually needs to be sanitized.
    name.innerText = evt.currentTarget.value;
  });
  var effectInput = document.createElement("textarea");
  effectInput.addEventListener("change", function onChange(evt) {
    // Also needs to be sanitized.
    effect.innerText = evt.currentTarget.value;
  });
  
  card.appendChild(name);
  card.appendChild(effect);
  
  container.appendChild(card);
  container.appendChild(nameInput);
  container.appendChild(effectInput);
  
})(document.body); // Optionally specify a different target where the instance should be created.

 

Component libraries

Up until this point, the code only involved regular HTML components like buttons, textfields and file inputs. After all, that is the only thing the browser knows how to render. They can however be a tad bland and don't look all that consistent accross browsers. You can get nicer results with some CSS, but some components are somewhat annoying to style, such as checkboxes, radio buttons and file inputs. Doing this manually is possible, and allows you to style them exactly as you like. 

But I prefer to use some ready made components that have been tested to work in various different browsers. Similar to before with React, Angular, Vue, and so on, there are a lot of component libraries (for use with React). Those include but are not limited to:

All of those listed are available under a permissive license, and provide variying amounts of customization of the components. Picking one is hard, and I have switched back and forth between some of the listed options a few times. Currently I'm using Material UI for development.

Edited by Yemachu
Actidentally posted the entry while still editing it

Share this post


Link to post
Share on other sites

It has been more than a month since the previous blog post, despite feeling like it was posted last week. Time sure flies.

Last time the topic was about component librarie; they certainly help with getting a uniform look-and-feel for any application. But having an application that has a consistent design isn't particularly useful if none of the controls (such as buttons, sliders, and so on) do anything. This brings us to the topic of this post: managing state and reacting to changes.

In TypeScript there are a few "primitives" which can be used to model your state.

  • string: Represents text. A card's name or effect could be represented this way. Doesn't inherently represent any 
  • number: A numeric value. A card's level could be represented this way, as well as the horizontal and vertical position at which an image should be drawn
  • Object: Group of "primitives" that can be accessed under a specific name. Useful for having closely related data together, such as all properties of a single card, the width and height of a rectangle, X and Y component of a coordinate, and so on.
  • Array: List of things in a specific order. Typically similarly typed content is put in an array (such as all different cards in the project).

A relatively simple representation of a card could be as follows:

{
  "name": "Dark Magician",
  "template": "NORMAL",
  "attribute": "DARK",
  "level": 7,
  "ATK": "2500",
  "DEF": "2100",
  "type": ["Spellcaster"],
  "effect": "The ultimate wizard in terms of attack and defense.",
}

A few of the standard traits of a Yu-Gi-Oh! cards have been represented in that block of text. Modifying those properties from code is easy enenough; simply reassign one of the values. 

const card = { /* All of the above properites */ }

const nameEditor = document.getElementById("nameEditor");
nameEditor.addEventListener("change", function(evt){
  card.name = evt.currentTarget.value; 
});

If one were to type some text in the "nameEditor" textfield, it would update the card's name. But is the rest of the application aware of the change that has been made? The above code does not notify that the name property has been changed, so other code that might have something to do with that property (such as the renderer that creates an image from it) might not act on the change.

True, the code could constantly be rerun so the lastest values are used, but this can be a bit pointless if nothing has been changed. It is however not entirely without merit; games tend to work this way. An alternative is to invoke a function notifying others that changes have been made. This could be a tad repetive, and is easy to forget if the code is not structured in a way that forces you to do so. 

Irrespective of how other parts of the code are notified about changes in the state, it needs to somehow figure out what has changed and how to respond to that, or it could start from scratch working from the new state. The former is rather finicky and error prone, the latter leads to a lot of extra work.

React (detailed in the previous post) takes an approach somewhere in between. Rather than making changes in place, you create a new immutable object that represents the state and notify the application about that new object. The immutable nature allows you to reuse components from the previous state; they will not be changing after all. This makes figuring out what elements need to be updated a whole lot easier: is this part of the state the same as the previous time? Yes: leave everything as is. No: Start from "scratch" (nested components might not need to be updated however).

var card = { name: "Dark Magician", level: 7 };

// Create a new object ({}), copy all properties from the previous state to it (card),
// and overwrite any of them with the newly specified values (level: 7). 
card = Object.assign({}, card, { level: 8 }); // Output: { name: "Dark Magician", level: 8 }

Assigning a new value has become a tad more unwieldy, but we have created a completely new object, rahter than having changed something in place. The above might not work correctly with deeply nested properties, but there are utilites for that available, such as Immer.js.

Leaving the issues of responding to updates aside for a bit, there is another thing with the state that needs to be addressed: maintaining integrity. In the above example a new value gets created where the level is overwritten. In this case it is an acceptable level, but in the more general case there might need to be checks in place making sure it falls between zero and twelve (inclusive)… or make that thirteen since "Number iC1000: Numeronious Numeronia" exists. 

Placing the logic in the event handler could work, but could become clunky if multiple sources were to work on same value. This could happen when a slider with a text field both control the value. Extracting the logic into its own function could help. 

function UpdateLevel(card, level) {
  if (0 <= level && level <= 13) {
    return Object.assign({}, card, { level });
  }
  return card; // Invalid level; leave it unchanged?
}

// Somewhere in an event handler
setState(UpdateLevel(card, evt.target.value));

In this case it is only one value that needs to be updated, but what if some more things were related? Say… changing the name should also update the effect so any references to the card's "old" name now reference its new name. This could of course be added to the function that updates the name:

function UpdateName(card, newName) {
  const newEffect = card.effect.replaceAll(card.name, newName);
  return Object.assign({}, card, { name: newName, effect: newEffect });
}

That works… but what about the pendulum effect? It can simply be added. It is however a solution that does not scale well. In the actual game various cards refer to other cards by name (i.e. Fusion Materials). Having the "UpdateName" function run over all cards in the project to update any effects that refers to its old name quickly becomes unwieldy.

It would be more convenient if we could tell the state that we intend to update the name of a particular card, and have related state figure out for itself if it needs to update as well. This, to my knowledge is kind of the idea behind Flux/Redux. 

import { createAction, createReducer, combineReducers } from "@reduxjs/toolkit";

const rename = createAction("card/rename", function(card, newName) {
  // When every instance is informed about a name change, it might be a good
  // idea to add a field indicating the intended target to prevent all cards
  // from taking on the same new name. Hence "id".
  return { payload: newName, meta: { id: card.id, oldName: card.name} };
});

const name = createReducer("Einzelgänger", function(builder) {
  builder.addMatcher(rename.match, function(state, action) {
    return action.payload;
  })
});

const effect = createReducer("You can only control 1 \"Einzelgänger\".", function(builder) { 
  builder.addMatcher(rename.match, function(state, action) {
    // Not a great implementation, but it should suffice for a demonstation.
    return state.replaceAll(action.meta.oldName, action.payload);
  });
});

const card = combineReducers({
  name,
  effect,
  // …
});

In order to rename a card you would then dispatch the "rename" action from your event handler.

dispatch(rename(card, evt.target.value));

The state then decides how it should respond to that action, without the event handler needing to worry about any of the other details about how the state should be changed. Isn't that wonderful? Another advantage of using this method of working with state is that you can test everything in isolation before integrating it with other code to make sure individual parts work correctly.

Something worth noting, is that this example might be a bit convoluted. It is probably a better idea to have a special syntax where any references get resolved upon rendering the card. This to avoid all letters 'M' getting replaced with "Mokey Mokey" when typing that name after writing its effect first, or affecting other cards with the same name.


That should be enough for this post. Not sure which topic to address next, but there are enough topics to cover. Till next time!

Share this post


Link to post
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Sign in to follow this  

  • Recently Browsing   0 members

    No registered users viewing this page.

×
×
  • Create New...