Better PacMan Ghost animations

This toy had a generic set of lights and I’ve wanted it to be able to blink morse code messages, be the right colors, and pretend someone ate a power dot since I got it. 47 RGBW LEDs later and I had something that met my expectations.

I played around with the patterns quite a bit to add some character to the ghost, and found it really useful. I’ve included it in case anyone can find something they want to ask about or can use. It’s kinda messy and I feel like I’d do a better job rewriting it with what I know now, but it seems to work.

/* The eye layout prioritized wiring ease over regularity, and the following tables 
  are eye 'gestures' based on pixel indexes rather than position.
                left eye                  |right eye
                left  down mid  up   right left  up   mid  down right */
lookLeft =     [1,    0,   0,   0,   0,    1,    0,   0,   0,   0    ]
lookRight =    [0,    0,   0,   0,   1,    0,    0,   0,   0,   1    ]
lookCenter =   [0.25, 0.5, 1,   0.5, 0.25, 0.25, 0.5, 1,   0.5, 0.25 ]
lookUp =       [0,    0,   0,   1,   0,    0,    1,   0,   0,   0    ]
lookDown =     [0,    1,   0,   0,   0,    0,    0,   0,   1,   0    ]
halfBlink =    [0.25, 0,   1,   0,   0.25, 0.25, 0,   1,   0,   0.25 ]
quarterBlink = [0,    0,   0.5, 0,   0,    0,    0,   0.5, 0,   0    ]
shut =         [0,    0,   0,   0,   0,    0,    0,   0,   0,   0    ]
 
/* Eye animations are a list of gestures and the time to take to transition to them. */
/* The first entry is a number to report current animation */
blinkSymbol = .8114
blink = [blinkSymbol,[halfBlink,0.001],[quarterBlink,0.001],[shut,0.001],[quarterBlink,0.001],[halfBlink,0.001],[lookCenter,0.001],[lookCenter,0.1]]

eyerollSymbol = 0.3731
eyeroll = [eyerollSymbol,[lookLeft,0.01],[lookUp,0.005],[lookRight,0.005],[lookDown,0.005],
                         [lookLeft,0.005],[lookUp,0.005],[lookRight,0.005],[lookDown,0.005],
                         [lookLeft,0.005],[lookCenter,0.005],[lookCenter,0.1]]

shiftySymbol = 0.5177
shifty = [shiftySymbol,[lookLeft,0.005],[lookLeft,0.025],[halfBlink,0.005],[lookRight,0.005],[lookRight,0.025],[lookCenter,0.005],[lookCenter,0.1]]

napSymbol = 0.4420
nap = [napSymbol,[halfBlink,0.1],[quarterBlink,0.001],[shut,0.001],[shut,0.001],
                 [quarterBlink,0.001],[halfBlink,0.001],[lookCenter,0.005],[halfBlink,0.005],[quarterBlink,0.1],[shut,0.001],[shut,0.01],
                 [quarterBlink,0.001],[shut,0.1],[shut,1],
      [quarterBlink,0.001],[halfBlink,0.001],[lookCenter,0.001],
      [lookCenter,0.01],[lookLeft,0.005],[lookLeft,0.05],[halfBlink,0.005],[lookRight,0.005],[lookRight,0.05],[lookCenter,0.005],[lookCenter,0.1],]

// Power Dot animations involve the ghost and the eyes so it's pretty irregular
powerDotSymbol = .2033
powerDot = [powerDotSymbol, [lookRight,0.05],[lookRight,0.05],[lookRight,0.045],[lookRight,0.045],[lookRight,0.04],[lookRight,0.04],
                            [lookRight,0.035],[lookRight,0.035],[lookRight,0.03],[lookRight,0.03],[lookRight,0.025],[lookRight,0.025],
                            [lookRight,0.02],[lookRight,0.02],[lookRight,0.015],[lookRight,0.015],[lookRight,0.01],[lookRight,0.01]]

// Go Home animations involve the ghost and the eyes so it's pretty irregular
goHomeSymbol = 0.4033
goHome = [goHomeSymbol, [lookRight,0.001],[lookRight,0.05],[lookUp,0.001],[lookUp,0.02],[lookLeft,0.001],[lookLeft,0.1],[lookUp,0.001],
                        [lookUp,0.05],[lookRight,0.001],[lookRight,0.02],[lookDown,0.001],[lookDown,0.02]]

/* Blinking morse code uses a fast blink, with the shut period scaled to the morseRate */
morseRate = 0.01
xRate = morseRate/10

ditSymbol = 0.217
dit = [ditSymbol,[halfBlink,xRate],[quarterBlink,xRate],[shut,xRate],[shut,morseRate-3*xRate],
                 [quarterBlink,xRate],[halfBlink,xRate],[lookCenter,xRate],[lookCenter,morseRate-3*xRate]]

dahSymbol = 0.444
dah = [dahSymbol,[halfBlink,xRate],[quarterBlink,xRate],[shut,xRate],[shut,3*morseRate-3*xRate],
                 [quarterBlink,xRate],[halfBlink,xRate],[lookCenter,xRate],[lookCenter,morseRate-3*xRate]]

// letter endings have a longer space
ltrEndSymbol = 0
ltrEnd = [ltrEndSymbol,[lookCenter,morseRate*2]]

_A = [dit,dah,ltrEnd]
_B = [dah,dit,dit,dit,ltrEnd]
_C = [dah,dit,dah,dit,ltrEnd]
_D = [dah,dit,dit,ltrEnd]
_E = [dit,ltrEnd]
_F = [dit,dit,dah,dit,ltrEnd]
_H = [dit,dit,dit,dit,ltrEnd]
_I = [dit,dit,ltrEnd]
_K = [dah,dit,dah,ltrEnd]
_L = [dit,dah,dit,dit,ltrEnd]
_M = [dah,dah,ltrEnd]
_N = [dah,dit,ltrEnd]
_O = [dah,dah,dah,ltrEnd]
_P = [dit,dah,dah,dit,ltrEnd]
_R = [dit,dah,dit,ltrEnd]
_S = [dit,dit,dit,ltrEnd]
_T = [dah,ltrEnd]
_U = [dit,dit,dah,ltrEnd]
_V = [dit,dit,dit,dah,ltrEnd]
_W = [dit,dah,dah,ltrEnd]
_X = [dah,dit,dit,dah,ltrEnd]
_Y = [dah,dit,dah,dah,ltrEnd]
_Z = [dah,dah,dit,dit,ltrEnd]
wrdEnd = [ltrEnd,ltrEnd]

// Track current action for eye animation
export var actionIdx = 1
var action = dah
from = lookCenter
to = lookCenter
rate = 0.1

var morseWord = HI
var wordIdx = 0
var morseWordLength = morseWord.length

var morseLetter = _A

var actionLength = wrdEnd.length

function chooseNewMorseLetter(){
  morseIdx = 0
  wordIdx ++
  if(wordIdx>=morseWord.length){
    morseLetter = wrdEnd
  } else {
    morseLetter = morseWord[wordIdx]
  }
}

    // Morse words
HI = [wrdEnd,_H,_I,wrdEnd]
HOWSYOURMORSECODE = [wrdEnd,_I,wrdEnd,_S,_P,_E,_A,_K,wrdEnd,_M,_O,_R,_S,_E,wrdEnd,_C,_O,_D,_E,wrdEnd,_S,_T,_O,_P,wrdEnd]
HAPPYBIRTHDAY = [wrdEnd,_H,_A,_P,_P,_Y,wrdEnd,_B,_I,_R,_T,_H,_D,_A,_Y,wrdEnd]
HAPPYFATHERSDAY = [wrdEnd,_H,_A,_P,_P,_Y,wrdEnd,_F,_A,_T,_H,_E,_R,_S,wrdEnd,_D,_A,_Y,wrdEnd]
HAPPYMOTHERSDAY = [wrdEnd,_H,_A,_P,_P,_Y,wrdEnd,_M,_O,_T,_H,_E,_R,_S,wrdEnd,_D,_A,_Y,wrdEnd]
HELPIMTRAPPEDINAMAZE = [wrdEnd,_H,_E,_L,_P,wrdEnd,_I,_M,wrdEnd,_T,_R,_A,_P,_P,_E,_D,wrdEnd,_I,_N,wrdEnd,_A,wrdEnd,_M,_A,_Z,_E,wrdEnd]
FORDADLOVEDREW = [wrdEnd,_F,_O,_R,wrdEnd,_D,_A,_D,wrdEnd,_L,_O,_V,_E,wrdEnd,_D,_R,_E,_W,wrdEnd]
BUILTWITHPIXELBLAZE = [wrdEnd,_B,_U,_I,_L,_T,wrdEnd,_W,_I,_T,_H,wrdEnd,_P,_I,_X,_E,_L,_B,_L,_A,_Z,_E,wrdEnd]

function chooseNewAnimation(){
  morseWordLength = morseWord.length
  if(wordIdx >= morseWord.length) {
    /* Advance to random eye animation */
    actionIdx = 1
    chance = random(1)
    
    if(chance<0.05){ // Start to signal a word of morse code
      if ((clockMonth()==6) && (clockWeekday()==1) && (clockDay()>=15) && (clockDay()<=22)) {
        morseWord = HAPPYFATHERSDAY
      } if ((clockMonth()==5) && (clockWeekday()==1) && (clockDay()>=8) && (clockDay()<=13)) {
        morseWord = HAPPYMOTHERSDAY
      } else {
        chance = random(1)
        if(chance<0.2){
          morseWord = HOWSYOURMORSECODE
        } else if (chance<0.4) {
          morseWord = HELPIMTRAPPEDINAMAZE
        } else if (chance<0.6) {
          morseWord = FORDADLOVEDREW
        } else if (chance<0.8) {
          morseWord = BUILTWITHPIXELBLAZE
        } else {
          morseWord = HI
        }
      }
      wordIdx = 0
      morseLetter = morseWord[0]
      morseIdx = 0
      action = morseLetter[0]
    } else if (chance<0.08) {
      action = powerDot
    } else if(chance<0.12){
     action = nap
    } else if (chance<0.30) {
      action = shifty
    } else if(chance<0.50){
     action = eyeroll
    } else if(chance<0.57){
     action = dit
    } else if(chance<0.62){
      action = dah
    } else {
      action = blink
    }
  } else { //continue ongoing morse word
    morseIdx++
    if(morseIdx>=morseLetter.length){
      chooseNewMorseLetter()
    }
    action = morseLetter[morseIdx]
  }
}

function advanceAnimation(){
  from = to
  actionIdx = actionIdx + 1
  actionLength = action.length
  if(actionIdx>=action.length){
    chooseNewAnimation()
  }
  actionLength = action.length
  if(action.length<=actionIdx){actionIdx=1}
  entry = action[actionIdx]
  to = entry[0]
  rate = entry[1]
  
  // Special case to catch power dot
  if(action == powerDot && random(1)>0.96){triggerGoHome()}
}

var t1
var maxvar = 32767
var hour
export function beforeRender(delta) {
  tCont = time(.5)
  actionTime += delta/rate
  if (actionTime < 0) {
    advanceAnimation()
    actionTime = 0
  }
  t1 = actionTime/maxvar
  
  computeBrightness()
}

function eyes(index) {
  return index<10
}

//convert HSV to RGB
//output sets r,g,b globals
function hsv2rgb(hh, ss, vv) {
  var h = mod(hh, 1)
  var s = clamp(ss, 0, 1)
  var v = clamp(vv, 0, 1)
  var i = floor(h * 6)
  var f = h * 6 - i
  var p = v * (1 - s)
  var q = v * (1 - (s * f))
  var t = v * (1 - (s * (1 - f)))

  if (i == 0) {
    r = v; g = t; b = p
  } else if (i == 1) {
    r = q; g = v; b = p
  } else if (i == 2) {
    r = p; g = v; b = t
  } else if (i == 3) {
    r = p; g = q; b = v
  } else if (i == 4) {
    r = t; g = p; b = v
  } else if (i == 5) {
    r = v; g = p; b = q
  }
}

var rSel = 1
var gSel = 0
var bSel = 0

export function rgbPickerColor(r, g, b){
  rSel = r
  gSel = g
  bSel = b
}

// Debugging buttons to test animations
export function triggerBlink(){
  action = blink
  actionIdx = 0
  rate=0.01
}
export function triggerShifty(){
  action = shifty
  actionIdx = 0
  rate=0.01
}
export function triggerNap(){
  action = nap
  actionIdx = 0
  rate=0.01
}
export function triggerEyeRoll(){
  action = eyeroll
  actionIdx = 0
  rate=0.01
}
export function triggerDit(){
  action = dit
  actionIdx = 0
  rate=0.01
}
export function triggerDah(){
  action = dah
  actionIdx = 0
  rate=0.01
}
export function triggerPowerDot(){
  action = powerDot
  actionIdx = 0
  rate=0.01
}
export function triggerGoHome(){
  action = goHome
  actionIdx = 0
  rate = 0.01
  chance = random(1)
  if(chance>0.75){pink()}
  else if (chance>0.5){lightBlue()}
  else if (chance>0.25){red()}
  else {yellow()}
  rSel = r; gSel = g; bSel = b
}

export function inputNumberMorseRate(v){
  morseRate = v
}

var brightnessOverride = 0
export function toggleFullBrightness(on){
  brightnessOverride = on
}

export function showNumberAction(){return action[0]}

export function showNumberBrightness(){return brightness}

second = -1
function computeBrightness() {
  if (second != clockSecond()) {
    if (brightnessOverride) {
      brightness = 1
    } else {
      second = clockSecond()
      nightBrightness = 0.05
      hour = clockHour() + clockMinute()/60 + second/3600
      brightness = nightBrightness + clamp((1-nightBrightness)*(8-abs(12-hour))/4,0,1-nightBrightness)
    }
  }
}
  
/* Scaled RGB applies a brightness effect to reduce brightness at night */
function sRGB(r,g,b) {
  rgb(r*brightness,g*brightness,b*brightness)
}

function white() {
  r = g = b = 1
}

function blue() {
  r = g = 0 ; b = .75
}

function lightBlue() {
  r = 0; g = 1; b = 0.8
}

function yellow() {
  r = 1 ; g = 0.7 ; b = 0
}

function green() {
  r = b = 0 ; g = 1
}

function red() {
  b = g = 0 ; r = 1
}

function pink() {
  r = 1 ; g = 0.6 ; b = 0.75
}

export function render3D(index,x,y,z) {
  if (action==powerDot) {
    if (actionIdx % 2) { 
      blue()
    } else {
      white()
    }
  } else if (action == goHome){
  } else if (clockMonth() == 6){
    hsv2rgb(x/6+tCont+y/4,1,1)
  } else if ((clockMonth()==2) && clockDay() == 14) {
    pink()
  } else if (((clockMonth()==12) && (clockDay()>=24)) || ((clockMonth()==1) && (clockDay()<=6))){
    t = (x/10+y/10+tCont)%1
    if      (t<0.25) {red()}
    else if (t<0.5)  {white()} 
    else if (t<0.75) {green()} 
    else             {white()}
  } else if ((clockMonth()==7) && (clockDay()==4)){
    t = (x/10+y/5+tCont)%1
    if      (t<0.25) {red()}
    else if (t<0.5)  {white()}
    else if (t<0.75) {blue()}
    else             {white()}
  } else {
    r = rSel ; g = gSel ; b = bSel
  }
  
  // Apply a lazy gamma effect
  g = g*g*g
  b = b*b*b
  if (eyes(index)) {
    if (action == powerDot){
      if(actionIdx%2) {
        v = to[index]
        sRGB(v+max(0,r/4-v),v+max(0,g/4-v),v+max(0,b/4-v))
      }
      else            {
        v = to[index]
        sRGB(v+max(0,r/4-v),max(0,g/4-v),max(0,b/4-v))
      }
    } else {
      v = mix(from[index],to[index],t1)
      // a shut eye should be the background color not black
      // the eye LEDs are about half as far from the plane so we reduce their brightness
      sRGB(v+max(0,r/4-v),v+max(0,g/4-v),v+max(0,b/4-v))
    }
  }
  else
  {
    // The bottom walks if we're not napping or signalling a message
    if(y>0.75 && action!=nap && action!=dit  && action!=dah && action!= ltrEnd){
      r*=wave(index/8+150*tCont)
      g*=wave(index/8+150*tCont)
      b*=wave(index/8+150*tCont)
    }
    sRGB(r,g,b)
  }
}
7 Likes

What! Thats cool! Morse code too!?!

Agreed! Very nicely done. Many thanks for sharing.