Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

What's the right API for variables and lists? #19

Closed
PullJosh opened this issue Nov 15, 2019 · 9 comments
Closed

What's the right API for variables and lists? #19

PullJosh opened this issue Nov 15, 2019 · 9 comments
Labels
discussion Looking for feedback and input
Milestone

Comments

@PullJosh
Copy link
Collaborator

Right now there's an API in place for replicating Scratch's variables, but it has some problems. The way it works right now is that scripts run by a trigger are passed two variables: one object containing the global variables, and another containing the sprite variables. Unfortunately, this setup means that variables are not currently accessible from within custom blocks (methods) unless they are passed manually. This is really bad.

We need a better system.

In Scratch, there are a variety of variable types:

  • Variables
    • Sprite
    • Stage
    • Cloud
  • Lists
    • Sprite
    • Stage

(Per-script variables don't exist in Scratch like they do in Snap!, but they should in scratch-js and will exist essentially by default.)

Cloud variables rely on server infrastructure to operate, so they probably don't make sense to support in scratch-js, at least in the beginning.

This leaves us with variables and lists at the sprite and stage level. There are few options to consider:

  1. Do we split up variables and lists as separate entities or merge them into one (with variables being assigned to array values)?
  2. Do we split up stage vs sprite variables or find a way to merge them into one?
  3. How do we access the variables in the first place? What is different between accessing variables from a sprite vs from the stage?
@PullJosh
Copy link
Collaborator Author

PullJosh commented Nov 15, 2019

Proposal

Let's answer the questions in order:

1. Split variables and lists?

Scratch makes a clear distinction between variables and lists. Javascript does not (arrays are stored in variables). Scratch-js it at the awkward intersection of these worlds, and we need to make a judgement call.

One of the things that makes scratch-js exciting to me is that it's possible to get all the functionality of Scratch (with its nice-to-haves like automatic rendering) while learning to write javascript proper. Much like Snap!, I think it's important to make decisions that enforce good habits and teach common programming paradigms to Scratch users. In that light, I think it makes sense to push people towards an understanding that lists are a data type to be stored in variables.

Let's merge variables and lists into one concept.

2. Split sprite and stage variables?

Globally accessible (stage) variables are a lot different than private (sprite) variables no matter how you spin it. When writing code, the difference should be clear. (Weirdly, Scratch does a terrible job of this.)

The only real downside to splitting up sprite vs. stage variables is that it makes compiling Scratch projects a bit harder. That's okay though; it's worth the sacrifice.

3. How to access variables?

When doing object-oriented programming with javascript, one might choose to set values on an instance of a class. (this.myVar = 5;) Sprites are classes, so this is a real option.

  • Good: It feels like this is probably the best representation of traditional object-oriented style
  • Good: If we do this, would could add a this.stage getter to sprites so that stage variables can be accessed and modified easily (this.stage.myGlobalVar = 5;)
  • Bad: Putting variables in the same globalish namespace as basic sprite methods like this.move(10) is mildly frightening

To solve the last problem we could store variables as a single object called vars. This has the benefit of avoiding gross name collisions, but the downside that the names are really long and ugly to read.

The options

Option 1: Pretty and terrifying

this.myVariable = 5;
this.stage.otherVar = 3;
console.log(this.myVariable + this.stage.otherVar);

// Possible issue:
this.goto = 10; // Oh no! We just replaced the goto method with the number 10!

Option 2: Ugly and safe

this.vars.myVariable = 5;
this.stage.vars.otherVar = 3;
console.log(this.vars.myVariable + this.stage.vars.otherVar);

Option 3?

Is there something better possible? (Please leave recommendations!) We could always get fancy with getters, setters, and proxies to create just about any concoction we so desire. It's all about getting the design right; implementation is easy, even with crazy ideas.

@PullJosh
Copy link
Collaborator Author

With option 1 it is possible to prevent overwriting built-in methods and variables (so that when you run this.goto = 10 nothing happens), but it still means that some variable names are off-limits and could catch people off gaurd.

@PullJosh
Copy link
Collaborator Author

PullJosh commented Nov 16, 2019

After sleeping on it (and realizing that there's more complexity to this than I had originally considered), I think it makes sense to go with option 2 (this.vars).

What I had failed to consider initially is variable watchers. We don't need to nail down the specifics on watchers right now, but they're definitely going to require a fairly standardized way to access a sprite's variables.

Option 2 it is.

Watchers concept

While I'm thinking about watchers, here's a quick concept for how they could work.

// Initializing a sprite
new MySprite(
  {
    x: 0,
    y: 0,
    // ...
  },
  {
    // Define sprite variables
    myListName: [1, 2, 3]
  }
)

// Initialize project with watchers
new Project(
  stage,
  sprites,
  [
    {
      // Global "answer" value shown in top left corner
      watch: [Stage, "sensing", "answer"],
      x1: -230,
      y1: 170
    },
    {
      // x position of MySprite shown in top right corner
      watch: [MySprite, "motion", "x"],
      x2: 230,
      y1: 170
    },
    {
      // MySprite's variable "myListName" shown on most of screen
      watch: [MySprite, "vars", "myListName"],
      x1: -230,
      x2: 230,
      y1: 150,
      y2: -170
    }
  ]
)

My thinking is that watchers should be able to show any value you throw at them. Whatever you store in a variable, whether it's a number, string, array, or object, it should show up nicely (while still resembling Scratch's watchers for basic types).

@PullJosh
Copy link
Collaborator Author

The simplified variables API (option 2) has been implemented in dc27d61

@PullJosh PullJosh added this to the Version 1.0.0 milestone Nov 16, 2019
@PullJosh PullJosh added the discussion Looking for feedback and input label Nov 16, 2019
@bates64
Copy link

bates64 commented Nov 16, 2019

Proxies let us do some pretty fancy stuff without resorting to this.vars, e.g. we can do Option 1 without users accidentally overwriting methods.

// -- Imagine we setup some Proxy p with get()/set() traps --
let p

p.move = 'foo'
yield p.goto(10, 10) // Woah! We called the original method!
console.log(p.goto) // 'foo'

Doing variables with proxies also means you can limit datatypes and do other magical things, like watchers:

// Can do this *without* modifying String.prototype:
p.goto = 'foo'
p.goto.watch() // Displays watcher.
p.move = 'bar'

Doing it like this may be evil, though; teaching the wrong ideas (this.move != this.move sometimes??). Perhaps just screaming if the user attempts to do something bad would be better (ie. protected methods):

this.myVariable = 'foo'
this.goto = 0 // throws Error: cannot mutate Sprite method 'goto'
console.log(this.goto) // Function goto

This isn't very JS-like, though, since this.goto() will call this.prototype.goto unless this.goto is defined.


Going a totally different route, something more akin to the following might be cute:

// I have no idea how scratch-js works, sorry :shipit:
function * tick() {
  // 'this' refers to sprite

  this.myVariable = 0

  yield moveSteps(10, 20) // note lack of this-- engine methods could be calls out to some global(ish) function which uses `this` to determine the sprite etc
}

Or, just, um, use the var keyword. Is that infeasible? Teaching the idea of scope is definitely something worth considering:

var globalVariable = 'foo'

/* in sprite but no particular script */ {
  var ourVariable = 'bar'

  /* in script */ {
    var myVariable = 'baz'

    // ...
  }
}

This might require a rethink of how new Sprite and friends work, but scope is a VERY important - and useful!! - concept...

@PullJosh
Copy link
Collaborator Author

PullJosh commented Nov 16, 2019

@nanaian There's a lot of fun to be had with proxies, but I don't think much of it is useful when the goal is to create a bog-standard API without any gimmicks (with the intent of teaching programming best practices for everyday situations).

You've hit on an interesting idea at the end with your most recent edit. It's the kind of idea that's good because it's so obvious in hindsight. Sprite vs. stage variables map very cleanly to scope in JS, so it totally makes sense to use that as the basis of the variable system.

You're also correct when you say that the scope-based approach doesn't really fit with the existing model. Currently, sprites (and the stage) are defined as classes that extend the built-in scratch-js base Sprite and Stage classes. (See the example project!)

I really like using classes for sprites (and the stage). Game design is a great use case for OOP, and it's also a nice model for dealing with clones (just create more instances of the class!). Is there a way to get the best of both worlds?

@bates64
Copy link

bates64 commented Nov 16, 2019

How about this?

// Cat.mjs

import * from 'https://scratch.js.org/blocks.mjs'

let ourVariable = 'foo' // Shared between all Cats.

// Note that Cat does *not* extend any kind of Sprite class! 
export default class Cat {
  constructor(name) {
    this.name = name // Local to this Cat instance only.

    // Declarative, rather than `this.costumes = [...]` etc.
    // Works by messing around with `this[SPRITE].costumes` (where `SPRITE` is a `Symbol`) etc.
    addCostume('cat', './cat.svg', { x: 47, y: 55 })
    onClick(this.clicked)
  }

  * clicked() {
    say(`Meow! My name is ${this.name}`)

    // Make a clone of this cat.
    let child = clone(this)
    child::goto(10, 10) // [1]
  }
}

[1] See https://github.com/tc39/proposal-bind-operator. Alternatively use goto.bind(child)(10, 10) which is.... eh, to say the least.


// index.mjs

import { createSprite } from 'https://scratch.js.org/project.mjs'

import Cat from './Cat.mjs'

createSprite(Cat)

Inheriting from Sprite brings problems, as we've seen, so doing it in this way might be interesting?

@PullJosh
Copy link
Collaborator Author

PullJosh commented Nov 16, 2019

Interesting, but this still doesn't allow sharing scope-based state between different sprites. As far as I can tell, sharing based on scope is essentially nonexistent when using imports.

Plus, sacrificing actual instance methods is a big cost. (That being said, I appreciate the effort and am still very curious about this idea...)

(Also, regarding https://scratch.js.org/, TIL about Scratch-JS 😛)

@PullJosh
Copy link
Collaborator Author

For now, option 2 seems to be working okay. Going to close for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion Looking for feedback and input
Projects
None yet
Development

No branches or pull requests

2 participants