learnwithjason / scenes

23 stars 4 forks source link

Suggestion for the submarine component #1

Open maazadeeb opened 3 years ago

maazadeeb commented 3 years ago

@jlengstorf I had an idea when watching the stream. I couldn't get the repo to run on my system, so I created a codesandbox with most of the code from submarine.js.

The idea is to have a 2D array of the directions, and use them to calculate the new left and top positions. Also, I added direction for the top/down commands as well.

const directions = {
  up: [0, -1],
  down: [0, 1],
  left: [-1, 0],
  right: [1, 0]
};

const wrapperRect = wrapper.getBoundingClientRect();
const maxLeft = wrapperRect.width - 100;
const maxTop = wrapperRect.height - 30;

const currentLeft = parseInt(styles.getPropertyValue("left"), 10);
const currentTop = parseInt(styles.getPropertyValue("top"), 10);

const nextLeft =
  currentLeft + directions[currentCommand][0] * MOVEMENT_DISTANCE;
const nextTop = currentTop + directions[currentCommand][1] * MOVEMENT_DISTANCE;

if (nextLeft !== currentLeft && nextLeft > 0 && nextLeft < maxLeft) {
  submarine.style.left = `${nextLeft}px`;
  submarine.style.transform = `scaleX(${-1 * directions[currentCommand][0]})`;
}
if (nextTop !== currentTop && nextTop > 0 && nextTop < maxTop) {
  submarine.style.top = `${nextTop}px`;
  submarine.style.transform = `rotate(${
    -90 * directions[currentCommand][1]
  }deg)`;
}

If you think this works well, I could submit a PR. I didn't know how else to ask, so I created an issue. :)

jlouis commented 3 years ago

Hi Maaz and Jason,

I ended up toying a bit with this as well, but my Javascript (; and certainly DOM/CSS) skills are somewhat lacking, so I used the language I happen to know, ReScript. The code compiles and type checks, so there's likely to be some arithmetic bugs in there, but the general structure of the code should hold. It doesn't take that much more effort to turn it into a React component that would be possible to hook from JS though.

First, we write a way to do 2d-linear algebra on vectors. And we define a way to transform the submarine and directional commands into a Vec.t. Thus, we can compute the new position of the submarine through scalar multiplication and vector addition.

Next, we define a way to turn a DOM element into a Box.t. The key function here is Box.inside which allows us to ask if a vector points inside the box or not.

Finally, define an Area as a box you can be either inside or outside, noting that they are complementary: you are outside a box if you are not inside it. A Box.t can be turned into a SafeArea.t if you also specify wether you want to stay inside or outside it. Finally, you can form the union of several safe-areas through SafeArea.union and you can ask if a vector is safe through SafeArea.safe.

With this in place, and a bit of glue, it essentially implements the core logic, but it would need more work to embed inside a React component because it doesn't care about E.style.transform for instance, nor does it compute the width/height accurately.

Oh, and note this was mostly just to have fun with this. I got the transformation idea and wanted to see how hard it would be to write. And how many more lines it would take over doing it directly. I ended up spending far more time on figuring out how you access CSS/DOM from ReScript, than the rest of the code which was somewhat straightforward. So in the end, I ended up learning something about CSS and the DOM in the process :)

(The Javascript the rescript compiler generates is quite readable. I'd happily attach it if you want to study what the compiler does).

/* Direction defines the directive commands we can supply to the system
 * In a more real setting this would be an action type in Rescript/React,
 * and would also contain the "pet" command. But it suffices for this
 */
type direction = Up | Down | Left | Right

// 2d Vectors
module Vec = {
  // The type of a vector
  type t = {x: int, y: int}
  // Create a new vector out of a pair.
  let make = (~x, ~y) => {x: x, y: y}

  // You can multiply vectors by a scalar, and you can add them
  let scale = (s, {x, y}) => {x: s * x, y: s * y}
  let add = ({x: x1, y: y1}, {x: x2, y: y2}) => {x: x1 + x2, y: y1 + y2}

  /* Compute a unit direction vector from a direction */
  let fromDirection = d =>
    switch d {
    | Up => {x: 0, y: -1}
    | Down => {x: 0, y: 1}
    | Left => {x: -1, y: 0}
    | Right => {x: 1, y: 0}
    }
}

// Type of boxes
module Box = {
  // A box is an upper-left point and a width + height
  type t = {
    ul: Vec.t,
    width: int,
    height: int,
  }

  // Not strictly necessary. But factoring box creation through this function
  // allows us to change the type above and reorder how we want to compute coordinates
  // later on if need be. We can force this by making `t` above abstract via
  // a module type later.
  let make = (~offset, ~width, ~height) => {ul: offset, width: width, height: height}

  // Predicate. Is p inside the box? We are assuming coordinates are relative to the window.
  let inside = (p: Vec.t, box: t) =>
    p.x > box.ul.x && p.y > box.ul.y && p.x < box.ul.x + box.width && p.y < box.ul.y + box.height

  // Create a box out of a dom element
  let fromElement = e => {
    let rect = Webapi.Dom.Element.getBoundingClientRect(e)
    let x = rect->Webapi.Dom.DomRect.x->Belt.Float.toInt
    let y = rect->Webapi.Dom.DomRect.y->Belt.Float.toInt
    let width = rect->Webapi.Dom.DomRect.width->Belt.Float.toInt
    let height = rect->Webapi.Dom.DomRect.height->Belt.Float.toInt
    make(~offset=Vec.make(~x, ~y), ~width, ~height)
  }
}

// Generalize boxes to safe areas. This allow you to bundle boxes together
// to form where it's safe to move the submarine.
module SafeArea = {
  // An area is either Inside-the-box or Outside-the-box
  type area =
    | Inside(Box.t)
    | Outside(Box.t)

  // A SafeArea is an array of areas
  type t = Js.Array2.t<area>

  // Construct areas. Either inside or outside
  let makeInside = box => [Inside(box)]
  let makeOutside = box => [Outside(box)]

  // If you have several safe areas, you can combine them into a union
  let union = Js.Array2.concat

  // A point p is safe if it is faithful to all the areas we have
  let safe = (p, areas) => {
    // valid analyzes a single area
    let valid = area =>
      switch area {
      | Inside(box) => Box.inside(p, box)
      | Outside(box) => !Box.inside(p, box)
      }

    areas->Js.Array2.every(valid)
  }
}

// Submarine helpers, just bundled up in a module so we can name-and-conquer.
module Submarine = {
  // Obtain a position from a Dom element. Turn it into a Vec if possible
  let position = (~submarine, ~window) => {
    let style = Webapi.Dom.Window.getComputedStyle(submarine, window)
    let l = style->Webapi.Dom.CssStyleDeclaration.left
    let t = style->Webapi.Dom.CssStyleDeclaration.top
    switch (Belt.Int.fromString(l), Belt.Int.fromString(t)) {
    | (Some(x), Some(y)) => Some(Vec.make(~x, ~y))
    | (_, _) => None
    }
  }

  // Given a style, set its position. A real solution also needs to care
  // for transform.
  let setPosition = (~style, ~pos: Vec.t) => {
    let set = (id, v) =>
      Webapi.Dom.CssStyleDeclaration.setProperty(id, Js.Int.toString(v) ++ "px", "", style)
    set("left", pos.x)
    set("top", pos.y)
  }
}

// Try to move the submarine from current position.
// Turn the move into an optional value where the return is Some(p) if
// the move is possible.
let tryMove = (~wrapper, ~currentPos, ~move) => {
  // Utilize our helper modules to compute this
  let box = Box.fromElement(wrapper)
  let safe = SafeArea.makeInside(box)
  let newPos = Vec.add(currentPos, move)
  switch SafeArea.safe(newPos, safe) {
  | false => None
  | true => Some(newPos)
  }
}

// Compute a new possible position for a submarine.
let computeMove = (~submarine, ~scale, ~direction, ~wrapper, ~window) => {
  let move = Vec.scale(scale, Vec.fromDirection(direction))
  switch Submarine.position(~submarine, ~window) {
  | None => {
      Js.log("Expected to find a submarine style, but none was found")
      None
    }
  | Some(currentPos) => tryMove(~wrapper, ~currentPos, ~move)
  }
}

let movement_distance = 10

// To set the submarine, use this function
let setSubmarine = (~submarine: Webapi.Dom.Element.t, ~direction, ~wrapper, ~window) => {
  let scale = movement_distance
  switch computeMove(~submarine, ~direction, ~wrapper, ~scale, ~window) {
  | None => // Movement isn't possible, so don't do anything
    ()
  | Some(pos: Vec.t) =>
    // Update the submarine position
    switch Webapi.Dom.HtmlElement.ofElement(submarine) {
    | None => Js.log("Could not find submarine in HTML Dom")
    | Some(e) => {
        let style = Webapi.Dom.HtmlElement.style(e)
        Submarine.setPosition(~style, ~pos)
      }
    }
  }
}
jlengstorf commented 3 years ago

@maazadeeb this is great! your solution is really nice — I'd love a PR!

@jlouis this is very cool as well! I love seeing another solution. I probably don't want to add it for this project, since I don't use Rescript, but I really appreciate seeing how you solved it!

jlouis commented 3 years ago

@jlengstorf It's the sensible move not to start relying on Rescript I think, and I didn't expect it to be included as well. It was more like a FYI, now I had written it, and I thought the solution was interesting enough that more people should see it. Over the weekend, looking at it now, there's a couple of changes that should definitely be considered: