0

I suspect that the answer is "you can't" - but perhaps you can show a me a better approach.

Consider this very simple game. You click on a button, and it keeps track of how many times you've clicked. Eventually, though, it will be much more complex - so I have a Player service which holds the timesClicked value.

angular.module( 'ClickGame', [] )

angular.module('ClickGame').factory( 'Player', function() {
    var svc = {};
    svc.timesClicked = 0;
    return svc;
} );

angular.module('ClickGame').controller( 'MainController', [ '$scope', 'Player', function( $scope, Player ) {

    // In practice, I wouldn't necessarily expose Player directly on the
    // scope like this, but it makes for simpler demo code.
    $scope.Player = Player;

    $scope.click = function() {
        Player.timesClicked++;
    };

} ] );

The HTML is pretty self-explanatory:

<html ng-app="ClickGame">

<body ng-controller="MainController">

    <p><button type="button" ng-click="click()">Click me!</button></p>

    <p>You have clicked {{ Player.timesClicked }} times.</p>

    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.15/angular.min.js"></script>
    <script src="demo.js"></script>

</body>

</html>

So far, so good. Here's where I'm running into problems: I'd like to add an achievements system, which will award badges to the player when they accomplish certain goals (say, clicking on the button five times). That seems like it belongs in its own component, so I create a second service named Achievements, which injects Player:

app.factory( 'Achievements', [ 'Player', function( Player ) {

    // I can't do this, because neither Achievements nor Player have a scope.
    $scope.$watch( 'Player.timesClicked', function( newValue, oldValue ) {
        if ( newValue >= 5 ) {
            console.log( 'ACHIEVEMENT UNLOCKED: Clicked 5 Times' );
            // TODO: unwatch this model, to conserve resources and prevent
            // the achievement from being awarded more than once
        }
    } );

} ] );

See the problem?

Approaches I've considered and rejected:

  • Use $interval to poll Player.timesClicked periodically. This is obviously horrible for a bunch of reasons.

  • Make Achievements a directive instead of a service, since directives do have scope. However, this would require me to put an <achievement> tag in my index.html, as sort of a side-door method of instantiating that scope and keeping it alive for the lifetime of the application. I've seen this approach recommended a couple of times, but it feels like a misuse of directives. There's gotta be a cleaner way.

  • Have Player $broadcast an event on the root scope whenever timesClicked is incremented, and listen for that event in Achievements. I don't love this. As the game grows in complexity, it'd end up with a lot of broadcast noise. Anyway, it's the old "just make it global" solution - effective, but usually not a good idea in the long run :)

  • In Achievements, create a new ad-hoc scope using $rootScope.$new(), attach Player to that, and then $watch that. (Or I could create the new scope in Player, and then expose that scope as a property of svc.) Honestly, I'm not sure whether this would even work - but when I find myself doing stuff like this, I suspect I need to backtrack and refactor something.

Is there another approach that I'm overlooking? Is there a way to refactor my application so that timesClicked is stored in a place that's easier to $watch (but still easy to share between different parts of my application)?

Sorry if I'm missing something super obvious; I've been staring at Angular code all day and I probably need a break :)

Update: I should mention that registering the $watch from within MainController won't work. Although I've kept this demo code simple, the actual application is a SPA using ngRoute - so MainController will be unloaded if the player navigates to a different route. But achievements should still be monitored and awarded.

greenie2600
  • 1,419
  • 3
  • 15
  • 22
  • factory and service doesn't support $scope – Sherali Turdiyev Sep 04 '15 at 02:20
  • @SheraliTurdiyev but it does support $rootScope. How ever, using $watch is heavy on performance especially when you have lots of them. I think he's looking for the observer pattern, see: [http://stackoverflow.com/questions/12576798/how-to-watch-service-variables/17558885#17558885](http://stackoverflow.com/questions/12576798/how-to-watch-service-variables/17558885#17558885) – iH8 Sep 04 '15 at 02:23
  • It looks like the first argument to $watch can also be a function (docs here, though I can't link to a specific part, so ctrl+f for "watch" and look at the parameters table: https://docs.angularjs.org/api/ng/type/$rootScope.Scope). have you tried using $rootScope in Achievement and watching `function() {return Player.timesClicked }` instead of "Player.links"? – martiansnoop Sep 04 '15 at 02:30

2 Answers2

1

A simple and efficient method would be to use pub/sub method, but with callbacks rather than events. Here is what I would do:

<html ng-app="ClickGame">
<body ng-controller="MainController">
    <p><button type="button" ng-click="click()">Click me!</button></p>
    <p>You have clicked {{ timesClicked }} times.</p>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.15/angular.min.js"></script>
    <script src="demo.js"></script>
</body>
</html>

angular.module( 'ClickGame', [] )
.factory( 'Player', function() {
    var timesClicked = 0, timesClickedChangedCallbacks = [];


    return {
        getTimesClicked: function() { return timesClicked; },
        setTimesClicked: function(val) {
            timesClicked = val;
            timesClickedChangedCallbacks.forEach(function(cb) { cb(val); });
        },
        onTimesClickedChanged: function(cb) {
            if (cb && typeof cb == 'function') {
                timesClickedChangedCallbacks.push(cb);
            }
        }
    };
})
.factory( 'Achievements', [ 'Player', function( Player ) {
    Player.onTimesClickedChanged(function(val) {
        if ( val == 5 ) {
            console.log( 'ACHIEVEMENT UNLOCKED: Clicked 5 Times' );
        }
    });
} ] )
.controller( 'MainController', [ '$scope', 'Player', function( $scope, Player ) {
    $scope.timesClicked = Player.getTimesClicked();

    $scope.click = function() {
        $scope.timesClicked++;
        Player.setTimesClicked($scope.timesClicked);
    };
}]);

You may also want to add a way to unsubscribe to timesClicked changes in the Player service.

GPicazo
  • 5,808
  • 2
  • 18
  • 23
0
    Inject Achievement Service in controller and put this service in your scope

    app.controller('MainController' , ['Achievements' ,function(Achievements){

    $scope.achivement = Achievements;  //Put this service in your scope
       $scope.$watch('achivement.Player.links',function(newVal,oldVal){
       //do your things
    }); 
   }])
Saurabh Ahuja
  • 473
  • 3
  • 12
  • Unfortunately this won't work, because - as I forgot to mention - MainController won't always be loaded. Although it's not shown in the sample code, this will be a SPA using ngRoute - so whatever $watches the value needs to be guaranteed to stay alive as long as the application does. – greenie2600 Sep 04 '15 at 12:41