scratch-html5/js/Sprite.js
2013-11-01 23:14:39 -04:00

466 lines
16 KiB
JavaScript

// Copyright (C) 2013 Massachusetts Institute of Technology
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 2,
// as published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
// Scratch HTML5 Player
// Sprite.js
// Tim Mickel, July 2011 - March 2012
// The Sprite provides the interface and implementation for Scratch sprite-control
'use strict';
var Sprite = function(data) {
if (!this.data) {
this.data = data;
}
// Public variables used for Scratch-accessible data.
this.visible = typeof(this.data.visible) == "undefined" ? true : data.visible;
this.scratchX = data.scratchX || 0;
this.scratchY = data.scratchY || 0;
this.scale = data.scale || 1.0;
this.direction = data.direction || 90;
this.rotation = (data.direction - 90) || 0;
this.rotationStyle = data.rotationStyle || 'normal';
this.isFlipped = data.direction < 0 && data.rotationStyle == 'leftRight';
this.costumes = data.costumes || [];
this.currentCostumeIndex = data.currentCostumeIndex || 0;
this.previousCostumeIndex = -1;
this.objName = data.objName || '';
this.variables = {};
if (data.variables) {
for (var i = 0; i < data.variables.length; i++) {
this.variables[data.variables[i]['name']] = data.variables[i]['value'];
}
}
this.lists = {};
if (data.lists) {
for (var i = 0; i < data.lists.length; i++) {
this.lists[data.lists[i]['listName']] = data.lists[i];
}
}
// Used for the pen
this.penIsDown = false;
this.penWidth = 1;
this.penHue = 120; // blue
this.penShade = 50; // full brightness and saturation
this.penColorCache = 0x0000FF;
// Used for layering
if (!this.z) this.z = io.getCount();
// HTML element for the talk bubbles
this.talkBubble = null;
this.talkBubbleBox = null;
this.talkBubbleStyler = null;
this.talkBubbleOn = false;
// Internal variables used for rendering meshes.
this.textures = [];
this.materials = [];
this.geometries = [];
this.mesh = null;
// Sound buffers and data
this.sounds = {};
if (data.sounds) {
for (var i = 0; i < data.sounds.length; i++) {
this.sounds[data.sounds[i]['soundName']] = data.sounds[i];
}
}
this.soundsLoaded = 0;
this.instrument = 1;
// Incremented when images are loaded by the browser.
this.costumesLoaded = 0;
// Stacks to be pushed to the interpreter and run
this.stacks = [];
};
// Attaches a Sprite (<img>) to a Scratch scene
Sprite.prototype.attach = function(scene) {
// Create textures and materials for each of the costumes.
for (var c in this.costumes) {
this.textures[c] = document.createElement('img');
$(this.textures[c])
.load([this, c], function(evo) {
var sprite = evo.handleObj.data[0];
var c = evo.handleObj.data[1];
sprite.costumesLoaded += 1;
sprite.updateCostume();
$(sprite.textures[c]).css('display', sprite.currentCostumeIndex == c ? 'inline' : 'none');
$(sprite.textures[c]).css('position', 'absolute').css('left', '0px').css('top', '0px');
$(sprite.textures[c]).bind('dragstart', function(evt) { evt.preventDefault(); })
.bind('selectstart', function(evt) { evt.preventDefault(); })
.bind('touchend', function(evt) { sprite.onClick(evt); $(this).addClass('touched'); })
.click(function(evt) {
if (!$(this).hasClass('touched')) {
sprite.onClick(evt);
} else {
$(this).removeClass('touched');
}
});
scene.append($(sprite.textures[c]));
})
.attr('src', io.asset_base + this.costumes[c].baseLayerMD5 + io.asset_suffix);
}
this.mesh = this.textures[this.currentCostumeIndex];
this.updateLayer();
this.updateVisible();
this.updateTransform();
this.talkBubble = $('<div class="bubble-container"></div>');
this.talkBubble.css('display', 'none');
this.talkBubbleBox = $('<div class="bubble"></div>');
this.talkBubbleStyler = $('<div class="bubble-say"></div>');
this.talkBubble.append(this.talkBubbleBox);
this.talkBubble.append(this.talkBubbleStyler);
runtime.scene.append(this.talkBubble);
};
// Load sounds from the server and buffer them
Sprite.prototype.loadSounds = function() {
var spr = this;
$.each(this.sounds, function(index, sound) {
io.soundRequest(sound, spr);
});
};
// True when all the costumes have been loaded
Sprite.prototype.isLoaded = function() {
return this.costumesLoaded == this.costumes.length && this.soundsLoaded == Object.keys(this.sounds).length;
};
// Step methods
Sprite.prototype.showCostume = function(costume) {
if (costume < 0) {
costume += this.costumes.length;
}
if (!this.textures[costume]) {
this.currentCostumeIndex = 0;
}
else {
this.currentCostumeIndex = costume;
}
this.updateCostume();
};
Sprite.prototype.indexOfCostumeNamed = function(name) {
for (var i in this.costumes) {
var c = this.costumes[i];
if (c['costumeName'] == name) {
return i;
}
}
return null;
};
Sprite.prototype.showCostumeNamed = function(name) {
var index = this.indexOfCostumeNamed(name);
if (!index) return;
this.showCostume(index);
};
Sprite.prototype.updateCostume = function() {
if (!this.textures[this.currentCostumeIndex]) {
this.currentCostumeIndex = 0;
}
$(this.mesh).css('display', 'none');
this.mesh = this.textures[this.currentCostumeIndex];
this.updateVisible();
this.updateTransform();
};
Sprite.prototype.onClick = function(evt) {
// TODO - needs work!!
var boxOffset = $('#container').offset();
var mouseX = runtime.mousePos[0] + 240 + boxOffset.left;
var mouseY = 180 - runtime.mousePos[1] + boxOffset.top;
if (this.mesh.src.indexOf('.svg') == -1) {
// HACK - if the image SRC doesn't indicate it's an SVG,
// then we'll try to detect if the point we clicked is transparent
// by rendering the sprite on a canvas. With an SVG,
// we are forced not to do this for now by Chrome/Webkit SOP:
// http://code.google.com/p/chromium/issues/detail?id=68568
var canv = document.createElement('canvas');
$(canv).css('width', 480).css('height', 360);
var ctx = canv.getContext('2d');
var drawWidth = this.textures[this.currentCostumeIndex].width * this.scale;
var drawHeight = this.textures[this.currentCostumeIndex].height * this.scale;
var rotationCenterX = this.costumes[this.currentCostumeIndex].rotationCenterX;
var rotationCenterY = this.costumes[this.currentCostumeIndex].rotationCenterY;
var drawX = this.scratchX + (480 / 2) - rotationCenterX;
var drawY = -this.scratchY + (360 / 2) - rotationCenterY;
ctx.rotate(this.rotation * Math.PI / 180.0);
ctx.drawImage(this.mesh, 0, 0, drawWidth, drawHeight);
var offsetX = mouseX - drawX - 9; // the 9 is a hack - webkit/moz
var offsetY = mouseY - drawY - 9;
var idata = ctx.getImageData(offsetX, offsetY, 1, 1).data;
var alpha = idata[3];
} else {
var alpha = 1;
}
if (alpha > 0) {
// Start clicked hats if the pixel is non-transparent
runtime.startClickedHats(this);
} else {
// Otherwise, move back a layer and trigger the click event
$(this.mesh).hide();
var underElement = document.elementFromPoint(mouseX, mouseY);
$(underElement).click();
$(this.mesh).show();
}
};
Sprite.prototype.setVisible = function(v) {
this.visible = v;
this.updateVisible();
};
Sprite.prototype.updateLayer = function() {
$(this.mesh).css('z-index', this.z);
if (this.talkBubble) this.talkBubble.css('z-index', this.z);
};
Sprite.prototype.updateVisible = function() {
$(this.mesh).css('display', this.visible ? 'inline' : 'none');
if (this.talkBubbleOn) {
this.talkBubble.css('display', this.visible ? 'inline-block' : 'none');
}
};
Sprite.prototype.updateTransform = function() {
var texture = this.textures[this.currentCostumeIndex];
var resolution = this.costumes[this.currentCostumeIndex].bitmapResolution || 1;
var drawWidth = texture.width * this.scale / resolution;
var drawHeight = texture.height * this.scale / resolution;
var rotationCenterX = this.costumes[this.currentCostumeIndex].rotationCenterX;
var rotationCenterY = this.costumes[this.currentCostumeIndex].rotationCenterY;
var drawX = this.scratchX + (480 / 2) - rotationCenterX;
var drawY = -this.scratchY + (360 / 2) - rotationCenterY;
var scaleXprepend = '';
if (this.isFlipped) {
scaleXprepend = '-'; // For a leftRight flip, we add a minus
// sign to the X scale.
}
$(this.mesh).css('transform',
'translatex(' + drawX + 'px) \
translatey(' + drawY + 'px) \
rotate(' + this.rotation + 'deg) \
scaleX(' + scaleXprepend + (this.scale / resolution) + ') scaleY(' + (this.scale / resolution) + ')');
$(this.mesh).css('-moz-transform',
'translatex(' + drawX + 'px) \
translatey(' + drawY + 'px) \
rotate(' + this.rotation + 'deg) \
scaleX(' + scaleXprepend + this.scale + ') scaleY(' + this.scale / resolution + ')');
$(this.mesh).css('-webkit-transform',
'translatex(' + drawX + 'px) \
translatey(' + drawY + 'px) \
rotate(' + this.rotation + 'deg) \
scaleX(' + scaleXprepend + (this.scale / resolution) + ') scaleY(' + (this.scale / resolution) + ')');
$(this.mesh).css('-webkit-transform-origin', rotationCenterX + 'px ' + rotationCenterY + 'px');
$(this.mesh).css('-moz-transform-origin', rotationCenterX + 'px ' + rotationCenterY + 'px');
$(this.mesh).css('-ms-transform-origin', rotationCenterX + 'px ' + rotationCenterY + 'px');
$(this.mesh).css('-o-transform-origin', rotationCenterX + 'px ' + rotationCenterY + 'px');
$(this.mesh).css('transform-origin', rotationCenterX + 'px ' + rotationCenterY + 'px');
// Don't forget to update the talk bubble.
if (this.talkBubble) {
var xy = this.getTalkBubbleXY();
this.talkBubble.css('left', xy[0] + 'px');
this.talkBubble.css('top', xy[1] + 'px');
}
this.updateLayer();
};
Sprite.prototype.getTalkBubbleXY = function() {
var texture = this.textures[this.currentCostumeIndex];
var drawWidth = texture.width * this.scale;
var drawHeight = texture.height * this.scale;
var rotationCenterX = this.costumes[this.currentCostumeIndex].rotationCenterX;
var rotationCenterY = this.costumes[this.currentCostumeIndex].rotationCenterY;
var drawX = this.scratchX + (480 / 2) - rotationCenterX;
var drawY = -this.scratchY + (360 / 2) - rotationCenterY;
return [drawX + drawWidth, drawY - drawHeight / 2];
};
Sprite.prototype.showBubble = function(text, type) {
var xy = this.getTalkBubbleXY();
this.talkBubbleOn = true;
this.talkBubble.css('z-index', this.z);
this.talkBubble.css('left', xy[0] + 'px');
this.talkBubble.css('top', xy[1] + 'px');
this.talkBubbleStyler.removeClass('bubble-say');
this.talkBubbleStyler.removeClass('bubble-think');
if (type == 'say') {
this.talkBubbleStyler.addClass('bubble-say');
} else if (type == 'think') {
this.talkBubbleStyler.addClass('bubble-think');
}
if (this.visible) {
this.talkBubble.css('display', 'inline-block');
}
this.talkBubbleBox.html(text);
};
Sprite.prototype.hideBubble = function() {
this.talkBubbleOn = false;
this.talkBubble.css('display', 'none');
};
Sprite.prototype.setXY = function(x, y) {
this.scratchX = x;
this.scratchY = y;
this.updateTransform();
};
Sprite.prototype.setDirection = function(d) {
var rotation;
d = d % 360
if (d < 0) d += 360;
this.direction = d > 180 ? d - 360 : d;
if (this.rotationStyle == 'normal') {
rotation = (this.direction - 90) % 360;
} else if (this.rotationStyle == 'leftRight') {
if (((this.direction - 90) % 360) >= 0) {
this.isFlipped = false;
} else {
this.isFlipped = true;
}
rotation = 0;
} else {
rotation = 0;
}
this.rotation = rotation;
this.updateTransform();
};
Sprite.prototype.setRotationStyle = function(r) {
this.rotationStyle = r;
};
Sprite.prototype.getSize = function() {
return this.scale * 100;
};
Sprite.prototype.setSize = function(percent) {
var newScale = percent / 100.0;
newScale = Math.max(0.05, Math.min(newScale, 100));
this.scale = newScale;
this.updateTransform();
};
// Move functions
Sprite.prototype.keepOnStage = function() {
var x = this.scratchX + 240;
var y = 180 - this.scratchY;
var myBox = this.getRect();
var inset = -Math.min(18, Math.min(myBox.width, myBox.height) / 2);
var edgeBox = new Rectangle(inset, inset, 480 - (2 * inset), 360 - (2 * inset));
if (myBox.intersects(edgeBox)) return; // sprite is sufficiently on stage
if (myBox.right < edgeBox.left) x += edgeBox.left - myBox.right;
if (myBox.left > edgeBox.right) x -= myBox.left - edgeBox.right;
if (myBox.bottom < edgeBox.top) y += edgeBox.top - myBox.bottom;
if (myBox.top > edgeBox.bottom) y -= myBox.top - edgeBox.bottom;
this.scratchX = x - 240;
this.scratchY = 180 - y;
};
Sprite.prototype.getRect = function() {
var cImg = this.textures[this.currentCostumeIndex];
var x = this.scratchX + 240 - (cImg.width/2.0);
var y = 180 - this.scratchY - (cImg.height/2.0);
var myBox = new Rectangle(x, y, cImg.width, cImg.height);
return myBox;
};
// Pen functions
Sprite.prototype.setPenColor = function(c) {
var hsv = Color.rgb2hsv(c);
this.penHue = (200 * hsv[0]) / 360 ;
this.penShade = 50 * hsv[2]; // not quite right; doesn't account for saturation
this.penColorCache = c;
};
Sprite.prototype.setPenHue = function(n) {
this.penHue = n % 200;
if (this.penHue < 0) this.penHue += 200;
this.updateCachedPenColor();
};
Sprite.prototype.setPenShade = function(n) {
this.penShade = n % 200;
if (this.penShade < 0) this.penShade += 200;
this.updateCachedPenColor();
};
Sprite.prototype.updateCachedPenColor = function() {
var c = Color.fromHSV((this.penHue * 180.0) / 100.0, 1, 1);
var shade = this.penShade > 100 ? 200 - this.penShade : this.penShade; // range 0..100
if (shade < 50) {
this.penColorCache = Color.mixRGB(0, c, (10 + shade) / 60.0);
} else {
this.penColorCache = Color.mixRGB(c, 0xFFFFFF, (shade - 50) / 60);
}
};
Sprite.prototype.stamp = function(canvas, opacity) {
var drawWidth = this.textures[this.currentCostumeIndex].width * this.scale;
var drawHeight = this.textures[this.currentCostumeIndex].height * this.scale;
var drawX = this.scratchX + (480 / 2);
var drawY = -this.scratchY + (360 / 2);
canvas.globalAlpha = opacity / 100.0;
canvas.save();
canvas.translate(drawX, drawY);
canvas.rotate(this.rotation * Math.PI / 180.0);
canvas.drawImage(this.mesh, -drawWidth/2, -drawHeight/2, drawWidth, drawHeight);
canvas.restore();
canvas.globalAlpha = 1;
};
Sprite.prototype.soundNamed = function(name) {
if (name in this.sounds && this.sounds[name].buffer) {
return this.sounds[name];
} else if (name in runtime.stage.sounds && runtime.stage.sounds[name].buffer) {
return runtime.stage.sounds[name];
}
return null;
};