diff --git a/.gitignore b/.gitignore index 0964b9a..b6332fc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ tags .vscode venv static/ +Pipfile diff --git a/CHANGELOG.md b/CHANGELOG.md index 006f2e1..734e76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## v3.3.0 +* Ajout d'icônes +* Le . est utilisé pour les décimaux +* Ajout de liens vers les profils de produits et utilisateurs +* Ajout de cotisations dans les transactions +* Ajout d'une page d'accueil. Les pressions du moment y sont affichées +* Belles couleurs sur le diagramme +* Verouillage automatique de la caisse +* Classement par produit +* Fix invalidation +* Recherche plus intuitive (le startswith devient contains) +* Easter egg sur 404 ## v3.2.2 * Fix cotisation cancer ## v3.2.1 diff --git a/coopeV3/templatetags/vip.py b/coopeV3/templatetags/vip.py index 3d49afa..436cb03 100644 --- a/coopeV3/templatetags/vip.py +++ b/coopeV3/templatetags/vip.py @@ -39,3 +39,9 @@ def global_message(): gp,_ = GeneralPreferences.objects.get_or_create(pk=1) messages = gp.global_message.split("\n") return random.choice(messages) + +@register.simple_tag +def logout_time(): + gp, _ = GeneralPreferences.objects.get_or_create(pk=1) + logout_time = gp.automatic_logout_time + return logout_time \ No newline at end of file diff --git a/coopeV3/urls.py b/coopeV3/urls.py index 83de9b1..ec4148f 100644 --- a/coopeV3/urls.py +++ b/coopeV3/urls.py @@ -20,6 +20,8 @@ from . import views urlpatterns = [ path('', views.home, name="home"), + path('home', views.homepage, name="homepage"), + path('coope-runner', views.coope_runner, name="coope-runner"), path('admin/doc/', include('django.contrib.admindocs.urls')), path('admin/', admin.site.urls), path('users/', include('users.urls')), diff --git a/coopeV3/views.py b/coopeV3/views.py index e1da5b8..612a342 100644 --- a/coopeV3/views.py +++ b/coopeV3/views.py @@ -1,11 +1,22 @@ -from django.shortcuts import redirect +from django.shortcuts import redirect, render from django.urls import reverse +from preferences.models import GeneralPreferences +from gestion.models import Keg + def home(request): if request.user.is_authenticated: if(request.user.has_perm('gestion.can_manage')): return redirect(reverse('gestion:manage')) else: - return redirect(reverse('users:profile', kwargs={'pk': request.user.pk})) + return redirect(reverse('homepage')) else: return redirect(reverse('users:login')) + +def homepage(request): + gp, _ = GeneralPreferences.objects.get_or_create(pk=1) + kegs = Keg.objects.filter(is_active=True) + return render(request, "home.html", {"home_text": gp.home_text, "kegs": kegs}) + +def coope_runner(request): + return render(request, "coope-runner.html") diff --git a/gestion/forms.py b/gestion/forms.py index 34d18da..eb11709 100644 --- a/gestion/forms.py +++ b/gestion/forms.py @@ -16,20 +16,21 @@ class ReloadForm(forms.ModelForm): class Meta: model = Reload fields = ("customer", "amount", "PaymentMethod") - widgets = {'customer': autocomplete.ModelSelect2(url='users:active-users-autocomplete', attrs={'data-minimum-input-length':2})} + widgets = {'customer': autocomplete.ModelSelect2(url='users:active-users-autocomplete', attrs={'data-minimum-input-length':2}), 'amount': forms.TextInput} class RefundForm(forms.ModelForm): class Meta: model = Refund fields = ("customer", "amount") - widgets = {'customer': autocomplete.ModelSelect2(url='users:active-users-autocomplete', attrs={'data-minimum-input-length':2})} + widgets = {'customer': autocomplete.ModelSelect2(url='users:active-users-autocomplete', attrs={'data-minimum-input-length':2}), 'amount': forms.TextInput} class ProductForm(forms.ModelForm): class Meta: model = Product fields = "__all__" + widgets = {'amount': forms.TextInput} class KegForm(forms.ModelForm): def __init__(self, *args, **kwargs): @@ -41,11 +42,13 @@ class KegForm(forms.ModelForm): class Meta: model = Keg exclude = ("is_active", ) + widgets = {'amount': forms.TextInput} class MenuForm(forms.ModelForm): class Meta: model = Menu fields = "__all__" + widgets = {'amount': forms.TextInput} class SearchProductForm(forms.Form): product = forms.ModelChoiceField(queryset=Product.objects.all(), required=True, label="Produit", widget=autocomplete.ModelSelect2(url='gestion:products-autocomplete', attrs={'data-minimum-input-length':2})) diff --git a/gestion/models.py b/gestion/models.py index a9fc147..9921418 100644 --- a/gestion/models.py +++ b/gestion/models.py @@ -45,6 +45,22 @@ class Product(models.Model): def __str__(self): return self.name + def user_ranking(self, pk): + user = User.objects.get(pk=pk) + consumptions = ConsumptionHistory.objects.filter(customer=user).filter(product=self) + # add menu + nb = 0 + for consumption in consumptions: + nb += consumption.quantity + return (user, nb) + + @property + def ranking(self): + users = User.objects.all() + ranking = [self.user_ranking(user.pk) for user in users] + ranking.sort(key=lambda x:x[1], reverse=True) + return ranking[0:25] + def isPinte(id): product = Product.objects.get(id=id) diff --git a/gestion/templates/gestion/kegs_list.html b/gestion/templates/gestion/kegs_list.html index 2589099..32b4035 100644 --- a/gestion/templates/gestion/kegs_list.html +++ b/gestion/templates/gestion/kegs_list.html @@ -13,13 +13,13 @@

Liste des fûts actifs

{% if perms.gestion.add_keg %} - Créer un fût + Créer un fût {% endif %} {% if perms.gestion.open_keg %} - Percuter un fût + Percuter un fût {% endif %} {% if perms.gestion.close_keg %} - Fermer un fût + Fermer un fût {% endif %}

@@ -33,6 +33,7 @@ Quantité vendue Montant vendu Prix du fût + Degré Historique Administrer @@ -47,8 +48,9 @@ {{ kegH.quantitySold }} L {{ kegH.amountSold }} € {{ kegH.keg.amount }} € + {{ kegH.keg.pinte.deg }}° Voir - {% if perms.gestion.close_keg %}Fermer {% endif %}{% if perms.gestion.change_keg %}Modifier{% endif %} + {% if perms.gestion.close_keg %} Fermer {% endif %}{% if perms.gestion.change_keg %} Modifier{% endif %} {% endfor %} @@ -60,13 +62,13 @@

Liste des fûts inactifs

{% if perms.gestion.add_keg %} - Créer un fût + Créer un fût {% endif %} {% if perms.gestion.open_keg %} - Percuter un fût + Percuter un fût {% endif %} {% if perms.gestion.close_keg %} - Fermer un fût + Fermer un fût {% endif %}

@@ -78,6 +80,7 @@ Code barre Capacité Prix du fût + Degré Historique Administrer @@ -90,8 +93,9 @@ {{ keg.barcode }} {{ keg.capacity }} L {{ keg.amount }} € + {{ keg.pinte.deg }}° Voir - {% if perms.gestion.open_keg %}{% if keg.stockHold > 0 %}Percuter {% endif %}{% endif %}{% if perms.gestion.change_keg %}Modifier{% endif %} + {% if perms.gestion.open_keg %}{% if keg.stockHold > 0 %} Percuter {% endif %}{% endif %}{% if perms.gestion.change_keg %} Modifier{% endif %} {% endfor %} diff --git a/gestion/templates/gestion/manage.html b/gestion/templates/gestion/manage.html index 1f9c6ee..3b4c3f0 100644 --- a/gestion/templates/gestion/manage.html +++ b/gestion/templates/gestion/manage.html @@ -61,7 +61,7 @@
- Annuler

+ Annuler

{{gestion_form}}
@@ -115,6 +115,19 @@
+ + {% for cotisation in cotisations %} + {% if forloop.counter0|divisibleby:4 %} + + {% endif %} + + {% if forloop.counter|divisibleby:4 %} + + {% endif %} + {% endfor %} + {% if not bieresPression|divisibleby:4 %} + + {% endif %} {% for product in bieresPression %} {% if forloop.counter0|divisibleby:4 %} @@ -214,7 +227,7 @@ {% csrf_token %} {{reload_form}}
- + {% endif %} @@ -227,7 +240,7 @@ {% csrf_token %} {{refund_form}}
- + {% endif %} diff --git a/gestion/templates/gestion/menus_list.html b/gestion/templates/gestion/menus_list.html index 230e719..22ef402 100644 --- a/gestion/templates/gestion/menus_list.html +++ b/gestion/templates/gestion/menus_list.html @@ -10,7 +10,7 @@

Liste des menus

- Créer un menu

+ Créer un menu

Cotisations
Bières pression
@@ -29,9 +29,9 @@ - + - + {% endfor %} diff --git a/gestion/templates/gestion/pintes_list.html b/gestion/templates/gestion/pintes_list.html index 88c0b95..937e13d 100644 --- a/gestion/templates/gestion/pintes_list.html +++ b/gestion/templates/gestion/pintes_list.html @@ -14,7 +14,7 @@

Général

{% if perms.gestion.add_pinte %} - Créer une ou plusieurs pintes

+ Créer une ou plusieurs pintes

{% endif %} Il a y actuellement {{ taken_pintes.count|add:free_pintes.count }} pintes, parmis lesquelles {{ free_pintes.count }} sont rendues et {{ taken_pintes.count }} ne sont pas rendues. @@ -37,10 +37,10 @@ {% for pinte in taken_pintes %} - - + + - + {% endfor %} @@ -64,7 +64,7 @@ {% for pinte in free_pintes %} - + {% endfor %} diff --git a/gestion/templates/gestion/pintes_user_list.html b/gestion/templates/gestion/pintes_user_list.html index d7de296..eb5a2ae 100644 --- a/gestion/templates/gestion/pintes_user_list.html +++ b/gestion/templates/gestion/pintes_user_list.html @@ -22,8 +22,8 @@ {% for user in users %} - - + + {% endfor %} diff --git a/gestion/templates/gestion/products_list.html b/gestion/templates/gestion/products_list.html index eda88c4..f986d20 100644 --- a/gestion/templates/gestion/products_list.html +++ b/gestion/templates/gestion/products_list.html @@ -11,7 +11,7 @@

Liste des produits

{% if perms.gestion.add_product %} - Créer un produit

+ Créer un produit

{% endif %}
{{ menu.name }} {{ menu.amount}} € {{ menu.barcode }}{% for art in menu.articles.all %}{{art}},{% endfor %}{% for art in menu.articles.all %}{{art}},{% endfor %} {{ menu.is_active | yesno:"Oui, Non"}}{% if perms.gestion.change_menu %}{% if menu.is_active %}Désa{% else %}A{% endif %}ctiver Modifier{% endif %}{% if perms.gestion.change_menu %}{% if menu.is_active %} Désa{% else %} A{% endif %}ctiver Modifier{% endif %}
{{ pinte.pk }}{{ pinte.current_owner }}{{ pinte.previous_owner }}{% if pinte.current_owner %}{{ pinte.current_owner }}{% endif %}{% if pinte.previous_owner %}{{ pinte.previous_owner }}{% endif %} {{ pinte.last_update_date }}{% if perms.gestion.change_pinte %} Libérer{% endif %}{% if perms.gestion.change_pinte %} Libérer{% endif %}
{{ pinte.pk }}{{ pinte.previous_owner }}{% if pinte.previous_owner %}{{ pinte.previous_owner }}{% endif %} {{ pinte.last_update_date }}
{{ user }}Profil{{user}} Profil {{ user.pintes_owned_currently.count }}
@@ -32,7 +32,7 @@ {% for product in products %} - + @@ -41,7 +41,7 @@ - + {% endfor %} diff --git a/gestion/templates/gestion/ranking.html b/gestion/templates/gestion/ranking.html index 95c5a0d..fb9d4eb 100644 --- a/gestion/templates/gestion/ranking.html +++ b/gestion/templates/gestion/ranking.html @@ -1,10 +1,11 @@ {% extends "base.html" %} {%load static %} {%block entete%}Classement{%endblock%} -{% block nav %} +{% block navbar %} {% endblock %} {% block content %} @@ -28,7 +29,7 @@ {%for customer in bestBuyers%} - + {%endfor%} @@ -59,7 +60,7 @@ {% for customer in bestDrinkers %} - + {%endfor%} @@ -70,4 +71,44 @@ +
+
+
+
+

Classement par produit

+
+
+
+
+ {% csrf_token %} + {{form}} +

+ + + {% if product_ranking %} +
{{ product.name }}{{ product.name }} {{ product.amount}} {{ product.stockHold }} {{ product.stockBar }}{{ product.is_active | yesno:"Oui, Non"}} {{ product.deg }} {{ product.volume }} clProfil {% if perms.gestion.change_product %}{% if product.is_active %}Désa{% else %}A{% endif %}ctiver Modifier{% endif %} Profil {% if perms.gestion.change_product %}{% if product.is_active %} Désa{% else %} A{% endif %}ctiver Modifier{% endif %}
{{ forloop.counter }}{{ customer.username }}{{ customer.username }} {{ customer.profile.debit }}€
{{ forloop.counter }}{{ customer.0.username }}{{ customer.0.username }} {{ customer.1 }}
+ + + + + + + + + {% for customer in product_ranking %} + + + + + + {%endfor%} + +
PlacePseudoQuantités consommées
{{ forloop.counter }}{{ customer.0.username }}{{ customer.1 }}
+ {% endif %} +
+
+
+ + +{{form.media}} {%endblock%} \ No newline at end of file diff --git a/gestion/views.py b/gestion/views.py index 525b202..fe76b11 100644 --- a/gestion/views.py +++ b/gestion/views.py @@ -7,19 +7,20 @@ from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required, permission_required from django.utils import timezone from django.http import HttpResponseRedirect +from django.db import transaction + +from datetime import datetime, timedelta from django_tex.views import render_to_pdf - from coopeV3.acl import active_required, acl_or, admin_required import simplejson as json from dal import autocomplete from decimal import * -import datetime from .forms import ReloadForm, RefundForm, ProductForm, KegForm, MenuForm, GestionForm, SearchMenuForm, SearchProductForm, SelectPositiveKegForm, SelectActiveKegForm, PinteForm, GenerateReleveForm from .models import Product, Menu, Keg, ConsumptionHistory, KegHistory, Consumption, MenuHistory, Pinte, Reload, Refund -from preferences.models import PaymentMethod, GeneralPreferences +from preferences.models import PaymentMethod, GeneralPreferences, Cotisation from users.models import CotisationHistory @active_required @@ -77,6 +78,7 @@ def manage(request): menus = Menu.objects.filter(is_active=True) kegs = Keg.objects.filter(is_active=True) gp, _ = GeneralPreferences.objects.get_or_create(pk=1) + cotisations = Cotisation.objects.all() floating_buttons = gp.floating_buttons for keg in kegs: if(keg.pinte): @@ -97,6 +99,7 @@ def manage(request): "menus": menus, "pay_buttons": pay_buttons, "floating_buttons": floating_buttons, + "cotisations": cotisations }) @csrf_exempt @@ -107,90 +110,135 @@ def order(request): """ Process the given order. Called by a js/JQuery script. """ - if("user" not in request.POST or "paymentMethod" not in request.POST or "amount" not in request.POST or "order" not in request.POST): - return HttpResponse("Erreur du POST") - else: - user = get_object_or_404(User, pk=request.POST['user']) - paymentMethod = get_object_or_404(PaymentMethod, pk=request.POST['paymentMethod']) - amount = Decimal(request.POST['amount']) - order = json.loads(request.POST["order"]) - menus = json.loads(request.POST["menus"]) - listPintes = json.loads(request.POST["listPintes"]) - gp,_ = GeneralPreferences.objects.get_or_create(pk=1) - if (not order) and (not menus): - return HttpResponse("Pas de commande") - adherentRequired = False - for o in order: - product = get_object_or_404(Product, pk=o["pk"]) - adherentRequired = adherentRequired or product.adherentRequired - for m in menus: - menu = get_object_or_404(Menu, pk=m["pk"]) - adherentRequired = adherentRequired or menu.adherent_required - if(adherentRequired and not user.profile.is_adherent): - return HttpResponse("N'est pas adhérent et devrait l'être") - # Partie un peu complexe : je libère toutes les pintes de la commande, puis je test - # s'il a trop de pintes non rendues, puis je réalloue les pintes - for pinte in listPintes: - allocate(pinte, None) - if(gp.lost_pintes_allowed and user.profile.nb_pintes >= gp.lost_pintes_allowed): - return HttpResponse("Impossible de réaliser la commande : l'utilisateur a perdu trop de pintes.") - for pinte in listPintes: - allocate(pinte, user) - if(paymentMethod.affect_balance): - if(user.profile.balance < amount): - return HttpResponse("Solde inférieur au prix de la commande") + error_message = "Impossible d'effectuer la transaction. Toute opération abandonnée. Veuillez contacter le président ou le trésorier" + try: + with transaction.atomic(): + if("user" not in request.POST or "paymentMethod" not in request.POST or "amount" not in request.POST or "order" not in request.POST): + raise Exception("Erreur du post") else: - user.profile.debit += amount - user.save() - for o in order: - product = get_object_or_404(Product, pk=o["pk"]) - quantity = int(o["quantity"]) - if(product.category == Product.P_PRESSION): - keg = get_object_or_404(Keg, pinte=product) - if(not keg.is_active): - return HttpResponse("Une erreur inconnue s'est produite. Veuillez contacter le trésorier ou le président") - kegHistory = get_object_or_404(KegHistory, keg=keg, isCurrentKegHistory=True) - kegHistory.quantitySold += Decimal(quantity * 0.5) - kegHistory.amountSold += Decimal(quantity * product.amount) - kegHistory.save() - elif(product.category == Product.D_PRESSION): - keg = get_object_or_404(Keg, demi=product) - if(not keg.is_active): - return HttpResponse("Une erreur inconnue s'est produite. Veuillez contacter le trésorier ou le président") - kegHistory = get_object_or_404(KegHistory, keg=keg, isCurrentKegHistory=True) - kegHistory.quantitySold += Decimal(quantity * 0.25) - kegHistory.amountSold += Decimal(quantity * product.amount) - kegHistory.save() - elif(product.category == Product.G_PRESSION): - keg = get_object_or_404(Keg, galopin=product) - if(not keg.is_active): - return HttpResponse("Une erreur inconnue s'est produite. Veuillez contacter le trésorier ou le président") - kegHistory = get_object_or_404(KegHistory, keg=keg, isCurrentKegHistory=True) - kegHistory.quantitySold += Decimal(quantity * 0.125) - kegHistory.amountSold += Decimal(quantity * product.amount) - kegHistory.save() - else: - if(product.stockHold > 0): - product.stockHold -= 1 - product.save() - consumption, _ = Consumption.objects.get_or_create(customer=user, product=product) - consumption.quantity += quantity - consumption.save() - ch = ConsumptionHistory(customer=user, quantity=quantity, paymentMethod=paymentMethod, product=product, amount=Decimal(quantity*product.amount), coopeman=request.user) - ch.save() - for m in menus: - menu = get_object_or_404(Menu, pk=m["pk"]) - quantity = int(m["quantity"]) - mh = MenuHistory(customer=user, quantity=quantity, paymentMethod=paymentMethod, menu=menu, amount=int(quantity*menu.amount), coopeman=request.user) - mh.save() - for article in menu.articles.all(): - consumption, _ = Consumption.objects.get_or_create(customer=user, product=article) - consumption.quantity += quantity - consumption.save() - if(article.stockHold > 0): - article.stockHold -= 1 - article.save() - return HttpResponse("La commande a bien été effectuée") + user = get_object_or_404(User, pk=request.POST['user']) + paymentMethod = get_object_or_404(PaymentMethod, pk=request.POST['paymentMethod']) + amount = Decimal(request.POST['amount']) + order = json.loads(request.POST["order"]) + menus = json.loads(request.POST["menus"]) + listPintes = json.loads(request.POST["listPintes"]) + cotisations = json.loads(request.POST['cotisations']) + gp,_ = GeneralPreferences.objects.get_or_create(pk=1) + if (not order) and (not menus) and (not cotisations): + error_message = "Pas de commande" + raise Exception(error_message) + if(cotisations): + for co in cotisations: + cotisation = Cotisation.objects.get(pk=co['pk']) + for i in range(co['quantity']): + cotisation_history = CotisationHistory(cotisation=cotisation) + if(paymentMethod.affect_balance): + if(user.profile.balance >= cotisation_history.cotisation.amount): + user.profile.debit += cotisation_history.cotisation.amount + else: + error_message = "Solde insuffisant" + raise Exception(error_message) + cotisation_history.user = user + cotisation_history.coopeman = request.user + cotisation_history.amount = cotisation.amount + cotisation_history.duration = cotisation.duration + cotisation_history.paymentMethod = paymentMethod + if(user.profile.cotisationEnd and user.profile.cotisationEnd > timezone.now()): + cotisation_history.endDate = user.profile.cotisationEnd + timedelta(days=cotisation.duration) + else: + cotisation_history.endDate = timezone.now() + timedelta(days=cotisation.duration) + user.profile.cotisationEnd = cotisation_history.endDate + user.save() + cotisation_history.save() + adherentRequired = False + for o in order: + product = get_object_or_404(Product, pk=o["pk"]) + adherentRequired = adherentRequired or product.adherentRequired + for m in menus: + menu = get_object_or_404(Menu, pk=m["pk"]) + adherentRequired = adherentRequired or menu.adherent_required + if(adherentRequired and not user.profile.is_adherent): + error_message = "N'est pas adhérent et devrait l'être." + raise Exception(error_message) + # Partie un peu complexe : je libère toutes les pintes de la commande, puis je test + # s'il a trop de pintes non rendues, puis je réalloue les pintes + for pinte in listPintes: + allocate(pinte, None) + if(gp.use_pinte_monitoring and gp.lost_pintes_allowed and user.profile.nb_pintes >= gp.lost_pintes_allowed): + error_message = "Impossible de réaliser la commande : l'utilisateur a perdu trop de pintes." + raise Exception(error_message) + for pinte in listPintes: + allocate(pinte, user) + if(paymentMethod.affect_balance): + if(user.profile.balance < amount): + error_message = "Solde inférieur au prix de la commande" + raise Exception(error_message) + else: + user.profile.debit += amount + user.save() + for o in order: + product = get_object_or_404(Product, pk=o["pk"]) + quantity = int(o["quantity"]) + if(product.category == Product.P_PRESSION): + keg = get_object_or_404(Keg, pinte=product) + if(not keg.is_active): + raise Exception("Fût non actif") + kegHistory = get_object_or_404(KegHistory, keg=keg, isCurrentKegHistory=True) + kegHistory.quantitySold += Decimal(quantity * 0.5) + kegHistory.amountSold += Decimal(quantity * product.amount) + kegHistory.save() + elif(product.category == Product.D_PRESSION): + keg = get_object_or_404(Keg, demi=product) + if(not keg.is_active): + raise Exception("Fût non actif") + kegHistory = get_object_or_404(KegHistory, keg=keg, isCurrentKegHistory=True) + kegHistory.quantitySold += Decimal(quantity * 0.25) + kegHistory.amountSold += Decimal(quantity * product.amount) + kegHistory.save() + elif(product.category == Product.G_PRESSION): + keg = get_object_or_404(Keg, galopin=product) + if(not keg.is_active): + raise Exception("Fût non actif") + kegHistory = get_object_or_404(KegHistory, keg=keg, isCurrentKegHistory=True) + kegHistory.quantitySold += Decimal(quantity * 0.125) + kegHistory.amountSold += Decimal(quantity * product.amount) + kegHistory.save() + else: + if(product.stockHold > 0): + product.stockHold -= 1 + product.save() + consumption, _ = Consumption.objects.get_or_create(customer=user, product=product) + consumption.quantity += quantity + consumption.save() + ch = ConsumptionHistory(customer=user, quantity=quantity, paymentMethod=paymentMethod, product=product, amount=Decimal(quantity*product.amount), coopeman=request.user) + ch.save() + if(user.profile.balance > Decimal(product.amount * quantity)): + user.profile.debit += Decimal(product.amount*quantity) + else: + error_message = "Solde insuffisant" + raise Exception(error_message) + for m in menus: + menu = get_object_or_404(Menu, pk=m["pk"]) + quantity = int(m["quantity"]) + mh = MenuHistory(customer=user, quantity=quantity, paymentMethod=paymentMethod, menu=menu, amount=int(quantity*menu.amount), coopeman=request.user) + mh.save() + if(user.profile.balance > Decimal(menu.amount * quantity)): + user.profile.debit += Decimal(menu.amount*quantity) + else: + error_message = "Solde insuffisant" + raise Exception(error_message) + for article in menu.articles.all(): + consumption, _ = Consumption.objects.get_or_create(customer=user, product=article) + consumption.quantity += quantity + consumption.save() + if(article.stockHold > 0): + article.stockHold -= 1 + article.save() + return HttpResponse("La commande a bien été effectuée") + except Exception as e: + print(e) + print("test") + return HttpResponse(error_message) @active_required @login_required @@ -341,7 +389,7 @@ def addProduct(request): product = form.save() messages.success(request, "Le produit a bien été ajouté") return redirect(reverse('gestion:productProfile', kwargs={'pk':product.pk})) - return render(request, "form.html", {"form": form, "form_title": "Ajout d'un produit", "form_button": "Ajouter"}) + return render(request, "form.html", {"form": form, "form_title": "Ajout d'un produit", "form_button": "Ajouter", "form_button_icon": "plus-square"}) @active_required @login_required @@ -374,7 +422,7 @@ def editProduct(request, pk): form.save() messages.success(request, "Le produit a bien été modifié") return redirect(reverse('gestion:productProfile', kwargs={'pk':product.pk})) - return render(request, "form.html", {"form": form, "form_title": "Modification d'un produit", "form_button": "Modifier"}) + return render(request, "form.html", {"form": form, "form_title": "Modification d'un produit", "form_button": "Modifier", "form_button_icon": "pencil-alt"}) @active_required @login_required @@ -420,7 +468,7 @@ def searchProduct(request): form = SearchProductForm(request.POST or None) if(form.is_valid()): return redirect(reverse('gestion:productProfile', kwargs={'pk': form.cleaned_data['product'].pk })) - return render(request, "form.html", {"form": form, "form_title":"Rechercher un produit", "form_button": "Rechercher"}) + return render(request, "form.html", {"form": form, "form_title":"Rechercher un produit", "form_button": "Rechercher", "form_button_icon": "search"}) @active_required @login_required @@ -484,7 +532,7 @@ class ProductsAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = Product.objects.all() if self.q: - qs = qs.filter(name__istartswith=self.q) + qs = qs.filter(name__contains=self.q) return qs class ActiveProductsAutocomplete(autocomplete.Select2QuerySetView): @@ -494,7 +542,7 @@ class ActiveProductsAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = Product.objects.filter(is_active=True) if self.q: - qs = qs.filter(name__istartswith=self.q) + qs = qs.filter(name__contains=self.q) return qs ########## Kegs ########## @@ -526,7 +574,7 @@ def addKeg(request): keg = form.save() messages.success(request, "Le fût " + keg.name + " a bien été ajouté") return redirect(reverse('gestion:kegsList')) - return render(request, "form.html", {"form":form, "form_title": "Ajout d'un fût", "form_button": "Ajouter"}) + return render(request, "form.html", {"form":form, "form_title": "Ajout d'un fût", "form_button": "Ajouter", "form_button_icon": "plus-square"}) @active_required @login_required @@ -559,7 +607,7 @@ def editKeg(request, pk): form.save() messages.success(request, "Le fût a bien été modifié") return redirect(reverse('gestion:kegsList')) - return render(request, "form.html", {"form": form, "form_title": "Modification d'un fût", "form_button": "Modifier"}) + return render(request, "form.html", {"form": form, "form_title": "Modification d'un fût", "form_button": "Modifier", "form_button_icon": "pencil-alt"}) @active_required @login_required @@ -598,7 +646,7 @@ def openKeg(request): keg.save() messages.success(request, "Le fut a bien été percuté") return redirect(reverse('gestion:kegsList')) - return render(request, "form.html", {"form": form, "form_title":"Percutage d'un fût", "form_button":"Percuter"}) + return render(request, "form.html", {"form": form, "form_title":"Percutage d'un fût", "form_button":"Percuter", "form_button_icon": "fill-drip"}) @active_required @login_required @@ -660,7 +708,7 @@ def closeKeg(request): keg.save() messages.success(request, "Le fût a bien été fermé") return redirect(reverse('gestion:kegsList')) - return render(request, "form.html", {"form": form, "form_title":"Fermeture d'un fût", "form_button":"Fermer le fût"}) + return render(request, "form.html", {"form": form, "form_title":"Fermeture d'un fût", "form_button":"Fermer le fût", "form_button_icon": "fill"}) @active_required @login_required @@ -742,7 +790,7 @@ class KegActiveAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = Keg.objects.filter(is_active = True) if self.q: - qs = qs.filter(name__istartswith=self.q) + qs = qs.filter(name__contains=self.q) return qs class KegPositiveAutocomplete(autocomplete.Select2QuerySetView): @@ -752,7 +800,7 @@ class KegPositiveAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = Keg.objects.filter(stockHold__gt = 0) if self.q: - qs = qs.filter(name__istartswith=self.q) + qs = qs.filter(name__contains=self.q) return qs ########## Menus ########## @@ -785,7 +833,7 @@ def addMenu(request): menu = form.save() messages.success(request, "Le menu " + menu.name + " a bien été ajouté") return redirect(reverse('gestion:menusList')) - return render(request, "form.html", {"form":form, "form_title": "Ajout d'un menu", "form_button": "Ajouter", "extra_css": extra_css}) + return render(request, "form.html", {"form":form, "form_title": "Ajout d'un menu", "form_button": "Ajouter", "form_button_icon": "plus-square", "extra_css": extra_css}) @active_required @login_required @@ -819,7 +867,7 @@ def edit_menu(request, pk): form.save() messages.success(request, "Le menu a bien été modifié") return redirect(reverse('gestion:menusList')) - return render(request, "form.html", {"form": form, "form_title": "Modification d'un menu", "form_button": "Modifier", "extra_css": extra_css}) + return render(request, "form.html", {"form": form, "form_title": "Modification d'un menu", "form_button": "Modifier", "form_button_icon": "pencil-alt", "extra_css": extra_css}) @active_required @login_required @@ -847,7 +895,7 @@ def searchMenu(request): if(form.is_valid()): menu = form.cleaned_data['menu'] return redirect(reverse('gestion:editMenu', kwargs={'pk':menu.pk})) - return render(request, "form.html", {"form": form, "form_title": "Recherche d'un menu", "form_button": "Modifier"}) + return render(request, "form.html", {"form": form, "form_title": "Recherche d'un menu", "form_button": "Modifier", "form_button_icon": "search"}) @active_required @login_required @@ -899,7 +947,7 @@ def get_menu(request, pk): for article in menu.articles: if article.category == Product.P_PRESSION: nb_pintes +=1 - data = json.dumps({"pk": menu.pk, "barcode" : menu.barcode, "name": menu.name, "amount" : menu.amount, needQuantityButton: False, "nb_pintes": nb_pintes}) + data = json.dumps({"pk": menu.pk, "barcode" : menu.barcode, "name": menu.name, "amount" : menu.amount, "needQuantityButton": False, "nb_pintes": nb_pintes}) return HttpResponse(data, content_type='application/json') class MenusAutocomplete(autocomplete.Select2QuerySetView): @@ -909,7 +957,7 @@ class MenusAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = Menu.objects.all() if self.q: - qs = qs.filter(name__istartswith=self.q) + qs = qs.filter(name__contains=self.q) return qs ########## Ranking ########## @@ -939,7 +987,12 @@ def ranking(request): alcohol = customer.profile.alcohol list.append([customer, alcohol]) bestDrinkers = sorted(list, key=lambda x: x[1], reverse=True)[:25] - return render(request, "gestion/ranking.html", {"bestBuyers": bestBuyers, "bestDrinkers": bestDrinkers}) + form = SearchProductForm(request.POST or None) + if(form.is_valid()): + product_ranking = form.cleaned_data['product'].ranking + else: + product_ranking = None + return render(request, "gestion/ranking.html", {"bestBuyers": bestBuyers, "bestDrinkers": bestDrinkers, "product_ranking": product_ranking, "form": form}) ########## Pinte monitoring ########## @@ -989,7 +1042,7 @@ def add_pintes(request): i += 1 messages.success(request, str(i) + " pinte(s) a(ont) été ajoutée(s)") return redirect(reverse('gestion:productsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Ajouter des pintes", "form_button": "Ajouter"}) + return render(request, "form.html", {"form": form, "form_title": "Ajouter des pintes", "form_button": "Ajouter", "form_button_icon": "plus-square"}) @active_required @login_required @@ -1008,7 +1061,7 @@ def release_pintes(request): i += 1 messages.success(request, str(i) + " pinte(s) a(ont) été libérée(s)") return redirect(reverse('gestion:productsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Libérer des pintes", "form_button": "Libérer"}) + return render(request, "form.html", {"form": form, "form_title": "Libérer des pintes", "form_button": "Libérer", "form_button_icon": "glass-whiskey"}) @active_required @login_required @@ -1069,7 +1122,7 @@ def gen_releve(request): value_lydia += cot.amount elif pm == cheque: value_cheque += cot.amount - now = datetime.datetime.now() + now = datetime.now() return render_to_pdf(request, 'gestion/releve.tex', {"consumptions": consumptions, "reloads": reloads, "refunds": refunds, "cotisations": cotisations, "begin": begin, "end": end, "now": now, "value_especes": value_especes, "value_lydia": value_lydia, "value_cheque": value_cheque}, filename="releve.pdf") else: - return render(request, "form.html", {"form": form, "form_title": "Génération d'un relevé", "form_button": "Générer"}) + return render(request, "form.html", {"form": form, "form_title": "Génération d'un relevé", "form_button": "Générer", "form_button_icon": "file-pdf"}) diff --git a/preferences/forms.py b/preferences/forms.py index d6224c6..6e92522 100644 --- a/preferences/forms.py +++ b/preferences/forms.py @@ -36,5 +36,6 @@ class GeneralPreferencesForm(forms.ModelForm): 'treasurer': forms.TextInput(attrs={'placeholder': 'Trésorier'}), 'brewer': forms.TextInput(attrs={'placeholder': 'Maître brasseur'}), 'grocer': forms.TextInput(attrs={'placeholder': 'Epic épicier'}), + 'home_text': forms.Textarea(attrs={'placeholder': 'Ce message sera affiché sur la page d\'accueil'}) } diff --git a/preferences/migrations/0006_auto_20190119_2326.py b/preferences/migrations/0006_auto_20190119_2326.py new file mode 100644 index 0000000..0762048 --- /dev/null +++ b/preferences/migrations/0006_auto_20190119_2326.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2019-01-19 22:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0005_auto_20190106_0513'), + ] + + operations = [ + migrations.AddField( + model_name='generalpreferences', + name='home_text', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='historicalgeneralpreferences', + name='home_text', + field=models.TextField(blank=True), + ), + ] diff --git a/preferences/migrations/0007_auto_20190120_1208.py b/preferences/migrations/0007_auto_20190120_1208.py new file mode 100644 index 0000000..18d808a --- /dev/null +++ b/preferences/migrations/0007_auto_20190120_1208.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2019-01-20 11:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0006_auto_20190119_2326'), + ] + + operations = [ + migrations.AddField( + model_name='generalpreferences', + name='automatic_logout_time', + field=models.PositiveIntegerField(null=True), + ), + migrations.AddField( + model_name='historicalgeneralpreferences', + name='automatic_logout_time', + field=models.PositiveIntegerField(null=True), + ), + ] diff --git a/preferences/models.py b/preferences/models.py index dd1cc7b..f4d1ce6 100644 --- a/preferences/models.py +++ b/preferences/models.py @@ -34,6 +34,8 @@ class GeneralPreferences(models.Model): use_pinte_monitoring = models.BooleanField(default=False) lost_pintes_allowed = models.PositiveIntegerField(default=0) floating_buttons = models.BooleanField(default=False) + home_text = models.TextField(blank=True) + automatic_logout_time = models.PositiveIntegerField(null=True) history = HistoricalRecords() class Cotisation(models.Model): diff --git a/preferences/templates/preferences/cotisations_index.html b/preferences/templates/preferences/cotisations_index.html index eacb342..8ede5a4 100644 --- a/preferences/templates/preferences/cotisations_index.html +++ b/preferences/templates/preferences/cotisations_index.html @@ -11,7 +11,7 @@

Liste des cotisations

{% if perms.preferences.add_cotisation %} - Créer une cotisation

+ Créer une cotisation

{% endif %}
@@ -27,7 +27,7 @@ - + {% endfor %} diff --git a/preferences/templates/preferences/general_preferences.html b/preferences/templates/preferences/general_preferences.html index f8ee3ba..5c1e047 100644 --- a/preferences/templates/preferences/general_preferences.html +++ b/preferences/templates/preferences/general_preferences.html @@ -26,7 +26,7 @@
- +
@@ -51,7 +51,7 @@
- +
@@ -89,7 +89,7 @@
- +
@@ -115,7 +115,7 @@
- +
@@ -127,15 +127,32 @@

Autre

+
+

Boutons flottants

{{form.floating_buttons}}
+
+

Déconnexion automatique

- + {{form.automatic_logout_time}} + +
+
+
+

Texte de la page d'accueil

+
+
+ {{form.home_text}} +
+
+
+
+
diff --git a/preferences/templates/preferences/payment_methods_index.html b/preferences/templates/preferences/payment_methods_index.html index b79ea22..31e6454 100644 --- a/preferences/templates/preferences/payment_methods_index.html +++ b/preferences/templates/preferences/payment_methods_index.html @@ -11,7 +11,7 @@

Liste des moyens de paiement

{% if perms.preferences.add_paymentmethod %} - Créer un moyen de paiement

+ Créer un moyen de paiement

{% endif %}
{{ cotisation.duration }} jours {{ cotisation.amount }} €{% if perms.preferences.change_cotisation %}Modifier {% endif %}{% if perms.preferences.delete_cotisation %}Supprimer{% endif %}{% if perms.preferences.change_cotisation %} Modifier {% endif %}{% if perms.preferences.delete_cotisation %} Supprimer{% endif %}
@@ -35,7 +35,7 @@ - + {% endfor %} diff --git a/preferences/urls.py b/preferences/urls.py index dd7d3b8..2b2bac4 100644 --- a/preferences/urls.py +++ b/preferences/urls.py @@ -15,4 +15,5 @@ urlpatterns = [ path('deletePaymentMethod/', views.deletePaymentMethod, name="deletePaymentMethod"), path('inactive', views.inactive, name="inactive"), path('getConfig', views.get_config, name="getConfig"), -] + path('getCotisation/', views.get_cotisation, name="getCotisation") +,] diff --git a/preferences/views.py b/preferences/views.py index b9bba9c..e8bae46 100644 --- a/preferences/views.py +++ b/preferences/views.py @@ -1,4 +1,4 @@ -import json +import simplejson as json from django.shortcuts import render, redirect, get_object_or_404 from django.contrib import messages @@ -84,7 +84,7 @@ def addCotisation(request): cotisation = form.save() messages.success(request, "La cotisation (" + str(cotisation.duration) + " jours, " + str(cotisation.amount) + "€) a bien été créée") return redirect(reverse('preferences:cotisationsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Création d'une cotisation", "form_button": "Créer"}) + return render(request, "form.html", {"form": form, "form_title": "Création d'une cotisation", "form_button": "Créer", "form_button_icon": "plus-square"}) @active_required @login_required @@ -117,7 +117,7 @@ def editCotisation(request, pk): cotisation = form.save() messages.success(request, "La cotisation (" + str(cotisation.duration) + " jours, " + str(cotisation.amount) + "€) a bien été modifiée") return redirect(reverse('preferences:cotisationsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Modification d'une cotisation", "form_button": "Modifier"}) + return render(request, "form.html", {"form": form, "form_title": "Modification d'une cotisation", "form_button": "Modifier", "form_button_icon": "pencil-alt"}) @active_required @login_required @@ -135,6 +135,19 @@ def deleteCotisation(request,pk): messages.success(request, message) return redirect(reverse('preferences:cotisationsIndex')) +@active_required +@login_required +@permission_required('preferences.view_cotisation') +def get_cotisation(request, pk): + """ + Get a cotisation by pk + + ``pk`` + The primary key of the cotisation + """ + cotisation = get_object_or_404(Cotisation, pk=pk) + data = json.dumps({"pk": cotisation.pk, "duration": cotisation.duration, "amount" : cotisation.amount, "needQuantityButton": False}) + return HttpResponse(data, content_type='application/json') ########## Payment Methods ########## @@ -184,7 +197,7 @@ def addPaymentMethod(request): paymentMethod = form.save() messages.success(request, "Le moyen de paiement " + paymentMethod.name + " a bien été crée") return redirect(reverse('preferences:paymentMethodsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Création d'un moyen de paiement", "form_button": "Créer"}) + return render(request, "form.html", {"form": form, "form_title": "Création d'un moyen de paiement", "form_button": "Créer", "form_button_icon": "plus-square"}) @active_required @login_required @@ -217,7 +230,7 @@ def editPaymentMethod(request, pk): paymentMethod = form.save() messages.success(request, "Le moyen de paiment " + paymentMethod.name + " a bien été modifié") return redirect(reverse('preferences:paymentMethodsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Modification d'un moyen de paiement", "form_button": "Modifier"}) + return render(request, "form.html", {"form": form, "form_title": "Modification d'un moyen de paiement", "form_button": "Modifier", "form_button_icon": "pencil-alt"}) @active_required @login_required diff --git a/staticfiles/css/runner.css b/staticfiles/css/runner.css new file mode 100644 index 0000000..b7e52b8 --- /dev/null +++ b/staticfiles/css/runner.css @@ -0,0 +1,136 @@ +/* Copyright 2013 The Chromium Authors. All rights reserved. + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. */ + +html, body { + padding: 0; + margin: 0; + width: 100%; + height: 100%; +} + +.icon { + -webkit-user-select: none; + user-select: none; + display: inline-block; +} + +.icon-offline { + content: -webkit-image-set( url(../runner-assets/default_100_percent/100-error-offline.png) 1x, url(../runner-assets/default_200_percent/200-error-offline.png) 2x); + position: relative; +} + +.hidden { + display: none; +} + + +/* Offline page */ + +.offline .interstitial-wrapper { + color: #2b2b2b; + font-size: 1em; + line-height: 1.55; + margin: 0 auto; + max-width: 600px; + padding-top: 100px; + width: 100%; +} + +.offline .runner-container { + height: 150px; + max-width: 600px; + overflow: hidden; + position: absolute; + top: 35px; + width: 44px; +} + +.offline .runner-canvas { + height: 150px; + max-width: 600px; + opacity: 1; + overflow: hidden; + position: absolute; + top: 0; + z-index: 2; +} + +.offline .controller { + background: rgba(247, 247, 247, .1); + height: 100vh; + left: 0; + position: absolute; + top: 0; + width: 100vw; + z-index: 1; +} + +#offline-resources { + display: none; +} + +@media (max-width: 420px) { + .suggested-left > #control-buttons, .suggested-right > #control-buttons { + float: none; + } + .snackbar { + left: 0; + bottom: 0; + width: 100%; + border-radius: 0; + } +} + +@media (max-height: 350px) { + h1 { + margin: 0 0 15px; + } + .icon-offline { + margin: 0 0 10px; + } + .interstitial-wrapper { + margin-top: 5%; + } + .nav-wrapper { + margin-top: 30px; + } +} + +@media (min-width: 600px) and (max-width: 736px) and (orientation: landscape) { + .offline .interstitial-wrapper { + margin-left: 0; + margin-right: 0; + } +} + +@media (min-width: 420px) and (max-width: 736px) and (min-height: 240px) and (max-height: 420px) and (orientation:landscape) { + .interstitial-wrapper { + margin-bottom: 100px; + } +} + +@media (min-height: 240px) and (orientation: landscape) { + .offline .interstitial-wrapper { + margin-bottom: 90px; + } + .icon-offline { + margin-bottom: 20px; + } +} + +@media (max-height: 320px) and (orientation: landscape) { + .icon-offline { + margin-bottom: 0; + } + .offline .runner-container { + top: 10px; + } +} + +@media (max-width: 240px) { + .interstitial-wrapper { + overflow: inherit; + padding: 0 8px; + } +} diff --git a/staticfiles/manage.js b/staticfiles/manage.js index c4c176f..a85f2cc 100644 --- a/staticfiles/manage.js +++ b/staticfiles/manage.js @@ -1,10 +1,11 @@ total = 0 products = [] menus = [] +cotisations = [] paymentMethod = null balance = 0 username = "" -id = 0 +id_user = 0 listPintes = [] nbPintes = 0; use_pinte_monitoring = false; @@ -29,9 +30,15 @@ function get_menu(id){ }); } +function get_cotisation(id){ + res = $.get("../preferences/getCotisation/" + id, function(data){ + add_cotisation(data.pk, "", data.duration, data.amount, data.needQuantityButton); + }); +} + function add_product(pk, barcode, name, amount, needQuantityButton){ exist = false - index = -1 + index = -1; for(k=0;k < products.length; k++){ if(products[k].pk == pk){ exist = true @@ -71,15 +78,36 @@ function add_menu(pk, barcode, name, amount){ generate_html(); } +function add_cotisation(pk, barcode, duration, amount){ + exist = false; + index = -1; + for(k=0; k < cotisations.length; k++){ + if(cotisations[k].pk == pk){ + exist = true; + index = k; + } + } + if(exist){ + cotisations[index].quantity += 1; + }else{ + cotisations.push({"pk": pk, "barcode": barcode, "duration": duration, "amount": amount, "quantity":1}); + } + generate_html(); +} + function generate_html(){ html = ""; + for(k=0;k'; + } for(k=0;k'; + html += ''; } for(k=0; k'; + html += ''; } $("#items").html(html) updateTotal(); @@ -93,6 +121,9 @@ function updateTotal(){ for(k=0; k 1; + + /** @const */ + var IS_IOS = /iPad|iPhone|iPod/.test(window.navigator.platform); + + /** @const */ + var IS_MOBILE = /Android/.test(window.navigator.userAgent) || IS_IOS; + + /** @const */ + var IS_TOUCH_ENABLED = 'ontouchstart' in window; + + /** + * Default game configuration. + * @enum {number} + */ + Runner.config = { + ACCELERATION: 0.001, + BG_CLOUD_SPEED: 0.2, + BOTTOM_PAD: 10, + CLEAR_TIME: 3000, + CLOUD_FREQUENCY: 0.5, + GAMEOVER_CLEAR_TIME: 750, + GAP_COEFFICIENT: 0.6, + GRAVITY: 0.6, + INITIAL_JUMP_VELOCITY: 12, + INVERT_FADE_DURATION: 12000, + INVERT_DISTANCE: 700, + MAX_BLINK_COUNT: 3, + MAX_CLOUDS: 6, + MAX_OBSTACLE_LENGTH: 3, + MAX_OBSTACLE_DUPLICATION: 2, + MAX_SPEED: 13, + MIN_JUMP_HEIGHT: 35, + MOBILE_SPEED_COEFFICIENT: 1.2, + RESOURCE_TEMPLATE_ID: 'audio-resources', + SPEED: 6, + SPEED_DROP_COEFFICIENT: 3 + }; + + + /** + * Default dimensions. + * @enum {string} + */ + Runner.defaultDimensions = { + WIDTH: DEFAULT_WIDTH, + HEIGHT: 150 + }; + + + /** + * CSS class names. + * @enum {string} + */ + Runner.classes = { + CANVAS: 'runner-canvas', + CONTAINER: 'runner-container', + CRASHED: 'crashed', + ICON: 'icon-offline', + INVERTED: 'inverted', + SNACKBAR: 'snackbar', + SNACKBAR_SHOW: 'snackbar-show', + TOUCH_CONTROLLER: 'controller' + }; + + + /** + * Sprite definition layout of the spritesheet. + * @enum {Object} + */ + Runner.spriteDefinition = { + LDPI: { + CACTUS_LARGE: { x: 332, y: 2 }, + CACTUS_SMALL: { x: 228, y: 2 }, + CLOUD: { x: 86, y: 2 }, + HORIZON: { x: 2, y: 54 }, + MOON: { x: 484, y: 2 }, + PTERODACTYL: { x: 134, y: 2 }, + RESTART: { x: 2, y: 2 }, + TEXT_SPRITE: { x: 655, y: 2 }, + TREX: { x: 848, y: 2 }, + STAR: { x: 645, y: 2 } + }, + HDPI: { + CACTUS_LARGE: { x: 652, y: 2 }, + CACTUS_SMALL: { x: 446, y: 2 }, + CLOUD: { x: 166, y: 2 }, + HORIZON: { x: 2, y: 104 }, + MOON: { x: 954, y: 2 }, + PTERODACTYL: { x: 260, y: 2 }, + RESTART: { x: 2, y: 2 }, + TEXT_SPRITE: { x: 1294, y: 2 }, + TREX: { x: 1678, y: 2 }, + STAR: { x: 1276, y: 2 } + } + }; + + + /** + * Sound FX. Reference to the ID of the audio tag on interstitial page. + * @enum {string} + */ + Runner.sounds = { + BUTTON_PRESS: 'offline-sound-press', + HIT: 'offline-sound-hit', + SCORE: 'offline-sound-reached' + }; + + + /** + * Key code mapping. + * @enum {Object} + */ + Runner.keycodes = { + JUMP: { '38': 1, '32': 1 }, // Up, spacebar + DUCK: { '40': 1 }, // Down + RESTART: { '13': 1 } // Enter + }; + + + /** + * Runner event names. + * @enum {string} + */ + Runner.events = { + ANIM_END: 'webkitAnimationEnd', + CLICK: 'click', + KEYDOWN: 'keydown', + KEYUP: 'keyup', + MOUSEDOWN: 'mousedown', + MOUSEUP: 'mouseup', + RESIZE: 'resize', + TOUCHEND: 'touchend', + TOUCHSTART: 'touchstart', + VISIBILITY: 'visibilitychange', + BLUR: 'blur', + FOCUS: 'focus', + LOAD: 'load' + }; + + + Runner.prototype = { + /** + * Whether the easter egg has been disabled. CrOS enterprise enrolled devices. + * @return {boolean} + */ + isDisabled: function () { + // return loadTimeData && loadTimeData.valueExists('disabledEasterEgg'); + return false; + }, + + /** + * For disabled instances, set up a snackbar with the disabled message. + */ + setupDisabledRunner: function () { + this.containerEl = document.createElement('div'); + this.containerEl.className = Runner.classes.SNACKBAR; + this.containerEl.textContent = loadTimeData.getValue('disabledEasterEgg'); + this.outerContainerEl.appendChild(this.containerEl); + + // Show notification when the activation key is pressed. + document.addEventListener(Runner.events.KEYDOWN, function (e) { + if (Runner.keycodes.JUMP[e.keyCode]) { + this.containerEl.classList.add(Runner.classes.SNACKBAR_SHOW); + document.querySelector('.icon').classList.add('icon-disabled'); + } + }.bind(this)); + }, + + /** + * Setting individual settings for debugging. + * @param {string} setting + * @param {*} value + */ + updateConfigSetting: function (setting, value) { + if (setting in this.config && value != undefined) { + this.config[setting] = value; + + switch (setting) { + case 'GRAVITY': + case 'MIN_JUMP_HEIGHT': + case 'SPEED_DROP_COEFFICIENT': + this.tRex.config[setting] = value; + break; + case 'INITIAL_JUMP_VELOCITY': + this.tRex.setJumpVelocity(value); + break; + case 'SPEED': + this.setSpeed(value); + break; + } + } + }, + + /** + * Cache the appropriate image sprite from the page and get the sprite sheet + * definition. + */ + loadImages: function () { + if (IS_HIDPI) { + Runner.imageSprite = document.getElementById('offline-resources-1x'); + this.spriteDef = Runner.spriteDefinition.HDPI; + } else { + Runner.imageSprite = document.getElementById('offline-resources-1x'); + this.spriteDef = Runner.spriteDefinition.LDPI; + } + + if (Runner.imageSprite.complete) { + this.init(); + } else { + // If the images are not yet loaded, add a listener. + Runner.imageSprite.addEventListener(Runner.events.LOAD, + this.init.bind(this)); + } + }, + + /** + * Load and decode base 64 encoded sounds. + */ + loadSounds: function () { + if (!IS_IOS) { + this.audioContext = new AudioContext(); + + var resourceTemplate = + document.getElementById(this.config.RESOURCE_TEMPLATE_ID).content; + + for (var sound in Runner.sounds) { + var soundSrc = + resourceTemplate.getElementById(Runner.sounds[sound]).src; + soundSrc = soundSrc.substr(soundSrc.indexOf(',') + 1); + var buffer = decodeBase64ToArrayBuffer(soundSrc); + + // Async, so no guarantee of order in array. + this.audioContext.decodeAudioData(buffer, function (index, audioData) { + this.soundFx[index] = audioData; + }.bind(this, sound)); + } + } + }, + + /** + * Sets the game speed. Adjust the speed accordingly if on a smaller screen. + * @param {number} opt_speed + */ + setSpeed: function (opt_speed) { + var speed = opt_speed || this.currentSpeed; + + // Reduce the speed on smaller mobile screens. + if (this.dimensions.WIDTH < DEFAULT_WIDTH) { + var mobileSpeed = speed * this.dimensions.WIDTH / DEFAULT_WIDTH * + this.config.MOBILE_SPEED_COEFFICIENT; + this.currentSpeed = mobileSpeed > speed ? speed : mobileSpeed; + } else if (opt_speed) { + this.currentSpeed = opt_speed; + } + }, + + /** + * Game initialiser. + */ + init: function () { + // Hide the static icon. + document.querySelector('.' + Runner.classes.ICON).style.visibility = + 'hidden'; + + this.adjustDimensions(); + this.setSpeed(); + + this.containerEl = document.createElement('div'); + this.containerEl.className = Runner.classes.CONTAINER; + + // Player canvas container. + this.canvas = createCanvas(this.containerEl, this.dimensions.WIDTH, + this.dimensions.HEIGHT, Runner.classes.PLAYER); + + this.canvasCtx = this.canvas.getContext('2d'); + this.canvasCtx.fillStyle = '#f7f7f7'; + this.canvasCtx.fill(); + Runner.updateCanvasScaling(this.canvas); + + // Horizon contains clouds, obstacles and the ground. + this.horizon = new Horizon(this.canvas, this.spriteDef, this.dimensions, + this.config.GAP_COEFFICIENT); + + // Distance meter + this.distanceMeter = new DistanceMeter(this.canvas, + this.spriteDef.TEXT_SPRITE, this.dimensions.WIDTH); + + // Draw t-rex + this.tRex = new Trex(this.canvas, this.spriteDef.TREX); + + this.outerContainerEl.appendChild(this.containerEl); + + if (IS_MOBILE) { + this.createTouchController(); + } + + this.startListening(); + this.update(); + + window.addEventListener(Runner.events.RESIZE, + this.debounceResize.bind(this)); + }, + + /** + * Create the touch controller. A div that covers whole screen. + */ + createTouchController: function () { + this.touchController = document.createElement('div'); + this.touchController.className = Runner.classes.TOUCH_CONTROLLER; + this.outerContainerEl.appendChild(this.touchController); + }, + + /** + * Debounce the resize event. + */ + debounceResize: function () { + if (!this.resizeTimerId_) { + this.resizeTimerId_ = + setInterval(this.adjustDimensions.bind(this), 250); + } + }, + + /** + * Adjust game space dimensions on resize. + */ + adjustDimensions: function () { + clearInterval(this.resizeTimerId_); + this.resizeTimerId_ = null; + + var boxStyles = window.getComputedStyle(this.outerContainerEl); + var padding = Number(boxStyles.paddingLeft.substr(0, + boxStyles.paddingLeft.length - 2)); + + this.dimensions.WIDTH = this.outerContainerEl.offsetWidth - padding * 2; + + // Redraw the elements back onto the canvas. + if (this.canvas) { + this.canvas.width = this.dimensions.WIDTH; + this.canvas.height = this.dimensions.HEIGHT; + + Runner.updateCanvasScaling(this.canvas); + + this.distanceMeter.calcXPos(this.dimensions.WIDTH); + this.clearCanvas(); + this.horizon.update(0, 0, true); + this.tRex.update(0); + + // Outer container and distance meter. + if (this.playing || this.crashed || this.paused) { + this.containerEl.style.width = this.dimensions.WIDTH + 'px'; + this.containerEl.style.height = this.dimensions.HEIGHT + 'px'; + this.distanceMeter.update(0, Math.ceil(this.distanceRan)); + this.stop(); + } else { + this.tRex.draw(0, 0); + } + + // Game over panel. + if (this.crashed && this.gameOverPanel) { + this.gameOverPanel.updateDimensions(this.dimensions.WIDTH); + this.gameOverPanel.draw(); + } + } + }, + + /** + * Play the game intro. + * Canvas container width expands out to the full width. + */ + playIntro: function () { + if (!this.activated && !this.crashed) { + this.playingIntro = true; + this.tRex.playingIntro = true; + + // CSS animation definition. + var keyframes = '@-webkit-keyframes intro { ' + + 'from { width:' + Trex.config.WIDTH + 'px }' + + 'to { width: ' + this.dimensions.WIDTH + 'px }' + + '}'; + + // create a style sheet to put the keyframe rule in + // and then place the style sheet in the html head + var sheet = document.createElement('style'); + sheet.innerHTML = keyframes; + document.head.appendChild(sheet); + + this.containerEl.addEventListener(Runner.events.ANIM_END, + this.startGame.bind(this)); + + this.containerEl.style.webkitAnimation = 'intro .4s ease-out 1 both'; + this.containerEl.style.width = this.dimensions.WIDTH + 'px'; + + // if (this.touchController) { + // this.outerContainerEl.appendChild(this.touchController); + // } + this.playing = true; + this.activated = true; + } else if (this.crashed) { + this.restart(); + } + }, + + + /** + * Update the game status to started. + */ + startGame: function () { + this.runningTime = 0; + this.playingIntro = false; + this.tRex.playingIntro = false; + this.containerEl.style.webkitAnimation = ''; + this.playCount++; + + // Handle tabbing off the page. Pause the current game. + document.addEventListener(Runner.events.VISIBILITY, + this.onVisibilityChange.bind(this)); + + window.addEventListener(Runner.events.BLUR, + this.onVisibilityChange.bind(this)); + + window.addEventListener(Runner.events.FOCUS, + this.onVisibilityChange.bind(this)); + }, + + clearCanvas: function () { + this.canvasCtx.clearRect(0, 0, this.dimensions.WIDTH, + this.dimensions.HEIGHT); + }, + + /** + * Update the game frame and schedules the next one. + */ + update: function () { + this.updatePending = false; + + var now = getTimeStamp(); + var deltaTime = now - (this.time || now); + this.time = now; + + if (this.playing) { + this.clearCanvas(); + + if (this.tRex.jumping) { + this.tRex.updateJump(deltaTime); + } + + this.runningTime += deltaTime; + var hasObstacles = this.runningTime > this.config.CLEAR_TIME; + + // First jump triggers the intro. + if (this.tRex.jumpCount == 1 && !this.playingIntro) { + this.playIntro(); + } + + // The horizon doesn't move until the intro is over. + if (this.playingIntro) { + this.horizon.update(0, this.currentSpeed, hasObstacles); + } else { + deltaTime = !this.activated ? 0 : deltaTime; + this.horizon.update(deltaTime, this.currentSpeed, hasObstacles, + this.inverted); + } + + // Check for collisions. + var collision = hasObstacles && + checkForCollision(this.horizon.obstacles[0], this.tRex); + + if (!collision) { + this.distanceRan += this.currentSpeed * deltaTime / this.msPerFrame; + + if (this.currentSpeed < this.config.MAX_SPEED) { + this.currentSpeed += this.config.ACCELERATION; + } + } else { + this.gameOver(); + } + + var playAchievementSound = this.distanceMeter.update(deltaTime, + Math.ceil(this.distanceRan)); + + if (playAchievementSound) { + this.playSound(this.soundFx.SCORE); + } + + // Night mode. + if (this.invertTimer > this.config.INVERT_FADE_DURATION) { + this.invertTimer = 0; + this.invertTrigger = false; + this.invert(); + } else if (this.invertTimer) { + this.invertTimer += deltaTime; + } else { + var actualDistance = + this.distanceMeter.getActualDistance(Math.ceil(this.distanceRan)); + + if (actualDistance > 0) { + this.invertTrigger = !(actualDistance % + this.config.INVERT_DISTANCE); + + if (this.invertTrigger && this.invertTimer === 0) { + this.invertTimer += deltaTime; + this.invert(); + } + } + } + } + + if (this.playing || (!this.activated && + this.tRex.blinkCount < Runner.config.MAX_BLINK_COUNT)) { + this.tRex.update(deltaTime); + this.scheduleNextUpdate(); + } + }, + + /** + * Event handler. + */ + handleEvent: function (e) { + return (function (evtType, events) { + switch (evtType) { + case events.KEYDOWN: + case events.TOUCHSTART: + case events.MOUSEDOWN: + this.onKeyDown(e); + break; + case events.KEYUP: + case events.TOUCHEND: + case events.MOUSEUP: + this.onKeyUp(e); + break; + } + }.bind(this))(e.type, Runner.events); + }, + + /** + * Bind relevant key / mouse / touch listeners. + */ + startListening: function () { + // Keys. + document.addEventListener(Runner.events.KEYDOWN, this); + document.addEventListener(Runner.events.KEYUP, this); + + if (IS_MOBILE) { + // Mobile only touch devices. + this.touchController.addEventListener(Runner.events.TOUCHSTART, this); + this.touchController.addEventListener(Runner.events.TOUCHEND, this); + this.containerEl.addEventListener(Runner.events.TOUCHSTART, this); + } else { + // Mouse. + document.addEventListener(Runner.events.MOUSEDOWN, this); + document.addEventListener(Runner.events.MOUSEUP, this); + } + }, + + /** + * Remove all listeners. + */ + stopListening: function () { + document.removeEventListener(Runner.events.KEYDOWN, this); + document.removeEventListener(Runner.events.KEYUP, this); + + if (IS_MOBILE) { + this.touchController.removeEventListener(Runner.events.TOUCHSTART, this); + this.touchController.removeEventListener(Runner.events.TOUCHEND, this); + this.containerEl.removeEventListener(Runner.events.TOUCHSTART, this); + } else { + document.removeEventListener(Runner.events.MOUSEDOWN, this); + document.removeEventListener(Runner.events.MOUSEUP, this); + } + }, + + /** + * Process keydown. + * @param {Event} e + */ + onKeyDown: function (e) { + // Prevent native page scrolling whilst tapping on mobile. + if (IS_MOBILE && this.playing) { + e.preventDefault(); + } + + if (e.target != this.detailsButton) { + if (!this.crashed && (Runner.keycodes.JUMP[e.keyCode] || + e.type == Runner.events.TOUCHSTART)) { + if (!this.playing) { + this.loadSounds(); + this.playing = true; + this.update(); + if (window.errorPageController) { + errorPageController.trackEasterEgg(); + } + } + // Play sound effect and jump on starting the game for the first time. + if (!this.tRex.jumping && !this.tRex.ducking) { + this.playSound(this.soundFx.BUTTON_PRESS); + this.tRex.startJump(this.currentSpeed); + } + } + + if (this.crashed && e.type == Runner.events.TOUCHSTART && + e.currentTarget == this.containerEl) { + this.restart(); + } + } + + if (this.playing && !this.crashed && Runner.keycodes.DUCK[e.keyCode]) { + e.preventDefault(); + if (this.tRex.jumping) { + // Speed drop, activated only when jump key is not pressed. + this.tRex.setSpeedDrop(); + } else if (!this.tRex.jumping && !this.tRex.ducking) { + // Duck. + this.tRex.setDuck(true); + } + } + }, + + + /** + * Process key up. + * @param {Event} e + */ + onKeyUp: function (e) { + var keyCode = String(e.keyCode); + var isjumpKey = Runner.keycodes.JUMP[keyCode] || + e.type == Runner.events.TOUCHEND || + e.type == Runner.events.MOUSEDOWN; + + if (this.isRunning() && isjumpKey) { + this.tRex.endJump(); + } else if (Runner.keycodes.DUCK[keyCode]) { + this.tRex.speedDrop = false; + this.tRex.setDuck(false); + } else if (this.crashed) { + // Check that enough time has elapsed before allowing jump key to restart. + var deltaTime = getTimeStamp() - this.time; + + if (Runner.keycodes.RESTART[keyCode] || this.isLeftClickOnCanvas(e) || + (deltaTime >= this.config.GAMEOVER_CLEAR_TIME && + Runner.keycodes.JUMP[keyCode])) { + this.restart(); + } + } else if (this.paused && isjumpKey) { + // Reset the jump state + this.tRex.reset(); + this.play(); + } + }, + + /** + * Returns whether the event was a left click on canvas. + * On Windows right click is registered as a click. + * @param {Event} e + * @return {boolean} + */ + isLeftClickOnCanvas: function (e) { + return e.button != null && e.button < 2 && + e.type == Runner.events.MOUSEUP && e.target == this.canvas; + }, + + /** + * RequestAnimationFrame wrapper. + */ + scheduleNextUpdate: function () { + if (!this.updatePending) { + this.updatePending = true; + this.raqId = requestAnimationFrame(this.update.bind(this)); + } + }, + + /** + * Whether the game is running. + * @return {boolean} + */ + isRunning: function () { + return !!this.raqId; + }, + + /** + * Game over state. + */ + gameOver: function () { + this.playSound(this.soundFx.HIT); + vibrate(200); + + this.stop(); + this.crashed = true; + this.distanceMeter.acheivement = false; + + this.tRex.update(100, Trex.status.CRASHED); + + // Game over panel. + if (!this.gameOverPanel) { + this.gameOverPanel = new GameOverPanel(this.canvas, + this.spriteDef.TEXT_SPRITE, this.spriteDef.RESTART, + this.dimensions); + } else { + this.gameOverPanel.draw(); + } + + // Update the high score. + if (this.distanceRan > this.highestScore) { + this.highestScore = Math.ceil(this.distanceRan); + this.distanceMeter.setHighScore(this.highestScore); + } + + // Reset the time clock. + this.time = getTimeStamp(); + }, + + stop: function () { + this.playing = false; + this.paused = true; + cancelAnimationFrame(this.raqId); + this.raqId = 0; + }, + + play: function () { + if (!this.crashed) { + this.playing = true; + this.paused = false; + this.tRex.update(0, Trex.status.RUNNING); + this.time = getTimeStamp(); + this.update(); + } + }, + + restart: function () { + if (!this.raqId) { + this.playCount++; + this.runningTime = 0; + this.playing = true; + this.crashed = false; + this.distanceRan = 0; + this.setSpeed(this.config.SPEED); + this.time = getTimeStamp(); + this.containerEl.classList.remove(Runner.classes.CRASHED); + this.clearCanvas(); + this.distanceMeter.reset(this.highestScore); + this.horizon.reset(); + this.tRex.reset(); + this.playSound(this.soundFx.BUTTON_PRESS); + this.invert(true); + this.update(); + } + }, + + /** + * Pause the game if the tab is not in focus. + */ + onVisibilityChange: function (e) { + if (document.hidden || document.webkitHidden || e.type == 'blur' || + document.visibilityState != 'visible') { + this.stop(); + } else if (!this.crashed) { + this.tRex.reset(); + this.play(); + } + }, + + /** + * Play a sound. + * @param {SoundBuffer} soundBuffer + */ + playSound: function (soundBuffer) { + if (soundBuffer) { + var sourceNode = this.audioContext.createBufferSource(); + sourceNode.buffer = soundBuffer; + sourceNode.connect(this.audioContext.destination); + sourceNode.start(0); + } + }, + + /** + * Inverts the current page / canvas colors. + * @param {boolean} Whether to reset colors. + */ + invert: function (reset) { + if (reset) { + document.body.classList.toggle(Runner.classes.INVERTED, false); + this.invertTimer = 0; + this.inverted = false; + } else { + this.inverted = document.body.classList.toggle(Runner.classes.INVERTED, + this.invertTrigger); + } + } + }; + + + /** + * Updates the canvas size taking into + * account the backing store pixel ratio and + * the device pixel ratio. + * + * See article by Paul Lewis: + * http://www.html5rocks.com/en/tutorials/canvas/hidpi/ + * + * @param {HTMLCanvasElement} canvas + * @param {number} opt_width + * @param {number} opt_height + * @return {boolean} Whether the canvas was scaled. + */ + Runner.updateCanvasScaling = function (canvas, opt_width, opt_height) { + var context = canvas.getContext('2d'); + + // Query the various pixel ratios + var devicePixelRatio = Math.floor(window.devicePixelRatio) || 1; + var backingStoreRatio = Math.floor(context.webkitBackingStorePixelRatio) || 1; + var ratio = devicePixelRatio / backingStoreRatio; + + // Upscale the canvas if the two ratios don't match + if (devicePixelRatio !== backingStoreRatio) { + var oldWidth = opt_width || canvas.width; + var oldHeight = opt_height || canvas.height; + + canvas.width = oldWidth * ratio; + canvas.height = oldHeight * ratio; + + canvas.style.width = oldWidth + 'px'; + canvas.style.height = oldHeight + 'px'; + + // Scale the context to counter the fact that we've manually scaled + // our canvas element. + context.scale(ratio, ratio); + return true; + } else if (devicePixelRatio == 1) { + // Reset the canvas width / height. Fixes scaling bug when the page is + // zoomed and the devicePixelRatio changes accordingly. + canvas.style.width = canvas.width + 'px'; + canvas.style.height = canvas.height + 'px'; + } + return false; + }; + + + /** + * Get random number. + * @param {number} min + * @param {number} max + * @param {number} + */ + function getRandomNum(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + + /** + * Vibrate on mobile devices. + * @param {number} duration Duration of the vibration in milliseconds. + */ + function vibrate(duration) { + if (IS_MOBILE && window.navigator.vibrate) { + window.navigator.vibrate(duration); + } + } + + + /** + * Create canvas element. + * @param {HTMLElement} container Element to append canvas to. + * @param {number} width + * @param {number} height + * @param {string} opt_classname + * @return {HTMLCanvasElement} + */ + function createCanvas(container, width, height, opt_classname) { + var canvas = document.createElement('canvas'); + canvas.className = opt_classname ? Runner.classes.CANVAS + ' ' + + opt_classname : Runner.classes.CANVAS; + canvas.width = width; + canvas.height = height; + container.appendChild(canvas); + + return canvas; + } + + + /** + * Decodes the base 64 audio to ArrayBuffer used by Web Audio. + * @param {string} base64String + */ + function decodeBase64ToArrayBuffer(base64String) { + var len = (base64String.length / 4) * 3; + var str = atob(base64String); + var arrayBuffer = new ArrayBuffer(len); + var bytes = new Uint8Array(arrayBuffer); + + for (var i = 0; i < len; i++) { + bytes[i] = str.charCodeAt(i); + } + return bytes.buffer; + } + + + /** + * Return the current timestamp. + * @return {number} + */ + function getTimeStamp() { + return IS_IOS ? new Date().getTime() : performance.now(); + } + + + //****************************************************************************** + + + /** + * Game over panel. + * @param {!HTMLCanvasElement} canvas + * @param {Object} textImgPos + * @param {Object} restartImgPos + * @param {!Object} dimensions Canvas dimensions. + * @constructor + */ + function GameOverPanel(canvas, textImgPos, restartImgPos, dimensions) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.canvasDimensions = dimensions; + this.textImgPos = textImgPos; + this.restartImgPos = restartImgPos; + this.draw(); + }; + + + /** + * Dimensions used in the panel. + * @enum {number} + */ + GameOverPanel.dimensions = { + TEXT_X: 0, + TEXT_Y: 13, + TEXT_WIDTH: 191, + TEXT_HEIGHT: 11, + RESTART_WIDTH: 36, + RESTART_HEIGHT: 32 + }; + + + GameOverPanel.prototype = { + /** + * Update the panel dimensions. + * @param {number} width New canvas width. + * @param {number} opt_height Optional new canvas height. + */ + updateDimensions: function (width, opt_height) { + this.canvasDimensions.WIDTH = width; + if (opt_height) { + this.canvasDimensions.HEIGHT = opt_height; + } + }, + + /** + * Draw the panel. + */ + draw: function () { + var dimensions = GameOverPanel.dimensions; + + var centerX = this.canvasDimensions.WIDTH / 2; + + // Game over text. + var textSourceX = dimensions.TEXT_X; + var textSourceY = dimensions.TEXT_Y; + var textSourceWidth = dimensions.TEXT_WIDTH; + var textSourceHeight = dimensions.TEXT_HEIGHT; + + var textTargetX = Math.round(centerX - (dimensions.TEXT_WIDTH / 2)); + var textTargetY = Math.round((this.canvasDimensions.HEIGHT - 25) / 3); + var textTargetWidth = dimensions.TEXT_WIDTH; + var textTargetHeight = dimensions.TEXT_HEIGHT; + + var restartSourceWidth = dimensions.RESTART_WIDTH; + var restartSourceHeight = dimensions.RESTART_HEIGHT; + var restartTargetX = centerX - (dimensions.RESTART_WIDTH / 2); + var restartTargetY = this.canvasDimensions.HEIGHT / 2; + + if (IS_HIDPI) { + textSourceY *= 2; + textSourceX *= 2; + textSourceWidth *= 2; + textSourceHeight *= 2; + restartSourceWidth *= 2; + restartSourceHeight *= 2; + } + + textSourceX += this.textImgPos.x; + textSourceY += this.textImgPos.y; + + // Game over text from sprite. + this.canvasCtx.drawImage(Runner.imageSprite, + textSourceX, textSourceY, textSourceWidth, textSourceHeight, + textTargetX, textTargetY, textTargetWidth, textTargetHeight); + + // Restart button. + this.canvasCtx.drawImage(Runner.imageSprite, + this.restartImgPos.x, this.restartImgPos.y, + restartSourceWidth, restartSourceHeight, + restartTargetX, restartTargetY, dimensions.RESTART_WIDTH, + dimensions.RESTART_HEIGHT); + } + }; + + + //****************************************************************************** + + /** + * Check for a collision. + * @param {!Obstacle} obstacle + * @param {!Trex} tRex T-rex object. + * @param {HTMLCanvasContext} opt_canvasCtx Optional canvas context for drawing + * collision boxes. + * @return {Array} + */ + function checkForCollision(obstacle, tRex, opt_canvasCtx) { + var obstacleBoxXPos = Runner.defaultDimensions.WIDTH + obstacle.xPos; + + // Adjustments are made to the bounding box as there is a 1 pixel white + // border around the t-rex and obstacles. + var tRexBox = new CollisionBox( + tRex.xPos + 1, + tRex.yPos + 1, + tRex.config.WIDTH - 2, + tRex.config.HEIGHT - 2); + + var obstacleBox = new CollisionBox( + obstacle.xPos + 1, + obstacle.yPos + 1, + obstacle.typeConfig.width * obstacle.size - 2, + obstacle.typeConfig.height - 2); + + // Debug outer box + if (opt_canvasCtx) { + drawCollisionBoxes(opt_canvasCtx, tRexBox, obstacleBox); + } + + // Simple outer bounds check. + if (boxCompare(tRexBox, obstacleBox)) { + var collisionBoxes = obstacle.collisionBoxes; + var tRexCollisionBoxes = tRex.ducking ? + Trex.collisionBoxes.DUCKING : Trex.collisionBoxes.RUNNING; + + // Detailed axis aligned box check. + for (var t = 0; t < tRexCollisionBoxes.length; t++) { + for (var i = 0; i < collisionBoxes.length; i++) { + // Adjust the box to actual positions. + var adjTrexBox = + createAdjustedCollisionBox(tRexCollisionBoxes[t], tRexBox); + var adjObstacleBox = + createAdjustedCollisionBox(collisionBoxes[i], obstacleBox); + var crashed = boxCompare(adjTrexBox, adjObstacleBox); + + // Draw boxes for debug. + if (opt_canvasCtx) { + drawCollisionBoxes(opt_canvasCtx, adjTrexBox, adjObstacleBox); + } + + if (crashed) { + return [adjTrexBox, adjObstacleBox]; + } + } + } + } + return false; + }; + + + /** + * Adjust the collision box. + * @param {!CollisionBox} box The original box. + * @param {!CollisionBox} adjustment Adjustment box. + * @return {CollisionBox} The adjusted collision box object. + */ + function createAdjustedCollisionBox(box, adjustment) { + return new CollisionBox( + box.x + adjustment.x, + box.y + adjustment.y, + box.width, + box.height); + }; + + + /** + * Draw the collision boxes for debug. + */ + function drawCollisionBoxes(canvasCtx, tRexBox, obstacleBox) { + canvasCtx.save(); + canvasCtx.strokeStyle = '#f00'; + canvasCtx.strokeRect(tRexBox.x, tRexBox.y, tRexBox.width, tRexBox.height); + + canvasCtx.strokeStyle = '#0f0'; + canvasCtx.strokeRect(obstacleBox.x, obstacleBox.y, + obstacleBox.width, obstacleBox.height); + canvasCtx.restore(); + }; + + + /** + * Compare two collision boxes for a collision. + * @param {CollisionBox} tRexBox + * @param {CollisionBox} obstacleBox + * @return {boolean} Whether the boxes intersected. + */ + function boxCompare(tRexBox, obstacleBox) { + var crashed = false; + var tRexBoxX = tRexBox.x; + var tRexBoxY = tRexBox.y; + + var obstacleBoxX = obstacleBox.x; + var obstacleBoxY = obstacleBox.y; + + // Axis-Aligned Bounding Box method. + if (tRexBox.x < obstacleBoxX + obstacleBox.width && + tRexBox.x + tRexBox.width > obstacleBoxX && + tRexBox.y < obstacleBox.y + obstacleBox.height && + tRexBox.height + tRexBox.y > obstacleBox.y) { + crashed = true; + } + + return crashed; + }; + + + //****************************************************************************** + + /** + * Collision box object. + * @param {number} x X position. + * @param {number} y Y Position. + * @param {number} w Width. + * @param {number} h Height. + */ + function CollisionBox(x, y, w, h) { + this.x = x; + this.y = y; + this.width = w; + this.height = h; + }; + + + //****************************************************************************** + + /** + * Obstacle. + * @param {HTMLCanvasCtx} canvasCtx + * @param {Obstacle.type} type + * @param {Object} spritePos Obstacle position in sprite. + * @param {Object} dimensions + * @param {number} gapCoefficient Mutipler in determining the gap. + * @param {number} speed + * @param {number} opt_xOffset + */ + function Obstacle(canvasCtx, type, spriteImgPos, dimensions, + gapCoefficient, speed, opt_xOffset) { + + this.canvasCtx = canvasCtx; + this.spritePos = spriteImgPos; + this.typeConfig = type; + this.gapCoefficient = gapCoefficient; + this.size = getRandomNum(1, Obstacle.MAX_OBSTACLE_LENGTH); + this.dimensions = dimensions; + this.remove = false; + this.xPos = dimensions.WIDTH + (opt_xOffset || 0); + this.yPos = 0; + this.width = 0; + this.collisionBoxes = []; + this.gap = 0; + this.speedOffset = 0; + + // For animated obstacles. + this.currentFrame = 0; + this.timer = 0; + + this.init(speed); + }; + + /** + * Coefficient for calculating the maximum gap. + * @const + */ + Obstacle.MAX_GAP_COEFFICIENT = 1.5; + + /** + * Maximum obstacle grouping count. + * @const + */ + Obstacle.MAX_OBSTACLE_LENGTH = 3, + + + Obstacle.prototype = { + /** + * Initialise the DOM for the obstacle. + * @param {number} speed + */ + init: function (speed) { + this.cloneCollisionBoxes(); + + // Only allow sizing if we're at the right speed. + if (this.size > 1 && this.typeConfig.multipleSpeed > speed) { + this.size = 1; + } + + this.width = this.typeConfig.width * this.size; + + // Check if obstacle can be positioned at various heights. + if (Array.isArray(this.typeConfig.yPos)) { + var yPosConfig = IS_MOBILE ? this.typeConfig.yPosMobile : + this.typeConfig.yPos; + this.yPos = yPosConfig[getRandomNum(0, yPosConfig.length - 1)]; + } else { + this.yPos = this.typeConfig.yPos; + } + + this.draw(); + + // Make collision box adjustments, + // Central box is adjusted to the size as one box. + // ____ ______ ________ + // _| |-| _| |-| _| |-| + // | |<->| | | |<--->| | | |<----->| | + // | | 1 | | | | 2 | | | | 3 | | + // |_|___|_| |_|_____|_| |_|_______|_| + // + if (this.size > 1) { + this.collisionBoxes[1].width = this.width - this.collisionBoxes[0].width - + this.collisionBoxes[2].width; + this.collisionBoxes[2].x = this.width - this.collisionBoxes[2].width; + } + + // For obstacles that go at a different speed from the horizon. + if (this.typeConfig.speedOffset) { + this.speedOffset = Math.random() > 0.5 ? this.typeConfig.speedOffset : + -this.typeConfig.speedOffset; + } + + this.gap = this.getGap(this.gapCoefficient, speed); + }, + + /** + * Draw and crop based on size. + */ + draw: function () { + var sourceWidth = this.typeConfig.width; + var sourceHeight = this.typeConfig.height; + + if (IS_HIDPI) { + sourceWidth = sourceWidth * 2; + sourceHeight = sourceHeight * 2; + } + + // X position in sprite. + var sourceX = (sourceWidth * this.size) * (0.5 * (this.size - 1)) + + this.spritePos.x; + + // Animation frames. + if (this.currentFrame > 0) { + sourceX += sourceWidth * this.currentFrame; + } + + this.canvasCtx.drawImage(Runner.imageSprite, + sourceX, this.spritePos.y, + sourceWidth * this.size, sourceHeight, + this.xPos, this.yPos, + this.typeConfig.width * this.size, this.typeConfig.height); + }, + + /** + * Obstacle frame update. + * @param {number} deltaTime + * @param {number} speed + */ + update: function (deltaTime, speed) { + if (!this.remove) { + if (this.typeConfig.speedOffset) { + speed += this.speedOffset; + } + this.xPos -= Math.floor((speed * FPS / 1000) * deltaTime); + + // Update frame + if (this.typeConfig.numFrames) { + this.timer += deltaTime; + if (this.timer >= this.typeConfig.frameRate) { + this.currentFrame = + this.currentFrame == this.typeConfig.numFrames - 1 ? + 0 : this.currentFrame + 1; + this.timer = 0; + } + } + this.draw(); + + if (!this.isVisible()) { + this.remove = true; + } + } + }, + + /** + * Calculate a random gap size. + * - Minimum gap gets wider as speed increses + * @param {number} gapCoefficient + * @param {number} speed + * @return {number} The gap size. + */ + getGap: function (gapCoefficient, speed) { + var minGap = Math.round(this.width * speed + + this.typeConfig.minGap * gapCoefficient); + var maxGap = Math.round(minGap * Obstacle.MAX_GAP_COEFFICIENT); + return getRandomNum(minGap, maxGap); + }, + + /** + * Check if obstacle is visible. + * @return {boolean} Whether the obstacle is in the game area. + */ + isVisible: function () { + return this.xPos + this.width > 0; + }, + + /** + * Make a copy of the collision boxes, since these will change based on + * obstacle type and size. + */ + cloneCollisionBoxes: function () { + var collisionBoxes = this.typeConfig.collisionBoxes; + + for (var i = collisionBoxes.length - 1; i >= 0; i--) { + this.collisionBoxes[i] = new CollisionBox(collisionBoxes[i].x, + collisionBoxes[i].y, collisionBoxes[i].width, + collisionBoxes[i].height); + } + } + }; + + + /** + * Obstacle definitions. + * minGap: minimum pixel space betweeen obstacles. + * multipleSpeed: Speed at which multiples are allowed. + * speedOffset: speed faster / slower than the horizon. + * minSpeed: Minimum speed which the obstacle can make an appearance. + */ + Obstacle.types = [ + { + type: 'CACTUS_SMALL', + width: 17, + height: 35, + yPos: 105, + multipleSpeed: 4, + minGap: 120, + minSpeed: 0, + collisionBoxes: [ + new CollisionBox(0, 7, 5, 27), + new CollisionBox(4, 0, 6, 34), + new CollisionBox(10, 4, 7, 14) + ] + }, + { + type: 'CACTUS_LARGE', + width: 25, + height: 50, + yPos: 90, + multipleSpeed: 7, + minGap: 120, + minSpeed: 0, + collisionBoxes: [ + new CollisionBox(0, 12, 7, 38), + new CollisionBox(8, 0, 7, 49), + new CollisionBox(13, 10, 10, 38) + ] + }, + { + type: 'PTERODACTYL', + width: 46, + height: 40, + yPos: [100, 75, 50], // Variable height. + yPosMobile: [100, 50], // Variable height mobile. + multipleSpeed: 999, + minSpeed: 8.5, + minGap: 150, + collisionBoxes: [ + new CollisionBox(15, 15, 16, 5), + new CollisionBox(18, 21, 24, 6), + new CollisionBox(2, 14, 4, 3), + new CollisionBox(6, 10, 4, 7), + new CollisionBox(10, 8, 6, 9) + ], + numFrames: 2, + frameRate: 1000 / 6, + speedOffset: .8 + } + ]; + + + //****************************************************************************** + /** + * T-rex game character. + * @param {HTMLCanvas} canvas + * @param {Object} spritePos Positioning within image sprite. + * @constructor + */ + function Trex(canvas, spritePos) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.spritePos = spritePos; + this.xPos = 0; + this.yPos = 0; + // Position when on the ground. + this.groundYPos = 0; + this.currentFrame = 0; + this.currentAnimFrames = []; + this.blinkDelay = 0; + this.blinkCount = 0; + this.animStartTime = 0; + this.timer = 0; + this.msPerFrame = 1000 / FPS; + this.config = Trex.config; + // Current status. + this.status = Trex.status.WAITING; + + this.jumping = false; + this.ducking = false; + this.jumpVelocity = 0; + this.reachedMinHeight = false; + this.speedDrop = false; + this.jumpCount = 0; + this.jumpspotX = 0; + + this.init(); + }; + + + /** + * T-rex player config. + * @enum {number} + */ + Trex.config = { + DROP_VELOCITY: -5, + GRAVITY: 0.6, + HEIGHT: 47, + HEIGHT_DUCK: 25, + INIITAL_JUMP_VELOCITY: -10, + INTRO_DURATION: 1500, + MAX_JUMP_HEIGHT: 30, + MIN_JUMP_HEIGHT: 30, + SPEED_DROP_COEFFICIENT: 3, + SPRITE_WIDTH: 262, + START_X_POS: 50, + WIDTH: 44, + WIDTH_DUCK: 59 + }; + + + /** + * Used in collision detection. + * @type {Array} + */ + Trex.collisionBoxes = { + DUCKING: [ + new CollisionBox(1, 18, 55, 25) + ], + RUNNING: [ + new CollisionBox(22, 0, 17, 16), + new CollisionBox(1, 18, 30, 9), + new CollisionBox(10, 35, 14, 8), + new CollisionBox(1, 24, 29, 5), + new CollisionBox(5, 30, 21, 4), + new CollisionBox(9, 34, 15, 4) + ] + }; + + + /** + * Animation states. + * @enum {string} + */ + Trex.status = { + CRASHED: 'CRASHED', + DUCKING: 'DUCKING', + JUMPING: 'JUMPING', + RUNNING: 'RUNNING', + WAITING: 'WAITING' + }; + + /** + * Blinking coefficient. + * @const + */ + Trex.BLINK_TIMING = 7000; + + + /** + * Animation config for different states. + * @enum {Object} + */ + Trex.animFrames = { + WAITING: { + frames: [44, 0], + msPerFrame: 1000 / 3 + }, + RUNNING: { + frames: [88, 132], + msPerFrame: 1000 / 12 + }, + CRASHED: { + frames: [220], + msPerFrame: 1000 / 60 + }, + JUMPING: { + frames: [0], + msPerFrame: 1000 / 60 + }, + DUCKING: { + frames: [264, 323], + msPerFrame: 1000 / 8 + } + }; + + + Trex.prototype = { + /** + * T-rex player initaliser. + * Sets the t-rex to blink at random intervals. + */ + init: function () { + this.groundYPos = Runner.defaultDimensions.HEIGHT - this.config.HEIGHT - + Runner.config.BOTTOM_PAD; + this.yPos = this.groundYPos; + this.minJumpHeight = this.groundYPos - this.config.MIN_JUMP_HEIGHT; + + this.draw(0, 0); + this.update(0, Trex.status.WAITING); + }, + + /** + * Setter for the jump velocity. + * The approriate drop velocity is also set. + */ + setJumpVelocity: function (setting) { + this.config.INIITAL_JUMP_VELOCITY = -setting; + this.config.DROP_VELOCITY = -setting / 2; + }, + + /** + * Set the animation status. + * @param {!number} deltaTime + * @param {Trex.status} status Optional status to switch to. + */ + update: function (deltaTime, opt_status) { + this.timer += deltaTime; + + // Update the status. + if (opt_status) { + this.status = opt_status; + this.currentFrame = 0; + this.msPerFrame = Trex.animFrames[opt_status].msPerFrame; + this.currentAnimFrames = Trex.animFrames[opt_status].frames; + + if (opt_status == Trex.status.WAITING) { + this.animStartTime = getTimeStamp(); + this.setBlinkDelay(); + } + } + + // Game intro animation, T-rex moves in from the left. + if (this.playingIntro && this.xPos < this.config.START_X_POS) { + this.xPos += Math.round((this.config.START_X_POS / + this.config.INTRO_DURATION) * deltaTime); + } + + if (this.status == Trex.status.WAITING) { + this.blink(getTimeStamp()); + } else { + this.draw(this.currentAnimFrames[this.currentFrame], 0); + } + + // Update the frame position. + if (this.timer >= this.msPerFrame) { + this.currentFrame = this.currentFrame == + this.currentAnimFrames.length - 1 ? 0 : this.currentFrame + 1; + this.timer = 0; + } + + // Speed drop becomes duck if the down key is still being pressed. + if (this.speedDrop && this.yPos == this.groundYPos) { + this.speedDrop = false; + this.setDuck(true); + } + }, + + /** + * Draw the t-rex to a particular position. + * @param {number} x + * @param {number} y + */ + draw: function (x, y) { + var sourceX = x; + var sourceY = y; + var sourceWidth = this.ducking && this.status != Trex.status.CRASHED ? + this.config.WIDTH_DUCK : this.config.WIDTH; + var sourceHeight = this.config.HEIGHT; + + if (IS_HIDPI) { + sourceX *= 2; + sourceY *= 2; + sourceWidth *= 2; + sourceHeight *= 2; + } + + // Adjustments for sprite sheet position. + sourceX += this.spritePos.x; + sourceY += this.spritePos.y; + + // Ducking. + if (this.ducking && this.status != Trex.status.CRASHED) { + this.canvasCtx.drawImage(Runner.imageSprite, sourceX, sourceY, + sourceWidth, sourceHeight, + this.xPos, this.yPos, + this.config.WIDTH_DUCK, this.config.HEIGHT); + } else { + // Crashed whilst ducking. Trex is standing up so needs adjustment. + if (this.ducking && this.status == Trex.status.CRASHED) { + this.xPos++; + } + // Standing / running + this.canvasCtx.drawImage(Runner.imageSprite, sourceX, sourceY, + sourceWidth, sourceHeight, + this.xPos, this.yPos, + this.config.WIDTH, this.config.HEIGHT); + } + }, + + /** + * Sets a random time for the blink to happen. + */ + setBlinkDelay: function () { + this.blinkDelay = Math.ceil(Math.random() * Trex.BLINK_TIMING); + }, + + /** + * Make t-rex blink at random intervals. + * @param {number} time Current time in milliseconds. + */ + blink: function (time) { + var deltaTime = time - this.animStartTime; + + if (deltaTime >= this.blinkDelay) { + this.draw(this.currentAnimFrames[this.currentFrame], 0); + + if (this.currentFrame == 1) { + // Set new random delay to blink. + this.setBlinkDelay(); + this.animStartTime = time; + this.blinkCount++; + } + } + }, + + /** + * Initialise a jump. + * @param {number} speed + */ + startJump: function (speed) { + if (!this.jumping) { + this.update(0, Trex.status.JUMPING); + // Tweak the jump velocity based on the speed. + this.jumpVelocity = this.config.INIITAL_JUMP_VELOCITY - (speed / 10); + this.jumping = true; + this.reachedMinHeight = false; + this.speedDrop = false; + } + }, + + /** + * Jump is complete, falling down. + */ + endJump: function () { + if (this.reachedMinHeight && + this.jumpVelocity < this.config.DROP_VELOCITY) { + this.jumpVelocity = this.config.DROP_VELOCITY; + } + }, + + /** + * Update frame for a jump. + * @param {number} deltaTime + * @param {number} speed + */ + updateJump: function (deltaTime, speed) { + var msPerFrame = Trex.animFrames[this.status].msPerFrame; + var framesElapsed = deltaTime / msPerFrame; + + // Speed drop makes Trex fall faster. + if (this.speedDrop) { + this.yPos += Math.round(this.jumpVelocity * + this.config.SPEED_DROP_COEFFICIENT * framesElapsed); + } else { + this.yPos += Math.round(this.jumpVelocity * framesElapsed); + } + + this.jumpVelocity += this.config.GRAVITY * framesElapsed; + + // Minimum height has been reached. + if (this.yPos < this.minJumpHeight || this.speedDrop) { + this.reachedMinHeight = true; + } + + // Reached max height + if (this.yPos < this.config.MAX_JUMP_HEIGHT || this.speedDrop) { + this.endJump(); + } + + // Back down at ground level. Jump completed. + if (this.yPos > this.groundYPos) { + this.reset(); + this.jumpCount++; + } + + this.update(deltaTime); + }, + + /** + * Set the speed drop. Immediately cancels the current jump. + */ + setSpeedDrop: function () { + this.speedDrop = true; + this.jumpVelocity = 1; + }, + + /** + * @param {boolean} isDucking. + */ + setDuck: function (isDucking) { + if (isDucking && this.status != Trex.status.DUCKING) { + this.update(0, Trex.status.DUCKING); + this.ducking = true; + } else if (this.status == Trex.status.DUCKING) { + this.update(0, Trex.status.RUNNING); + this.ducking = false; + } + }, + + /** + * Reset the t-rex to running at start of game. + */ + reset: function () { + this.yPos = this.groundYPos; + this.jumpVelocity = 0; + this.jumping = false; + this.ducking = false; + this.update(0, Trex.status.RUNNING); + this.midair = false; + this.speedDrop = false; + this.jumpCount = 0; + } + }; + + + //****************************************************************************** + + /** + * Handles displaying the distance meter. + * @param {!HTMLCanvasElement} canvas + * @param {Object} spritePos Image position in sprite. + * @param {number} canvasWidth + * @constructor + */ + function DistanceMeter(canvas, spritePos, canvasWidth) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.image = Runner.imageSprite; + this.spritePos = spritePos; + this.x = 0; + this.y = 5; + + this.currentDistance = 0; + this.maxScore = 0; + this.highScore = 0; + this.container = null; + + this.digits = []; + this.acheivement = false; + this.defaultString = ''; + this.flashTimer = 0; + this.flashIterations = 0; + this.invertTrigger = false; + + this.config = DistanceMeter.config; + this.maxScoreUnits = this.config.MAX_DISTANCE_UNITS; + this.init(canvasWidth); + }; + + + /** + * @enum {number} + */ + DistanceMeter.dimensions = { + WIDTH: 10, + HEIGHT: 13, + DEST_WIDTH: 11 + }; + + + /** + * Y positioning of the digits in the sprite sheet. + * X position is always 0. + * @type {Array} + */ + DistanceMeter.yPos = [0, 13, 27, 40, 53, 67, 80, 93, 107, 120]; + + + /** + * Distance meter config. + * @enum {number} + */ + DistanceMeter.config = { + // Number of digits. + MAX_DISTANCE_UNITS: 5, + + // Distance that causes achievement animation. + ACHIEVEMENT_DISTANCE: 100, + + // Used for conversion from pixel distance to a scaled unit. + COEFFICIENT: 0.025, + + // Flash duration in milliseconds. + FLASH_DURATION: 1000 / 4, + + // Flash iterations for achievement animation. + FLASH_ITERATIONS: 3 + }; + + + DistanceMeter.prototype = { + /** + * Initialise the distance meter to '00000'. + * @param {number} width Canvas width in px. + */ + init: function (width) { + var maxDistanceStr = ''; + + this.calcXPos(width); + this.maxScore = this.maxScoreUnits; + for (var i = 0; i < this.maxScoreUnits; i++) { + this.draw(i, 0); + this.defaultString += '0'; + maxDistanceStr += '9'; + } + + this.maxScore = parseInt(maxDistanceStr); + }, + + /** + * Calculate the xPos in the canvas. + * @param {number} canvasWidth + */ + calcXPos: function (canvasWidth) { + this.x = canvasWidth - (DistanceMeter.dimensions.DEST_WIDTH * + (this.maxScoreUnits + 1)); + }, + + /** + * Draw a digit to canvas. + * @param {number} digitPos Position of the digit. + * @param {number} value Digit value 0-9. + * @param {boolean} opt_highScore Whether drawing the high score. + */ + draw: function (digitPos, value, opt_highScore) { + var sourceWidth = DistanceMeter.dimensions.WIDTH; + var sourceHeight = DistanceMeter.dimensions.HEIGHT; + var sourceX = DistanceMeter.dimensions.WIDTH * value; + var sourceY = 0; + + var targetX = digitPos * DistanceMeter.dimensions.DEST_WIDTH; + var targetY = this.y; + var targetWidth = DistanceMeter.dimensions.WIDTH; + var targetHeight = DistanceMeter.dimensions.HEIGHT; + + // For high DPI we 2x source values. + if (IS_HIDPI) { + sourceWidth *= 2; + sourceHeight *= 2; + sourceX *= 2; + } + + sourceX += this.spritePos.x; + sourceY += this.spritePos.y; + + this.canvasCtx.save(); + + if (opt_highScore) { + // Left of the current score. + var highScoreX = this.x - (this.maxScoreUnits * 2) * + DistanceMeter.dimensions.WIDTH; + this.canvasCtx.translate(highScoreX, this.y); + } else { + this.canvasCtx.translate(this.x, this.y); + } + + this.canvasCtx.drawImage(this.image, sourceX, sourceY, + sourceWidth, sourceHeight, + targetX, targetY, + targetWidth, targetHeight + ); + + this.canvasCtx.restore(); + }, + + /** + * Covert pixel distance to a 'real' distance. + * @param {number} distance Pixel distance ran. + * @return {number} The 'real' distance ran. + */ + getActualDistance: function (distance) { + return distance ? Math.round(distance * this.config.COEFFICIENT) : 0; + }, + + /** + * Update the distance meter. + * @param {number} distance + * @param {number} deltaTime + * @return {boolean} Whether the acheivement sound fx should be played. + */ + update: function (deltaTime, distance) { + var paint = true; + var playSound = false; + + if (!this.acheivement) { + distance = this.getActualDistance(distance); + // Score has gone beyond the initial digit count. + if (distance > this.maxScore && this.maxScoreUnits == + this.config.MAX_DISTANCE_UNITS) { + this.maxScoreUnits++; + this.maxScore = parseInt(this.maxScore + '9'); + } else { + this.distance = 0; + } + + if (distance > 0) { + // Acheivement unlocked + if (distance % this.config.ACHIEVEMENT_DISTANCE == 0) { + // Flash score and play sound. + this.acheivement = true; + this.flashTimer = 0; + playSound = true; + } + + // Create a string representation of the distance with leading 0. + var distanceStr = (this.defaultString + + distance).substr(-this.maxScoreUnits); + this.digits = distanceStr.split(''); + } else { + this.digits = this.defaultString.split(''); + } + } else { + // Control flashing of the score on reaching acheivement. + if (this.flashIterations <= this.config.FLASH_ITERATIONS) { + this.flashTimer += deltaTime; + + if (this.flashTimer < this.config.FLASH_DURATION) { + paint = false; + } else if (this.flashTimer > + this.config.FLASH_DURATION * 2) { + this.flashTimer = 0; + this.flashIterations++; + } + } else { + this.acheivement = false; + this.flashIterations = 0; + this.flashTimer = 0; + } + } + + // Draw the digits if not flashing. + if (paint) { + for (var i = this.digits.length - 1; i >= 0; i--) { + this.draw(i, parseInt(this.digits[i])); + } + } + + this.drawHighScore(); + return playSound; + }, + + /** + * Draw the high score. + */ + drawHighScore: function () { + this.canvasCtx.save(); + this.canvasCtx.globalAlpha = .8; + for (var i = this.highScore.length - 1; i >= 0; i--) { + this.draw(i, parseInt(this.highScore[i], 10), true); + } + this.canvasCtx.restore(); + }, + + /** + * Set the highscore as a array string. + * Position of char in the sprite: H - 10, I - 11. + * @param {number} distance Distance ran in pixels. + */ + setHighScore: function (distance) { + distance = this.getActualDistance(distance); + var highScoreStr = (this.defaultString + + distance).substr(-this.maxScoreUnits); + + this.highScore = ['10', '11', ''].concat(highScoreStr.split('')); + }, + + /** + * Reset the distance meter back to '00000'. + */ + reset: function () { + this.update(0); + this.acheivement = false; + } + }; + + + //****************************************************************************** + + /** + * Cloud background item. + * Similar to an obstacle object but without collision boxes. + * @param {HTMLCanvasElement} canvas Canvas element. + * @param {Object} spritePos Position of image in sprite. + * @param {number} containerWidth + */ + function Cloud(canvas, spritePos, containerWidth) { + this.canvas = canvas; + this.canvasCtx = this.canvas.getContext('2d'); + this.spritePos = spritePos; + this.containerWidth = containerWidth; + this.xPos = containerWidth; + this.yPos = 0; + this.remove = false; + this.cloudGap = getRandomNum(Cloud.config.MIN_CLOUD_GAP, + Cloud.config.MAX_CLOUD_GAP); + + this.init(); + }; + + + /** + * Cloud object config. + * @enum {number} + */ + Cloud.config = { + HEIGHT: 14, + MAX_CLOUD_GAP: 400, + MAX_SKY_LEVEL: 30, + MIN_CLOUD_GAP: 100, + MIN_SKY_LEVEL: 71, + WIDTH: 46 + }; + + + Cloud.prototype = { + /** + * Initialise the cloud. Sets the Cloud height. + */ + init: function () { + this.yPos = getRandomNum(Cloud.config.MAX_SKY_LEVEL, + Cloud.config.MIN_SKY_LEVEL); + this.draw(); + }, + + /** + * Draw the cloud. + */ + draw: function () { + this.canvasCtx.save(); + var sourceWidth = Cloud.config.WIDTH; + var sourceHeight = Cloud.config.HEIGHT; + + if (IS_HIDPI) { + sourceWidth = sourceWidth * 2; + sourceHeight = sourceHeight * 2; + } + + this.canvasCtx.drawImage(Runner.imageSprite, this.spritePos.x, + this.spritePos.y, + sourceWidth, sourceHeight, + this.xPos, this.yPos, + Cloud.config.WIDTH, Cloud.config.HEIGHT); + + this.canvasCtx.restore(); + }, + + /** + * Update the cloud position. + * @param {number} speed + */ + update: function (speed) { + if (!this.remove) { + this.xPos -= Math.ceil(speed); + this.draw(); + + // Mark as removeable if no longer in the canvas. + if (!this.isVisible()) { + this.remove = true; + } + } + }, + + /** + * Check if the cloud is visible on the stage. + * @return {boolean} + */ + isVisible: function () { + return this.xPos + Cloud.config.WIDTH > 0; + } + }; + + + //****************************************************************************** + + /** + * Nightmode shows a moon and stars on the horizon. + */ + function NightMode(canvas, spritePos, containerWidth) { + this.spritePos = spritePos; + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.xPos = containerWidth - 50; + this.yPos = 30; + this.currentPhase = 0; + this.opacity = 0; + this.containerWidth = containerWidth; + this.stars = []; + this.drawStars = false; + this.placeStars(); + }; + + /** + * @enum {number} + */ + NightMode.config = { + FADE_SPEED: 0.035, + HEIGHT: 40, + MOON_SPEED: 0.25, + NUM_STARS: 2, + STAR_SIZE: 9, + STAR_SPEED: 0.3, + STAR_MAX_Y: 70, + WIDTH: 20 + }; + + NightMode.phases = [140, 120, 100, 60, 40, 20, 0]; + + NightMode.prototype = { + /** + * Update moving moon, changing phases. + * @param {boolean} activated Whether night mode is activated. + * @param {number} delta + */ + update: function (activated, delta) { + // Moon phase. + if (activated && this.opacity == 0) { + this.currentPhase++; + + if (this.currentPhase >= NightMode.phases.length) { + this.currentPhase = 0; + } + } + + // Fade in / out. + if (activated && (this.opacity < 1 || this.opacity == 0)) { + this.opacity += NightMode.config.FADE_SPEED; + } else if (this.opacity > 0) { + this.opacity -= NightMode.config.FADE_SPEED; + } + + // Set moon positioning. + if (this.opacity > 0) { + this.xPos = this.updateXPos(this.xPos, NightMode.config.MOON_SPEED); + + // Update stars. + if (this.drawStars) { + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.stars[i].x = this.updateXPos(this.stars[i].x, + NightMode.config.STAR_SPEED); + } + } + this.draw(); + } else { + this.opacity = 0; + this.placeStars(); + } + this.drawStars = true; + }, + + updateXPos: function (currentPos, speed) { + if (currentPos < -NightMode.config.WIDTH) { + currentPos = this.containerWidth; + } else { + currentPos -= speed; + } + return currentPos; + }, + + draw: function () { + var moonSourceWidth = this.currentPhase == 3 ? NightMode.config.WIDTH * 2 : + NightMode.config.WIDTH; + var moonSourceHeight = NightMode.config.HEIGHT; + var moonSourceX = this.spritePos.x + NightMode.phases[this.currentPhase]; + var moonOutputWidth = moonSourceWidth; + var starSize = NightMode.config.STAR_SIZE; + var starSourceX = Runner.spriteDefinition.LDPI.STAR.x; + + if (IS_HIDPI) { + moonSourceWidth *= 2; + moonSourceHeight *= 2; + moonSourceX = this.spritePos.x + + (NightMode.phases[this.currentPhase] * 2); + starSize *= 2; + starSourceX = Runner.spriteDefinition.HDPI.STAR.x; + } + + this.canvasCtx.save(); + this.canvasCtx.globalAlpha = this.opacity; + + // Stars. + if (this.drawStars) { + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.canvasCtx.drawImage(Runner.imageSprite, + starSourceX, this.stars[i].sourceY, starSize, starSize, + Math.round(this.stars[i].x), this.stars[i].y, + NightMode.config.STAR_SIZE, NightMode.config.STAR_SIZE); + } + } + + // Moon. + this.canvasCtx.drawImage(Runner.imageSprite, moonSourceX, + this.spritePos.y, moonSourceWidth, moonSourceHeight, + Math.round(this.xPos), this.yPos, + moonOutputWidth, NightMode.config.HEIGHT); + + this.canvasCtx.globalAlpha = 1; + this.canvasCtx.restore(); + }, + + // Do star placement. + placeStars: function () { + var segmentSize = Math.round(this.containerWidth / + NightMode.config.NUM_STARS); + + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.stars[i] = {}; + this.stars[i].x = getRandomNum(segmentSize * i, segmentSize * (i + 1)); + this.stars[i].y = getRandomNum(0, NightMode.config.STAR_MAX_Y); + + if (IS_HIDPI) { + this.stars[i].sourceY = Runner.spriteDefinition.HDPI.STAR.y + + NightMode.config.STAR_SIZE * 2 * i; + } else { + this.stars[i].sourceY = Runner.spriteDefinition.LDPI.STAR.y + + NightMode.config.STAR_SIZE * i; + } + } + }, + + reset: function () { + this.currentPhase = 0; + this.opacity = 0; + this.update(false); + } + + }; + + + //****************************************************************************** + + /** + * Horizon Line. + * Consists of two connecting lines. Randomly assigns a flat / bumpy horizon. + * @param {HTMLCanvasElement} canvas + * @param {Object} spritePos Horizon position in sprite. + * @constructor + */ + function HorizonLine(canvas, spritePos) { + this.spritePos = spritePos; + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.sourceDimensions = {}; + this.dimensions = HorizonLine.dimensions; + this.sourceXPos = [this.spritePos.x, this.spritePos.x + + this.dimensions.WIDTH]; + this.xPos = []; + this.yPos = 0; + this.bumpThreshold = 0.5; + + this.setSourceDimensions(); + this.draw(); + }; + + + /** + * Horizon line dimensions. + * @enum {number} + */ + HorizonLine.dimensions = { + WIDTH: 600, + HEIGHT: 12, + YPOS: 127 + }; + + + HorizonLine.prototype = { + /** + * Set the source dimensions of the horizon line. + */ + setSourceDimensions: function () { + + for (var dimension in HorizonLine.dimensions) { + if (IS_HIDPI) { + if (dimension != 'YPOS') { + this.sourceDimensions[dimension] = + HorizonLine.dimensions[dimension] * 2; + } + } else { + this.sourceDimensions[dimension] = + HorizonLine.dimensions[dimension]; + } + this.dimensions[dimension] = HorizonLine.dimensions[dimension]; + } + + this.xPos = [0, HorizonLine.dimensions.WIDTH]; + this.yPos = HorizonLine.dimensions.YPOS; + }, + + /** + * Return the crop x position of a type. + */ + getRandomType: function () { + return Math.random() > this.bumpThreshold ? this.dimensions.WIDTH : 0; + }, + + /** + * Draw the horizon line. + */ + draw: function () { + this.canvasCtx.drawImage(Runner.imageSprite, this.sourceXPos[0], + this.spritePos.y, + this.sourceDimensions.WIDTH, this.sourceDimensions.HEIGHT, + this.xPos[0], this.yPos, + this.dimensions.WIDTH, this.dimensions.HEIGHT); + + this.canvasCtx.drawImage(Runner.imageSprite, this.sourceXPos[1], + this.spritePos.y, + this.sourceDimensions.WIDTH, this.sourceDimensions.HEIGHT, + this.xPos[1], this.yPos, + this.dimensions.WIDTH, this.dimensions.HEIGHT); + }, + + /** + * Update the x position of an indivdual piece of the line. + * @param {number} pos Line position. + * @param {number} increment + */ + updateXPos: function (pos, increment) { + var line1 = pos; + var line2 = pos == 0 ? 1 : 0; + + this.xPos[line1] -= increment; + this.xPos[line2] = this.xPos[line1] + this.dimensions.WIDTH; + + if (this.xPos[line1] <= -this.dimensions.WIDTH) { + this.xPos[line1] += this.dimensions.WIDTH * 2; + this.xPos[line2] = this.xPos[line1] - this.dimensions.WIDTH; + this.sourceXPos[line1] = this.getRandomType() + this.spritePos.x; + } + }, + + /** + * Update the horizon line. + * @param {number} deltaTime + * @param {number} speed + */ + update: function (deltaTime, speed) { + var increment = Math.floor(speed * (FPS / 1000) * deltaTime); + + if (this.xPos[0] <= 0) { + this.updateXPos(0, increment); + } else { + this.updateXPos(1, increment); + } + this.draw(); + }, + + /** + * Reset horizon to the starting position. + */ + reset: function () { + this.xPos[0] = 0; + this.xPos[1] = HorizonLine.dimensions.WIDTH; + } + }; + + + //****************************************************************************** + + /** + * Horizon background class. + * @param {HTMLCanvasElement} canvas + * @param {Object} spritePos Sprite positioning. + * @param {Object} dimensions Canvas dimensions. + * @param {number} gapCoefficient + * @constructor + */ + function Horizon(canvas, spritePos, dimensions, gapCoefficient) { + this.canvas = canvas; + this.canvasCtx = this.canvas.getContext('2d'); + this.config = Horizon.config; + this.dimensions = dimensions; + this.gapCoefficient = gapCoefficient; + this.obstacles = []; + this.obstacleHistory = []; + this.horizonOffsets = [0, 0]; + this.cloudFrequency = this.config.CLOUD_FREQUENCY; + this.spritePos = spritePos; + this.nightMode = null; + + // Cloud + this.clouds = []; + this.cloudSpeed = this.config.BG_CLOUD_SPEED; + + // Horizon + this.horizonLine = null; + this.init(); + }; + + + /** + * Horizon config. + * @enum {number} + */ + Horizon.config = { + BG_CLOUD_SPEED: 0.2, + BUMPY_THRESHOLD: .3, + CLOUD_FREQUENCY: .5, + HORIZON_HEIGHT: 16, + MAX_CLOUDS: 6 + }; + + + Horizon.prototype = { + /** + * Initialise the horizon. Just add the line and a cloud. No obstacles. + */ + init: function () { + this.addCloud(); + this.horizonLine = new HorizonLine(this.canvas, this.spritePos.HORIZON); + this.nightMode = new NightMode(this.canvas, this.spritePos.MOON, + this.dimensions.WIDTH); + }, + + /** + * @param {number} deltaTime + * @param {number} currentSpeed + * @param {boolean} updateObstacles Used as an override to prevent + * the obstacles from being updated / added. This happens in the + * ease in section. + * @param {boolean} showNightMode Night mode activated. + */ + update: function (deltaTime, currentSpeed, updateObstacles, showNightMode) { + this.runningTime += deltaTime; + this.horizonLine.update(deltaTime, currentSpeed); + this.nightMode.update(showNightMode); + this.updateClouds(deltaTime, currentSpeed); + + if (updateObstacles) { + this.updateObstacles(deltaTime, currentSpeed); + } + }, + + /** + * Update the cloud positions. + * @param {number} deltaTime + * @param {number} currentSpeed + */ + updateClouds: function (deltaTime, speed) { + var cloudSpeed = this.cloudSpeed / 1000 * deltaTime * speed; + var numClouds = this.clouds.length; + + if (numClouds) { + for (var i = numClouds - 1; i >= 0; i--) { + this.clouds[i].update(cloudSpeed); + } + + var lastCloud = this.clouds[numClouds - 1]; + + // Check for adding a new cloud. + if (numClouds < this.config.MAX_CLOUDS && + (this.dimensions.WIDTH - lastCloud.xPos) > lastCloud.cloudGap && + this.cloudFrequency > Math.random()) { + this.addCloud(); + } + + // Remove expired clouds. + this.clouds = this.clouds.filter(function (obj) { + return !obj.remove; + }); + } else { + this.addCloud(); + } + }, + + /** + * Update the obstacle positions. + * @param {number} deltaTime + * @param {number} currentSpeed + */ + updateObstacles: function (deltaTime, currentSpeed) { + // Obstacles, move to Horizon layer. + var updatedObstacles = this.obstacles.slice(0); + + for (var i = 0; i < this.obstacles.length; i++) { + var obstacle = this.obstacles[i]; + obstacle.update(deltaTime, currentSpeed); + + // Clean up existing obstacles. + if (obstacle.remove) { + updatedObstacles.shift(); + } + } + this.obstacles = updatedObstacles; + + if (this.obstacles.length > 0) { + var lastObstacle = this.obstacles[this.obstacles.length - 1]; + + if (lastObstacle && !lastObstacle.followingObstacleCreated && + lastObstacle.isVisible() && + (lastObstacle.xPos + lastObstacle.width + lastObstacle.gap) < + this.dimensions.WIDTH) { + this.addNewObstacle(currentSpeed); + lastObstacle.followingObstacleCreated = true; + } + } else { + // Create new obstacles. + this.addNewObstacle(currentSpeed); + } + }, + + removeFirstObstacle: function () { + this.obstacles.shift(); + }, + + /** + * Add a new obstacle. + * @param {number} currentSpeed + */ + addNewObstacle: function (currentSpeed) { + var obstacleTypeIndex = getRandomNum(0, Obstacle.types.length - 1); + var obstacleType = Obstacle.types[obstacleTypeIndex]; + + // Check for multiples of the same type of obstacle. + // Also check obstacle is available at current speed. + if (this.duplicateObstacleCheck(obstacleType.type) || + currentSpeed < obstacleType.minSpeed) { + this.addNewObstacle(currentSpeed); + } else { + var obstacleSpritePos = this.spritePos[obstacleType.type]; + + this.obstacles.push(new Obstacle(this.canvasCtx, obstacleType, + obstacleSpritePos, this.dimensions, + this.gapCoefficient, currentSpeed, obstacleType.width)); + + this.obstacleHistory.unshift(obstacleType.type); + + if (this.obstacleHistory.length > 1) { + this.obstacleHistory.splice(Runner.config.MAX_OBSTACLE_DUPLICATION); + } + } + }, + + /** + * Returns whether the previous two obstacles are the same as the next one. + * Maximum duplication is set in config value MAX_OBSTACLE_DUPLICATION. + * @return {boolean} + */ + duplicateObstacleCheck: function (nextObstacleType) { + var duplicateCount = 0; + + for (var i = 0; i < this.obstacleHistory.length; i++) { + duplicateCount = this.obstacleHistory[i] == nextObstacleType ? + duplicateCount + 1 : 0; + } + return duplicateCount >= Runner.config.MAX_OBSTACLE_DUPLICATION; + }, + + /** + * Reset the horizon layer. + * Remove existing obstacles and reposition the horizon line. + */ + reset: function () { + this.obstacles = []; + this.horizonLine.reset(); + this.nightMode.reset(); + }, + + /** + * Update the canvas width and scaling. + * @param {number} width Canvas width. + * @param {number} height Canvas height. + */ + resize: function (width, height) { + this.canvas.width = width; + this.canvas.height = height; + }, + + /** + * Add a new cloud to the horizon. + */ + addCloud: function () { + this.clouds.push(new Cloud(this.canvas, this.spritePos.CLOUD, + this.dimensions.WIDTH)); + } + }; +})(); + + +function onDocumentLoad() { + new Runner('.interstitial-wrapper'); +} + +document.addEventListener('DOMContentLoaded', onDocumentLoad); diff --git a/templates/404.html b/templates/404.html index b09fd6d..5f83c26 100644 --- a/templates/404.html +++ b/templates/404.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load static %} {% block entete %}Page introuvable{% endblock %} {% block navbar %}
    @@ -6,6 +7,30 @@
{% endblock %} {% block content %} +

Erreur 404

@@ -13,5 +38,6 @@
Une erreur s'est produite lors de l'accès à cette page (la page que vous demandez n'existe pas). Vous pouvez revenir à l'accueil en cliquant ici.
+ {% endblock %} diff --git a/templates/base.html b/templates/base.html index d9c654f..b4c0ed2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -8,7 +8,9 @@ - + + {% block extra_css %}{% endblock %} + {% block extra_script %}{% endblock %} @@ -42,5 +44,16 @@ {% include 'footer.html'%} + {% if request.user.is_authenticated %} + + {% endif %} diff --git a/templates/coope-runner.html b/templates/coope-runner.html new file mode 100644 index 0000000..5493d4d --- /dev/null +++ b/templates/coope-runner.html @@ -0,0 +1,86 @@ +{% load static %} + + + + + + + + + + + + + + +
+

Press up arrow to start

+
+
+
+
+
+
+
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/templates/footer.html b/templates/footer.html index c2618ec..cca478e 100644 --- a/templates/footer.html +++ b/templates/footer.html @@ -39,6 +39,6 @@
  • Facebook
  • - + diff --git a/templates/form.html b/templates/form.html index 592927a..be760ba 100644 --- a/templates/form.html +++ b/templates/form.html @@ -16,7 +16,7 @@ {% csrf_token %} {{ form }}
    - + diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..4e8a3ff --- /dev/null +++ b/templates/home.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} +{% block entete %}Accueil{% endblock %} +{% block navbar %} + +{% endblock %} +{% block content %} +
    +
    +

    Accueil

    +
    +
    + {{ home_text }} +
    +
    +
    +
    +

    Les pressions du moment

    +
    +
    + Les bières pressions actuellement en Coopé : +
      + {% for keg in kegs %} +
    • {{keg}} ({% if keg.pinte %} Pinte : {{keg.pinte.amount}}€,{% endif %}{% if keg.demi %} Demi : {{keg.demi.amount}}€,{% endif %}{% if keg.galopin %} Galopin : {{keg.galopin.amount}}€{% endif %}) : {{keg.pinte.deg}}°
    • + {% endfor %} +
    +
    +
    +{% endblock %} diff --git a/templates/nav.html b/templates/nav.html index 188251a..0c636e5 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -1,3 +1,6 @@ + + Accueil + {% if request.user.is_authenticated %} Mon profil @@ -18,11 +21,11 @@ {% endif %} +
    Classement
    {% if perms.preferences.change_generalpreferences %} -
    Admin
    {% endif %} @@ -45,5 +48,7 @@ Deconnexion {% else %} - Connexion + + Connexion + {% endif %} diff --git a/users/templates/users/admins_index.html b/users/templates/users/admins_index.html index 8dacf18..f021b2b 100644 --- a/users/templates/users/admins_index.html +++ b/users/templates/users/admins_index.html @@ -10,7 +10,7 @@

    Liste des admins

    - Ajouter un admin

    + Ajouter un admin

    {{ pm.is_usable_in_reload | yesno:"Oui, Non" }} {{ pm.affect_balance | yesno:"Oui, Non" }} {% if perms.preferences.change_paymentmethod %}Modifier {% endif %}{% if perms.preferences.delete_paymentmethod %}Supprimer{% endif %}{% if perms.preferences.change_paymentmethod %} Modifier {% endif %}{% if perms.preferences.delete_paymentmethod %} Supprimer{% endif %}
    ' + String(cotisation.amount) + ' €' + String(Number((cotisation.quantity * cotisation.amount).toFixed(2))) + ' €
    ' + product.name + '' + String(product.amount) + '' + String(Number((product.quantity * product.amount).toFixed(2))) + '
    ' + product.barcode + '' + product.name + '' + String(product.amount) + ' €' + String(Number((product.quantity * product.amount).toFixed(2))) + ' €
    ' + menu.name + '' + String(menu.amount) + '' + String(Number((menu.quantity * menu.amount).toFixed(2))) + '
    ' + menu.barcode + '' + menu.name + '' + String(menu.amount) + ' €' + String(Number((menu.quantity * menu.amount).toFixed(2))) + ' €
    @@ -23,9 +23,9 @@ {% for user in admins %} - - - + + + {% endfor %} diff --git a/users/templates/users/allReloads.html b/users/templates/users/allReloads.html index e06fe90..6731132 100644 --- a/users/templates/users/allReloads.html +++ b/users/templates/users/allReloads.html @@ -4,7 +4,7 @@ {% block navbar %} {% endblock %} {% block content %} diff --git a/users/templates/users/all_consumptions.html b/users/templates/users/all_consumptions.html index 02729bd..985963c 100644 --- a/users/templates/users/all_consumptions.html +++ b/users/templates/users/all_consumptions.html @@ -10,7 +10,7 @@ {% block content %}
    -

    Consommations ({{user}})

    +

    Consommations ({{user}})

    @@ -28,7 +28,7 @@
    {% for c in consumptions %} - + diff --git a/users/templates/users/all_menus.html b/users/templates/users/all_menus.html index 0e3921d..b412d90 100644 --- a/users/templates/users/all_menus.html +++ b/users/templates/users/all_menus.html @@ -10,7 +10,7 @@ {% block content %}
    -

    Consommations de menus ({{user}})

    +

    Consommations de menus ({{user}})

    diff --git a/users/templates/users/group_profile.html b/users/templates/users/group_profile.html index 3d5ebf0..056f38f 100644 --- a/users/templates/users/group_profile.html +++ b/users/templates/users/group_profile.html @@ -21,12 +21,12 @@
    {% if perms.auth.change_group %} {% endif %} {% if perms.auth.delete_group %} {% endif %}
    @@ -49,7 +49,7 @@
    - + {% endfor %} @@ -72,9 +72,9 @@ {% for user in group.user_set.all %} - - - + + + {% endfor %} diff --git a/users/templates/users/groups_index.html b/users/templates/users/groups_index.html index 2bc9ddb..a43f9df 100644 --- a/users/templates/users/groups_index.html +++ b/users/templates/users/groups_index.html @@ -11,7 +11,7 @@

    Liste des groupes de droit

    {% if perms.auth.add_group %} - Ajouter un groupe de droit

    + Ajouter un groupe de droit

    {% endif %}
    {{ user }} {% if user.is_superuser %}(superuser){% endif %}Profil{% if not user.is_superuser %}Retirer des admins{% endif %}{{ user }}{ {% if user.is_superuser %}(superuser){% endif %} Profil{% if not user.is_superuser %} Retirer des admins{% endif %}
    {{c.product}}{% if perms.gestion.view_product %}{{ c.product.name }}{% else %}{{c.product.name}}{% endif %} {{c.quantity}} {{c.amount}} {{c.paymentMethod}}
    {{perm.codename}} {{perm.name}}Enlever le droit Enlever le droit
    {{ user }}ProfilRetirer{{user}} Profil Retirer
    @@ -29,7 +29,7 @@ - + {% endfor %} diff --git a/users/templates/users/index.html b/users/templates/users/index.html index 451c552..3c53a5a 100644 --- a/users/templates/users/index.html +++ b/users/templates/users/index.html @@ -107,7 +107,7 @@ {% csrf_token %} {{export_form}}
    - + {% endif %} diff --git a/users/templates/users/profile.html b/users/templates/users/profile.html index caea4fd..fcdb7bf 100644 --- a/users/templates/users/profile.html +++ b/users/templates/users/profile.html @@ -2,7 +2,7 @@ {% load static %} {% load users_extra %} {% block entete %}{% if self %}Mon Profil{% else %}Profil de {{user}}{% endif %}{%endblock%} - +{% block extra_script %}{% endblock %} {% block navbar %} {% for c in lastConsumptions %} - + - + {%endfor%} @@ -163,7 +169,7 @@ - + {%endfor%} @@ -196,7 +202,7 @@ {% if perms.gestion.delete_reload %} - + {% endif %} {% endfor %} @@ -211,7 +217,7 @@

    {{ self | yesno:"Mes cotisations,Cotisations"}}

    - Ajouter une cotisation

    + Ajouter une cotisation

    {{ group.name }} {{ group.permissions.count }} {{ group.user_set.count }}Voir {% if perms.auth.change_group %}Éditer {% endif %}{% if perms.auth.delete_group %}Supprimer{% endif %} Voir {% if perms.auth.change_group %} Éditer {% endif %}{% if perms.auth.delete_group %} Supprimer{% endif %}
    {{c.product}}{% if perms.gestion.view_product %}{{ c.product.name }}{% else %}{{c.product}}{% endif %} {{c.quantity}} {{c.amount}} € {{c.paymentMethod}} {{c.date}}{% if perms.gestion.delete_consumptionhistory %}Annuler{% endif %}{% if perms.gestion.delete_consumptionhistory %} Annuler{% endif %}
    {{m.amount}} € {{m.paymentMethod}} {{m.date}}{% if perms.gestion.delete_menuhistory %}Annuler{% endif %}{% if perms.gestion.delete_menuhistory %} Annuler{% endif %}
    {{reload.PaymentMethod}} {{reload.date}}Annuler Annuler
    @@ -234,7 +240,7 @@ - + {% endfor %} @@ -248,7 +254,7 @@

    {{ self | yesno:"Mes accès gracieux,Accès gracieux"}}

    - Ajouter un accès à titre gracieux

    + Ajouter un accès à titre gracieux

    {{cotisation.paymentMethod}} {{cotisation.endDate}} {{cotisation.valid}}{% if perms.users.validate_cotisationHistory %}Valider Invalider{% endif %}{% if perms.users.validate_cotisationHistory %} Valider Invalider{% endif %}
    diff --git a/users/templates/users/schools_index.html b/users/templates/users/schools_index.html index 47c7065..913e543 100644 --- a/users/templates/users/schools_index.html +++ b/users/templates/users/schools_index.html @@ -10,7 +10,7 @@

    Liste des écoles

    - Créer une école

    + Créer une école

    @@ -23,7 +23,7 @@ {% for school in schools %} - + {% endfor %} diff --git a/users/templates/users/superusers_index.html b/users/templates/users/superusers_index.html index 157aebf..02fa984 100644 --- a/users/templates/users/superusers_index.html +++ b/users/templates/users/superusers_index.html @@ -10,7 +10,7 @@

    Liste des superusers

    - Ajouter un superuser

    + Ajouter un superuser

    {{ school }}{% if perms.gestion.change_school %}Modifier {% endif %}{% if perms.gestion.delete_school %}Supprimer{% endif %}{% if perms.gestion.change_school %} Modifier {% endif %}{% if perms.gestion.delete_school %} Supprimer{% endif %}
    @@ -23,9 +23,9 @@ {% for user in superusers %} - - - + + + {% endfor %} diff --git a/users/templates/users/users_index.html b/users/templates/users/users_index.html index 7cc5545..c0c73b0 100644 --- a/users/templates/users/users_index.html +++ b/users/templates/users/users_index.html @@ -10,7 +10,7 @@

    Liste des utilisateurs

    - Créer un utilisateur

    + Créer un utilisateur

    {{ user }}ProfilRetirer des superusers{{user}} Profil Retirer des superusers
    @@ -25,10 +25,10 @@ {% for user in users %} - - + + {% if perms.auth.change_user %} - + {% endif %} {% endfor %} diff --git a/users/views.py b/users/views.py index ca37f2f..a2c3a0c 100644 --- a/users/views.py +++ b/users/views.py @@ -52,7 +52,7 @@ def loginView(request): return redirect(reverse('users:profile', kwargs={'pk':request.user.pk})) else: messages.error(request, "Nom d'utilisateur et/ou mot de passe invalide") - return render(request, "form.html", {"form_entete": "Connexion", "form": form, "form_title": "Connexion", "form_button": "Se connecter"}) + return render(request, "form.html", {"form_entete": "Connexion", "form": form, "form_title": "Connexion", "form_button": "Se connecter", "form_button_icon": "sign-in-alt"}) @active_required @login_required @@ -225,7 +225,7 @@ def createUser(request): user.save() messages.success(request, "L'utilisateur a bien été créé") return redirect(reverse('users:profile', kwargs={'pk':user.pk})) - return render(request, "form.html", {"form_entete": "Gestion des utilisateurs", "form":form, "form_title":"Création d'un nouvel utilisateur", "form_button":"Créer l'utilisateur"}) + return render(request, "form.html", {"form_entete": "Gestion des utilisateurs", "form":form, "form_title":"Création d'un nouvel utilisateur", "form_button":"Créer l'utilisateur", "form_button_icon": "user-plus"}) @active_required @login_required @@ -252,7 +252,7 @@ def searchUser(request): form = SelectUserForm(request.POST or None) if(form.is_valid()): return redirect(reverse('users:profile', kwargs={"pk":form.cleaned_data['user'].pk})) - return render(request, "form.html", {"form_entete": "Gestion des utilisateurs", "form": form, "form_title": "Rechercher un utilisateur", "form_button": "Afficher le profil"}) + return render(request, "form.html", {"form_entete": "Gestion des utilisateurs", "form": form, "form_title": "Rechercher un utilisateur", "form_button": "Afficher le profil", "form_button_icon": "search"}) @active_required @login_required @@ -305,7 +305,7 @@ def editGroups(request, pk): messages.success(request, "Les groupes de l'utilisateur " + user.username + " ont bien été enregistrés.") return redirect(reverse('users:profile', kwargs={'pk':pk})) extra_css = "#id_groups{height:200px;}" - return render(request, "form.html", {"form_entete": "Gestion de l'utilisateur " + user.username, "form": form, "form_title": "Modification des groupes", "form_button": "Enregistrer", "extra_css": extra_css}) + return render(request, "form.html", {"form_entete": "Gestion de l'utilisateur " + user.username, "form": form, "form_title": "Modification des groupes", "form_button": "Enregistrer", "form_button_icon": "pencil-alt", "extra_css": extra_css}) @active_required @login_required @@ -345,7 +345,7 @@ def editPassword(request, pk): return redirect(reverse('users:profile', kwargs={'pk':pk})) else: messages.error(request, "Le mot de passe actuel est incorrect") - return render(request, "form.html", {"form_entete": "Modification de mon compte", "form": form, "form_title": "Modification de mon mot de passe", "form_button": "Modifier mon mot de passe"}) + return render(request, "form.html", {"form_entete": "Modification de mon compte", "form": form, "form_title": "Modification de mon mot de passe", "form_button": "Modifier mon mot de passe", "form_button_icon": "pencil-alt"}) @active_required @login_required @@ -379,7 +379,7 @@ def editUser(request, pk): user.save() messages.success(request, "Les modifications ont bien été enregistrées") return redirect(reverse('users:profile', kwargs={'pk': pk})) - return render(request, "form.html", {"form_entete":"Modification du compte " + user.username, "form": form, "form_title": "Modification des informations", "form_button": "Modifier"}) + return render(request, "form.html", {"form_entete":"Modification du compte " + user.username, "form": form, "form_title": "Modification des informations", "form_button": "Modifier", "form_button_icon": "pencil-alt"}) @active_required @login_required @@ -413,7 +413,7 @@ def getUser(request, pk): The pk of the user """ user = get_object_or_404(User, pk=pk) - data = json.dumps({"username": user.username, "balance": user.profile.balance}) + data = json.dumps({"username": user.username, "balance": user.profile.balance, "is_adherent": user.profile.is_adherent}) return HttpResponse(data, content_type='application/json') @active_required @@ -583,7 +583,7 @@ def createGroup(request): group = form.save() messages.success(request, "Le groupe " + form.cleaned_data['name'] + " a bien été crée.") return redirect(reverse('users:groupProfile', kwargs={'pk': group.pk})) - return render(request, "form.html", {"form_entete": "Gestion des utilisateurs", "form":form, "form_title": "Création d'un groupe de droit", "form_button": "Créer le groupe de droit"}) + return render(request, "form.html", {"form_entete": "Gestion des utilisateurs", "form":form, "form_title": "Création d'un groupe de droit", "form_button": "Créer le groupe de droit", "form_button_icon": "plus-square"}) @active_required @login_required @@ -617,7 +617,7 @@ def editGroup(request, pk): form.save() messages.success(request, "Le groupe " + group.name + " a bien été modifié.") return redirect(reverse('users:groupProfile', kwargs={'pk': group.pk})) - return render(request, "form.html", {"form_entete": "Gestion des utilisateurs", "form": form, "form_title": "Modification du groupe de droit " + group.name, "form_button": "Modifier le groupe de droit", "extra_css":extra_css}) + return render(request, "form.html", {"form_entete": "Gestion des utilisateurs", "form": form, "form_title": "Modification du groupe de droit " + group.name, "form_button": "Modifier le groupe de droit", "form_button_icon": "pencil-alt", "extra_css":extra_css}) @active_required @login_required @@ -736,7 +736,7 @@ def addAdmin(request): user.save() messages.success(request, "L'utilisateur " + user.username + " a bien été rajouté aux admins") return redirect(reverse('users:adminsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Ajout d'un admin", "form_button":"Ajouter l'utilisateur aux admins"}) + return render(request, "form.html", {"form": form, "form_title": "Ajout d'un admin", "form_button": "Ajouter l'utilisateur aux admins", "form_button_icon": "user-plus"}) @active_required @login_required @@ -814,7 +814,7 @@ def addSuperuser(request): user.save() messages.success(request, "L'utilisateur " + user.username + " a bien été rajouté aux superusers") return redirect(reverse('users:superusersIndex')) - return render(request, "form.html", {"form_entete": "Gestion des superusers", "form": form, "form_title": "Ajout d'un superuser", "form_button":"Ajouter l'utilisateur aux superusers"}) + return render(request, "form.html", {"form_entete": "Gestion des superusers", "form": form, "form_title": "Ajout d'un superuser", "form_button":"Ajouter l'utilisateur aux superusers", "form_button_icon": "user-plus"}) @active_required @login_required @@ -888,7 +888,7 @@ def addCotisationHistory(request, pk): cotisation.save() messages.success(request, "La cotisation a bien été ajoutée") return redirect(reverse('users:profile',kwargs={'pk':user.pk})) - return render(request, "form.html",{"form": form, "form_title": "Ajout d'une cotisation pour l'utilisateur " + str(user), "form_button": "Ajouter"}) + return render(request, "form.html",{"form": form, "form_title": "Ajout d'une cotisation pour l'utilisateur " + str(user), "form_button": "Ajouter", "form_button_icon": "plus-square"}) @active_required @login_required @@ -922,7 +922,7 @@ def invalidateCotisationHistory(request, pk): user = cotisationHistory.user user.profile.cotisationEnd = user.profile.cotisationEnd - timedelta(days=cotisationHistory.duration) if(cotisationHistory.paymentMethod.affect_balance): - user.profile.balance += cotisation.amount + user.profile.debit -= cotisationHistory.cotisation.amount user.save() messages.success(request, "La cotisation a bien été invalidée") return HttpResponseRedirect(request.META.get('HTTP_REFERER')) @@ -969,7 +969,7 @@ def addWhiteListHistory(request, pk): whiteList.save() messages.success(request, "L'accès gracieux a bien été ajouté") return redirect(reverse('users:profile', kwargs={'pk':user.pk})) - return render(request, "form.html", {"form": form, "form_title": "Ajout d'un accès gracieux pour " + user.username, "form_button": "Ajouter"}) + return render(request, "form.html", {"form": form, "form_title": "Ajout d'un accès gracieux pour " + user.username, "form_button": "Ajouter", "form_button_icon": "plus-square"}) ########## Schools ########## @@ -1019,7 +1019,7 @@ def createSchool(request): form.save() messages.success(request, "L'école a bien été créée") return redirect(reverse('users:schoolsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Création d'une école", "form_button": "Créer"}) + return render(request, "form.html", {"form": form, "form_title": "Création d'une école", "form_button": "Créer", "form_button_icon": "plus-square"}) @active_required @login_required @@ -1052,7 +1052,7 @@ def editSchool(request, pk): form.save() messages.success(request, "L'école a bien été modifiée") return redirect(reverse('users:schoolsIndex')) - return render(request, "form.html", {"form": form, "form_title": "Modification de l'école " + str(school), "form_button": "Modifier"}) + return render(request, "form.html", {"form": form, "form_title": "Modification de l'école " + str(school), "form_button": "Modifier", "form_button": "pencil-alt"}) @active_required @login_required @@ -1079,7 +1079,7 @@ class AllUsersAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = User.objects.all() if self.q: - qs = qs.filter(Q(username__istartswith=self.q) | Q(first_name__istartswith=self.q) | Q(last_name__istartswith=self.q)) + qs = qs.filter(Q(username__contains=self.q) | Q(first_name__contains=self.q) | Q(last_name__contains=self.q)) return qs class ActiveUsersAutocomplete(autocomplete.Select2QuerySetView): @@ -1089,7 +1089,7 @@ class ActiveUsersAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = User.objects.filter(is_active=True) if self.q: - qs = qs.filter(Q(username__istartswith=self.q) | Q(first_name__istartswith=self.q) | Q(last_name__istartswith=self.q)) + qs = qs.filter(Q(username__contains=self.q) | Q(first_name__contains=self.q) | Q(last_name__contains=self.q)) return qs class AdherentAutocomplete(autocomplete.Select2QuerySetView): @@ -1098,8 +1098,13 @@ class AdherentAutocomplete(autocomplete.Select2QuerySetView): """ def get_queryset(self): qs = User.objects.all() + pks = [x.pk for x in qs if x.is_adherent] + qs = User.objects.filter(pk__in=pks) + if self.q: + qs = qs.filter(Q(username__contains=self.q) | Q(first_name__contains=self.q) | Q(last_name__contains=self.q)) return qs + class NonSuperUserAutocomplete(autocomplete.Select2QuerySetView): """ Autocomplete for non-superuser users @@ -1107,7 +1112,7 @@ class NonSuperUserAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = User.objects.filter(is_superuser=False) if self.q: - qs = qs.filter(Q(username__istartswith=self.q) | Q(first_name__istartswith=self.q) | Q(last_name__istartswith=self.q)) + qs = qs.filter(Q(username__contains=self.q) | Q(first_name__contains=self.q) | Q(last_name__contains=self.q)) return qs class NonAdminUserAutocomplete(autocomplete.Select2QuerySetView): @@ -1117,5 +1122,5 @@ class NonAdminUserAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): qs = User.objects.filter(is_staff=False) if self.q: - qs = qs.filter(Q(username__istartswith=self.q) | Q(first_name__istartswith=self.q) | Q(last_name__istartswith=self.q)) + qs = qs.filter(Q(username__contains=self.q) | Q(first_name__contains=self.q) | Q(last_name__contains=self.q)) return qs \ No newline at end of file
    {{ user }}Profil{{user}} Profil{{ user.is_active | yesno:"Désa,A"}}ctiver{% if user.is_active %} Désactiver{% else %} Activer{% endif %}