Jak stworzyliśmy świetny interfejs
Wprowadzenie
JAM with Chrome to internetowy projekt muzyczny stworzony przez Google. JAM with Chrome pozwala użytkownikom z całego świata tworzyć zespoły i grać w czasie rzeczywistym w przeglądarce. DinahMoe przesunął granice tego, co jest możliwe dzięki interfejsowi Web Audio API w Chrome. Nasz zespół w Tool of North America stworzył interfejs do gry na komputerze jak na instrumencie muzycznym.
Pod kierunkiem Google Creative Lab ilustrator Rob Bailey stworzył szczegółowe ilustracje wszystkich 19 instrumentów dostępnych w JAM. Na ich podstawie dyrektor ds. interakcji Ben Tricklebank i nasz zespół projektowy w Tool stworzyli prosty i profesjonalny interfejs dla każdego instrumentu.
Każdy instrument jest wizualnie niepowtarzalny, więc dyrektor techniczny Toola, Bartek Drozdz, i ja połączyliśmy je ze sobą, używając kombinacji obrazów PNG, CSS, SVG i elementów Canvas.
Wiele instrumentów musiało obsługiwać różne metody interakcji (takie jak kliknięcia, przeciąganie i pociągnięcia smyczka – wszystkie czynności, które można wykonywać za pomocą instrumentu), zachowując przy tym interfejs silnika dźwiękowego DinahMoe. Okazało się, że aby zapewnić użytkownikom przyjemną rozgrywkę, nie wystarczyło im tylko użycie metod JavaScript mouseup i mousedown.
Aby uwzględnić te wszystkie różnice, stworzyliśmy element „Scena”, który pokrywa obszar gry, obsługując kliknięcia, przeciąganie i pociągnięcia palcem na wszystkich instrumentach.
Etap
Stage to nasz kontroler, którego używamy do konfigurowania funkcji w poszczególnych instrumentach. Na przykład dodanie różnych części instrumentów, z którymi użytkownik będzie wchodzić w interakcję. W miarę dodawania kolejnych interakcji (np. „kliknięcia”) możesz je dodawać do prototypu sceny.
function Stage(el) {
// Grab the elements from the dom
this.el = document.getElementById(el);
this.elOutput = document.getElementById("output-1");
// Find the position of the stage element
this.position();
// Listen for events
this.listeners();
return this;
}
Stage.prototype.position = function() {
// Get the position
};
Stage.prototype.offset = function() {
// Get the offset of the element in the window
};
Stage.prototype.listeners = function() {
// Listen for Resizes or Scrolling
// Listen for Mouse events
};
Pobieranie pozycji elementu i kursora
Naszym pierwszym zadaniem jest przekształcenie współrzędnych myszy w oknie przeglądarki tak, aby były względne do elementu Stage. Aby to zrobić, musieliśmy wziąć pod uwagę, gdzie na stronie znajduje się nasz Stage.
Ponieważ musimy określić położenie elementu względem całego okna, a nie tylko jego elementu nadrzędnego, sprawa jest nieco bardziej skomplikowana niż tylko sprawdzenie offsetu góry i offsetu lewej strony. Najprostszą opcją jest użycie metody getBoundingClientRect, która zwraca pozycję w stosunku do okna, podobnie jak zdarzenia myszy, i jest dobrze obsługiwana w nowszych przeglądarkach.
Stage.prototype.offset = function() {
var _x, _y,
el = this.el;
// Check to see if bouding is available
if (typeof el.getBoundingClientRect !== "undefined") {
return el.getBoundingClientRect();
} else {
_x = 0;
_y = 0;
// Go up the chain of parents of the element
// and add their offsets to the offset of our Stage element
while (el && !isNaN( el.offsetLeft ) && !isNaN( el.offsetTop ) ) {
_x += el.offsetLeft;
_y += el.offsetTop;
el = el.offsetParent;
}
// Subtract any scrolling movment
return {top: _y - window.scrollY, left: _x - window.scrollX};
}
};
Jeśli funkcja getBoundingClientRect nie istnieje, mamy prostą funkcję, która po prostu doda przesunięcia, przechodząc w górę łańcucha elementów nadrzędnych, aż do elementu body. Następnie odejmujemy odległość przewinięcia okna, aby uzyskać pozycję względem okna. Jeśli używasz jQuery, funkcja offset() świetnie radzi sobie z złożonością określania lokalizacji na różnych platformach, ale nadal musisz odjąć wartość przewinięcia.
Gdy przewijasz stronę lub zmieniasz jej rozmiar, pozycja elementu może się zmienić. Możemy nasłuchiwać tych zdarzeń i ponownie sprawdzać pozycję. Te zdarzenia są wywoływane wiele razy podczas typowego przewijania lub zmiany rozmiaru, dlatego w rzeczywistej aplikacji najlepiej jest ograniczyć częstotliwość sprawdzania pozycji. Można to zrobić na wiele sposobów, ale na stronie HTML5 Rocks znajdziesz artykuł o debounce’owaniu zdarzeń przewijania za pomocą metody requestAnimationFrame, która sprawdzi się w tym przypadku.
Zanim przejdziemy do wykrywania uderzeń, w tym pierwszym przykładzie będziemy po prostu wyświetlać względne współrzędne x i y, gdy tylko przesuniesz mysz w obszarze sceny.
Stage.prototype.listeners = function() {
var output = document.getElementById("output");
this.el.addEventListener('mousemove', function(e) {
// Subtract the elements position from the mouse event's x and y
var x = e.clientX - _self.positionLeft,
y = e.clientY - _self.positionTop;
// Print out the coordinates
output.innerHTML = (x + "," + y);
}, false);
};
Aby zacząć obserwować ruch kursora, utworzymy nowy obiekt Stage i przekażemy mu identyfikator div, którego chcemy użyć jako sceny.
//-- Create a new Stage object, for a div with id of "stage"
var stage = new Stage("stage");
Prosty sposób wykrywania trafień
W JAM z Chrome nie wszystkie interfejsy instrumentów są skomplikowane. Pady bębna są prostymi prostokątami, co ułatwia wykrycie, czy kliknięcie znajduje się w ich obrębie.
Zaczniemy od prostokątów, a potem skonfigurujemy podstawowe typy kształtów. Każdy obiekt kształtu musi znać swoje granice i mieć możliwość sprawdzenia, czy punkt znajduje się w jego obrębie.
function Rect(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
return this;
}
Rect.prototype.inside = function(x, y) {
return x >= this.x && y >= this.y
&& x <= this.x + this.width
&& y <= this.y + this.height;
};
Każdy nowy typ kształtu, który dodamy, będzie wymagał funkcji w obiekcie Stage, aby zarejestrować go jako strefę dotknięcia.
Stage.prototype.addRect = function(id) {
var el = document.getElementById(id),
rect = new Rect(
el.offsetLeft,
el.offsetTop,
el.offsetWidth,
el.offsetHeight
);
rect.el = el;
this.hitZones.push(rect);
return rect;
};
W przypadku zdarzeń myszy każda instancja kształtu sprawdza, czy przekazane współrzędne x i y myszy są dla niej odpowiednie, i zwraca wartość true lub false.
Możemy też dodać do elementu sceny klasę „active”, która spowoduje, że kursor myszy zmieni się w wskaźnik po najechaniu na kwadrat.
this.el.addEventListener ('mousemove', function(e) {
var x = e.clientX - _self.positionLeft,
y = e.clientY - _self.positionTop;
_self.hitZones.forEach (function(zone){
if (zone.inside(x, y)) {
// Add class to change colors
zone.el.classList.add('hit');
// change cursor to pointer
this.el.classList.add('active');
} else {
zone.el.classList.remove('hit');
this.el.classList.remove('active');
}
});
}, false);
Więcej kształtów
Im bardziej skomplikowane są kształty, tym bardziej skomplikowane są obliczenia służące do sprawdzania, czy punkt leży wewnątrz nich. Jednak te równania są dobrze znane i szczegółowo opisane w wielu miejscach w internecie. Niektóre z najlepszych przykładów kodu JavaScript, jakie widziałem, pochodzą z biblioteki geometrycznej Kevina Lindseya.
Na szczęście podczas tworzenia JAM w Chrome nie musieliśmy nigdy używać innych kształtów niż koła i prostokąty. Do tworzenia bardziej złożonych kształtów używaliśmy kombinacji tych kształtów i warstw.
Kręgi
Aby sprawdzić, czy punkt znajduje się w okrągłym bębnie, musimy utworzyć okrągły kształt bazowy. Chociaż jest on dość podobny do prostokąta, ma własne metody określania granic i sprawdzania, czy punkt znajduje się wewnątrz koła.
function Circle(x, y, radius) {
this.x = x;
this.y = y;
this.radius = radius;
return this;
}
Circle.prototype.inside = function(x, y) {
var dx = x - this.x,
dy = y - this.y,
r = this.radius;
return dx * dx + dy * dy <= r * r;
};
Zamiast zmieniać kolor, dodanie klasy hit spowoduje uruchomienie animacji CSS3. Rozmiar tła pozwala szybko skalować obraz bębna bez wpływu na jego położenie. Aby to działało, musisz dodać inne prefiksy przeglądarek (-moz, -o i -ms). Możesz też dodać wersję bez prefiksu.
#snare.hit{
{ % mixin animation: drumHit .15s linear infinite; % }
}
@{ % mixin keyframes drumHit % } {
0% { background-size: 100%;}
10% { background-size: 95%; }
30% { background-size: 97%; }
50% { background-size: 100%;}
60% { background-size: 98%; }
70% { background-size: 100%;}
80% { background-size: 99%; }
100% { background-size: 100%;}
}
Strings
Funkcja GuitarString przyjmuje identyfikator kanwy i obiekt Rect, a następnie rysuje linię przez środek tego prostokąta.
function GuitarString(rect) {
this.x = rect.x;
this.y = rect.y + rect.height / 2;
this.width = rect.width;
this._strumForce = 0;
this.a = 0;
}
Gdy chcemy, aby wibrował, wywołujemy funkcję strum, aby ustawić strunę w ruchu. W każdym renderowanym ujęciu siła będzie nieco mniejsza, a licznik, który powoduje oscylacje struny, będzie się zwiększał.
GuitarString.prototype.strum = function() {
this._strumForce = 5;
};
GuitarString.prototype.render = function(ctx, canvas) {
ctx.strokeStyle = "#000000";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(this.x, this.y);
ctx.bezierCurveTo(
this.x, this.y + Math.sin(this.a) * this._strumForce,
this.x + this.width, this.y + Math.sin(this.a) * this._strumForce,
this.x + this.width, this.y);
ctx.stroke();
this._strumForce *= 0.99;
this.a += 0.5;
};
Intersections and Strumming
Obszar docelowy dla tego ciągu będzie znów pudełkiem. Kliknięcie tego pola powinno wywołać animację ciągu znaków. Ale kto chce klikać gitarę?
Aby dodać struny, musimy sprawdzić przecięcie pola strun i linii, po której porusza się mysz użytkownika.
Aby uzyskać wystarczającą odległość między poprzednią a bieżącą pozycją myszy, musimy spowolnić szybkość, z jaką otrzymujemy zdarzenia dotyczące jej ruchu. W tym przykładzie po prostu ustawimy flagę, aby ignorować zdarzenia mousemove przez 50 ms.
document.addEventListener('mousemove', function(e) {
var x, y;
if (!this.dragging || this.limit) return;
this.limit = true;
this.hitZones.forEach(function(zone) {
this.checkIntercept(
this.prev[0],
this.prev[1],
x,
y,
zone
);
});
this.prev = [x, y];
setInterval(function() {
this.limit = false;
}, 50);
};
Następnie będziemy musieli polegać na kodzie kolizji napisanym przez Kevina Lindseya, aby sprawdzić, czy linia ruchu myszy przecina środek naszego prostokąta.
Rect.prototype.intersectLine = function(a1, a2, b1, b2) {
//-- http://www.kevlindev.com/gui/math/intersection/Intersection.js
var result,
ua_t = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x),
ub_t = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x),
u_b = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y);
if (u_b != 0) {
var ua = ua_t / u_b;
var ub = ub_t / u_b;
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
result = true;
} else {
result = false; //-- No Intersection
}
} else {
if (ua_t == 0 || ub_t == 0) {
result = false; //-- Coincident
} else {
result = false; //-- Parallel
}
}
return result;
};
Na koniec dodamy nową funkcję do tworzenia instrumentów smyczkowych. Spowoduje to utworzenie nowego etapu, skonfigurowanie kilku ciągów znaków i uzyskanie kontekstu płótna, na którym zostanie narysowany wykres.
function StringInstrument(stageID, canvasID, stringNum){
this.strings = [];
this.canvas = document.getElementById(canvasID);
this.stage = new Stage(stageID);
this.ctx = this.canvas.getContext('2d');
this.stringNum = stringNum;
this.create();
this.render();
return this;
}
Następnie ustawimy obszary dopasowania strun, a potem dodamy je do elementu Scena.
StringInstrument.prototype.create = function() {
for (var i = 0; i < this.stringNum; i++) {
var srect = new Rect(10, 90 + i * 15, 380, 5);
var s = new GuitarString(srect);
this.stage.addString(srect, s);
this.strings.push(s);
}
};
Na koniec funkcja renderowania StringInstrument będzie przeszukiwać wszystkie nasze ciągi znaków i wywoływać ich metody renderowania. Jest ona wykonywana cały czas z taką szybkością, jaką uzna za odpowiednią metoda requestAnimationFrame. Więcej informacji o metodzie requestAnimationFrame znajdziesz w artykule Paula Irisha requestAnimationFrame – inteligentna animacja.
W rzeczywistej aplikacji możesz ustawić flagę, gdy nie występuje animacja, aby zatrzymać rysowanie nowej ramki kanwy.
StringInstrument.prototype.render = function() {
var _self = this;
requestAnimFrame(function(){
_self.render();
});
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
for (var i = 0; i < this.stringNum; i++) {
this.strings[i].render(this.ctx);
}
};
Podsumowanie
Użycie wspólnego elementu Etap do obsługi wszystkich interakcji ma swoje wady. Jest to bardziej złożone pod względem obliczeniowym, a zdarzenia związane z wskaźnikiem kursora są ograniczone bez dodawania dodatkowego kodu, który je zmienia. Jednak w przypadku JAM w Chrome zalety abstrakcyjnego traktowania zdarzeń myszy w poszczególnych elementach okazały się bardzo przydatne. Dzięki temu mogliśmy lepiej poeksperymentować z projektowaniem interfejsu, przełączać się między metodami animowania elementów, zastępować obrazy podstawowych kształtów plikami SVG, łatwo wyłączać obszary docelowe i wykonywać inne czynności.
Aby zobaczyć bębny i motywy dźwiękowe w akcji, utwórz własną JAM i wybierz Standardowe bębny lub Klasyczna czysta gitara elektryczna.